Files
Webklar/src/test/relaunch-layout.test.tsx
T
rootandClaude Opus 5.5 cbc149f9f8 Kontaktformular: Anfragen über die Kontakt-API, damit WEBklar per Mail davon erfährt
Bisher schrieb das Formular als Gast direkt in Appwrite, und niemand las die
Einträge. Jetzt geht es an POST /api/contact (webklar-webclaw-contact-api):
die API speichert in Appwrite und meldet jede Anfrage sofort an
support@webklar.com, Antworten gehen an die Absenderadresse.

- src/lib/contact.ts ersetzt src/lib/appwrite.ts, kein Appwrite-SDK mehr im Bundle
- Honeypot-Feld "website" (unsichtbar, nicht per Tab erreichbar)
- maxLength passend zu den Grenzen der API (120/200/200/4000)
- eigener Hinweis bei 429 (zu viele Anfragen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 17:53:02 +00:00

283 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, within, fireEvent, cleanup } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { AppRoutes } from "@/routes";
import { NAVIGATION } from "@/components/site/navigation";
import { projekte } from "@/data/projekte";
// Netzwerk gibt es in jsdom nicht: das Kontaktformular-Backend wird durch eine Attrappe ersetzt.
vi.mock("@/lib/contact", async (original) => ({
...(await original<typeof import("@/lib/contact")>()),
sendContactMessage: vi.fn().mockResolvedValue(undefined),
}));
// Dieselben Future-Flags wie der BrowserRouter in App.tsx, sonst warnt React Router in der Testausgabe.
const routerFuture = { v7_startTransition: true, v7_relativeSplatPath: true };
/** Alle Seiten im gemeinsamen Layout (src/routes.tsx). /datenschutz leitet nur weiter und hat kein Layout. */
const ROUTEN = ["/", "/kontakt", "/ueber-uns", "/agb", "/impressum", "/wartungs-abo", "/hosting-abo", "/gibt-es-nicht"];
const UNTERSEITEN = ROUTEN.slice(1);
const seite = (pfad: string) =>
render(
<MemoryRouter initialEntries={[pfad]} future={routerFuture}>
<AppRoutes />
</MemoryRouter>,
);
const lies = (pfad: string) => readFileSync(join(process.cwd(), pfad), "utf8");
afterEach(() => {
cleanup();
document.body.style.overflow = "";
});
describe.each(ROUTEN)("Layout auf %s", (pfad) => {
it("hat genau eine Kopfleiste mit der Hauptnavigation", () => {
seite(pfad);
const kopf = screen.getByRole("banner");
expect(screen.getAllByRole("banner")).toHaveLength(1);
const nav = within(kopf).getByRole("navigation", { name: "Hauptnavigation" });
for (const name of ["Kundenportal", "Was wir bauen", "Preise", "Über uns"]) {
expect(within(nav).getByRole("link", { name })).toBeInTheDocument();
}
// „Projekte“ gibt es nur mit freigegebenen Referenzen (src/data/projekte.ts)
expect(within(nav).queryByRole("link", { name: "Projekte" } as const) !== null).toBe(projekte.length > 0);
expect(within(kopf).getByRole("link", { name: /WEBklar – zur Startseite/ })).toHaveAttribute("href", "/");
expect(within(kopf).getAllByRole("link", { name: "Projekt besprechen" })[0]).toHaveAttribute("href", "/kontakt");
});
it("hat genau einen Footer mit Impressum, Datenschutz, AGB, Kündigen und Widerrufen", () => {
seite(pfad);
const footer = screen.getByRole("contentinfo");
expect(screen.getAllByRole("contentinfo")).toHaveLength(1);
for (const name of ["Impressum", "Datenschutz", "AGB", "Verträge hier kündigen", "Vertrag widerrufen"]) {
expect(within(footer).getByRole("link", { name })).toBeInTheDocument();
}
});
it("hat genau ein main (mit dem Sprungziel #inhalt) und genau ein h1", () => {
seite(pfad);
const inhalt = screen.getByRole("main");
expect(screen.getAllByRole("main")).toHaveLength(1);
expect(inhalt).toHaveAttribute("id", "inhalt");
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
expect(screen.getByRole("link", { name: "Zum Inhalt springen" })).toHaveAttribute("href", "#inhalt");
});
it("Kopfleiste und Footer stehen außerhalb von main, in der Reihenfolge Kopf, Inhalt, Footer", () => {
seite(pfad);
const kopf = screen.getByRole("banner");
const inhalt = screen.getByRole("main");
const footer = screen.getByRole("contentinfo");
expect(inhalt).not.toContainElement(kopf);
expect(inhalt).not.toContainElement(footer);
expect(kopf.compareDocumentPosition(inhalt) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(inhalt.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});
describe("Navigation", () => {
it("die Einträge der Kopfleiste verweisen auf die Anker der Startseite (Spezifikation 6.1)", () => {
seite("/");
const nav = within(screen.getByRole("banner")).getByRole("navigation", { name: "Hauptnavigation" });
const erwartet = NAVIGATION.map(({ label, to }) => [label, to]);
expect(erwartet).toEqual(
expect.arrayContaining([
["Kundenportal", "/#kundenportal"],
["Was wir bauen", "/#leistungen"],
["Preise", "/#preise"],
["Über uns", "/#ueber-uns"],
]),
);
for (const [name, ziel] of erwartet) {
expect(within(nav).getByRole("link", { name })).toHaveAttribute("href", ziel);
}
});
it("jede Unterseite lädt keine eigene Kopfzeile mit Zurück-Knopf mehr (alter glass-nav-Block)", () => {
for (const pfad of UNTERSEITEN) {
const { container } = seite(pfad);
expect(container.querySelector(".glass-nav"), pfad).toBeNull();
expect(screen.queryByRole("link", { name: "Zurück" }), pfad).toBeNull();
expect(screen.queryByRole("button", { name: "Zurück" }), pfad).toBeNull();
cleanup();
}
});
});
describe("Handy-Menü", () => {
it("der Menü-Knopf hat aria-expanded und öffnet und schließt das Menü", () => {
seite("/kontakt");
const knopf = screen.getByRole("button", { name: "Menü öffnen" });
expect(knopf).toHaveAttribute("aria-expanded", "false");
expect(knopf).toHaveAttribute("aria-controls");
expect(screen.queryByRole("navigation", { name: "Menü" })).toBeNull();
fireEvent.click(knopf);
const geoeffnet = screen.getByRole("button", { name: "Menü schließen" });
expect(geoeffnet).toHaveAttribute("aria-expanded", "true");
const menue = screen.getByRole("navigation", { name: "Menü" });
expect(document.getElementById(geoeffnet.getAttribute("aria-controls") as string)).toContainElement(menue);
for (const name of ["Kundenportal", "Was wir bauen", "Preise", "Über uns"]) {
expect(within(menue).getByRole("link", { name })).toBeInTheDocument();
}
// Seite ist gesperrt, solange das Menü offen ist
expect(document.body.style.overflow).toBe("hidden");
fireEvent.click(geoeffnet);
expect(screen.getByRole("button", { name: "Menü öffnen" })).toHaveAttribute("aria-expanded", "false");
expect(document.body.style.overflow).not.toBe("hidden");
});
it("Escape schließt das Menü und gibt den Fokus an den Knopf zurück", () => {
seite("/");
fireEvent.click(screen.getByRole("button", { name: "Menü öffnen" }));
expect(screen.getByRole("button", { name: "Menü schließen" })).toHaveAttribute("aria-expanded", "true");
fireEvent.keyDown(window, { key: "Escape" });
const knopf = screen.getByRole("button", { name: "Menü öffnen" });
expect(knopf).toHaveAttribute("aria-expanded", "false");
expect(knopf).toHaveFocus();
});
it("enthält den Button „Projekt besprechen“ und den Hell/Dunkel-Schalter; ein Klick auf einen Link schließt es", () => {
seite("/impressum");
fireEvent.click(screen.getByRole("button", { name: "Menü öffnen" }));
const menue = screen.getByRole("navigation", { name: "Menü" }).parentElement as HTMLElement;
expect(within(menue).getByRole("link", { name: "Projekt besprechen" })).toHaveAttribute("href", "/kontakt");
expect(within(menue).getByRole("button", { name: /Modus einschalten/ })).toBeInTheDocument();
fireEvent.click(within(menue).getByRole("link", { name: "Preise" }));
expect(screen.getByRole("button", { name: "Menü öffnen" })).toHaveAttribute("aria-expanded", "false");
});
it("Menü-Knopf und Schalter sind mindestens 44 × 44 px groß (Spezifikation 9.6)", () => {
const css = lies("src/components/site/site.css");
for (const klasse of [".wk-header__menuknopf", ".wk-icon-btn"]) {
const block = css.match(new RegExp(`${klasse.replace(".", "\\.")}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
expect(block, klasse).toMatch(/width:\s*44px/);
expect(block, klasse).toMatch(/height:\s*44px/);
}
});
});
describe("/kontakt im neuen Layout", () => {
it("hat den Datenschutzhinweis mit Link und nennt das Wort »Potenzialanalyse« nirgends", () => {
seite("/kontakt");
const formular = screen.getByRole("button", { name: "Nachricht senden" }).closest("form") as HTMLFormElement;
expect(formular).toHaveTextContent(
"Wir verwenden Ihre Angaben nur, um Ihre Anfrage zu beantworten. Mehr dazu in unserer Datenschutzerklärung.",
);
expect(within(formular).getByRole("link", { name: "Datenschutzerklärung" })).toHaveAttribute(
"href",
"https://project.webklar.com/datenschutz.html",
);
expect(document.body).not.toHaveTextContent(/Potenzialanalyse/);
});
it("Felder haben Beschriftung, passendes autocomplete und mindestens 16 px Schrift", () => {
seite("/kontakt");
expect(screen.getByLabelText(/^Name/)).toHaveAttribute("autocomplete", "name");
expect(screen.getByLabelText(/^E-Mail/)).toHaveAttribute("autocomplete", "email");
expect(screen.getByLabelText(/^E-Mail/)).toHaveAttribute("type", "email");
expect(screen.getByLabelText(/^Unternehmen/)).toHaveAttribute("autocomplete", "organization");
for (const name of [/^Name/, /^E-Mail/, /^Nachricht/]) expect(screen.getByLabelText(name)).toBeRequired();
expect(screen.getByLabelText(/^Unternehmen/)).not.toBeRequired();
// iOS zoomt bei weniger als 16 px: rem-Wert der Felder prüfen
const css = lies("src/pages/unterseiten.css");
const block = css.match(/\.wk-feld input,\s*\.wk-feld textarea\s*\{([^}]*)\}/)?.[1] ?? "";
const rem = Number(block.match(/font-size:\s*([\d.]+)rem/)?.[1]);
expect(rem * 16).toBeGreaterThanOrEqual(16);
});
it("Senden-Knopf ist ein wk-btn, E-Mail und Telefon stehen genau einmal da", () => {
seite("/kontakt");
expect(screen.getByRole("button", { name: "Nachricht senden" })).toHaveClass("wk-btn", "wk-btn--primary");
const haupt = screen.getByRole("main");
expect(within(haupt).getAllByRole("link", { name: "support@webklar.com" })).toHaveLength(1);
const telefon = within(haupt).getAllByRole("link", { name: "0176 23726355" });
expect(telefon).toHaveLength(1);
expect(telefon[0]).toHaveAttribute("href", "tel:+4917623726355");
expect(within(haupt).getByRole("link", { name: "support@webklar.com" })).toHaveAttribute(
"href",
"mailto:support@webklar.com",
);
});
it("die Rückmeldung steht in einem aria-live-Bereich, der schon vor dem Absenden im Dokument ist", () => {
seite("/kontakt");
const bereich = document.querySelector("[aria-live]");
expect(bereich).not.toBeNull();
expect(bereich).toBeEmptyDOMElement();
expect(bereich).toHaveAttribute("aria-live", "polite");
});
});
describe("Quelltext der Unterseiten", () => {
const SEITEN = ["Contact", "About", "NotFound", "AGB", "Impressum", "WartungsAbo", "HostingAbo"].map((name) => ({
name,
text: lies(`src/pages/${name}.tsx`),
}));
it.each(SEITEN)("$name: kein eigenes main, keine eigene Kopfleiste, keine Versalien, keine Alt-Buttons, keine Effekte", ({ text }) => {
expect(text).not.toMatch(/<main\b/);
expect(text).not.toMatch(/<header\b/);
expect(text).not.toMatch(/\buppercase\b|tracking-(wider|widest)|font-display|label-tag/);
expect(text).not.toMatch(/glass-nav|btn-minimal|btn-wrapper|className="btn\b/);
expect(text).not.toMatch(/BorderGlow|CountUp|FlickeringGrid|PixelBlast|LightRays|Silk/);
// Seitenrahmen kommt aus dem neuen Layout
expect(text).toMatch(/wk-seite/);
});
it("die Seiten tragen ihr Stylesheet selbst und laden nichts von fremden Servern", () => {
const css = lies("src/pages/unterseiten.css");
expect(css).not.toMatch(/https?:\/\/|@import/);
for (const { name, text } of SEITEN) expect(text, name).toContain('import "./unterseiten.css"');
});
it("Bewegung nur über transform und opacity, Hover nur mit feinem Zeiger", () => {
const css = lies("src/pages/unterseiten.css");
// alle Übergänge der Unterseiten betreffen nur Farben (kein Layout)
for (const [, wert] of css.matchAll(/transition:\s*([^;]+);/g)) {
expect(wert).toMatch(/^(border-color|text-decoration-color)\b/);
}
// oberste Blöcke (Regel oder @media) zerlegen; :hover darf nur in @media (hover: hover) and (pointer: fine) stehen
const bloecke: { kopf: string; inhalt: string }[] = [];
let tiefe = 0;
let start = 0;
let kopfStart = 0;
let kopf = "";
const ohneKommentare = css.replace(/\/\*[\s\S]*?\*\//g, "");
for (let i = 0; i < ohneKommentare.length; i++) {
const z = ohneKommentare[i];
if (z === "{") {
if (tiefe === 0) {
kopf = ohneKommentare.slice(kopfStart, i).trim();
start = i + 1;
}
tiefe++;
} else if (z === "}") {
tiefe--;
if (tiefe === 0) {
bloecke.push({ kopf, inhalt: ohneKommentare.slice(start, i) });
kopfStart = i + 1;
}
}
}
expect(bloecke.length).toBeGreaterThan(20);
let hoverRegeln = 0;
for (const { kopf: k, inhalt } of bloecke) {
const text = `${k}${inhalt}`;
if (!text.includes(":hover")) continue;
hoverRegeln++;
expect(k, `${k} enthält :hover`).toBe("@media (hover: hover) and (pointer: fine)");
}
expect(hoverRegeln).toBeGreaterThan(0);
});
});