diff --git a/src/zoid/qr-code/component.test.js b/src/zoid/qr-code/component.test.js new file mode 100644 index 0000000000..5ba374cd6f --- /dev/null +++ b/src/zoid/qr-code/component.test.js @@ -0,0 +1,46 @@ +/* @flow */ +/** + * Unit tests for QR Code component creation and configuration. + * Verifies component initialization, memoization, and proper setup of SDK dependencies. + */ + +import { describe, expect, test, vi } from "vitest"; + +import { getQRCodeComponent } from "./component"; + +vi.mock("@paypal/sdk-client/src", () => ({ + getLogger: vi.fn(() => ({ + metric: vi.fn().mockReturnThis(), + error: vi.fn().mockReturnThis(), + track: vi.fn().mockReturnThis(), + flush: vi.fn().mockReturnThis(), + metricCounter: vi.fn().mockReturnThis(), + })), + getPayPalDomainRegex: vi.fn(() => /paypal\.com/), + getPayPalDomain: vi.fn(() => "https://www.paypal.com"), + getCSPNonce: vi.fn(() => "mock-nonce"), + getSDKMeta: vi.fn(() => "mock-sdk-meta"), + getDebug: vi.fn(() => false), + getEnv: vi.fn(() => "test"), + getSessionID: vi.fn(() => "mock-session-id"), + getLocale: vi.fn(() => ({ country: "US", lang: "en" })), + getClientID: vi.fn(() => "mock-client-id"), + getCorrelationID: vi.fn(() => "mock-correlation-id"), + getBuyerCountry: vi.fn(() => "US"), +})); + +describe("getQRCodeComponent", () => { + test("should create Zoid component", () => { + const component = getQRCodeComponent(); + + expect(component).toBeDefined(); + expect(typeof component).toBe("function"); + }); + + test("should memoize component instance", () => { + const component1 = getQRCodeComponent(); + const component2 = getQRCodeComponent(); + + expect(component1).toBe(component2); + }); +}); diff --git a/src/zoid/qr-code/container.jsx b/src/zoid/qr-code/container.jsx index a2c40bb374..3b835ab764 100644 --- a/src/zoid/qr-code/container.jsx +++ b/src/zoid/qr-code/container.jsx @@ -4,6 +4,7 @@ import { destroyElement, type EventEmitterType } from "@krakenjs/belter/src"; import { EVENT, type RenderOptionsType } from "@krakenjs/zoid/src"; import { node, dom, type ChildType } from "@krakenjs/jsx-pragmatic/src"; +import { getCSPNonce } from "@paypal/sdk-client/src"; import { type QRCodeProps } from "./types"; @@ -113,7 +114,6 @@ export function QRCodeContainer({ export function containerTemplate({ frame, prerenderFrame, - props, doc, uid, event, @@ -122,7 +122,7 @@ export function containerTemplate({ return; } - const { cspNonce } = props; + const cspNonce = __WEB__ ? getCSPNonce() : undefined; return ( ({ + getCSPNonce: vi.fn(), +})); + +const createMocks = () => ({ + frame: document.createElement("iframe"), + prerenderFrame: document.createElement("iframe"), + // $FlowFixMe - mock event emitter for tests + event: { + on: vi.fn(), + trigger: vi.fn(), + once: vi.fn(), + reset: vi.fn(), + triggerOnce: vi.fn(), + }, +}); + +const originalWebValue = global.__WEB__; + +const setupTest = () => { + vi.clearAllMocks(); + global.__WEB__ = true; +}; + +const teardownTest = () => { + global.__WEB__ = originalWebValue; +}; + +describe("containerTemplate", () => { + beforeEach(setupTest); + afterEach(teardownTest); + + test.each([[true], [false]])( + "when __WEB__ is %s, should call getCSPNonce conditionally", + (webValue) => { + global.__WEB__ = webValue; + // $FlowIssue - mock return value + getCSPNonce.mockReturnValue(TEST_NONCE); + + const { frame, prerenderFrame, event } = createMocks(); + // $FlowIssue - test mock parameters + const result = containerTemplate({ + frame, + prerenderFrame, + doc: document, + uid: TEST_UID, + event, + // $FlowIssue - test mock parameters + props: {}, + }); + + if (webValue) { + expect(getCSPNonce).toHaveBeenCalled(); + // Verify nonce value is available for use + expect(getCSPNonce).toHaveReturnedWith(TEST_NONCE); + expect(result).toBeDefined(); + } else { + expect(getCSPNonce).not.toHaveBeenCalled(); + } + } + ); + + test.each([ + [ + "frame", + { frame: null, prerenderFrame: document.createElement("iframe") }, + ], + [ + "prerenderFrame", + { frame: document.createElement("iframe"), prerenderFrame: null }, + ], + ])("should return undefined when %s is missing", (_paramName, frames) => { + // $FlowIssue - test props + const result = containerTemplate({ + ...frames, + doc: document, + uid: TEST_UID, + event: createMocks().event, + props: {}, + }); + expect(result).toBeUndefined(); + }); + + test("should apply cspNonce to style element in rendered output", () => { + // $FlowIssue - mock return value + getCSPNonce.mockReturnValue(TEST_NONCE); + + const { frame, prerenderFrame, event } = createMocks(); + // $FlowIssue - test mock parameters + const result = containerTemplate({ + frame, + prerenderFrame, + doc: document, + uid: TEST_UID, + event, + // $FlowIssue - test mock parameters + props: {}, + }); + + // $FlowFixMe - result is HTMLElement in test context + const styleElement = result.querySelector("style"); + expect(styleElement?.getAttribute("nonce")).toBe(TEST_NONCE); + }); +}); + +describe("QRCodeContainer", () => { + beforeEach(setupTest); + afterEach(teardownTest); + + test.each([ + ["frame", (mocks) => ({ ...mocks, frame: null })], + ["prerenderFrame", (mocks) => ({ ...mocks, prerenderFrame: null })], + ])("should throw error when %s is missing", (_paramName, modifyMocks) => { + const mocks = createMocks(); + // $FlowIssue - intentionally passing null for error test + expect(() => + QRCodeContainer({ uid: TEST_UID, ...modifyMocks(mocks) }) + ).toThrow("Expected frame and prerenderframe"); + }); + + test("should set up visibility classes for prerender transition", () => { + // Initial state: prerenderFrame visible, component frame invisible + const { frame, prerenderFrame, event } = createMocks(); + QRCodeContainer({ uid: TEST_UID, frame, prerenderFrame, event }); + + // Verify component frame is initially hidden + expect(frame.classList.contains("component-frame")).toBe(true); + expect(frame.classList.contains("invisible")).toBe(true); + + // Verify prerender frame is initially visible + expect(prerenderFrame.classList.contains("prerender-frame")).toBe(true); + expect(prerenderFrame.classList.contains("visible")).toBe(true); + }); + + test("should toggle frame visibility when EVENT.RENDERED fires", () => { + const mocks = createMocks(); + QRCodeContainer({ uid: TEST_UID, ...mocks }); + + // Extract and invoke the registered handler + // $FlowFixMe - accessing vitest mock properties + const handler = mocks.event.on.mock.calls[0][1]; + handler(); + + // Verify visibility toggle: prerenderFrame becomes invisible + expect(mocks.prerenderFrame.classList.contains("invisible")).toBe(true); + expect(mocks.prerenderFrame.classList.contains("visible")).toBe(false); + + // Verify visibility toggle: component frame becomes visible + expect(mocks.frame.classList.contains("visible")).toBe(true); + expect(mocks.frame.classList.contains("invisible")).toBe(false); + }); + + test("should pass cspNonce to QRCodeContainer and apply to rendered style", () => { + const { frame, prerenderFrame, event } = createMocks(); + const result = QRCodeContainer({ + uid: TEST_UID, + frame, + prerenderFrame, + event, + cspNonce: TEST_NONCE, + }); + + // Render to DOM to access style element + // $FlowFixMe - result is ChildType with render method + const rendered = result.render(dom({ doc: document })); + const styleElement = rendered.querySelector("style"); + + expect(styleElement?.getAttribute("nonce")).toBe(TEST_NONCE); + }); +}); diff --git a/src/zoid/qr-code/prerender.jsx b/src/zoid/qr-code/prerender.jsx index a405497b1e..a92e3462e9 100644 --- a/src/zoid/qr-code/prerender.jsx +++ b/src/zoid/qr-code/prerender.jsx @@ -4,14 +4,16 @@ import { type RenderOptionsType } from "@krakenjs/zoid/src"; import { node, dom } from "@krakenjs/jsx-pragmatic/src"; import { SpinnerPage } from "@paypal/common-components/src"; +import { getCSPNonce } from "@paypal/sdk-client/src"; import { type QRCodeProps } from "./types"; export function prerenderTemplate({ doc, - props, close, }: RenderOptionsType): ?HTMLElement { + const cspNonce = __WEB__ ? getCSPNonce() : undefined; + const style = ` #close { position: absolute; @@ -42,11 +44,9 @@ export function prerenderTemplate({ `; const children = [ -