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>
283 lines
13 KiB
TypeScript
283 lines
13 KiB
TypeScript
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);
|
||
});
|
||
});
|