Deleting and correcting a history entry stopped at the present: anything not yet effective stayed put. That was not a principle, it was a missing link. A planned change lives as a payload in pending_org_changes, and nothing tied it to the history row — only a person and a date, and the data already holds an Eintritt and a Vertragsänderung sharing one. So employee_history now carries pending_id, set by change_employee_data when it schedules something. One planned change can carry two history rows: Stammdaten and Vertrag are kept apart but scheduled together. Taking one back therefore strips only that group's fields from the payload, and cancels the operation only when nothing is left. Correcting one rewrites its group and the effective date, and touches no employee data — the change has not happened yet. An entry stays on its side of the present. Pulling a planned change into today, or pushing an effective one into the future, would mean adjusting the employee record and the pending payload in opposite directions; that is what the real operations are for. Existing rows were linked where exactly one running operation matched the person and date and no other row had claimed it. All five of them matched. Anything ambiguous would have kept the old refusal, which now says the actual reason. The edit dialog surfaced a bug in useDialogFocus that predates it: the effect depended on the identity of onClose, which almost every caller rebuilds on render, so it re-ran after each keystroke and its cleanup pulled focus back to whatever opened the dialog. Any dialog with a text field would have accepted one character. It never showed because until now no dialog kept its own state next to its own onClose. Rehearsed against real data: a two-row planned change corrected, one row taken back with the operation continuing on the rest, the second taken back with the operation cancelled, and both refusals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
237 lines
10 KiB
TypeScript
237 lines
10 KiB
TypeScript
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(<ToastProvider>{ui}</ToastProvider>);
|
|
}
|
|
|
|
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>): 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(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
expect(screen.getByText("Feldweg 66")).toBeInTheDocument();
|
|
expect(screen.getByText("Bahnhofstrasse 5")).toBeInTheDocument();
|
|
});
|
|
|
|
it("stellt Vorher und Nachher je Feld gegenüber", () => {
|
|
render(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
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(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
expect(screen.getByText(/2 Felder im Detail/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("benennt ein einzelnes Feld im Singular", () => {
|
|
render(<HistorieTab history={[eintrag({ changes: [{ feld: "Ort", vorher: "Brno", nachher: "Linz" }] })]} employeeId="e1" />);
|
|
expect(screen.getByText(/1 Feld im Detail/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("macht aus einem leeren Vorher-Wert eine Aussage statt einer Lücke", () => {
|
|
render(<HistorieTab history={[eintrag({ changes: [{ feld: "Telefon", vorher: null, nachher: "0664 1234567" }] })]} employeeId="e1" />);
|
|
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(<HistorieTab history={[eintrag({ event_type: "Eintritt", description: "Eintritt als Elektrotechniker:in" })]} employeeId="e1" />);
|
|
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(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
expect(screen.getByRole("button", { name: /Stammdatenänderung vom .* löschen/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it("bietet es am Eintritt nicht an und sagt, warum", () => {
|
|
render(
|
|
<HistorieTab
|
|
history={[eintrag({ event_type: "Eintritt", changes: [{ feld: "Adresse", vorher: "a", nachher: "b" }] })]}
|
|
employeeId="e1"
|
|
/>
|
|
);
|
|
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(
|
|
<HistorieTab
|
|
history={[eintrag({ event_type: "Versetzung", changes: [{ feld: "Adresse", vorher: "a", nachher: "b" }] })]}
|
|
employeeId="e1"
|
|
/>
|
|
);
|
|
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(<HistorieTab history={[eintrag({ event_date: "2099-01-01" })]} employeeId="e1" />);
|
|
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(<HistorieTab history={[neu, alt]} employeeId="e1" />);
|
|
|
|
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(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
|
|
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(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
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(<HistorieTab history={[eintrag({ event_type: "Versetzung" })]} employeeId="e1" />);
|
|
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(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
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(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
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(<HistorieTab history={[ADRESSWECHSEL]} employeeId="e1" />);
|
|
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(<HistorieTab history={[]} employeeId="e1" />);
|
|
expect(screen.getByText("Keine Historieneinträge vorhanden.")).toBeInTheDocument();
|
|
});
|
|
});
|