import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderWithProviders } from "@repo/core-testing/react"; import { screen, fireEvent, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { InMemoryConsent } from "@repo/core-consent"; import { ConsentProvider } from "@repo/core-consent/react"; import { CookieConsentBanner } from "./cookie-consent-banner"; import { CookieConsentBannerLoader } from "./cookie-consent-banner-loader"; import { CONSENT_COOKIE_NAME, readConsentCookie, writeConsentCookie, clearConsentCookie, } from "./cookie-helpers"; import type { ConsentCookieState } from "./types"; beforeEach(() => { // Clear the consent cookie before each test document.cookie = `${CONSENT_COOKIE_NAME}=; Max-Age=0; Path=/`; }); // --------------------------------------------------------------------------- // Cookie helpers // --------------------------------------------------------------------------- describe("readConsentCookie", () => { it("returns null when no cookie is set", () => { expect(readConsentCookie()).toBeNull(); }); it("reads back a cookie written by writeConsentCookie", () => { const state: ConsentCookieState = { _v: 1, categories: { essential: true, analytics: false, marketing: false }, }; writeConsentCookie(state); expect(readConsentCookie()).toEqual(state); }); it("returns null for malformed JSON", () => { document.cookie = `${CONSENT_COOKIE_NAME}=notjson`; expect(readConsentCookie()).toBeNull(); }); it("returns null when _v does not match COOKIE_VERSION", () => { const bad = encodeURIComponent(JSON.stringify({ _v: 99, categories: {} })); document.cookie = `${CONSENT_COOKIE_NAME}=${bad}`; expect(readConsentCookie()).toBeNull(); }); it("returns null when categories key is missing", () => { const bad = encodeURIComponent(JSON.stringify({ _v: 1 })); document.cookie = `${CONSENT_COOKIE_NAME}=${bad}`; expect(readConsentCookie()).toBeNull(); }); it("returns null when categories is not an object", () => { const bad = encodeURIComponent( JSON.stringify({ _v: 1, categories: "bad" }), ); document.cookie = `${CONSENT_COOKIE_NAME}=${bad}`; expect(readConsentCookie()).toBeNull(); }); }); describe("clearConsentCookie", () => { it("removes the cookie so readConsentCookie returns null", () => { writeConsentCookie({ _v: 1, categories: { essential: true } }); clearConsentCookie(); expect(readConsentCookie()).toBeNull(); }); }); // --------------------------------------------------------------------------- // CookieConsentBanner — Reject All // --------------------------------------------------------------------------- describe("CookieConsentBanner — Reject All", () => { it("fires onConsentChange with all non-essential categories denied", async () => { const onConsentChange = vi.fn(); renderWithProviders( , ); await userEvent.click(screen.getByRole("button", { name: "Reject All" })); await waitFor(() => expect(onConsentChange).toHaveBeenCalledOnce()); const [states] = onConsentChange.mock.calls[0] as [ Array<{ category: string; state: string }>, ]; const find = (id: string) => states.find((s) => s.category === id); expect(find("essential")?.state).toBe("granted"); // required — always granted expect(find("functional")?.state).toBe("denied"); expect(find("analytics")?.state).toBe("denied"); expect(find("marketing")?.state).toBe("denied"); }); }); // --------------------------------------------------------------------------- // CookieConsentBanner — Save Selected with toggle // --------------------------------------------------------------------------- describe("CookieConsentBanner — Save Selected with toggle", () => { it("fires onConsentChange with analytics granted after toggling it on", async () => { const onConsentChange = vi.fn(); renderWithProviders( , ); // Toggle analytics ON (starts off by default) await userEvent.click( screen.getByRole("switch", { name: "Analytics cookies" }), ); await userEvent.click( screen.getByRole("button", { name: "Save Selected" }), ); await waitFor(() => expect(onConsentChange).toHaveBeenCalledOnce()); const [states] = onConsentChange.mock.calls[0] as [ Array<{ category: string; state: string }>, ]; expect(states.find((s) => s.category === "analytics")?.state).toBe( "granted", ); // Other non-essential categories remain denied expect(states.find((s) => s.category === "marketing")?.state).toBe( "denied", ); }); }); // --------------------------------------------------------------------------- // CookieConsentBanner — ESC key // --------------------------------------------------------------------------- describe("CookieConsentBanner — ESC key", () => { it("ESC in modal triggers Reject All semantics", async () => { const onConsentChange = vi.fn(); renderWithProviders( , ); fireEvent.keyDown(document, { key: "Escape" }); await waitFor(() => expect(onConsentChange).toHaveBeenCalledOnce()); const [states] = onConsentChange.mock.calls[0] as [ Array<{ category: string; state: string }>, ]; expect(states.find((s) => s.category === "analytics")?.state).toBe( "denied", ); }); it("ESC does not trigger Reject All for banner variant", async () => { const onConsentChange = vi.fn(); renderWithProviders( , ); fireEvent.keyDown(document, { key: "Escape" }); // Give async handler a chance to run if it were registered await new Promise((r) => setTimeout(r, 10)); expect(onConsentChange).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // CookieConsentBanner — Tab order // --------------------------------------------------------------------------- describe("CookieConsentBanner — Tab order", () => { it("Reject All precedes Accept All in DOM order (CNIL equal-prominence)", () => { renderWithProviders(); const rejectBtn = screen.getByRole("button", { name: "Reject All" }); const acceptBtn = screen.getByRole("button", { name: "Accept All" }); // DOCUMENT_POSITION_FOLLOWING (4) means acceptBtn follows rejectBtn in the DOM expect( rejectBtn.compareDocumentPosition(acceptBtn) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); }); it("Reject All precedes Accept All in banner variant too", () => { renderWithProviders(); const rejectBtn = screen.getByRole("button", { name: "Reject All" }); const acceptBtn = screen.getByRole("button", { name: "Accept All" }); expect( rejectBtn.compareDocumentPosition(acceptBtn) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); }); }); // --------------------------------------------------------------------------- // CookieConsentBanner — Modal focus trap // --------------------------------------------------------------------------- describe("CookieConsentBanner — Modal focus trap", () => { it("Tab on last focusable element wraps focus to first", () => { renderWithProviders(); const dialog = screen.getByRole("dialog"); const focusable = Array.from( dialog.querySelectorAll( 'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])', ), ); const first = focusable[0]; const last = focusable[focusable.length - 1]; expect(first).toBeDefined(); expect(last).toBeDefined(); last!.focus(); fireEvent.keyDown(document, { key: "Tab", shiftKey: false }); expect(document.activeElement).toBe(first); }); it("Shift+Tab on first focusable element wraps focus to last", () => { renderWithProviders(); const dialog = screen.getByRole("dialog"); const focusable = Array.from( dialog.querySelectorAll( 'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])', ), ); const first = focusable[0]; const last = focusable[focusable.length - 1]; first!.focus(); fireEvent.keyDown(document, { key: "Tab", shiftKey: true }); expect(document.activeElement).toBe(last); }); it("focus is contained within the dialog after Tab on last element", () => { renderWithProviders(); const dialog = screen.getByRole("dialog"); const focusable = Array.from( dialog.querySelectorAll( 'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])', ), ); focusable[focusable.length - 1]!.focus(); fireEvent.keyDown(document, { key: "Tab", shiftKey: false }); expect(dialog.contains(document.activeElement)).toBe(true); }); }); // --------------------------------------------------------------------------- // CookieConsentBanner — render-prop overrides // --------------------------------------------------------------------------- describe("CookieConsentBanner — render-prop overrides", () => { it("renderHeader replaces the default heading", () => { renderWithProviders( (
{title}
)} />, ); expect(screen.getByTestId("custom-header")).toBeInTheDocument(); expect(screen.queryByRole("heading")).not.toBeInTheDocument(); }); it("renderActions replaces the default action buttons", () => { renderWithProviders( ( )} />, ); const customReject = screen.getByTestId("custom-reject"); expect(customReject).toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Reject All" }), ).not.toBeInTheDocument(); }); it("renderCategoryRow replaces the default category row", () => { renderWithProviders( (
{category.label}
)} />, ); expect(screen.getByTestId("custom-row-analytics")).toBeInTheDocument(); expect(screen.queryByRole("switch")).not.toBeInTheDocument(); }); }); // --------------------------------------------------------------------------- // CookieConsentBanner — Accept All // --------------------------------------------------------------------------- describe("CookieConsentBanner — Accept All", () => { it("fires onConsentChange with all categories granted", async () => { const onConsentChange = vi.fn(); renderWithProviders( , ); await userEvent.click(screen.getByRole("button", { name: "Accept All" })); await waitFor(() => expect(onConsentChange).toHaveBeenCalledOnce()); const [states] = onConsentChange.mock.calls[0] as [ Array<{ category: string; state: string }>, ]; expect(states.every((s) => s.state === "granted")).toBe(true); }); }); // --------------------------------------------------------------------------- // CookieConsentBanner — ARIA roles // --------------------------------------------------------------------------- describe("CookieConsentBanner — ARIA roles", () => { it("modal variant renders a dialog element", () => { renderWithProviders(); expect(screen.getByRole("dialog")).toBeInTheDocument(); }); it("banner variant renders a region element", () => { renderWithProviders(); expect( screen.getByRole("region", { name: "Cookie Preferences" }), ).toBeInTheDocument(); }); it("essential toggle is disabled (non-toggleable)", () => { renderWithProviders(); const essential = screen.getByRole("switch", { name: "Essential cookies" }); expect(essential).toBeDisabled(); }); }); // --------------------------------------------------------------------------- // CookieConsentBanner — cookie initialisation // --------------------------------------------------------------------------- describe("CookieConsentBanner — cookie initialisation", () => { it("initialises toggles from an existing __consent_state cookie", () => { writeConsentCookie({ _v: 1, categories: { essential: true, analytics: true, functional: false, marketing: false, }, }); renderWithProviders(); expect( screen.getByRole("switch", { name: "Analytics cookies" }), ).toHaveAttribute("aria-checked", "true"); expect( screen.getByRole("switch", { name: "Functional cookies" }), ).toHaveAttribute("aria-checked", "false"); }); }); // --------------------------------------------------------------------------- // CookieConsentBanner — ConsentProvider integration // --------------------------------------------------------------------------- describe("CookieConsentBanner — ConsentProvider integration", () => { it("reads initial state from consent when inside ConsentProvider (no cookie)", async () => { const consent = new InMemoryConsent(); await consent.grant("analytics"); renderWithProviders( , ); // analytics was pre-granted, should show as checked expect( screen.getByRole("switch", { name: "Analytics cookies" }), ).toHaveAttribute("aria-checked", "true"); }); it("calls consent.grant for each granted category on Accept All", async () => { const consent = new InMemoryConsent(); const grantSpy = vi.spyOn(consent, "grant"); const onConsentChange = vi.fn(); renderWithProviders( , ); await userEvent.click(screen.getByRole("button", { name: "Accept All" })); await waitFor(() => expect(onConsentChange).toHaveBeenCalledOnce()); expect(grantSpy).toHaveBeenCalledWith( "analytics", expect.objectContaining({ method: "banner-accept" }), ); }); it("calls consent.withdraw for denied categories on Reject All", async () => { const consent = new InMemoryConsent(); await consent.grant("analytics"); // pre-grant so withdraw is called const withdrawSpy = vi.spyOn(consent, "withdraw"); renderWithProviders( , ); await userEvent.click(screen.getByRole("button", { name: "Reject All" })); await waitFor(() => expect(withdrawSpy).toHaveBeenCalledWith("analytics")); }); }); // --------------------------------------------------------------------------- // CookieConsentBannerLoader // --------------------------------------------------------------------------- describe("CookieConsentBannerLoader", () => { it("mounts the banner client-side after hydration", async () => { renderWithProviders(); // After mounting (useEffect fires), the banner should appear await waitFor(() => { expect(screen.getByRole("dialog")).toBeInTheDocument(); }); }); });