diff --git a/__tests__/CardUtils.test.tsx b/__tests__/CardUtils.test.tsx
new file mode 100644
index 0000000..7252d46
--- /dev/null
+++ b/__tests__/CardUtils.test.tsx
@@ -0,0 +1,43 @@
+import {
+ formatCard,
+ getCardType,
+ getIcon,
+ isValidCard,
+ cvvLength,
+ isValidCvv,
+ isValidDate,
+} from "../src/utils/card";
+
+describe("card utils", () => {
+ it("formats and validates card number", () => {
+ expect(formatCard("4242424242424242")).toBe("4242 4242 4242 4242");
+ expect(isValidCard("4242 4242 4242 4242")).toBe(true);
+ });
+
+ it("detects card type including extended whitelist (Discover)", () => {
+ expect(getCardType("6011111111111117")).toBe("Discover");
+ });
+
+ it("cvvLength varies by type (Amex -> 4)", () => {
+ // Amex test number
+ const amex = "378282246310005";
+ expect(cvvLength(amex)).toBe(4);
+ expect(cvvLength("4242 4242 4242 4242")).toBe(3);
+ });
+
+ it("validates cvv by inferred type", () => {
+ // Amex 4-digit CVC (It's an AMEX Test card number)
+ expect(isValidCvv("1234", "345678901234564")).toBe(true);
+ // Visa 3-digit CVC
+ expect(isValidCvv("123", "4242 4242 4242 4242")).toBe(true);
+ });
+
+ it("getIcon returns undefined for unknown type", () => {
+ // @ts-ignore
+ expect(getIcon("UnknownBrand")).toBeUndefined();
+ });
+
+ it("isValidDate rejects incomplete or past dates", () => {
+ expect(isValidDate("06/")).toBe(false);
+ });
+});
diff --git a/__tests__/FramesContextGuards.test.tsx b/__tests__/FramesContextGuards.test.tsx
new file mode 100644
index 0000000..38c5638
--- /dev/null
+++ b/__tests__/FramesContextGuards.test.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+import { render } from "@testing-library/react-native";
+
+import { CardNumber, ExpiryDate, Cvv, SubmitButton } from "../src/index";
+
+describe("Component context guards", () => {
+ it("throws when CardNumber is rendered outside Frames", () => {
+ expect(() => render()).toThrow(
+ "It looks like you are trying to render the CardNumber outside of the Frames Component."
+ );
+ });
+
+ it("throws when ExpiryDate is rendered outside Frames", () => {
+ expect(() => render()).toThrow(
+ "It looks like you are trying to render the ExpiryDate outside of the Frames Component."
+ );
+ });
+
+ it("throws when Cvv is rendered outside Frames", () => {
+ expect(() => render()).toThrow(
+ "It looks like you are trying to render the Cvv outside of the Frames Component."
+ );
+ });
+
+ it("throws when SubmitButton is rendered outside Frames", () => {
+ expect(() => render()).toThrow(
+ "It looks like you are trying to render the SubmitButton outside of the Frames Component."
+ );
+ });
+});
diff --git a/__tests__/HttpAndLogger.test.tsx b/__tests__/HttpAndLogger.test.tsx
new file mode 100644
index 0000000..1adee0b
--- /dev/null
+++ b/__tests__/HttpAndLogger.test.tsx
@@ -0,0 +1,204 @@
+import React from "react";
+import { render, fireEvent, waitFor } from "@testing-library/react-native";
+
+import {
+ Frames,
+ CardNumber,
+ ExpiryDate,
+ Cvv,
+ SubmitButton,
+} from "../src/index";
+
+import { SANDBOX_LOGGER, LIVE_LOGGER } from "../src/utils/constants";
+
+describe("http.tokenize and helpers", () => {
+ const { tokenize, formatDataForTokenization, getEnvironment } =
+ jest.requireActual("../src/utils/http");
+
+ beforeEach(() => {
+ // @ts-ignore
+ global.fetch = jest.fn();
+ });
+
+ it("tokenize returns json when response ok", async () => {
+ // @ts-ignore
+ global.fetch.mockResolvedValueOnce({
+ ok: true,
+ json: jest.fn().mockResolvedValue({ ok: true }),
+ });
+
+ const res = await tokenize({
+ key: "pk_sbox_dummy",
+ body: {
+ type: "card",
+ number: "4242424242424242",
+ expiry_month: "12",
+ expiry_year: "2030",
+ cvv: "123",
+ },
+ });
+
+ expect(res).toEqual({ ok: true });
+ });
+
+ it("tokenize throws json when response not ok", async () => {
+ // @ts-ignore
+ global.fetch.mockResolvedValueOnce({
+ ok: false,
+ json: jest.fn().mockResolvedValue({ error: "bad" }),
+ });
+
+ await expect(
+ tokenize({
+ key: "pk_sbox_dummy",
+ body: {
+ type: "card",
+ number: "4242424242424242",
+ expiry_month: "12",
+ expiry_year: "2030",
+ cvv: "123",
+ },
+ })
+ ).rejects.toEqual({ error: "bad" });
+ });
+
+ it("formatDataForTokenization maps fields and billing address including addressLine2", () => {
+ const { EXPIRY_DATE_DELIMITER } = require("../src/utils/constants");
+ const state = {
+ cardNumber: "4242 4242 4242 4242",
+ cardBin: { bin: null, scheme: null },
+ cardIcon: undefined,
+ cardType: null,
+ expiryDate: `12${EXPIRY_DATE_DELIMITER}30`,
+ cvv: "123",
+ cvvLength: 3,
+ validation: { cardNumber: true, expiryDate: true, cvv: true, card: true },
+ };
+
+ const config = {
+ publicKey: "pk_sbox_dummy",
+ cardholder: {
+ name: "John Doe",
+ phone: "+1234567890",
+ billingAddress: {
+ addressLine1: "Line 1",
+ addressLine2: "Line 2",
+ city: "City",
+ state: "ST",
+ zip: "12345",
+ country: "US",
+ },
+ },
+ };
+
+ const params = formatDataForTokenization(state, config);
+ expect(params.key).toBe(config.publicKey);
+ expect(params.body.number).toBe("4242424242424242");
+ expect(params.body.expiry_month).toBe("12");
+ expect(params.body.expiry_year).toMatch(/^20\d{2}$/);
+ expect(params.body.cvv).toBe("123");
+ expect(params.body.name).toBe("John Doe");
+ expect(params.body.billing_address).toEqual({
+ address_line1: "Line 1",
+ address_line2: "Line 2",
+ city: "City",
+ state: "ST",
+ zip: "12345",
+ country: "US",
+ });
+ expect(params.body.phone).toEqual({ number: "+1234567890" });
+ });
+
+ it("formatDataForTokenization sets billing_address to null when not provided", () => {
+ const state = {
+ cardNumber: "4242 4242 4242 4242",
+ cardBin: { bin: null, scheme: null },
+ cardIcon: undefined,
+ cardType: null,
+ expiryDate: "12/30",
+ cvv: "123",
+ cvvLength: 3,
+ validation: { cardNumber: true, expiryDate: true, cvv: true, card: true },
+ };
+
+ const config = {
+ publicKey: "pk_sbox_dummy",
+ cardholder: { name: "John Doe" },
+ };
+
+ const params = formatDataForTokenization(state, config);
+ // @ts-ignore
+ expect(params.body.billing_address).toBeNull();
+ });
+
+ it("getEnvironment returns expected token URL", () => {
+ expect(getEnvironment("pk_sbox_dummy")).toBe(
+ "https://api.sandbox.checkout.com/tokens"
+ );
+ });
+});
+
+describe("logger.log", () => {
+ const { log, getEnvironment } = require("../src/utils/logger");
+
+ beforeEach(() => {
+ // @ts-ignore
+ global.fetch = jest.fn().mockResolvedValue({});
+ });
+
+ it("posts to sandbox logger with structured body", async () => {
+ await log("info", "type_event", { publicKey: "pk_sbox_dummy" }, { a: 1 });
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ SANDBOX_LOGGER,
+ expect.anything()
+ );
+ const [, options] = (global.fetch as jest.Mock).mock.calls[0];
+ const body = JSON.parse(options.body);
+ expect(body.specversion).toBe("1.0");
+ expect(body.type).toBe("type_event");
+ expect(body.data.environment).toBe("sandbox");
+ expect(body.cko.loglevel).toBe("info");
+ });
+
+ it("getEnvironment returns live logger for prod keys", () => {
+ expect(getEnvironment("pk_4296fd52-efba-4a38-b6ce-cf0d93639d8a")).toBe(
+ LIVE_LOGGER
+ );
+ });
+});
+
+describe("Frames error path on tokenization failure", () => {
+ it("emits cardTokenizationFailed when tokenization rejects", async () => {
+ const httpMod = require("../src/utils/http");
+ jest.spyOn(httpMod, "tokenize").mockRejectedValueOnce({ error: "failure" });
+
+ const { Frames } = require("../src/index");
+
+ const cardTokenizationFailed = jest.fn();
+
+ const screen = render(
+
+
+
+
+
+
+ );
+
+ fireEvent.changeText(
+ screen.getByPlaceholderText("•••• •••• •••• ••••"),
+ "4242 4242 4242 4242"
+ );
+ fireEvent.changeText(screen.getByPlaceholderText("MM/YY"), "12/30");
+ fireEvent.changeText(screen.getByPlaceholderText("•••"), "123");
+
+ fireEvent.press(screen.getByText("Pay"));
+
+ await waitFor(() => expect(cardTokenizationFailed).toHaveBeenCalled());
+ expect(cardTokenizationFailed).toHaveBeenCalledWith({ error: "failure" });
+ });
+});
diff --git a/__tests__/Integration.test.tsx b/__tests__/Integration.test.tsx
index fccaedd..440d2b1 100644
--- a/__tests__/Integration.test.tsx
+++ b/__tests__/Integration.test.tsx
@@ -59,8 +59,8 @@ describe("Frames integration", () => {
type: "card",
token: "tok_test_123",
expires_on: "2030-12-31T23:59:59Z",
- expiry_month: "12",
- expiry_year: "2030",
+ expiry_month: 12,
+ expiry_year: 2030,
scheme: "Visa",
last4: "4242",
bin: "424242",
@@ -147,8 +147,8 @@ describe("Frames integration", () => {
type: "card",
token: "tok_test_ref_123",
expires_on: "2030-12-31T23:59:59Z",
- expiry_month: "12",
- expiry_year: "2030",
+ expiry_month: 12,
+ expiry_year: 2030,
scheme: "Visa",
last4: "4242",
bin: "424242",
@@ -187,4 +187,46 @@ describe("Frames integration", () => {
await waitFor(() => expect(tokenize).toHaveBeenCalledTimes(1));
expect(cardTokenized).toHaveBeenCalledWith(mockTokenResponse);
});
+
+ it("SubmitButton triggers submitCard and forwards onPress to consumer", async () => {
+ const { tokenize } = require("../src/utils/http");
+
+ const mockTokenResponse = {
+ type: "card",
+ token: "tok_test_press_123",
+ expires_on: "2030-12-31T23:59:59Z",
+ expiry_month: 12,
+ expiry_year: 2030,
+ scheme: "Visa",
+ last4: "4242",
+ bin: "424242",
+ };
+
+ tokenize.mockResolvedValueOnce(mockTokenResponse);
+
+ const onPressSpy = jest.fn();
+ const cardTokenized = jest.fn();
+
+ const screen = render(
+
+
+
+
+
+
+ );
+
+ fireEvent.changeText(
+ screen.getByPlaceholderText("•••• •••• •••• ••••"),
+ validVisa
+ );
+ fireEvent.changeText(screen.getByPlaceholderText("MM/YY"), validExpiry);
+ fireEvent.changeText(screen.getByPlaceholderText("•••"), validCvv);
+
+ fireEvent.press(screen.getByText("Pay"));
+
+ await waitFor(() => expect(tokenize).toHaveBeenCalledTimes(1));
+ expect(cardTokenized).toHaveBeenCalledWith(mockTokenResponse);
+ expect(onPressSpy).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/package.json b/package.json
index d8bafab..c6047b3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "frames-react-native",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "Frames React Native",
"main": "dist/index.js",
"scripts": {
diff --git a/src/components/CardNumber.tsx b/src/components/CardNumber.tsx
index a504fda..133836f 100644
--- a/src/components/CardNumber.tsx
+++ b/src/components/CardNumber.tsx
@@ -24,15 +24,16 @@ const CardNumber: React.FC = (props) => {
value={state.cardNumber ?? ""}
style={[styles.cardNumber, props.style]}
onChangeText={(val: string) => {
- dispatch({ type: CARD_CHANGE, payload: val });
+ const safeValue = val ?? "";
+ dispatch({ type: CARD_CHANGE, payload: safeValue });
+ const digits = safeValue.replace(/[^0-9]/g, "");
if (
- val.replace(/[^0-9]/g, "").length >= 8 &&
- val.replace(/[^0-9]/g, "").substring(0, 8) !==
- state.cardBin.bin
+ digits.length >= 8 &&
+ digits.substring(0, 8) !== state.cardBin.bin
) {
dispatch({
type: BIN_CHANGE,
- payload: val.replace(/[^0-9]/g, "").substring(0, 8),
+ payload: digits.substring(0, 8),
});
}
}}
diff --git a/src/types/types.tsx b/src/types/types.tsx
index 5f10738..2a2716f 100644
--- a/src/types/types.tsx
+++ b/src/types/types.tsx
@@ -170,8 +170,8 @@ export interface FrameCardTokenizedEvent {
type: string;
token: string;
expires_on: string;
- expiry_month: string;
- expiry_year: string;
+ expiry_month: number;
+ expiry_year: number;
scheme?: Scheme;
scheme_local?: string;
last4: string;
diff --git a/src/utils/http.tsx b/src/utils/http.tsx
index 21fc97f..7623dd1 100644
--- a/src/utils/http.tsx
+++ b/src/utils/http.tsx
@@ -42,11 +42,17 @@ export const formatDataForTokenization = (
state: FramesState,
config: FramesConfig
): TokenizationParams => {
- let number = state.cardNumber!.replace(/[^A-Z0-9]+/gi, "");
- let expiry_month = state.expiryDate!.split(EXPIRY_DATE_DELIMITER)[0];
- let expiry_year = `${new Date().getFullYear().toString().substring(0, 2)}${
- state.expiryDate!.split(EXPIRY_DATE_DELIMITER)[1]
- }`;
+ const rawNumber = state.cardNumber || "";
+ const number = rawNumber.replace(/[^A-Z0-9]+/gi, "");
+
+ const rawExpiry = state.expiryDate || "";
+ const [expiry_month = "", expiryYearSuffix] = rawExpiry.split(
+ EXPIRY_DATE_DELIMITER
+ );
+ const currentCentury = new Date().getFullYear().toString().substring(0, 2);
+ const expiry_year = expiryYearSuffix
+ ? `${currentCentury}${expiryYearSuffix}`
+ : "";
let billing_address: GatewayBillingAddress = {
address_line1: "",
@@ -83,7 +89,7 @@ export const formatDataForTokenization = (
number,
expiry_month,
expiry_year,
- cvv: state.cvv!,
+ cvv: state.cvv || "",
name: config.cardholder?.name,
billing_address,
phone: {