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