/kontakt, /ueber-uns, /agb, /impressum, /wartungs-abo, /hosting-abo und die 404-Seite bekommen den vorhandenen Footer (Impressum, Datenschutz, Kündigen/Widerrufen) über ein gemeinsames Layout in src/routes.tsx. Die Seiten selbst bleiben unverändert, damit die bestehenden Abo- und AGB-Tests nicht auf doppelte Footer-Links stoßen. Die Startseite behält ihren eigenen Footer, /datenschutz leitet weiter und bleibt ohne. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
285 lines
12 KiB
TypeScript
285 lines
12 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from "vitest";
|
||
import { render, screen, within, fireEvent, act, cleanup } from "@testing-library/react";
|
||
import { MemoryRouter } from "react-router-dom";
|
||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { AppRoutes } from "@/routes";
|
||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||
import Header from "@/components/Header";
|
||
import Footer from "@/components/Footer";
|
||
import Hero from "@/components/Hero";
|
||
import ProblemSection from "@/components/ProblemSection";
|
||
import SolutionSection from "@/components/SolutionSection";
|
||
import Values from "@/components/Values";
|
||
import Services from "@/components/Services";
|
||
import Process from "@/components/Process";
|
||
import KontaktAbschnitt from "@/components/Contact";
|
||
import About from "@/pages/About";
|
||
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
|
||
import { Toaster } from "@/components/ui/toaster";
|
||
|
||
// WebGL, Canvas und Netzwerk laden wir in jsdom nicht: die Effekt-Komponenten und
|
||
// das Kontaktformular-Backend werden durch Attrappen ersetzt.
|
||
vi.mock("@/components/Silk", () => ({ default: () => null }));
|
||
vi.mock("@/components/PixelBlast", () => ({ default: () => null }));
|
||
vi.mock("@/components/LightRays", () => ({ default: () => null }));
|
||
vi.mock("@/components/ui/flickering-grid", () => ({ FlickeringGrid: () => null }));
|
||
vi.mock("@/lib/appwrite", () => ({ createContactDocument: 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 };
|
||
|
||
const DATENSCHUTZ = "https://project.webklar.com/datenschutz.html";
|
||
const TELEFON = "0176 23726355";
|
||
const TELEFON_HREF = "tel:+4917623726355";
|
||
|
||
/** Rendert die komplette Routenliste (mit gemeinsamem Footer) an einem Pfad. */
|
||
const seite = (pfad: string) =>
|
||
render(
|
||
<MemoryRouter initialEntries={[pfad]} future={routerFuture}>
|
||
<AppRoutes />
|
||
</MemoryRouter>,
|
||
);
|
||
|
||
/** Rendert eine einzelne Komponente im Router. */
|
||
const zeige = (ui: React.ReactElement) =>
|
||
render(
|
||
<MemoryRouter future={routerFuture}>
|
||
{ui}
|
||
</MemoryRouter>,
|
||
);
|
||
|
||
// ── Quelltext-Scan ─────────────────────────────────────────────────────────────
|
||
// vitest läuft im Projektverzeichnis
|
||
const WURZEL = `${process.cwd()}/`;
|
||
const dateien = (ordner: string): string[] =>
|
||
readdirSync(ordner).flatMap((name) => {
|
||
const pfad = join(ordner, name);
|
||
return statSync(pfad).isDirectory() ? dateien(pfad) : [pfad];
|
||
});
|
||
const QUELLEN = [
|
||
...dateien(join(WURZEL, "src")).filter((p) => /\.(tsx?|css)$/.test(p) && !p.includes("/src/test/")),
|
||
join(WURZEL, "index.html"),
|
||
].map((pfad) => ({ pfad: pfad.replace(WURZEL, ""), text: readFileSync(pfad, "utf8") }));
|
||
|
||
/** Pfade aller Quelldateien (ohne Tests), in denen das Muster vorkommt. */
|
||
const vorkommen = (muster: RegExp) => QUELLEN.filter(({ text }) => muster.test(text)).map(({ pfad }) => pfad);
|
||
const lies = (pfad: string) => readFileSync(join(WURZEL, pfad), "utf8");
|
||
const gibtEs = (pfad: string) => existsSync(join(WURZEL, pfad));
|
||
|
||
const meta = (name: string) => document.querySelector(`meta[name="${name}"]`)?.getAttribute("content");
|
||
|
||
afterEach(() => {
|
||
cleanup();
|
||
vi.restoreAllMocks();
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
describe("B1 Hero: ehrliche Fakten statt Zahlenleiste", () => {
|
||
const FAKTEN = [
|
||
["Falkenstein", "Server in Deutschland"],
|
||
["Live-Vorschau", "im Kundenportal"],
|
||
["ab 20 €", "Hosting im Monat"],
|
||
];
|
||
|
||
it("zeigt genau drei Fakten mit Wert und Beschriftung", () => {
|
||
zeige(<Hero />);
|
||
for (const [wert, beschriftung] of FAKTEN) {
|
||
expect(screen.getByText(wert)).toBeInTheDocument();
|
||
expect(screen.getByText(beschriftung)).toBeInTheDocument();
|
||
}
|
||
const raster = screen.getByText("Falkenstein").closest(".grid");
|
||
expect(raster?.children).toHaveLength(3);
|
||
expect(raster).toHaveClass("grid-cols-1", "md:grid-cols-3");
|
||
});
|
||
|
||
it("die alte Zahlenleiste ist weg: kein Projektzähler, kein »Am Markt«, keine 99,9 %, keine Flagge", () => {
|
||
const { container } = zeige(<Hero />);
|
||
expect(screen.queryByText("Am Markt")).toBeNull();
|
||
expect(screen.queryByText("Projekte")).toBeNull();
|
||
expect(screen.queryByText("Systemverfügbarkeit")).toBeNull();
|
||
expect(container).not.toHaveTextContent(/99[,.]9|🇩🇪|Serverstandort/);
|
||
});
|
||
|
||
it("hat nur noch den Potenzialanalyse-Button, keinen »Projekte ansehen«", () => {
|
||
zeige(<Hero />);
|
||
expect(screen.getAllByRole("button").map((b) => b.getAttribute("aria-label"))).toEqual([
|
||
"Kostenlose Potenzialanalyse sichern",
|
||
]);
|
||
});
|
||
|
||
it("lädt nichts vom Netz und braucht keinen QueryClient", () => {
|
||
const abruf = vi.fn();
|
||
vi.stubGlobal("fetch", abruf);
|
||
zeige(<Hero />);
|
||
expect(abruf).not.toHaveBeenCalled();
|
||
expect(gibtEs("src/lib/projects.ts")).toBe(false);
|
||
expect(vorkommen(/api\/projects|fetchProjects|FOUNDING_DATE/)).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("B2 Projekte-Bereich ausgeblendet", () => {
|
||
it("ProjectShowcase ist gelöscht; nichts verweist auf #projects oder Unsplash", () => {
|
||
expect(gibtEs("src/components/ProjectShowcase.tsx")).toBe(false);
|
||
expect(vorkommen(/#projects|id="projects"|images\.unsplash\.com|ProjectShowcase/)).toEqual([]);
|
||
});
|
||
|
||
it("die Startseite hat keinen Projekte-Abschnitt und keinen Nav-Link dorthin", () => {
|
||
const { container } = seite("/");
|
||
expect(container.querySelector("#projects")).toBeNull();
|
||
expect(container.querySelector('a[href$="#projects"]')).toBeNull();
|
||
expect(screen.queryByRole("link", { name: "Projekte" })).toBeNull();
|
||
expect(screen.queryByText("Verbatim Labs")).toBeNull();
|
||
// die übrigen Nav-Punkte bleiben
|
||
for (const name of ["Über uns", "Leistungen", "Ablauf"]) {
|
||
expect(screen.getAllByRole("link", { name }).length).toBeGreaterThan(0);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("B3 Eine Telefonnummer überall", () => {
|
||
it("die alte Nummer 0170 4969375 kommt in keiner Quelldatei mehr vor", () => {
|
||
expect(vorkommen(/0170\s?4969375|491704969375/)).toEqual([]);
|
||
});
|
||
|
||
it("Kontaktseite zeigt 0176 23726355 mit passendem tel:-Link", () => {
|
||
seite("/kontakt");
|
||
const links = screen.getAllByRole("link", { name: TELEFON });
|
||
expect(links.length).toBeGreaterThan(0);
|
||
for (const link of links) expect(link).toHaveAttribute("href", TELEFON_HREF);
|
||
});
|
||
|
||
it("der Footer zeigt dieselbe Nummer", () => {
|
||
zeige(<Footer />);
|
||
expect(screen.getByRole("link", { name: TELEFON })).toHaveAttribute("href", TELEFON_HREF);
|
||
});
|
||
|
||
it("der Kontaktabschnitt der Startseite zeigt dieselbe Nummer", () => {
|
||
zeige(<KontaktAbschnitt />);
|
||
expect(screen.getByRole("link", { name: TELEFON })).toHaveAttribute("href", TELEFON_HREF);
|
||
});
|
||
});
|
||
|
||
describe("B4 Antwortzeit: spätestens am nächsten Werktag", () => {
|
||
const STARTSEITE =
|
||
"WEBklar digitalisiert KMU: Website, Prozesse und Systeme aus einer Hand. Kostenlose Potenzialanalyse – Antwort spätestens am nächsten Werktag.";
|
||
const KONTAKT =
|
||
"Beschreiben Sie Ihr Projekt – WEBklar meldet sich spätestens am nächsten Werktag mit den nächsten Schritten. Unverbindlich und kostenlos.";
|
||
|
||
it("kein 24-Stunden-Versprechen mehr in Quelltext und index.html (Tailwind-Klassen wie mt-24 h-48 zählen nicht)", () => {
|
||
expect(vorkommen(/(?<![\w-])24[\s-]*(Stunden|Std\b|h(?![\w-]))/i)).toEqual([]);
|
||
});
|
||
|
||
it("index.html: Beschreibung mit der neuen Antwortzeit", () => {
|
||
expect(lies("index.html")).toContain(`content="${STARTSEITE}"`);
|
||
});
|
||
|
||
it("Standard-Beschreibung des Meta-Hooks und der Startseite", () => {
|
||
const Probe = () => {
|
||
usePageMeta("Probe");
|
||
return null;
|
||
};
|
||
render(<Probe />);
|
||
expect(meta("description")).toBe(STARTSEITE);
|
||
cleanup();
|
||
seite("/");
|
||
expect(meta("description")).toBe(STARTSEITE);
|
||
});
|
||
|
||
it("Kontaktseite: Meta-Beschreibung und Einleitung", () => {
|
||
seite("/kontakt");
|
||
expect(meta("description")).toBe(KONTAKT);
|
||
expect(screen.getByText(/den größten Hebel haben\./)).toHaveTextContent(
|
||
"den größten Hebel haben. Antwort spätestens am nächsten Werktag, unverbindlich.",
|
||
);
|
||
});
|
||
|
||
it("Kontaktformular: Erfolgsmeldung mit der neuen Antwortzeit", async () => {
|
||
render(
|
||
<MemoryRouter initialEntries={["/kontakt"]} future={routerFuture}>
|
||
<AppRoutes />
|
||
<Toaster />
|
||
</MemoryRouter>,
|
||
);
|
||
fireEvent.change(screen.getByLabelText(/^Name/), { target: { value: "Erika Muster" } });
|
||
fireEvent.change(screen.getByLabelText(/^E-Mail/), { target: { value: "erika@example.de" } });
|
||
fireEvent.change(screen.getByLabelText(/^Nachricht/), { target: { value: "Wir brauchen eine neue Website." } });
|
||
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/ }));
|
||
expect(await screen.findByText("Wir melden uns spätestens am nächsten Werktag bei Ihnen.")).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
describe("B5 Erfolgsversprechen entschärft", () => {
|
||
it("die alten Versprechen kommen nirgends mehr vor", () => {
|
||
expect(vorkommen(/Planbare Neukunden|Sichtbar bei Google|Umsatz bringt/)).toEqual([]);
|
||
});
|
||
|
||
it("die toten Dateien AgitationSection und DifferentiationSection sind gelöscht", () => {
|
||
expect(gibtEs("src/components/AgitationSection.tsx")).toBe(false);
|
||
expect(gibtEs("src/components/DifferentiationSection.tsx")).toBe(false);
|
||
});
|
||
|
||
it("Values: Karte »Kundenportal« statt »Planbare Neukunden«", () => {
|
||
zeige(<Values />);
|
||
expect(screen.getByRole("heading", { level: 3, name: "Kundenportal" })).toBeInTheDocument();
|
||
expect(screen.getByText("Fortschritt, Chat und Rechnungen an einem Ort, im Browser oder als App.")).toBeInTheDocument();
|
||
expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(6);
|
||
});
|
||
|
||
it("SolutionSection: Ergebnis-Überschrift ohne Umsatzversprechen", () => {
|
||
zeige(<SolutionSection />);
|
||
expect(
|
||
screen.getByRole("heading", { level: 3, name: "Sie sehen, was läuft. Mehr Zeit für Kunden statt für Tool-Pflege:" }),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it("Services: Punkt 04 heißt »Hosting & Betreuung«", () => {
|
||
zeige(<Services />);
|
||
expect(screen.getByRole("heading", { level: 3, name: "Hosting & Betreuung" })).toBeInTheDocument();
|
||
expect(
|
||
screen.getByText("Hosting auf unserem Server in Deutschland, Pflege und Updates im Wartungs-Abo."),
|
||
).toBeInTheDocument();
|
||
expect(screen.queryByText(/SEO/)).toBeNull();
|
||
});
|
||
|
||
it("Process: Schritt 4 verweist auf das Wartungs-Abo", () => {
|
||
zeige(<Process />);
|
||
expect(screen.getByRole("heading", { level: 3, name: "Launch & Betreuung" })).toBeInTheDocument();
|
||
expect(
|
||
screen.getByText("Go-live mit Tests und Übergabe. Pflege und Updates danach im Wartungs-Abo."),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it("Über uns: Karte »Betreuung im Abo« statt »Langfristige Partnerschaft«", () => {
|
||
zeige(<About />);
|
||
expect(screen.getByRole("heading", { level: 3, name: "Betreuung im Abo" })).toBeInTheDocument();
|
||
expect(
|
||
screen.getByText("Nach dem Go-live kümmern wir uns im Wartungs-Abo um Updates und kleine Änderungen."),
|
||
).toBeInTheDocument();
|
||
expect(screen.queryByText("Langfristige Partnerschaft")).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("C1 Footer auf allen Unterseiten", () => {
|
||
const unterseiten = ["/kontakt", "/ueber-uns", "/agb", "/impressum", "/wartungs-abo", "/hosting-abo", "/gibt-es-nicht"];
|
||
|
||
it.each(unterseiten)("%s zeigt genau einen Footer mit Impressum und Datenschutz", (pfad) => {
|
||
seite(pfad);
|
||
const footer = screen.getByRole("contentinfo");
|
||
expect(within(footer).getByRole("link", { name: "Impressum" })).toHaveAttribute("href", "/impressum");
|
||
expect(within(footer).getByRole("link", { name: "Datenschutz" })).toHaveAttribute("href", DATENSCHUTZ);
|
||
});
|
||
|
||
it("die Startseite hat weiterhin genau einen Footer", () => {
|
||
seite("/");
|
||
expect(screen.getAllByRole("contentinfo")).toHaveLength(1);
|
||
});
|
||
|
||
it("der Footer steht hinter dem Seiteninhalt", () => {
|
||
seite("/impressum");
|
||
const inhalt = screen.getByRole("main");
|
||
const footer = screen.getByRole("contentinfo");
|
||
expect(inhalt.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||
});
|
||
});
|