import React from "react"; import { render, waitFor, fireEvent } from "@testing-library/react-native"; import { SoluCXWidgetView as SoluCXWidget } from "../SoluCXWidgetView"; import { WidgetData, WidgetOptions } from "../domain"; import { requestWidgetOptions, requestWidgetUrl } from "../services/WidgetBootstrapService"; jest.mock("react-native-webview", () => { const { forwardRef } = require("react"); const { View } = require("react-native"); return { __esModule: true, WebView: forwardRef((props: any, ref: any) => ), }; }); const mockClose = jest.fn(); const mockHide = jest.fn(); const mockOpen = jest.fn(); const mockShouldDisplayWidget = jest.fn(); const mockShouldDisplayForTransaction = jest.fn(); const mockStateManager = { getLogs: jest.fn().mockResolvedValue({ lastFirstAccess: 0, lastDisplay: 0 }), updateTimestamp: jest.fn().mockResolvedValue(undefined), incrementAttempt: jest.fn().mockResolvedValue(undefined), hasAnsweredTransaction: jest.fn().mockResolvedValue(false), markTransactionAnswered: jest.fn().mockResolvedValue(undefined), resetAttempts: jest.fn().mockResolvedValue(undefined), }; jest.mock("../hooks/useWidget", () => ({ useWidget: () => ({ widgetHeight: 400, isWidgetVisible: true, hide: mockHide, resize: jest.fn(), open: mockOpen, close: mockClose, userId: "test-user-123", stateManager: mockStateManager, }), })); jest.mock("../services/WidgetEventService", () => ({ WidgetEventService: jest.fn().mockImplementation(() => ({ handleMessage: jest.fn(), })), })); jest.mock("../services/WidgetValidationService", () => ({ WidgetValidationService: jest.fn().mockImplementation(() => ({ shouldDisplayForTransaction: mockShouldDisplayForTransaction, shouldDisplayForTransactionAlreadyAnswered: mockShouldDisplayForTransaction, shouldDisplayWidget: mockShouldDisplayWidget, })), })); jest.mock("../services/WidgetBootstrapService", () => ({ requestWidgetOptions: jest.fn(), requestWidgetUrl: jest.fn(), })); jest.mock("../constants/Constants", () => ({ SDK_NAME: "rn-widget-sdk", SDK_VERSION: "0.1.16", })); jest.mock("../hooks/useClientVersionCollector", () => ({ getClientVersion: jest.fn(() => "1.0.0"), })); jest.mock("../hooks/useDeviceInfoCollector", () => ({ getDeviceInfo: jest.fn(() => ({ platform: "ios", osVersion: "16.0", screenWidth: 390, screenHeight: 844, windowWidth: 390, windowHeight: 844, scale: 3, fontScale: 1, deviceType: "phone", model: "iPhone 14 Pro", })), })); const mockRequestWidgetUrl = requestWidgetUrl as jest.MockedFunction; const mockRequestWidgetOptions = requestWidgetOptions as jest.MockedFunction; const bootstrappedWidgetUrl = "https://widgets.solucx.com/widget/bootstrap-result"; const baseProps = { soluCXKey: "test-key-abc", data: { customer_id: "cust-001", transaction_id: "txn-999", form_id: "form-123", } as WidgetData, options: { height: 400, } as WidgetOptions, }; beforeEach(() => { jest.clearAllMocks(); mockShouldDisplayForTransaction.mockReset(); mockShouldDisplayForTransaction.mockResolvedValue({ canDisplay: true }); mockShouldDisplayWidget.mockReset(); mockShouldDisplayWidget.mockResolvedValue({ canDisplay: true }); mockRequestWidgetOptions.mockReset(); mockRequestWidgetOptions.mockResolvedValue({ height: 400 }); mockRequestWidgetUrl.mockReset(); mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); }); describe("SoluCXWidget type routing", () => { it("should render ModalWidget when type is modal", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const { UNSAFE_getByType } = render(); await waitFor(() => { const modal = UNSAFE_getByType(require("react-native").Modal); expect(modal).toBeTruthy(); }, { timeout: 10000 }); }, 15000); it("should render close button inside ModalWidget", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const { getByText } = render(); await waitFor(() => { expect(getByText("✕")).toBeTruthy(); }); }); it("should render WebView inside ModalWidget with correct source", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const { getByTestId } = render(); await waitFor(() => { const webview = getByTestId("webview"); expect(webview.props.source.uri).toBe(bootstrappedWidgetUrl); }); }); it("should render InlineWidget when type is inline", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const { queryByTestId, getByText } = render(); await waitFor(() => { expect(queryByTestId("webview")).toBeTruthy(); expect(getByText("✕")).toBeTruthy(); }); }); it("should not render Modal when type is inline", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const { UNSAFE_queryByType } = render(); await waitFor(() => { const modal = UNSAFE_queryByType(require("react-native").Modal); expect(modal).toBeNull(); }); }); it("should render OverlayWidget when type is bottom", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const { queryByTestId, getByText } = render(); await waitFor(() => { expect(queryByTestId("webview")).toBeTruthy(); expect(getByText("✕")).toBeTruthy(); }); }); it("should render OverlayWidget when type is top", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const { queryByTestId, getByText } = render(); await waitFor(() => { expect(queryByTestId("webview")).toBeTruthy(); expect(getByText("✕")).toBeTruthy(); }); }); it("should not render Modal when type is bottom", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const { UNSAFE_queryByType } = render(); await waitFor(() => { const modal = UNSAFE_queryByType(require("react-native").Modal); expect(modal).toBeNull(); }); }); }); describe("SoluCXWidget WebView configuration", () => { it("should set originWhitelist to allow all origins", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const { getByTestId } = render(); await waitFor(() => { const webview = getByTestId("webview"); expect(webview.props.originWhitelist).toEqual(["*"]); }); }); it("should set WebView width style to screen width", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const { getByTestId } = render(); await waitFor(() => { const webview = getByTestId("webview"); const widthStyle = webview.props.style.find((s: any) => s.width !== undefined); expect(widthStyle.width).toBeGreaterThan(0); }); }); it("should set WebView height style to widgetHeight", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const { getByTestId } = render(); await waitFor(() => { const webview = getByTestId("webview"); const heightStyle = webview.props.style.find((s: any) => s.height !== undefined); expect(heightStyle.height).toBe(400); }); }); it("should use bootstrapped URL for widgets", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const { getByTestId } = render(); await waitFor(() => { const webview = getByTestId("webview"); expect(webview.props.source.uri).toBe(bootstrappedWidgetUrl); }); }); }); describe("SoluCXWidget bootstrap flow", () => { it("respects fetched type when local options are not provided", async () => { mockRequestWidgetOptions.mockResolvedValue({ type: "inline", height: 520 }); mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const props = { ...baseProps, type: "modal" as const, options: undefined, data: { customer_id: "cust-777", journey: "sac", }, }; const { UNSAFE_queryByType, queryByTestId } = render(); await waitFor(() => { expect(queryByTestId("webview")).toBeTruthy(); const modal = UNSAFE_queryByType(require("react-native").Modal); expect(modal).toBeNull(); }); }); it("respects fetched height when local options are not provided", async () => { mockRequestWidgetOptions.mockResolvedValue({ type: "inline", height: 520 }); mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const props = { ...baseProps, type: "modal" as const, options: undefined, data: { customer_id: "cust-777", journey: "sac", }, }; const { getByTestId } = render(); await waitFor(() => { const webview = getByTestId("webview"); const heightStyle = webview.props.style.find((s: { height?: number }) => s.height !== undefined); expect(heightStyle.height).toBe(520); }); }); it("fetches widget URL for form widgets", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const props = { ...baseProps, type: "modal" as const, }; const { queryByTestId, getByTestId } = render(); expect(queryByTestId("webview")).toBeNull(); await waitFor(() => { expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1); expect(mockRequestWidgetUrl).toHaveBeenCalledWith( "test-key-abc", expect.objectContaining({ form_id: "form-123", }), "test-user-123", ); expect(mockShouldDisplayWidget).toHaveBeenCalledTimes(1); expect(mockOpen).toHaveBeenCalledTimes(1); expect(queryByTestId("webview")).toBeTruthy(); }); const webview = getByTestId("webview"); expect(webview.props.source.uri).toBe(bootstrappedWidgetUrl); }); it("fetches widget URL before rendering non-form widgets", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const props = { ...baseProps, data: { customer_id: "cust-777", journey: "sac", }, type: "bottom" as const, }; const { queryByTestId, getByTestId } = render(); expect(queryByTestId("webview")).toBeNull(); await waitFor(() => { expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1); expect(mockShouldDisplayWidget).toHaveBeenCalledTimes(1); expect(mockOpen).toHaveBeenCalledTimes(1); expect(queryByTestId("webview")).toBeTruthy(); }); const webview = getByTestId("webview"); expect(webview.props.source.uri).toBe(bootstrappedWidgetUrl); }); it("keeps widget hidden when preflight fetch fails", async () => { mockRequestWidgetUrl.mockRejectedValue(new Error("network")); const props = { ...baseProps, data: { customer_id: "cust-777", journey: "sac", }, type: "bottom" as const, }; const { queryByTestId } = render(); expect(queryByTestId("webview")).toBeNull(); await waitFor(() => { // Validation runs BEFORE preflight to reduce API load at scale expect(mockShouldDisplayWidget).toHaveBeenCalledTimes(1); expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1); expect(queryByTestId("webview")).toBeNull(); }); }); it("keeps widget hidden when validation fails before preflight", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); mockShouldDisplayWidget.mockResolvedValue({ canDisplay: false, blockReason: "BLOCKED_BY_MAX_ATTEMPTS" }); const props = { ...baseProps, data: { customer_id: "cust-777", journey: "sac", }, type: "bottom" as const, }; const { queryByTestId } = render(); expect(queryByTestId("webview")).toBeNull(); await waitFor(() => { // Validation blocks BEFORE preflight — preflight is never called expect(mockShouldDisplayWidget).toHaveBeenCalledTimes(1); expect(mockRequestWidgetUrl).not.toHaveBeenCalled(); expect(mockOpen).not.toHaveBeenCalled(); expect(queryByTestId("webview")).toBeNull(); }); }); it("keeps widget hidden and skips all fetches when transaction was already answered", async () => { mockShouldDisplayForTransaction.mockResolvedValue({ canDisplay: false, blockReason: "BLOCKED_BY_TRANSACTION_ALREADY_ANSWERED", }); const onBlocked = jest.fn(); const props = { ...baseProps, type: "bottom" as const, callbacks: { onBlocked }, }; const { queryByTestId } = render(); await waitFor(() => { expect(mockShouldDisplayForTransaction).toHaveBeenCalledWith('txn-999'); expect(mockShouldDisplayWidget).not.toHaveBeenCalled(); expect(mockRequestWidgetOptions).not.toHaveBeenCalled(); expect(mockRequestWidgetUrl).not.toHaveBeenCalled(); expect(onBlocked).toHaveBeenCalledWith('BLOCKED_BY_TRANSACTION_ALREADY_ANSWERED'); expect(queryByTestId("webview")).toBeNull(); }); }); }); describe("SoluCXWidget callbacks", () => { it("saves lastDismiss when native close button is pressed", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); const { getByText } = render(); await waitFor(() => { expect(getByText("✕")).toBeTruthy(); }); fireEvent.press(getByText("✕")); await waitFor(() => { expect(mockHide).toHaveBeenCalledTimes(1); expect(mockStateManager.updateTimestamp).toHaveBeenCalledWith("lastDismiss"); }); }); it("calls onError when data is not provided", () => { const onError = jest.fn(); const props = { ...baseProps, data: null as any, callbacks: { onError }, }; render(); expect(onError).toHaveBeenCalled(); expect(onError).toHaveBeenCalledWith("Widget data is required but was not provided"); }); it("calls onError with Error message when bootstrap fails with Error instance", async () => { mockRequestWidgetUrl.mockRejectedValue(new Error("Network timeout")); const onError = jest.fn(); const props = { ...baseProps, data: { customer_id: "cust-777", journey: "sac", }, type: "bottom" as const, callbacks: { onError }, }; render(); await waitFor(() => { expect(onError).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith("Network timeout"); expect(mockHide).toHaveBeenCalledTimes(1); }); }); it("calls onError with string when bootstrap fails with string error", async () => { mockRequestWidgetUrl.mockRejectedValue("Connection failed"); const onError = jest.fn(); const props = { ...baseProps, data: { customer_id: "cust-777", journey: "sac", }, type: "bottom" as const, callbacks: { onError }, }; render(); await waitFor(() => { expect(onError).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith("Connection failed"); }); }); it("calls onError with JSON string when bootstrap fails with object error", async () => { mockRequestWidgetUrl.mockRejectedValue({ code: 500, message: "Server error" }); const onError = jest.fn(); const props = { ...baseProps, data: { customer_id: "cust-777", journey: "sac", }, type: "bottom" as const, callbacks: { onError }, }; render(); await waitFor(() => { expect(onError).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith('{"code":500,"message":"Server error"}'); }); }); it('calls onError with "Unknown error" when bootstrap fails with null', async () => { mockRequestWidgetUrl.mockRejectedValue(null); const onError = jest.fn(); const props = { ...baseProps, data: { customer_id: "cust-777", journey: "sac", }, type: "bottom" as const, callbacks: { onError }, }; render(); await waitFor(() => { expect(onError).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith("Unknown error"); }); }); it("calls onBlocked when validation fails", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); mockShouldDisplayWidget.mockResolvedValue({ canDisplay: false, blockReason: "BLOCKED_BY_MAX_ATTEMPTS", }); const onBlocked = jest.fn(); const props = { ...baseProps, data: { customer_id: "cust-777", journey: "sac", }, type: "bottom" as const, callbacks: { onBlocked }, }; render(); await waitFor(() => { expect(onBlocked).toHaveBeenCalledTimes(1); expect(onBlocked).toHaveBeenCalledWith("BLOCKED_BY_MAX_ATTEMPTS"); expect(mockHide).toHaveBeenCalledTimes(1); }); }); it("calls onPreOpen, onOpened callbacks for successful bootstrap", async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: bootstrappedWidgetUrl }); mockShouldDisplayWidget.mockResolvedValue({ canDisplay: true }); const onPreOpen = jest.fn(); const onOpened = jest.fn(); const props = { ...baseProps, data: { customer_id: "cust-777", journey: "sac", }, type: "bottom" as const, callbacks: { onPreOpen, onOpened }, }; render(); await waitFor(() => { expect(onPreOpen).toHaveBeenCalledTimes(1); expect(onPreOpen).toHaveBeenCalledWith("test-user-123"); expect(onOpened).toHaveBeenCalledTimes(1); expect(onOpened).toHaveBeenCalledWith("test-user-123"); }); }); }); describe("SoluCXWidget error handling from requestWidgetUrl", () => { it("calls onError when requestWidgetUrl throws error for non-ok response (404)", async () => { mockRequestWidgetUrl.mockRejectedValue(new Error("Failed to get error response: 404 Not Found")); const onError = jest.fn(); const props = { ...baseProps, type: "modal" as const, callbacks: { onError }, }; const { queryByTestId } = render(); await waitFor(() => { expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith("Failed to get error response: 404 Not Found"); expect(mockHide).toHaveBeenCalledTimes(1); expect(queryByTestId("webview")).toBeNull(); }); }); it("calls onError when requestWidgetUrl throws error for non-ok response (500)", async () => { mockRequestWidgetUrl.mockRejectedValue(new Error("Failed to get error response: 500 Internal Server Error")); const onError = jest.fn(); const props = { ...baseProps, type: "inline" as const, callbacks: { onError }, }; const { queryByTestId } = render(); await waitFor(() => { expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith("Failed to get error response: 500 Internal Server Error"); expect(mockHide).toHaveBeenCalledTimes(1); expect(queryByTestId("webview")).toBeNull(); }); }); it("calls onError when requestWidgetUrl throws error for fetch not available", async () => { mockRequestWidgetUrl.mockRejectedValue(new Error("Fetch is not available")); const onError = jest.fn(); const props = { ...baseProps, type: "bottom" as const, callbacks: { onError }, }; const { queryByTestId } = render(); await waitFor(() => { expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith("Fetch is not available"); expect(mockHide).toHaveBeenCalledTimes(1); expect(queryByTestId("webview")).toBeNull(); }); }); it("calls onError when requestWidgetUrl throws error for missing URL in response", async () => { mockRequestWidgetUrl.mockRejectedValue(new Error("Failed to get the widget api response.")); const onError = jest.fn(); const props = { ...baseProps, type: "top" as const, callbacks: { onError }, }; const { queryByTestId } = render(); await waitFor(() => { expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith("Failed to get the widget api response."); expect(mockHide).toHaveBeenCalledTimes(1); expect(queryByTestId("webview")).toBeNull(); }); }); it("calls shouldDisplayWidget before requestWidgetUrl and handles preflight failure", async () => { mockRequestWidgetUrl.mockRejectedValue(new Error("Failed to fetch widget URL: 403 Forbidden")); const onError = jest.fn(); const props = { ...baseProps, type: "modal" as const, callbacks: { onError }, }; render(); await waitFor(() => { // Validation runs BEFORE preflight to avoid unnecessary API calls expect(mockShouldDisplayWidget).toHaveBeenCalled(); expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1); expect(mockOpen).not.toHaveBeenCalled(); expect(onError).toHaveBeenCalledWith("Failed to fetch widget URL: 403 Forbidden"); }); }); it("does not call onPreOpen or onOpened when requestWidgetUrl fails", async () => { mockRequestWidgetUrl.mockRejectedValue(new Error("Failed to fetch widget URL: 401 Unauthorized")); const onPreOpen = jest.fn(); const onOpened = jest.fn(); const onError = jest.fn(); const props = { ...baseProps, type: "inline" as const, callbacks: { onPreOpen, onOpened, onError }, }; render(); await waitFor(() => { // Validation runs first, then preflight fails expect(mockShouldDisplayWidget).toHaveBeenCalled(); expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1); expect(onPreOpen).not.toHaveBeenCalled(); expect(onOpened).not.toHaveBeenCalled(); expect(onError).toHaveBeenCalledWith("Failed to fetch widget URL: 401 Unauthorized"); }); }); it("widget remains hidden when requestWidgetUrl fails with 404", async () => { mockRequestWidgetUrl.mockRejectedValue(new Error("Failed to get error response: 404 Not Found")); const props = { ...baseProps, type: "bottom" as const, }; const { queryByTestId } = render(); // Initially null expect(queryByTestId("webview")).toBeNull(); await waitFor(() => { // Validation runs first, then preflight fails expect(mockShouldDisplayWidget).toHaveBeenCalled(); expect(mockRequestWidgetUrl).toHaveBeenCalledTimes(1); }); // Should remain null after failure expect(queryByTestId("webview")).toBeNull(); expect(mockHide).toHaveBeenCalledTimes(1); }); });