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(); }); });