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
226 lines
9.4 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|