From d792f823e40bb138babf9b2d2fa040558ce97f36 Mon Sep 17 00:00:00 2001 From: ioan-ghisoi-cko Date: Wed, 13 Aug 2025 10:50:01 +0100 Subject: [PATCH 1/3] Add unit tests for card utilities and context guards; enhance HTTP and logger tests, update types --- __tests__/CardUtils.test.tsx | 43 ++++++ __tests__/FramesContextGuards.test.tsx | 30 ++++ __tests__/HttpAndLogger.test.tsx | 204 +++++++++++++++++++++++++ __tests__/Integration.test.tsx | 50 +++++- src/types/types.tsx | 4 +- 5 files changed, 325 insertions(+), 6 deletions(-) create mode 100644 __tests__/CardUtils.test.tsx create mode 100644 __tests__/FramesContextGuards.test.tsx create mode 100644 __tests__/HttpAndLogger.test.tsx 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/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; From 9d557ca89bf334236f73e4438478b231a1db4140 Mon Sep 17 00:00:00 2001 From: ioan-ghisoi-cko Date: Wed, 13 Aug 2025 11:13:42 +0100 Subject: [PATCH 2/3] Refactor CardNumber and formatDataForTokenization for improved safety and clarity - Updated CardNumber component to ensure safe handling of card number input, replacing null values and using a sanitized digit string for validation. - Enhanced formatDataForTokenization function to handle potential null values for card number and expiry date, improving robustness in data formatting. --- src/components/CardNumber.tsx | 11 ++++++----- src/utils/http.tsx | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 11 deletions(-) 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/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: { From c02faf6b3328a00b1b7b67475535d25a9a45efb5 Mon Sep 17 00:00:00 2001 From: ioan-ghisoi-cko Date: Wed, 13 Aug 2025 11:30:08 +0100 Subject: [PATCH 3/3] Update package version to 1.2.4 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": {