import { render as rtlRender, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { HistorieTab } from "@/components/employees/tabs/HistorieTab"; import { ToastProvider } from "@/components/ui/Toast"; import type { Database } from "@/lib/supabase/types"; // Der Löschknopf hängt an einer Server-Action, und die zieht über lib/db das // Paket `server-only` nach — im Test ein Fehler beim Import. Ersetzt wird // deshalb das Aktionsmodul, nicht die Komponente: geprüft wird hier, was die // Historie *anzeigt*, nicht was die Datenbank tut. Das steht in // tests/unit/history.test.ts und in der Migration selbst. vi.mock("@/actions/employees", () => ({ deleteHistoryEntry: vi.fn(async () => ({ success: true })), updateHistoryEntry: vi.fn(async () => ({ success: true })), })); // Ausserhalb von Next gibt es keinen Router; die Komponente ruft nach dem // Löschen refresh() auf. vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }), })); function render(ui: React.ReactElement) { return rtlRender({ui}); } type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"]; // Der Anlass: eine Adressänderung war in der Historie der Person nur als // „Geänderte Felder: Adresse" zu sehen. Die alte Anschrift stand allein im // Protokoll — einer nach Zeitpunkt sortierten Seite, auf der man erst wissen // muss, wonach man sucht. Damit war nicht nachvollziehbar, ob und wie sich // eine Adresse je geändert hat. function eintrag(teil: Partial): HistoryRow { return { id: "h1", employee_id: "e1", event_date: "2026-03-02", event_type: "Stammdatenänderung", description: "Geänderte Felder: Adresse, Ort, wirksam ab 2026-03-02", changes: null, pending_id: null, created_at: "2026-03-02T08:00:00.000Z", ...teil, }; } const ADRESSWECHSEL = eintrag({ changes: [ { feld: "Adresse", vorher: "Feldweg 66", nachher: "Bahnhofstrasse 5" }, { feld: "Ort", vorher: "Brno", nachher: "Linz" }, ], }); describe("HistorieTab", () => { it("zeigt die alte Anschrift, nicht nur die neue", () => { render(); expect(screen.getByText("Feldweg 66")).toBeInTheDocument(); expect(screen.getByText("Bahnhofstrasse 5")).toBeInTheDocument(); }); it("stellt Vorher und Nachher je Feld gegenüber", () => { render(); const zeile = screen.getByText("Adresse").closest("tr")!; const zellen = within(zeile).getAllByRole("cell"); expect(zellen[1]).toHaveTextContent("Feldweg 66"); expect(zellen[2]).toHaveTextContent("Bahnhofstrasse 5"); }); it("nennt die Zahl der Felder am Aufklapper", () => { render(); expect(screen.getByText(/2 Felder im Detail/)).toBeInTheDocument(); }); it("benennt ein einzelnes Feld im Singular", () => { render(); expect(screen.getByText(/1 Feld im Detail/)).toBeInTheDocument(); }); it("macht aus einem leeren Vorher-Wert eine Aussage statt einer Lücke", () => { render(); const zeile = screen.getByText("Telefon").closest("tr")!; expect(within(zeile).getAllByRole("cell")[1]).toHaveTextContent("leer"); }); it("hängt keinen Aufklapper an Ereignisse ohne Feldwerte", () => { // Eintritt, Austritt und die Zeilen von vor dieser Erweiterung haben // keine Werte. Ein leerer Aufklapper würde einen Fehler vermuten lassen. render(); expect(screen.getByText("Eintritt als Elektrotechniker:in")).toBeInTheDocument(); expect(screen.queryByText(/im Detail/)).not.toBeInTheDocument(); expect(document.querySelector("details")).toBeNull(); }); it("bietet das Löschen an einer irrtümlichen Änderung an", () => { render(); expect(screen.getByRole("button", { name: /Stammdatenänderung vom .* löschen/ })).toBeInTheDocument(); }); it("bietet es am Eintritt nicht an und sagt, warum", () => { render( ); expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument(); expect(screen.getByText(/Anfang der Zeitleiste/)).toBeInTheDocument(); }); it("bietet es an einer Versetzung nicht an", () => { render( ); expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument(); expect(screen.getByText(/Planstellen oder den Status/)).toBeInTheDocument(); }); it("bietet es an einer noch nicht wirksamen Änderung nicht an", () => { render(); expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument(); }); it("sagt vor dem Löschen, was zurückgesetzt wird und was bleibt", async () => { const user = userEvent.setup(); const alt = eintrag({ id: "alt", event_date: "2026-08-01", created_at: "2026-08-01T09:00:00.000Z", changes: [ { feld: "Adresse", vorher: "Feldweg 66", nachher: "Zwischenweg 1" }, { feld: "Telefon", vorher: "0664 000", nachher: "0664 111" }, ], }); const neu = eintrag({ id: "neu", event_date: "2026-08-10", created_at: "2026-08-10T09:00:00.000Z", changes: [{ feld: "Adresse", vorher: "Zwischenweg 1", nachher: "Endstrasse 9" }], }); render(); await user.click(screen.getByRole("button", { name: /Stammdatenänderung vom 01\.08\.2026 löschen/ })); const dialog = screen.getByRole("dialog"); // Das Telefon geht zurück, die Adresse nicht — sie wurde später erneut // geändert, und diese Änderung ist die schlagende. const zurueck = within(dialog).getByRole("heading", { name: "Wird zurückgesetzt" }).parentElement!; expect(within(zurueck).getByText("Telefon")).toBeInTheDocument(); expect(within(zurueck).queryByText("Adresse")).not.toBeInTheDocument(); const bleibt = within(dialog).getByRole("heading", { name: "Bleibt unverändert" }).parentElement!; expect(within(bleibt).getByText("Adresse")).toBeInTheDocument(); }); it("löscht erst nach ausdrücklicher Bestätigung", async () => { const user = userEvent.setup(); const { deleteHistoryEntry } = await import("@/actions/employees"); render(); await user.click(screen.getByRole("button", { name: /löschen$/ })); expect(deleteHistoryEntry).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: "Löschen und zurücksetzen" })); expect(deleteHistoryEntry).toHaveBeenCalledWith({ history_id: "h1", employee_id: "e1" }); }); it("bietet neben dem Löschen auch das Berichtigen an", () => { render(); expect(screen.getByRole("button", { name: /Stammdatenänderung vom .* bearbeiten/ })).toBeInTheDocument(); }); it("bietet das Berichtigen dort nicht an, wo auch nicht gelöscht werden darf", () => { render(); expect(screen.queryByRole("button", { name: /bearbeiten/ })).not.toBeInTheDocument(); }); it("zeigt beim Berichtigen das Vorher unveränderlich und das Nachher zum Tippen", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: /bearbeiten/ })); const dialog = screen.getByRole("dialog"); // Das Vorher ist Geschichte und steht nur da. expect(within(dialog).getByText("Feldweg 66")).toBeInTheDocument(); expect(within(dialog).queryByDisplayValue("Feldweg 66")).not.toBeInTheDocument(); // Das Nachher lässt sich ändern. expect(within(dialog).getByLabelText("Adresse — neuer Wert")).toHaveValue("Bahnhofstrasse 5"); }); it("schickt nur ab, wenn wirklich etwas anders ist", async () => { const user = userEvent.setup(); const { updateHistoryEntry } = await import("@/actions/employees"); render(); await user.click(screen.getByRole("button", { name: /bearbeiten/ })); const knopf = screen.getByRole("button", { name: "Berichtigen" }); expect(knopf).toBeDisabled(); const feld = screen.getByLabelText("Adresse — neuer Wert"); await user.clear(feld); await user.type(feld, "Bahnhofstraße 5"); expect(knopf).toBeEnabled(); await user.click(knopf); expect(updateHistoryEntry).toHaveBeenCalledWith( expect.objectContaining({ history_id: "h1", employee_id: "e1", werte: expect.arrayContaining([{ feld: "Adresse", nachher: "Bahnhofstraße 5" }]), }) ); }); it("verwirft die Eingaben beim Abbrechen", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: /bearbeiten/ })); await user.clear(screen.getByLabelText("Adresse — neuer Wert")); await user.type(screen.getByLabelText("Adresse — neuer Wert"), "Verworfen 1"); await user.click(screen.getByRole("button", { name: "Abbrechen" })); await user.click(screen.getByRole("button", { name: /bearbeiten/ })); expect(screen.getByLabelText("Adresse — neuer Wert")).toHaveValue("Bahnhofstrasse 5"); }); it("bleibt bei leerer Historie bei einem Satz", () => { render(); expect(screen.getByText("Keine Historieneinträge vorhanden.")).toBeInTheDocument(); }); }); // Eine Akte wird meist aus einem von zwei Gründen aufgeschlagen: „was steht // an" oder „was war damals". Ohne Filter muss man dafür durch alles scrollen, // und je länger jemand da ist, desto weniger taugt das. describe("HistorieTab — Filter", () => { const VERGANGEN = eintrag({ id: "v", event_type: "Versetzung", event_date: "2020-05-01", description: "Versetzung damals" }); const HEUTE_EINTRAG = eintrag({ id: "h", event_type: "Stammdatenänderung", event_date: "2026-03-02", description: "Heute-Eintrag" }); const KUENFTIG = eintrag({ id: "z", event_type: "Versetzung", event_date: "2099-01-01", description: "Versetzung künftig" }); const ALLE = [KUENFTIG, HEUTE_EINTRAG, VERGANGEN]; it("zeigt ohne Filter alles", () => { render(); expect(screen.getByText("Versetzung damals")).toBeInTheDocument(); expect(screen.getByText("Versetzung künftig")).toBeInTheDocument(); }); it("nennt die Zahl der anstehenden Punkte, auch ohne dass jemand filtert", () => { render(); expect(screen.getByText("1 anstehend")).toBeInTheDocument(); }); it("blendet mit „Anstehend“ alles Gelaufene aus", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: "Anstehend" })); expect(screen.getByText("Versetzung künftig")).toBeInTheDocument(); expect(screen.queryByText("Versetzung damals")).not.toBeInTheDocument(); }); it("blendet mit „Gelaufen“ das Anstehende aus", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: "Gelaufen" })); expect(screen.queryByText("Versetzung künftig")).not.toBeInTheDocument(); expect(screen.getByText("Versetzung damals")).toBeInTheDocument(); }); it("grenzt über den Zeitraum ein", async () => { const user = userEvent.setup(); render(); await user.type(screen.getByLabelText("Von"), "2026-01-01"); expect(screen.queryByText("Versetzung damals")).not.toBeInTheDocument(); expect(screen.getByText("Heute-Eintrag")).toBeInTheDocument(); }); it("filtert nach Ereignisart", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: "Versetzung", pressed: false })); expect(screen.getByText("Versetzung damals")).toBeInTheDocument(); expect(screen.queryByText("Heute-Eintrag")).not.toBeInTheDocument(); }); it("sagt bei leerem Ergebnis, wie viele Einträge es überhaupt gibt", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: "Anstehend" })); expect(screen.getByText(/Kein Eintrag passt zu dieser Auswahl/)).toBeInTheDocument(); expect(screen.getByText(/1 Eintrag sind vorhanden|1 Eintrag/)).toBeInTheDocument(); }); it("bietet das Eintrittsdatum zum Bearbeiten an, aber nicht zum Löschen", () => { // Der Eintritt ist der Anfang der Zeitleiste und bleibt — sein Datum kann // trotzdem falsch erfasst sein. render(); expect(screen.getByRole("button", { name: /Eintritt vom .* bearbeiten/ })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument(); }); }); // Eine geplante Abwesenheit oder Rückkehr hat keine einzelnen Felder, die // sich herausnehmen liessen — sie fällt als Ganzes. Der Dialog muss das sagen, // sonst steht unter „Wird nicht mehr passieren" eine leere Liste. describe("HistorieTab — geplanten Vorgang abbrechen", () => { const GEPLANT = eintrag({ event_type: "Rückkehr", event_date: "2099-09-01", description: "Rückkehr aus Elternkarenz am 2099-09-01", changes: null, pending_id: "p1", }); it("bietet das Abbrechen an", () => { render(); expect(screen.getByRole("button", { name: /Rückkehr vom 01\.09\.2099 löschen/ })).toBeInTheDocument(); }); it("nennt den Knopf beim richtigen Namen und sagt, was entfällt", async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: /löschen/ })); const dialog = screen.getByRole("dialog"); expect(within(dialog).getByText(/Der Vorgang entfällt ganz/)).toBeInTheDocument(); expect(within(dialog).getByRole("button", { name: "Vorgang abbrechen" })).toBeInTheDocument(); expect(within(dialog).queryByRole("button", { name: "Löschen und zurücksetzen" })).not.toBeInTheDocument(); }); it("bietet es ohne hinterlegten Vorgang nicht an", () => { render(); expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument(); }); });