diff --git a/payment_sdk/src/__tests__/CardInputGroup.test.tsx b/payment_sdk/src/__tests__/CardInputGroup.test.tsx new file mode 100644 index 0000000..868808f --- /dev/null +++ b/payment_sdk/src/__tests__/CardInputGroup.test.tsx @@ -0,0 +1,133 @@ +import React, { ReactNode } from "react"; +import { render, fireEvent } from "@testing-library/react-native"; + +import { formatCreditCardNumber, formatExpiry } from "../util/helpers"; +import { Actions, DispatchContext, StateContext } from "../state"; +import CardInputGroup from "../components/CardInputGroup"; +import { isCardNumberValid, validateCardExpiry } from "../util/validator"; + +// Mock helper functions +jest.mock("../util/helpers", () => ({ + formatCreditCardNumber: jest.fn(), + formatExpiry: jest.fn(), +})); + +// Mock validate function + +jest.mock("../util/validator", () => ({ + isCardNumberValid: jest.fn(), + validateCardExpiry: jest.fn(), +})); + +// Mocked context values +const mockState = { + cardCVV: "", + cardNumber: "", + cardExpiredDate: "", +}; + +const mockDispatch = jest.fn(); + +describe("CardInputGroup Component", () => { + const renderWithContext = (component: ReactNode) => { + return render( + + + {component} + + + ); + }; + + beforeEach(() => { + // Clear all mock call history before each test + jest.clearAllMocks(); + }); + + it("renders correctly with initial state", () => { + const { getByTestId } = renderWithContext(); + + // Check if the inputs render with the initial values from context + expect(getByTestId("cardNumberInput").props.value).toBe( + mockState.cardNumber + ); + expect(getByTestId("cardExpiryInput").props.value).toBe( + mockState.cardExpiredDate + ); + expect(getByTestId("cardCVVInput").props.value).toBe(mockState.cardCVV); + }); + + it("updates card number when valid and dispatches action", () => { + isCardNumberValid.mockReturnValue(true); + formatCreditCardNumber.mockReturnValue("1234 1234 1234 1234"); + + const { getByTestId } = renderWithContext(); + + const cardNumberInput = getByTestId("cardNumberInput"); + + // Simulate typing a valid card number + fireEvent.changeText(cardNumberInput, "1234123412341234"); + + // Check if the validation function was called + expect(isCardNumberValid).toHaveBeenCalledWith("1234123412341234"); + + // Check if the dispatch was called with the correct action + expect(mockDispatch).toHaveBeenCalledWith({ + type: Actions.SET_CARD_NUMBER, + payload: "1234 1234 1234 1234", + }); + }); + + it("does not update card number when invalid", () => { + isCardNumberValid.mockReturnValue(false); + + const { getByTestId } = renderWithContext(); + + const cardNumberInput = getByTestId("cardNumberInput"); + + // Simulate typing an invalid card number + fireEvent.changeText(cardNumberInput, "1234"); + + // Check if the validation function was called + expect(isCardNumberValid).toHaveBeenCalledWith("1234"); + + // Check that dispatch was not called + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it("updates card expiry date and dispatches action", () => { + validateCardExpiry.mockReturnValue(true); + formatExpiry.mockReturnValue("12 / 25"); + + const { getByTestId } = renderWithContext(); + + const cardExpiryInput = getByTestId("cardExpiryInput"); + + // Simulate typing a valid expiry date + fireEvent.changeText(cardExpiryInput, "12/25"); + + // Check if the validation function was called + expect(validateCardExpiry).toHaveBeenCalledWith("12/25"); + + // Check if the dispatch was called with the correct action + expect(mockDispatch).toHaveBeenCalledWith({ + type: Actions.SET_CARD_EXPIRED_DATE, + payload: "12 / 25", + }); + }); + + it("updates card CVV and dispatches action", () => { + const { getByTestId } = renderWithContext(); + + const cardCVVInput = getByTestId("cardCVVInput"); + + // Simulate typing a CVV + fireEvent.changeText(cardCVVInput, "123"); + + // Check if the dispatch was called with the correct action + expect(mockDispatch).toHaveBeenCalledWith({ + type: Actions.SET_CARD_CVV, + payload: "123", + }); + }); +}); diff --git a/payment_sdk/src/__tests__/CardSection.test.tsx b/payment_sdk/src/__tests__/CardSection.test.tsx index 81e95ef..9b7cd6b 100644 --- a/payment_sdk/src/__tests__/CardSection.test.tsx +++ b/payment_sdk/src/__tests__/CardSection.test.tsx @@ -1,9 +1,74 @@ -import React from "react"; -import { render } from "@testing-library/react-native"; +import React, { ReactNode } from "react"; +import { fireEvent, render, waitFor } from "@testing-library/react-native"; import CardSection from "../components/sections/CardSection"; -test("Cardholder name should be initially rendered", () => { - const { getByText } = render(); - expect(getByText("Cardholder name")).toBeTruthy(); +import StateProvider from "../components/paymentState/stateProvider"; +import { DispatchContext, StateContext } from "../state"; +import { PaymentType } from "../util/types"; + +export const mockState = { + sessionPay: jest.fn(), + cardholderName: "John Doe", + cardCVV: "", + cardNumber: "", + cardExpiredDate: "", + amount: 1000, + currency: "USD", +}; + +export const mockDispatch = jest.fn(); + +export const renderWithContext = (component: ReactNode) => { + return render( + + + {component} + + + ); +}; + +describe("Card Section Test cases", () => { + test("Cardholder name should be initially rendered", async () => { + const { getByText, getByTestId } = render( + + + + ); + const cardHolderText = getByText("Cardholder name"); + expect(cardHolderText).toBeTruthy(); + }); + + test("When enter card holders name state context should capture the card holder name", async () => { + const { getByText, getByTestId } = render( + + + + ); + fireEvent.changeText(getByText("Cardholder name"), "Kasun Prabath"); + await waitFor(() => + expect(getByTestId("cardHolderName").props.value).toBe("Kasun Prabath") + ); + }); + + it("calls sessionPay with correct parameters on button press", () => { + const { getByTestId } = renderWithContext(); + + const payButton = getByTestId("PayCTA"); + + // Simulate pressing the Pay button + fireEvent.press(payButton); + + // Check if sessionPay was called with the correct arguments + expect(mockState.sessionPay).toHaveBeenCalledWith({ + paymentType: PaymentType.CREDIT, + cardDetails: { + cardholderName: mockState.cardholderName, + cardCVV: mockState.cardCVV, + cardNumber: mockState.cardNumber, + cardExpiredDate: mockState.cardExpiredDate, + }, + }); + }); }); diff --git a/payment_sdk/src/__tests__/paymentService.test.ts b/payment_sdk/src/__tests__/paymentService.test.ts new file mode 100644 index 0000000..d24c339 --- /dev/null +++ b/payment_sdk/src/__tests__/paymentService.test.ts @@ -0,0 +1,89 @@ +import payForSession from "../services/paymentService"; +import { PaymentType } from "../util/types"; + +// Mock the fetch function +global.fetch = jest.fn(); + +const paymentDetails = { + publicKey: "samplepublickKey", + sessionId: "sessionID123", + paymentType: PaymentType.CREDIT, + cardDetails: { + cardholderName: "John Doe", + cardNumber: "1234 5678 9012 3456", + cardExpiredDate: "12/25", + cardCVV: "123", + }, +}; + +describe("payForSession", () => { + test("should successfully process credit card payment", async () => { + // Mock successful response from the server + + const successPaymentResponse = { + redirect_url: null, + status: "completed", + payment: { + id: "15xvkc1r4we8g4xjgcrj2ltzd", + resource: "payment", + status: "captured", + amount: 1000, + tax: 0, + customer: null, + payment_deadline: "2024-06-08T14:59:59Z", + payment_details: { + type: "credit_card", + email: null, + brand: "visa", + last_four_digits: "0100", + month: 8, + year: 2025, + }, + payment_method_fee: 0, + total: 1000, + currency: "JPY", + description: null, + captured_at: "2024-06-06T12:56:14Z", + external_order_num: null, + metadata: {}, + created_at: "2024-06-06T12:56:14Z", + amount_refunded: 0, + locale: "ja", + session: "6u1jrun2y7fmr8wwu7qskwgov", + customer_family_name: null, + customer_given_name: null, + mcc: null, + statement_descriptor: null, + refunds: [], + refund_requests: [], + }, + }; + + fetch.mockResolvedValueOnce({ + json: jest.fn().mockResolvedValueOnce(successPaymentResponse), + }); + + const paymentResponse = await payForSession(paymentDetails); + expect(paymentResponse).toBe(successPaymentResponse); + }); + + test("should handle network error gracefully", async () => { + const errorResponse = { + error: { + code: "unprocessable_entity", + message: + "Validation failed: Payment is invalid, Payment source is invalid, Payment source number is not a valid credit card number", + param: null, + details: {}, + }, + }; + // Mock network error response from the server + fetch.mockResolvedValueOnce({ + json: jest.fn().mockResolvedValueOnce(errorResponse), + }); + const paymentResponse = await payForSession(paymentDetails); + expect(paymentResponse).toBe(errorResponse); + }); + + // Add more test cases for different scenarios +}); diff --git a/payment_sdk/src/__tests__/validator.test.ts b/payment_sdk/src/__tests__/validator.test.ts new file mode 100644 index 0000000..8c41437 --- /dev/null +++ b/payment_sdk/src/__tests__/validator.test.ts @@ -0,0 +1,102 @@ +// util/helpers.test.js + +import { PaymentStatuses } from "../util/types"; +import { + isCardNumberValid, + validateCardExpiry, + validateSessionResponse, +} from "../util/validator"; + +describe("isCardNumberValid", () => { + it("returns true for empty input", () => { + expect(isCardNumberValid("")).toBe(true); + }); + + it("returns false for input exceeding max card length", () => { + expect(isCardNumberValid("12345678901234567")).toBe(false); + }); + + it("returns false for input with non-numeric characters", () => { + expect(isCardNumberValid("1234abcd5678")).toBe(false); + }); + + it("returns true for valid card number input", () => { + expect(isCardNumberValid("1234 5678 1234 5678")).toBe(true); + }); + + it("returns false for card number with spaces but exceeding max length", () => { + expect(isCardNumberValid("1234 5678 1234 5678 90")).toBe(false); + }); + + it("returns false for input length not matching numeric conversion", () => { + expect(isCardNumberValid("000012341234")).toBe(false); + }); +}); + +describe("validateCardExpiry", () => { + it("returns true for empty expiry date", () => { + expect(validateCardExpiry("")).toBe(true); + }); + + it("returns false for expiry date with invalid month", () => { + expect(validateCardExpiry("13 / 25")).toBe(false); + }); + + it("returns false for expiry date in the past", () => { + const pastDate = new Date(); + pastDate.setMonth(pastDate.getMonth() - 1); // One month in the past + const pastMonth = String(pastDate.getMonth() + 1).padStart(2, "0"); + const pastYear = String(pastDate.getFullYear() % 100).padStart(2, "0"); + expect(validateCardExpiry(`${pastMonth} / ${pastYear}`)).toBe(false); + }); + + it("returns true for valid future expiry date", () => { + const futureDate = new Date(); + futureDate.setMonth(futureDate.getMonth() + 1); // One month in the future + const futureMonth = String(futureDate.getMonth() + 1).padStart(2, "0"); + const futureYear = String(futureDate.getFullYear() % 100).padStart(2, "0"); + expect(validateCardExpiry(`${futureMonth} / ${futureYear}`)).toBe(true); + }); + + it("returns false for expiry date with year more than two digits", () => { + expect(validateCardExpiry("12 / 2025")).toBe(false); + }); + + it("returns false for expiry date with year exceeding 99", () => { + expect(validateCardExpiry("12 / 100")).toBe(false); + }); +}); + +describe("validateSessionResponse", () => { + it("returns true for null session data", () => { + expect(validateSessionResponse(null)).toBe(true); + }); + + it("returns true for session data with an error", () => { + expect(validateSessionResponse({ error: "Some error" })).toBe("Some error"); + }); + + it("returns true for expired session data", () => { + expect(validateSessionResponse({ expired: true })).toBe(true); + }); + + it("returns true for session data with SUCCESS status", () => { + expect(validateSessionResponse({ status: PaymentStatuses.SUCCESS })).toBe( + true + ); + }); + + it("returns true for session data with ERROR status", () => { + expect(validateSessionResponse({ status: PaymentStatuses.ERROR })).toBe( + true + ); + }); + + it("returns false for session data with any other status", () => { + expect(validateSessionResponse({ status: "PENDING" })).toBe(false); + }); + + it("returns false for empty object session data", () => { + expect(validateSessionResponse({})).toBe(false); + }); +}); diff --git a/payment_sdk/src/components/CardInputGroup.tsx b/payment_sdk/src/components/CardInputGroup.tsx index e7738ca..7a39ccc 100644 --- a/payment_sdk/src/components/CardInputGroup.tsx +++ b/payment_sdk/src/components/CardInputGroup.tsx @@ -17,6 +17,7 @@ const CardInputGroup = memo(() => { { if (isCardNumberValid(text)) { @@ -33,6 +34,7 @@ const CardInputGroup = memo(() => { { if (validateCardExpiry(text)) { @@ -47,6 +49,7 @@ const CardInputGroup = memo(() => { { dispatch({ type: Actions.SET_CARD_CVV, payload: text }); diff --git a/payment_sdk/src/components/Input.tsx b/payment_sdk/src/components/Input.tsx index fc29ef8..13950fe 100644 --- a/payment_sdk/src/components/Input.tsx +++ b/payment_sdk/src/components/Input.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import {View, TextInput, Text, StyleSheet, ViewStyle} from 'react-native'; +import React from "react"; +import { View, TextInput, Text, StyleSheet, ViewStyle } from "react-native"; interface InputProps { value: string; @@ -8,6 +8,7 @@ interface InputProps { placeholder?: string; hasBorder?: boolean; inputStyle?: ViewStyle; + testID?: string; } const Input: React.FC = ({ @@ -17,6 +18,7 @@ const Input: React.FC = ({ inputStyle, hasBorder, placeholder, + testID, }) => { return ( @@ -26,6 +28,7 @@ const Input: React.FC = ({ onChangeText={onChangeText} placeholder={placeholder} style={[styles.input, inputStyle, hasBorder && styles.withBorder]} + testID={testID} /> ); @@ -35,15 +38,15 @@ const styles = StyleSheet.create({ label: { fontSize: 16, marginBottom: 8, - color: '#172E44', + color: "#172E44", }, input: { - height: '100%', + height: "100%", paddingLeft: 16, fontSize: 16, }, withBorder: { - borderColor: '#CAD6E1', + borderColor: "#CAD6E1", borderWidth: 1, borderRadius: 8, }, diff --git a/payment_sdk/src/components/SubmitButton.tsx b/payment_sdk/src/components/SubmitButton.tsx index 5c2f400..92add96 100644 --- a/payment_sdk/src/components/SubmitButton.tsx +++ b/payment_sdk/src/components/SubmitButton.tsx @@ -1,14 +1,19 @@ -import {StyleSheet, Text, TouchableOpacity} from 'react-native'; -import React from 'react'; +import { StyleSheet, Text, TouchableOpacity } from "react-native"; +import React from "react"; type Props = { onPress: () => void; label: string; + testID?: string; }; -const SubmitButton = ({label, onPress}: Props) => { +const SubmitButton = ({ label, onPress, testID }: Props) => { return ( - + {label} ); @@ -18,17 +23,17 @@ export default SubmitButton; const styles = StyleSheet.create({ buttonWrapper: { - backgroundColor: '#0B82EE', + backgroundColor: "#0B82EE", borderRadius: 8, minHeight: 50, marginHorizontal: 16, flex: 1, - justifyContent: 'center', - alignItems: 'center', + justifyContent: "center", + alignItems: "center", }, label: { - color: 'white', + color: "white", fontSize: 16, - fontWeight: 'bold', + fontWeight: "bold", }, }); diff --git a/payment_sdk/src/components/sections/CardSection.tsx b/payment_sdk/src/components/sections/CardSection.tsx index 03a81b4..cb3e31f 100644 --- a/payment_sdk/src/components/sections/CardSection.tsx +++ b/payment_sdk/src/components/sections/CardSection.tsx @@ -41,12 +41,14 @@ const CardSection = (props: Props) => { }} hasBorder inputStyle={styles.inputStyle} + testID="cardHolderName" /> ); @@ -56,7 +58,7 @@ export default CardSection; const styles = StyleSheet.create({ cardContainer: { - position: 'relative', + position: "relative", flex: 1, }, cardNameContainer: {