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