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