Files
nexarch/web/mail-search/app/page.test.tsx
sysopsandClaude Sonnet 5 acc2b0c5dd SRC-06: facetten-ui-filter-chips
Facetten-Oberfläche mit Filter-Chips: aktive Filter sichtbar, einzeln
entfernbar, Trefferzahl je Facettenwert live angezeigt.

- app/api/facets/route.ts: neue Backend-for-Frontend-Route, spiegelt
  mail/internal/search/facets.go minimal (nur Trefferzahl je
  Facettenwert).
- lib/manticoreQuery.ts: gemeinsamer statischer bool.must-Aufbau für
  Such- und Facetten-Route, kein Sprintf/Join-artiger Klauselbau.
- app/api/search/route.ts (SRC-04): akzeptiert jetzt wiederholbare
  ?filter=feld:wert-Parameter.
- app/FacetPanel.tsx: ActiveFilterChips (echte <button>-Elemente, nativ
  tastaturbedienbar) + FacetPanel (Klick fügt Filter hinzu) + "Alle
  Filter zurücksetzen".
- lib/filterState.ts: reine Filterzustandsfunktionen, ohne React.

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-06-PRUEFPROTOKOLL.md):
1. Manueller Test gegen echten next start + live Manticore auf
   192.168.1.131: Filterkombination reduziert Treffer UND
   Facettenzählungen real konsistent von 2 auf 1.
2. @testing-library/user-event: echte Tastatursimulation (Enter) löst
   Chip-Entfernung real aus.
3. 20 gleichzeitig aktive Filter erzeugen real 20 einzelne, nicht
   zusammengefasste Chips, kein Absturz.

Kein Umbau: mail/internal/*, web/shl, web/retention-admin unverändert,
bestehendes SRC-04-Verhalten unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 23:18:08 +02:00

226 lines
9.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
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();
});
// SRC-06: SearchPage ruft jetzt sowohl /api/search als auch /api/facets
// auf (Promise.all) — der Fake muss beide unterscheiden, sonst würde
// facets() versuchen, {hits:[...]} als FacetsResponse zu lesen.
function mockSearchResponse(hits: unknown[], facetValues: Record<string, unknown[]> = {}) {
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation((url: string) => {
if (url.includes("/api/facets")) {
return Promise.resolve(new Response(JSON.stringify({ values: facetValues }), { status: 200 }));
}
return Promise.resolve(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$/);
});
});
});
// SRC-06: Facetten-UI & Filter-Chips.
describe("SearchPage — Facetten-UI & Filter-Chips", () => {
it("Klick auf Facettenwert fügt einen entfernbaren Chip hinzu und aktualisiert Trefferzahlen live (AC1/AC2)", async () => {
mockSearchResponse(
[{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }],
{ sender: [{ value: "alice@example.com", count: 3 }], mailbox: [], attachment_type: [], tag: [] }
);
render(<SearchPage />);
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } });
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
const facetButton = await screen.findByRole("button", { name: /alice@example\.com \(3\)/ });
fireEvent.click(facetButton);
const chip = await screen.findByRole("button", { name: /Filter Absender: alice@example\.com entfernen/ });
expect(chip).toHaveTextContent("Absender: alice@example.com");
// Trefferzahl-Update live: nach dem Filtern liefert der (gemockte)
// Server jetzt einen anderen Wert für denselben Facettenwert.
mockSearchResponse(
[{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }],
{ sender: [{ value: "alice@example.com", count: 1 }], mailbox: [], attachment_type: [], tag: [] }
);
fireEvent.click(chip);
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } });
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
await screen.findByRole("button", { name: /alice@example\.com \(1\)/ });
});
it("Zurücksetzen aller Filter entfernt alle Chips mit einem Klick (AC3)", async () => {
mockSearchResponse(
[{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }],
{
sender: [{ value: "alice@example.com", count: 2 }],
mailbox: [{ value: "inbox", count: 2 }],
attachment_type: [],
tag: [],
}
);
render(<SearchPage />);
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } });
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
fireEvent.click(await screen.findByRole("button", { name: /alice@example\.com \(2\)/ }));
fireEvent.click(await screen.findByRole("button", { name: /inbox \(2\)/ }));
await screen.findByRole("button", { name: /Filter Absender: alice@example\.com entfernen/ });
await screen.findByRole("button", { name: /Filter Postfach: inbox entfernen/ });
fireEvent.click(screen.getByRole("button", { name: "Alle Filter zurücksetzen" }));
await waitFor(() => {
expect(screen.queryByRole("button", { name: /Filter Absender/ })).toBeNull();
expect(screen.queryByRole("button", { name: /Filter Postfach/ })).toBeNull();
});
});
// Pflichtprüfung 2: Tastaturbedienbarkeit der Filter-Chips.
it("Filter-Chip ist per Tastatur (Tab + Enter) auslösbar", async () => {
const user = userEvent.setup();
mockSearchResponse(
[{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }],
{ sender: [{ value: "alice@example.com", count: 1 }], mailbox: [], attachment_type: [], tag: [] }
);
render(<SearchPage />);
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } });
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
await user.click(await screen.findByRole("button", { name: /alice@example\.com \(1\)/ }));
const chip = await screen.findByRole("button", { name: /Filter Absender: alice@example\.com entfernen/ });
chip.focus();
expect(document.activeElement).toBe(chip);
await user.keyboard("{Enter}");
await waitFor(() => {
expect(screen.queryByRole("button", { name: /Filter Absender/ })).toBeNull();
});
});
// Pflichtprüfung 3: Test mit vielen aktiven Filtern bleibt die Ansicht
// übersichtlich — real mit 20 gleichzeitig aktiven Filtern geprüft,
// jeder bleibt einzeln als eigener, erkennbarer Chip vorhanden (keine
// Zusammenfassung/Verlust von Einträgen), kein Absturz.
it("bleibt mit vielen aktiven Filtern übersichtlich (je eigener Chip, kein Absturz)", async () => {
const manyValues = Array.from({ length: 20 }, (_, i) => ({ value: `tag-${i}`, count: i + 1 }));
mockSearchResponse(
[{ messageId: "msg-1", subjectSnippet: "Betreff", bodySnippet: "Text", score: 1 }],
{ sender: [], mailbox: [], attachment_type: [], tag: manyValues }
);
render(<SearchPage />);
fireEvent.change(screen.getByLabelText("Suchbegriff"), { target: { value: "Betreff" } });
fireEvent.submit(screen.getByLabelText("Suchbegriff").closest("form")!);
for (let i = 0; i < manyValues.length; i++) {
const facetButton = await screen.findByRole("button", { name: new RegExp(`tag-${i} \\(${i + 1}\\)`) });
fireEvent.click(facetButton);
}
const chipRegion = await screen.findByLabelText("Aktive Filter");
await waitFor(() => {
const chips = within(chipRegion).getAllByRole("button", { name: /^Filter Tag: /i });
expect(chips).toHaveLength(20);
});
});
});