Skip to content

Commit d885a30

Browse files
authored
Merge pull request #1625 from LawalRahman/feature/fe-add-comprehensive-component-unit-and-snapshot-tests-for-checkout-qr-modal
feat(frontend): add comprehensive component unit and snapshot tests f…
2 parents 55cfa09 + 1fb13a6 commit d885a30

2 files changed

Lines changed: 631 additions & 0 deletions

File tree

Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
import React from "react";
2+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3+
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
4+
import "@testing-library/jest-dom/vitest";
5+
import { NextIntlClientProvider } from "next-intl";
6+
7+
import CheckoutQrModal from "./CheckoutQrModal";
8+
9+
// ─── i18n messages ───────────────────────────────────────────────────────────
10+
const messages = {
11+
checkout: {
12+
qrModalTitle: "Payment QR Code",
13+
qrModalDescription:
14+
"Scan this QR code with your Stellar-compatible wallet to complete the payment.",
15+
downloadQr: "Download QR",
16+
downloadQrSuccess: "QR code downloaded successfully",
17+
downloadQrError: "Failed to download QR code",
18+
},
19+
};
20+
21+
// ─── framer-motion mock ───────────────────────────────────────────────────────
22+
vi.mock("framer-motion", () => {
23+
const strip = (props: Record<string, unknown>) => {
24+
const { initial: _i, animate: _a, exit: _e, transition: _t, ...rest } = props;
25+
return rest;
26+
};
27+
return {
28+
motion: {
29+
div: ({ children, ...p }: any) => <div {...strip(p)}>{children}</div>,
30+
},
31+
AnimatePresence: ({ children }: any) => <>{children}</>,
32+
};
33+
});
34+
35+
// ─── sonner (toast) mock ──────────────────────────────────────────────────────
36+
const mockToastSuccess = vi.fn();
37+
const mockToastError = vi.fn();
38+
vi.mock("sonner", () => ({
39+
toast: {
40+
success: (...args: unknown[]) => mockToastSuccess(...args),
41+
error: (...args: unknown[]) => mockToastError(...args),
42+
},
43+
}));
44+
45+
// ─── qrcode.react mock ───────────────────────────────────────────────────────
46+
vi.mock("qrcode.react", () => ({
47+
QRCodeCanvas: ({ value, size, level }: { value: string; size: number; level: string }) => (
48+
<canvas
49+
data-testid="qr-canvas"
50+
data-value={value}
51+
data-size={size}
52+
data-level={level}
53+
width={size}
54+
height={size}
55+
/>
56+
),
57+
}));
58+
59+
// ─── Default props ────────────────────────────────────────────────────────────
60+
const defaultProps = {
61+
isOpen: true,
62+
onClose: vi.fn(),
63+
qrValue: "stellar:GABC...XYZ?amount=10.00&asset_code=USDC",
64+
paymentId: "pay_test_abc123",
65+
};
66+
67+
// ─── Render helper ────────────────────────────────────────────────────────────
68+
function renderModal(props: Partial<typeof defaultProps> = {}) {
69+
return render(
70+
<NextIntlClientProvider locale="en" messages={messages}>
71+
<CheckoutQrModal {...defaultProps} {...props} />
72+
</NextIntlClientProvider>,
73+
);
74+
}
75+
76+
// ─── Tests ────────────────────────────────────────────────────────────────────
77+
78+
describe("CheckoutQrModal", () => {
79+
beforeEach(() => {
80+
vi.clearAllMocks();
81+
});
82+
83+
afterEach(() => {
84+
vi.restoreAllMocks();
85+
});
86+
87+
// ── Visibility ─────────────────────────────────────────────────────────────
88+
89+
describe("visibility", () => {
90+
it("renders modal content when isOpen is true", () => {
91+
renderModal({ isOpen: true });
92+
expect(screen.getByTestId("modal-content")).toBeInTheDocument();
93+
});
94+
95+
it("does not render modal content when isOpen is false", () => {
96+
renderModal({ isOpen: false });
97+
expect(screen.queryByTestId("modal-content")).not.toBeInTheDocument();
98+
});
99+
100+
it("renders the modal title", () => {
101+
renderModal();
102+
expect(screen.getByText("Payment QR Code")).toBeInTheDocument();
103+
});
104+
105+
it("renders the modal description", () => {
106+
renderModal();
107+
expect(
108+
screen.getByText(
109+
"Scan this QR code with your Stellar-compatible wallet to complete the payment.",
110+
),
111+
).toBeInTheDocument();
112+
});
113+
});
114+
115+
// ── QR Code ────────────────────────────────────────────────────────────────
116+
117+
describe("QR code rendering", () => {
118+
it("renders a QR canvas with the correct value prop", () => {
119+
renderModal({ qrValue: "stellar:GABC...?amount=5.00" });
120+
const canvas = screen.getByTestId("qr-canvas");
121+
expect(canvas).toBeInTheDocument();
122+
expect(canvas).toHaveAttribute("data-value", "stellar:GABC...?amount=5.00");
123+
});
124+
125+
it("renders QR canvas with high error correction level", () => {
126+
renderModal();
127+
const canvas = screen.getByTestId("qr-canvas");
128+
expect(canvas).toHaveAttribute("data-level", "H");
129+
});
130+
131+
it("renders QR canvas with size 512", () => {
132+
renderModal();
133+
const canvas = screen.getByTestId("qr-canvas");
134+
expect(canvas).toHaveAttribute("data-size", "512");
135+
});
136+
137+
it("re-renders with an updated qrValue", () => {
138+
const { rerender } = renderModal({ qrValue: "stellar:OLD" });
139+
expect(screen.getByTestId("qr-canvas")).toHaveAttribute("data-value", "stellar:OLD");
140+
141+
rerender(
142+
<NextIntlClientProvider locale="en" messages={messages}>
143+
<CheckoutQrModal {...defaultProps} qrValue="stellar:NEW" />
144+
</NextIntlClientProvider>,
145+
);
146+
expect(screen.getByTestId("qr-canvas")).toHaveAttribute("data-value", "stellar:NEW");
147+
});
148+
});
149+
150+
// ── Download button ────────────────────────────────────────────────────────
151+
152+
describe("download button", () => {
153+
it("renders the download QR button", () => {
154+
renderModal();
155+
expect(screen.getByRole("button", { name: /download qr/i })).toBeInTheDocument();
156+
});
157+
158+
it("download button is of type button (not submit)", () => {
159+
renderModal();
160+
const btn = screen.getByRole("button", { name: /download qr/i });
161+
expect(btn).toHaveAttribute("type", "button");
162+
});
163+
164+
it("triggers a download and shows success toast when canvas is available", async () => {
165+
// Mock toDataURL on the canvas prototype
166+
const toDataURLSpy = vi
167+
.spyOn(HTMLCanvasElement.prototype, "toDataURL")
168+
.mockReturnValue("data:image/png;base64,abc");
169+
170+
const realCreateElement = document.createElement.bind(document);
171+
const clickSpy = vi.fn();
172+
const createElementSpy = vi
173+
.spyOn(document, "createElement")
174+
.mockImplementation((tag: string, options?: any) => {
175+
if (tag === "a") {
176+
const anchor = {
177+
click: clickSpy,
178+
download: "",
179+
href: "",
180+
} as unknown as HTMLAnchorElement;
181+
return anchor;
182+
}
183+
return realCreateElement(tag, options);
184+
});
185+
186+
renderModal({ paymentId: "pay_xyz" });
187+
188+
await act(async () => {
189+
fireEvent.click(screen.getByRole("button", { name: /download qr/i }));
190+
});
191+
192+
expect(clickSpy).toHaveBeenCalledOnce();
193+
expect(mockToastSuccess).toHaveBeenCalledWith(messages.checkout.downloadQrSuccess);
194+
195+
toDataURLSpy.mockRestore();
196+
createElementSpy.mockRestore();
197+
});
198+
199+
it("sets the correct download filename using paymentId", async () => {
200+
const toDataURLSpy = vi
201+
.spyOn(HTMLCanvasElement.prototype, "toDataURL")
202+
.mockReturnValue("data:image/png;base64,abc");
203+
204+
const realCreateElement = document.createElement.bind(document);
205+
let capturedAnchor: { download: string; href: string; click: () => void } | null = null;
206+
const createElementSpy = vi
207+
.spyOn(document, "createElement")
208+
.mockImplementation((tag: string, options?: any) => {
209+
if (tag === "a") {
210+
capturedAnchor = { download: "", href: "", click: vi.fn() };
211+
return capturedAnchor as unknown as HTMLAnchorElement;
212+
}
213+
return realCreateElement(tag, options);
214+
});
215+
216+
renderModal({ paymentId: "pay_custom_id" });
217+
await act(async () => {
218+
fireEvent.click(screen.getByRole("button", { name: /download qr/i }));
219+
});
220+
221+
expect(capturedAnchor?.download).toBe("stellar-payment-pay_custom_id.png");
222+
223+
toDataURLSpy.mockRestore();
224+
createElementSpy.mockRestore();
225+
});
226+
227+
it("shows error toast if canvas element is not found", async () => {
228+
// The QRCodeCanvas mock renders a <canvas> but we remove it to simulate absence
229+
renderModal();
230+
// Remove the rendered canvas from the DOM
231+
document.querySelectorAll("canvas").forEach((c) => c.remove());
232+
233+
await act(async () => {
234+
fireEvent.click(screen.getByRole("button", { name: /download qr/i }));
235+
});
236+
237+
expect(mockToastError).toHaveBeenCalledWith(messages.checkout.downloadQrError);
238+
expect(mockToastSuccess).not.toHaveBeenCalled();
239+
});
240+
241+
it("shows error toast if toDataURL throws", async () => {
242+
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockImplementation(() => {
243+
throw new Error("Canvas tainted");
244+
});
245+
246+
renderModal();
247+
248+
await act(async () => {
249+
fireEvent.click(screen.getByRole("button", { name: /download qr/i }));
250+
});
251+
252+
expect(mockToastError).toHaveBeenCalledWith(messages.checkout.downloadQrError);
253+
});
254+
});
255+
256+
// ── Close behaviour ────────────────────────────────────────────────────────
257+
258+
describe("close behaviour", () => {
259+
it("calls onClose when the modal close button is clicked", () => {
260+
const onClose = vi.fn();
261+
renderModal({ onClose });
262+
fireEvent.click(screen.getByTestId("modal-close"));
263+
expect(onClose).toHaveBeenCalledOnce();
264+
});
265+
266+
it("calls onClose when the backdrop is clicked", () => {
267+
const onClose = vi.fn();
268+
renderModal({ onClose });
269+
fireEvent.click(screen.getByTestId("modal-backdrop"));
270+
expect(onClose).toHaveBeenCalledOnce();
271+
});
272+
273+
it("calls onClose when Escape key is pressed", () => {
274+
const onClose = vi.fn();
275+
renderModal({ onClose });
276+
fireEvent.keyDown(document, { key: "Escape" });
277+
expect(onClose).toHaveBeenCalledOnce();
278+
});
279+
});
280+
281+
// ── Accessibility ──────────────────────────────────────────────────────────
282+
283+
describe("accessibility", () => {
284+
it("the modal container has role=dialog", () => {
285+
renderModal();
286+
expect(screen.getByRole("dialog")).toBeInTheDocument();
287+
});
288+
289+
it("modal has aria-modal=true", () => {
290+
renderModal();
291+
expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true");
292+
});
293+
294+
it("modal is labelled by its title", () => {
295+
renderModal();
296+
const dialog = screen.getByRole("dialog");
297+
const labelledBy = dialog.getAttribute("aria-labelledby");
298+
expect(labelledBy).toBeTruthy();
299+
const titleEl = document.getElementById(labelledBy!);
300+
expect(titleEl).toHaveTextContent("Payment QR Code");
301+
});
302+
303+
it("close button has accessible label", () => {
304+
renderModal();
305+
const closeBtn = screen.getByTestId("modal-close");
306+
const ariaLabel = closeBtn.getAttribute("aria-label");
307+
expect(ariaLabel).toBeTruthy();
308+
expect(ariaLabel).toMatch(/close/i);
309+
});
310+
311+
it("download button is keyboard-accessible (visible in tab order)", () => {
312+
renderModal();
313+
const btn = screen.getByRole("button", { name: /download qr/i });
314+
expect(btn).not.toHaveAttribute("tabindex", "-1");
315+
});
316+
317+
it("backdrop has aria-hidden=true so screen readers skip it", () => {
318+
renderModal();
319+
expect(screen.getByTestId("modal-backdrop")).toHaveAttribute("aria-hidden", "true");
320+
});
321+
});
322+
323+
// ── Snapshot ───────────────────────────────────────────────────────────────
324+
325+
describe("snapshots", () => {
326+
it("matches snapshot when modal is open", () => {
327+
const { container } = renderModal({ isOpen: true });
328+
expect(container).toMatchSnapshot();
329+
});
330+
331+
it("matches snapshot when modal is closed (renders nothing)", () => {
332+
const { container } = renderModal({ isOpen: false });
333+
expect(container).toMatchSnapshot();
334+
});
335+
336+
it("matches snapshot with a custom qrValue", () => {
337+
const { container } = renderModal({ qrValue: "stellar:GCUSTOM...XYZ?amount=99.99" });
338+
expect(container).toMatchSnapshot();
339+
});
340+
341+
it("matches snapshot with a custom paymentId", () => {
342+
const { container } = renderModal({ paymentId: "pay_snap_id_001" });
343+
expect(container).toMatchSnapshot();
344+
});
345+
});
346+
});

0 commit comments

Comments
 (0)