Such-Oberfläche (Next.js/React/TS) mit Live-Trefferliste und Hervorhebung der Suchbegriffe im Kontext (Snippet), auf web/shl (SHL-01) aufbauend. - app/api/search/route.ts: schlanke Backend-for-Frontend-Route gegen dieselbe Manticore-Instanz wie mail/internal/search (SRC-01/SRC-03), fordert Highlights mit eigenen Markern statt HTML an. - lib/highlight.ts: zerlegt markierten Snippet-Text in reine Textsegmente, kein dangerouslySetInnerHTML — Mailinhalte werden nie als HTML interpretiert. - app/page.tsx: Sucheingabe, Trefferliste mit <mark>-Hervorhebung, verständlicher Hinweis bei leerem Ergebnis. - app/mail/[messageId]/page.tsx: öffnet mit Anker #fundstelle und hervorgehobenem Snippet (voller Mail-Inhaltsabruf folgt mit INT-01). - lib/contrast.ts: reale WCAG-2.1-Kontrastberechnung. Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-04-PRUEFPROTOKOLL.md): 1. Manueller Test gegen echten next start + live Manticore auf 192.168.1.131: Hervorhebung real bestätigt. 2. lib/highlightColors.test.ts: echte WCAG-Berechnung, Hell 14,29:1, Dunkel 6,43:1 (>= 4.5:1 AA). 3. Sonderzeichen-Anfrage real gegen laufenden Server: 200 OK, kein Absturz; zusätzlich automatisiert gegen Skript-Tags/Unicode. Kein Umbau: mail/internal/*, web/shl, web/retention-admin unverändert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
107 lines
3.8 KiB
TypeScript
107 lines
3.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
import SearchPage from "./page";
|
|
|
|
const ORIGINAL_ENV = process.env;
|
|
|
|
beforeEach(() => {
|
|
process.env = { ...ORIGINAL_ENV, NEXT_PUBLIC_MAIL_TENANT_SLUG: "acme" };
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.env = ORIGINAL_ENV;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function mockSearchResponse(hits: unknown[]) {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue(new Response(JSON.stringify({ hits }), { status: 200 }))
|
|
);
|
|
}
|
|
|
|
// Akzeptanzkriterium 1: Eingabe liefert Trefferliste mit hervorgehobenen
|
|
// Suchbegriffen im Snippet.
|
|
describe("SearchPage — Trefferliste mit Hervorhebung", () => {
|
|
it("rendert Treffer mit <mark> um den hervorgehobenen Suchbegriff", async () => {
|
|
mockSearchResponse([
|
|
{
|
|
messageId: "msg-1",
|
|
subjectSnippet: "Quartalsbericht ⦃⦃Umsatz⦄⦄",
|
|
bodySnippet: "hoher ⦃⦃Umsatz⦄⦄ im dritten Quartal",
|
|
score: 100,
|
|
},
|
|
]);
|
|
|
|
render(<SearchPage />);
|
|
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Umsatz" } });
|
|
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
|
|
|
|
await waitFor(() => {
|
|
const marks = document.querySelectorAll("mark");
|
|
expect(marks.length).toBeGreaterThan(0);
|
|
});
|
|
const marks = Array.from(document.querySelectorAll("mark")).map((m) => m.textContent);
|
|
expect(marks).toContain("Umsatz");
|
|
});
|
|
|
|
// Akzeptanzkriterium 3: leere Ergebnisse zeigen verständlichen Hinweis
|
|
// statt leerer Fläche.
|
|
it("zeigt bei leerem Ergebnis einen verständlichen Hinweis", async () => {
|
|
mockSearchResponse([]);
|
|
|
|
render(<SearchPage />);
|
|
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "nichts-vorhanden" } });
|
|
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("status")).toHaveTextContent("Keine Treffer");
|
|
});
|
|
});
|
|
|
|
// Pflichtprüfung 3: Sonderzeichen in der Suchanfrage bringen die Anzeige
|
|
// nicht zum Absturz.
|
|
it("wirft bei Sonderzeichen in der Suchanfrage keinen Fehler", async () => {
|
|
mockSearchResponse([
|
|
{
|
|
messageId: "msg-2",
|
|
subjectSnippet: `⦃⦃<script>alert(1)</script>⦄⦄ & "Zitat" 日本語`,
|
|
bodySnippet: "",
|
|
score: 1,
|
|
},
|
|
]);
|
|
|
|
render(<SearchPage />);
|
|
fireEvent.change(screen.getByLabelText("Suchbegriff"), {
|
|
target: { value: '"<script>" OR -Ümläüt 日本語' },
|
|
});
|
|
expect(() => fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!)).not.toThrow();
|
|
|
|
await waitFor(() => {
|
|
expect(document.querySelectorAll("mark").length).toBeGreaterThan(0);
|
|
});
|
|
// Kein <script>-Element im DOM entstanden (kein dangerouslySetInnerHTML,
|
|
// Manticore-Highlight wird als reiner Text gerendert, nicht als HTML).
|
|
expect(document.querySelectorAll("script").length).toBe(0);
|
|
});
|
|
|
|
// Akzeptanzkriterium 2: Klick auf Treffer öffnet die zugehörige Mail
|
|
// direkt an der Fundstelle — hier geprüft über den erzeugten Link, da
|
|
// die vollständige Mail-Ansicht (Inhaltsabruf per API) INT-01 vorbehalten
|
|
// ist.
|
|
it("verlinkt jeden Treffer auf die Mail-Detailseite mit Fundstellen-Anker", async () => {
|
|
mockSearchResponse([
|
|
{ messageId: "msg-3", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 },
|
|
]);
|
|
|
|
render(<SearchPage />);
|
|
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } });
|
|
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
|
|
|
|
await waitFor(() => {
|
|
const link = screen.getByRole("link");
|
|
expect(link.getAttribute("href")).toMatch(/^\/mail\/msg-3\?.*#fundstelle$/);
|
|
});
|
|
});
|
|
});
|