Implements the EU-compliant cookie consent banner with: - modal + banner variants, CNIL equal-prominence Reject/Accept buttons - granular category toggles (essential non-toggleable) - __consent_state cookie management (SameSite=Lax, Secure, 1-yr, _v:1) - render-prop overrides: renderHeader, renderCategoryRow, renderActions - useConsent() integration when ConsentProvider is present - CookieConsentBannerLoader SSR-safe wrapper - RTL behavioral tests: Reject All, Save Selected, ESC=Reject, focus-trap - Storybook stories for modal, banner, render-prop, and a11y tab demo - jsdom configured with HTTPS origin for Secure cookie testing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
455 lines
16 KiB
TypeScript
455 lines
16 KiB
TypeScript
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(
|
|
<CookieConsentBanner variant="modal" onConsentChange={onConsentChange} />,
|
|
);
|
|
|
|
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(
|
|
<CookieConsentBanner variant="modal" onConsentChange={onConsentChange} />,
|
|
);
|
|
|
|
// 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(
|
|
<CookieConsentBanner variant="modal" onConsentChange={onConsentChange} />,
|
|
);
|
|
|
|
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(
|
|
<CookieConsentBanner
|
|
variant="banner"
|
|
onConsentChange={onConsentChange}
|
|
/>,
|
|
);
|
|
|
|
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(<CookieConsentBanner variant="modal" />);
|
|
|
|
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(<CookieConsentBanner variant="banner" />);
|
|
|
|
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(<CookieConsentBanner variant="modal" />);
|
|
|
|
const dialog = screen.getByRole("dialog");
|
|
const focusable = Array.from(
|
|
dialog.querySelectorAll<HTMLElement>(
|
|
'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(<CookieConsentBanner variant="modal" />);
|
|
|
|
const dialog = screen.getByRole("dialog");
|
|
const focusable = Array.from(
|
|
dialog.querySelectorAll<HTMLElement>(
|
|
'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(<CookieConsentBanner variant="modal" />);
|
|
|
|
const dialog = screen.getByRole("dialog");
|
|
const focusable = Array.from(
|
|
dialog.querySelectorAll<HTMLElement>(
|
|
'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(
|
|
<CookieConsentBanner
|
|
variant="modal"
|
|
renderHeader={({ title }) => (
|
|
<div data-testid="custom-header">{title}</div>
|
|
)}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByTestId("custom-header")).toBeInTheDocument();
|
|
expect(screen.queryByRole("heading")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renderActions replaces the default action buttons", () => {
|
|
renderWithProviders(
|
|
<CookieConsentBanner
|
|
variant="modal"
|
|
renderActions={({ onRejectAll: reject }) => (
|
|
<button onClick={reject} data-testid="custom-reject">
|
|
Custom Reject
|
|
</button>
|
|
)}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<CookieConsentBanner
|
|
variant="modal"
|
|
renderCategoryRow={({ category }) => (
|
|
<div data-testid={`custom-row-${category.id}`}>{category.label}</div>
|
|
)}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<CookieConsentBanner variant="modal" onConsentChange={onConsentChange} />,
|
|
);
|
|
|
|
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(<CookieConsentBanner variant="modal" />);
|
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
});
|
|
|
|
it("banner variant renders a region element", () => {
|
|
renderWithProviders(<CookieConsentBanner variant="banner" />);
|
|
expect(
|
|
screen.getByRole("region", { name: "Cookie Preferences" }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("essential toggle is disabled (non-toggleable)", () => {
|
|
renderWithProviders(<CookieConsentBanner variant="modal" />);
|
|
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(<CookieConsentBanner variant="modal" />);
|
|
|
|
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(
|
|
<ConsentProvider value={consent}>
|
|
<CookieConsentBanner variant="modal" />
|
|
</ConsentProvider>,
|
|
);
|
|
|
|
// 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(
|
|
<ConsentProvider value={consent}>
|
|
<CookieConsentBanner
|
|
variant="modal"
|
|
onConsentChange={onConsentChange}
|
|
/>
|
|
</ConsentProvider>,
|
|
);
|
|
|
|
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(
|
|
<ConsentProvider value={consent}>
|
|
<CookieConsentBanner variant="modal" />
|
|
</ConsentProvider>,
|
|
);
|
|
|
|
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(<CookieConsentBannerLoader variant="modal" />);
|
|
|
|
// After mounting (useEffect fires), the banner should appear
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|