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);
});
});