diff --git a/src/components/turnstile-widget.test.tsx b/src/components/turnstile-widget.test.tsx new file mode 100644 index 0000000..4b401e7 --- /dev/null +++ b/src/components/turnstile-widget.test.tsx @@ -0,0 +1,126 @@ +/** + * Regressionstests fuer das Turnstile-Widget. + * + * Der behobene Fehler war unauffaellig und folgenschwer zugleich: das Widget + * erschien nicht, es gab also kein Token, und ohne Token weist der Server das + * Formular ab — Anmeldung, Registrierung und Checkout waren blockiert, ohne + * dass irgendwo eine Fehlermeldung stand. Ausgeloest wurde er dadurch, dass + * next/script sein onLoad fuer eine bereits geladene Quelle nicht erneut + * ausfuehrt: das ZWEITE Widget einer Seite und jedes Widget nach einer + * Navigation im Seitenverlauf warteten auf ein Ereignis, das nie kam. + * + * Diese Tests halten genau das fest: ein Widget muss sich auch dann aufbauen, + * wenn das Skript laengst geladen ist. + */ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { TurnstileWidget } from "./turnstile-widget"; +import { I18nProvider } from "@/i18n/i18n-provider"; + +const SITE_KEY = "1x00000000000000000000AA"; + +// Mit Signatur, damit mock.calls[0][1] (die Optionen) typisiert ist. +const turnstileRender = + vi.fn<(container: HTMLElement, options: Record) => string>(() => "widget-1"); +const turnstileRemove = vi.fn(); + +function mountWidget(onVerify: (token: string) => void = vi.fn()) { + return render( + + + + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + delete window.turnstile; + document.querySelectorAll("script").forEach((script) => script.remove()); +}); + +describe("TurnstileWidget", () => { + test("baut sich auch dann auf, wenn das Skript schon geladen ist", async () => { + // Genau der Fall, der vorher stillschweigend nichts tat: window.turnstile + // existiert bereits (weil die Newsletter-Anmeldung im Footer das Skript + // auf praktisch jeder Seite geladen hat), ein onLoad kommt also nie mehr. + window.turnstile = { render: turnstileRender, remove: turnstileRemove }; + + mountWidget(); + + await waitFor(() => expect(turnstileRender).toHaveBeenCalledOnce()); + expect(turnstileRender.mock.calls[0][1]).toMatchObject({ sitekey: SITE_KEY }); + }); + + test("baut zwei Widgets auf derselben Seite unabhaengig voneinander auf", async () => { + // Die Kontoseite zeigt Anmeldung und Registrierung untereinander, jede mit + // eigenem Widget. Vorher bekam nur eine von beiden eines. + window.turnstile = { render: turnstileRender, remove: turnstileRemove }; + + render( + + + + + ); + + await waitFor(() => expect(turnstileRender).toHaveBeenCalledTimes(2)); + }); + + test("reicht das geloeste Token an das Formular weiter", async () => { + const onVerify = vi.fn(); + window.turnstile = { render: turnstileRender, remove: turnstileRemove }; + + mountWidget(onVerify); + await waitFor(() => expect(turnstileRender).toHaveBeenCalledOnce()); + + const options = turnstileRender.mock.calls[0][1] as Record void>; + options.callback("0.abc123"); + expect(onVerify).toHaveBeenCalledWith("0.abc123"); + + // Ein abgelaufenes Token muss das Formular ebenso erfahren, sonst schickt + // es ein laengst ungueltiges mit. + options["expired-callback"](""); + expect(onVerify).toHaveBeenLastCalledWith(""); + }); + + test("raeumt das Widget beim Verlassen der Seite wieder ab", async () => { + window.turnstile = { render: turnstileRender, remove: turnstileRemove }; + + const { unmount } = mountWidget(); + await waitFor(() => expect(turnstileRender).toHaveBeenCalledOnce()); + + unmount(); + expect(turnstileRemove).toHaveBeenCalledWith("widget-1"); + }); + + test("zeigt einen Hinweis mit Wiederholung, wenn das Skript nicht laedt", async () => { + // jsdom laedt keine externen Skripte — der Fehlerfall wird ausgeloest, + // indem auf dem eingehaengten Element ein "error" ausgeloest wird. + const appendChild = vi + .spyOn(document.head, "appendChild") + .mockImplementation(((node: T): T => { + queueMicrotask(() => node.dispatchEvent(new Event("error"))); + return node; + }) as typeof document.head.appendChild); + + mountWidget(); + + // Der Hinweis ist der eigentliche Fortschritt: vorher blieb an dieser + // Stelle schlicht eine leere Flaeche stehen. + const retry = await screen.findByRole("button", { name: /erneut versuchen/i }); + expect(screen.getByText(/sicherheitsprüfung konnte nicht geladen werden/i)).toBeInTheDocument(); + + // Wiederholung mit inzwischen verfuegbarem Skript: das Widget kommt. + appendChild.mockRestore(); + window.turnstile = { render: turnstileRender, remove: turnstileRemove }; + await userEvent.click(retry); + + await waitFor(() => expect(turnstileRender).toHaveBeenCalledOnce()); + }); +}); diff --git a/src/components/turnstile-widget.tsx b/src/components/turnstile-widget.tsx index 5a79fab..d2ccd1d 100644 --- a/src/components/turnstile-widget.tsx +++ b/src/components/turnstile-widget.tsx @@ -1,16 +1,97 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import Script from "next/script"; +import { useI18n } from "@/i18n/i18n-provider"; + +interface TurnstileApi { + render: (container: HTMLElement, options: Record) => string; + remove: (widgetId: string) => void; +} declare global { interface Window { - turnstile?: { - render: (container: HTMLElement, options: Record) => string; - }; + turnstile?: TurnstileApi; } } +// "render=explicit" schaltet die automatische Suche nach .cf-turnstile- +// Elementen ab: dieses Widget wird ausschliesslich von Hand gerendert, damit +// wir die Widget-ID kennen und es beim Verlassen wieder entfernen koennen. +const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"; + +/** + * Laedt das Turnstile-Skript je Seitenbesuch genau einmal; alle Widgets + * teilen sich dieses eine Versprechen. + * + * Zuvor uebernahm das