442 lines
15 KiB
TypeScript
442 lines
15 KiB
TypeScript
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import type { ComponentProps } from "react";
|
|
import { openCookiePreferences } from "@/lib/analytics";
|
|
import GoogleAnalytics from "./GoogleAnalytics";
|
|
|
|
/**
|
|
* Testes de caminhos-base de GoogleAnalytics (complemento de GoogleAnalytics.test.tsx):
|
|
* modo de desenvolvimento, teclas da barra, reaceite, nova concessão depois de revogar,
|
|
* cookies de analytics x outros cookies, reaproveitamento de gtag/dataLayer e recuperação de falha de gravação.
|
|
*/
|
|
|
|
jest.mock("next/script", () => ({
|
|
__esModule: true,
|
|
default: ({
|
|
strategy: _strategy,
|
|
...props
|
|
}: ComponentProps<"script"> & { strategy?: string }) => <script {...props} />,
|
|
}));
|
|
|
|
const CONSENT_KEY = "evandro.analytics-consent.v1";
|
|
const GA_DISABLE_KEY = "ga-disable-G-QTT9QT0T3S";
|
|
|
|
type AnalyticsWindow = Window & {
|
|
dataLayer?: IArguments[];
|
|
gtag?: (...args: unknown[]) => void;
|
|
[GA_DISABLE_KEY]?: boolean;
|
|
};
|
|
|
|
const analyticsWindow = window as AnalyticsWindow;
|
|
|
|
function setCookie(value: string) {
|
|
// biome-ignore lint/suspicious/noDocumentCookie: JSDOM cookie setup is the behavior boundary exercised by revocation.
|
|
document.cookie = value;
|
|
}
|
|
|
|
function expireCookie(name: string) {
|
|
setCookie(`${name}=; Max-Age=0; Path=/`);
|
|
}
|
|
|
|
const COOKIES = ["_ga", "_ga_ABC123", "_gid", "_gat", "portfolio_session"];
|
|
|
|
function commands() {
|
|
return (analyticsWindow.dataLayer ?? []).map((entry) => Array.from(entry));
|
|
}
|
|
|
|
function commandsNamed(...names: string[]) {
|
|
return commands().filter((entry) =>
|
|
names.every((name, index) => entry[index] === name),
|
|
);
|
|
}
|
|
|
|
function OpenPreferencesButton() {
|
|
return (
|
|
<button type="button" onClick={openCookiePreferences}>
|
|
Preferências de cookies
|
|
</button>
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.replaceProperty(process.env, "NODE_ENV", "production");
|
|
document.getElementById("google-analytics-script")?.remove();
|
|
localStorage.clear();
|
|
for (const name of COOKIES) expireCookie(name);
|
|
analyticsWindow.dataLayer = undefined;
|
|
analyticsWindow.gtag = undefined;
|
|
analyticsWindow[GA_DISABLE_KEY] = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
document.getElementById("google-analytics-script")?.remove();
|
|
for (const name of COOKIES) expireCookie(name);
|
|
});
|
|
|
|
describe("GoogleAnalytics: modo e teclas", () => {
|
|
it("path: outside production -> the cookie bar and the choice are still there (dev and production behave the same), but the gtag script is never loaded", async () => {
|
|
jest.replaceProperty(process.env, "NODE_ENV", "development");
|
|
const user = userEvent.setup();
|
|
|
|
render(<GoogleAnalytics />);
|
|
|
|
// a barra (e o foco por Tab) é a mesma da produção
|
|
const dialog = await screen.findByRole("dialog", {
|
|
name: "Cookies e privacidade",
|
|
});
|
|
expect(dialog).toBeVisible();
|
|
expect(
|
|
document.documentElement.style.getPropertyValue("--cookie-bar-height"),
|
|
).not.toBe("");
|
|
await user.click(screen.getByRole("button", { name: "Aceitar" }));
|
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
// mesmo com o consentimento, fora da produção o gtag não é baixado
|
|
await act(async () => {});
|
|
expect(screen.queryByTestId("google-analytics-script")).toBeNull();
|
|
expect(document.getElementById("google-analytics-script")).toBeNull();
|
|
});
|
|
|
|
it("path: the same consent in production loads the gtag script (the only thing tied to NODE_ENV)", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<GoogleAnalytics />);
|
|
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
|
|
|
|
expect(
|
|
await screen.findByTestId("google-analytics-script"),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("path: Escape on the first-visit bar -> bar stays and no choice is stored", async () => {
|
|
const user = userEvent.setup();
|
|
render(<GoogleAnalytics />);
|
|
const accept = await screen.findByRole("button", { name: "Aceitar" });
|
|
accept.focus();
|
|
|
|
await user.keyboard("{Escape}");
|
|
|
|
expect(screen.getByRole("dialog")).toBeVisible();
|
|
expect(localStorage.getItem(CONSENT_KEY)).toBeNull();
|
|
expect(screen.queryByText(/Escolha atual/)).toBeNull();
|
|
});
|
|
|
|
it("path: another key on the reopened bar -> stays open", async () => {
|
|
const user = userEvent.setup();
|
|
localStorage.setItem(CONSENT_KEY, "denied");
|
|
render(
|
|
<>
|
|
<OpenPreferencesButton />
|
|
<GoogleAnalytics />
|
|
</>,
|
|
);
|
|
await act(async () => {});
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
|
);
|
|
|
|
await user.keyboard("x{Tab}");
|
|
|
|
expect(screen.queryByRole("dialog")).not.toBeNull();
|
|
});
|
|
|
|
it("path: reopened with a refusal stored -> the bar announces 'recusado'", async () => {
|
|
const user = userEvent.setup();
|
|
localStorage.setItem(CONSENT_KEY, "denied");
|
|
render(
|
|
<>
|
|
<OpenPreferencesButton />
|
|
<GoogleAnalytics />
|
|
</>,
|
|
);
|
|
await act(async () => {});
|
|
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
|
);
|
|
|
|
expect(screen.getByRole("dialog")).toHaveAccessibleDescription(
|
|
/Escolha atual: recusado/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("GoogleAnalytics: aceitar e recusar", () => {
|
|
it("path: reopened while already granted, 'Aceitar' only closes the bar", async () => {
|
|
const user = userEvent.setup();
|
|
localStorage.setItem(CONSENT_KEY, "granted");
|
|
analyticsWindow.gtag = jest.fn();
|
|
render(
|
|
<>
|
|
<OpenPreferencesButton />
|
|
<GoogleAnalytics />
|
|
</>,
|
|
);
|
|
await screen.findByTestId("google-analytics-script");
|
|
const gtag = analyticsWindow.gtag as jest.Mock;
|
|
const callsAfterMount = gtag.mock.calls.length;
|
|
const opener = screen.getByRole("button", {
|
|
name: "Preferências de cookies",
|
|
});
|
|
|
|
await user.click(opener);
|
|
await user.click(screen.getByRole("button", { name: "Aceitar" }));
|
|
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
expect(gtag.mock.calls).toHaveLength(callsAfterMount);
|
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
|
|
expect(screen.getByTestId("google-analytics-script")).toBeInTheDocument();
|
|
expect(opener).toHaveFocus();
|
|
});
|
|
|
|
it("path: granting again after a revocation -> bootstrap runs once, GA is re-enabled", async () => {
|
|
const user = userEvent.setup();
|
|
localStorage.setItem(CONSENT_KEY, "granted");
|
|
render(
|
|
<>
|
|
<OpenPreferencesButton />
|
|
<GoogleAnalytics />
|
|
</>,
|
|
);
|
|
await screen.findByTestId("google-analytics-script");
|
|
const opener = screen.getByRole("button", {
|
|
name: "Preferências de cookies",
|
|
});
|
|
|
|
await user.click(opener);
|
|
await user.click(screen.getByRole("button", { name: "Recusar" }));
|
|
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
|
|
expect(screen.queryByTestId("google-analytics-script")).toBeNull();
|
|
|
|
await user.click(opener);
|
|
await user.click(screen.getByRole("button", { name: "Aceitar" }));
|
|
|
|
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(false);
|
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
|
|
expect(screen.getByTestId("google-analytics-script")).toBeInTheDocument();
|
|
// "default" e "js" só na primeira ativação; a configuração dos dois destinos roda a cada concessão
|
|
expect(commandsNamed("consent", "default")).toHaveLength(1);
|
|
expect(commandsNamed("js")).toHaveLength(1);
|
|
expect(commandsNamed("config", "G-QTT9QT0T3S")).toHaveLength(2);
|
|
expect(commandsNamed("config", "AW-18432694647")).toHaveLength(2);
|
|
});
|
|
|
|
it("path: first visit refused -> nothing is revoked because nothing was granted", async () => {
|
|
const user = userEvent.setup();
|
|
setCookie("_ga=keep-me; Path=/");
|
|
const cookieWrites = jest.spyOn(document, "cookie", "set");
|
|
render(<GoogleAnalytics />);
|
|
|
|
await user.click(await screen.findByRole("button", { name: "Recusar" }));
|
|
|
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
|
|
expect(analyticsWindow[GA_DISABLE_KEY]).toBeUndefined();
|
|
expect(analyticsWindow.dataLayer).toBeUndefined();
|
|
expect(cookieWrites).not.toHaveBeenCalled();
|
|
expect(document.cookie).toContain("_ga=keep-me");
|
|
});
|
|
|
|
it("path: revoking after gtag disappeared -> still persists, disables GA and does not throw", async () => {
|
|
const user = userEvent.setup();
|
|
localStorage.setItem(CONSENT_KEY, "granted");
|
|
render(
|
|
<>
|
|
<OpenPreferencesButton />
|
|
<GoogleAnalytics />
|
|
</>,
|
|
);
|
|
await screen.findByTestId("google-analytics-script");
|
|
delete analyticsWindow.gtag;
|
|
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Recusar" }));
|
|
|
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("denied");
|
|
expect(analyticsWindow[GA_DISABLE_KEY]).toBe(true);
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("GoogleAnalytics: cookies na revogação", () => {
|
|
it("path: only _ga and _ga_* are expired; look-alikes and other cookies stay", async () => {
|
|
const user = userEvent.setup();
|
|
localStorage.setItem(CONSENT_KEY, "granted");
|
|
for (const name of COOKIES) setCookie(`${name}=1; Path=/`);
|
|
render(
|
|
<>
|
|
<OpenPreferencesButton />
|
|
<GoogleAnalytics />
|
|
</>,
|
|
);
|
|
await screen.findByTestId("google-analytics-script");
|
|
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Recusar" }));
|
|
|
|
const remaining = document.cookie.split(";").map((c) => c.trim());
|
|
expect(remaining).toEqual(
|
|
expect.arrayContaining(["_gid=1", "_gat=1", "portfolio_session=1"]),
|
|
);
|
|
expect(remaining.some((c) => c.startsWith("_ga="))).toBe(false);
|
|
expect(remaining.some((c) => c.startsWith("_ga_ABC123="))).toBe(false);
|
|
});
|
|
|
|
it("path: no analytics cookie present -> revocation writes no cookie", async () => {
|
|
const user = userEvent.setup();
|
|
localStorage.setItem(CONSENT_KEY, "granted");
|
|
setCookie("portfolio_session=1; Path=/");
|
|
render(
|
|
<>
|
|
<OpenPreferencesButton />
|
|
<GoogleAnalytics />
|
|
</>,
|
|
);
|
|
await screen.findByTestId("google-analytics-script");
|
|
const cookieWrites = jest.spyOn(document, "cookie", "set");
|
|
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Recusar" }));
|
|
|
|
expect(cookieWrites).not.toHaveBeenCalled();
|
|
expect(document.cookie).toContain("portfolio_session=1");
|
|
});
|
|
|
|
it("path: host is not the site domain (localhost) -> expiry is written without a Domain attribute", async () => {
|
|
const user = userEvent.setup();
|
|
localStorage.setItem(CONSENT_KEY, "granted");
|
|
setCookie("_ga=1; Path=/");
|
|
render(
|
|
<>
|
|
<OpenPreferencesButton />
|
|
<GoogleAnalytics />
|
|
</>,
|
|
);
|
|
await screen.findByTestId("google-analytics-script");
|
|
const cookieWrites = jest.spyOn(document, "cookie", "set");
|
|
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Preferências de cookies" }),
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Recusar" }));
|
|
|
|
expect(cookieWrites).toHaveBeenCalledTimes(1);
|
|
expect(cookieWrites.mock.calls[0][0]).toMatch(/^_ga=; Max-Age=0;/);
|
|
expect(cookieWrites.mock.calls[0][0]).not.toContain("Domain=");
|
|
});
|
|
});
|
|
|
|
describe("GoogleAnalytics: gtag, dataLayer e carregador", () => {
|
|
it("path: dataLayer already exists -> the same array receives the commands", async () => {
|
|
const user = userEvent.setup();
|
|
const existing: IArguments[] = [];
|
|
analyticsWindow.dataLayer = existing;
|
|
render(<GoogleAnalytics />);
|
|
|
|
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
|
|
|
|
expect(analyticsWindow.dataLayer).toBe(existing);
|
|
expect(existing.length).toBeGreaterThan(0);
|
|
expect(typeof analyticsWindow.gtag).toBe("function");
|
|
});
|
|
|
|
it("path: a gtag already exists -> it is reused instead of being replaced", async () => {
|
|
const user = userEvent.setup();
|
|
const gtag = jest.fn();
|
|
analyticsWindow.gtag = gtag;
|
|
render(<GoogleAnalytics />);
|
|
|
|
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
|
|
|
|
expect(analyticsWindow.gtag).toBe(gtag);
|
|
expect(gtag).toHaveBeenCalledWith("js", expect.any(Date));
|
|
expect(analyticsWindow.dataLayer).toBeDefined();
|
|
});
|
|
|
|
it.each([
|
|
["denied", "denied"],
|
|
["no choice yet", null],
|
|
])(
|
|
"path: %s -> a loader left by next/script is removed",
|
|
async (_label, stored) => {
|
|
if (stored) localStorage.setItem(CONSENT_KEY, stored);
|
|
const loader = document.createElement("script");
|
|
loader.id = "google-analytics-script";
|
|
document.head.append(loader);
|
|
|
|
render(<GoogleAnalytics />);
|
|
await act(async () => {});
|
|
|
|
expect(document.getElementById("google-analytics-script")).toBeNull();
|
|
},
|
|
);
|
|
|
|
it("path: granted -> the loader stays", async () => {
|
|
localStorage.setItem(CONSENT_KEY, "granted");
|
|
const loader = document.createElement("script");
|
|
loader.id = "google-analytics-script";
|
|
document.head.append(loader);
|
|
|
|
render(<GoogleAnalytics />);
|
|
await act(async () => {});
|
|
|
|
expect(document.getElementById("google-analytics-script")).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("GoogleAnalytics: foco e falha de gravação", () => {
|
|
it("path: opener is not an HTML element (SVG) -> closing the bar has no element to refocus", async () => {
|
|
const user = userEvent.setup();
|
|
localStorage.setItem(CONSENT_KEY, "denied");
|
|
render(
|
|
<>
|
|
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: elemento SVG de teste só para receber o foco */}
|
|
<svg data-testid="svg-opener" tabIndex={0} />
|
|
<GoogleAnalytics />
|
|
</>,
|
|
);
|
|
await act(async () => {});
|
|
const svg = screen.getByTestId("svg-opener");
|
|
svg.focus();
|
|
expect(document.activeElement).toBe(svg);
|
|
|
|
act(() => openCookiePreferences());
|
|
await user.keyboard("{Escape}");
|
|
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
expect(document.activeElement).not.toBe(svg);
|
|
});
|
|
|
|
it("path: acceptance fails to persist, then succeeds -> the alert goes away and analytics turns on", async () => {
|
|
const user = userEvent.setup();
|
|
const setItem = jest
|
|
.spyOn(Storage.prototype, "setItem")
|
|
.mockImplementation(() => {
|
|
throw new Error("storage blocked");
|
|
});
|
|
render(<GoogleAnalytics />);
|
|
|
|
await user.click(await screen.findByRole("button", { name: "Aceitar" }));
|
|
expect(
|
|
screen.getByRole("alert", { name: "Falha ao salvar o consentimento" }),
|
|
).toHaveTextContent(
|
|
"Não foi possível salvar sua aceitação. Tente novamente para concluir.",
|
|
);
|
|
expect(screen.getByRole("dialog")).toBeVisible();
|
|
expect(analyticsWindow.dataLayer).toBeUndefined();
|
|
|
|
setItem.mockRestore();
|
|
await user.click(screen.getByRole("button", { name: "Aceitar" }));
|
|
|
|
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
|
|
expect(localStorage.getItem(CONSENT_KEY)).toBe("granted");
|
|
expect(screen.getByTestId("google-analytics-script")).toBeInTheDocument();
|
|
});
|
|
});
|