Let an entry be taken back, along with what it did
HR can now delete a history entry, but only where deleting one is an honest thing to do — and deleting it also undoes it. The rule they asked for is the interesting part: the last valid change wins. Deleting an entry walks its fields one at a time. If a later entry touched the same field, the current value stays — that later change is the one in force. Otherwise the field goes back to what the deleted entry recorded as its "before". So the middle of three entries can be removed without an old value overwriting a newer one. Four kinds of entry refuse to be deleted, each saying why in the place the button would have been. Eintritt anchors the timeline. Transfers, promotions, absences and exits moved positions and status — they have proper operations for that, and guessing backwards is how you corrupt an org chart. Anything not yet effective hangs off a planned change, and that link is not trustworthy: there is no key between a history row and its pending row, only a person and a date, and the data already has an Eintritt and a Vertragsänderung sharing one. Matching on the date would eventually cancel a change nobody meant. And entries from before the history carried values have nothing to fall back to. Confirmation is not "are you sure" — that question gets a reflex yes by the third time. The dialog says what will be different afterwards: which field goes back to which value, and which one stays because something later claimed it. employee_history keeps its append-only policies; delete_history_entry is SECURITY DEFINER and checks the permission itself in its first line. The audit log keeps the deletion with the values that were removed, and the audit log genuinely cannot be edited. The rule lives twice — in SQL and in lib/history.ts. The database is the authority; the copy exists so the UI can hide a button that would fail and print the reason instead. Rehearsed against real data in a rolled-back transaction first: the later change held, the untouched field reverted, all four refusals fired. Also corrected in the data catalogue: I had written that require_hr_admin was called by nothing. It guards all sixteen mutating functions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,29 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
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 })),
|
||||
}));
|
||||
|
||||
// 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
|
||||
@@ -33,13 +54,13 @@ const ADRESSWECHSEL = eintrag({
|
||||
|
||||
describe("HistorieTab", () => {
|
||||
it("zeigt die alte Anschrift, nicht nur die neue", () => {
|
||||
render(<HistorieTab history={[ADRESSWECHSEL]} />);
|
||||
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]} />);
|
||||
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");
|
||||
@@ -47,17 +68,17 @@ describe("HistorieTab", () => {
|
||||
});
|
||||
|
||||
it("nennt die Zahl der Felder am Aufklapper", () => {
|
||||
render(<HistorieTab history={[ADRESSWECHSEL]} />);
|
||||
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" }] })]} />);
|
||||
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" }] })]} />);
|
||||
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");
|
||||
});
|
||||
@@ -65,14 +86,90 @@ describe("HistorieTab", () => {
|
||||
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" })]} />);
|
||||
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("bleibt bei leerer Historie bei einem Satz", () => {
|
||||
render(<HistorieTab history={[]} />);
|
||||
render(<HistorieTab history={[]} employeeId="e1" />);
|
||||
expect(screen.getByText("Keine Historieneinträge vorhanden.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
109
tests/unit/history.test.ts
Normal file
109
tests/unit/history.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { darfGeloeschtWerden, loeschVorschau } from "@/lib/history";
|
||||
import type { AuditChange, HistoryEventType } from "@/lib/supabase/types";
|
||||
|
||||
const HEUTE = "2026-08-13";
|
||||
|
||||
function eintrag(teil: Partial<{ id: string; event_type: HistoryEventType; event_date: string; created_at: string; changes: AuditChange[] | null }> = {}) {
|
||||
return {
|
||||
id: "h1",
|
||||
event_type: "Stammdatenänderung" as HistoryEventType,
|
||||
event_date: "2026-08-10",
|
||||
created_at: "2026-08-10T09:00:00.000Z",
|
||||
changes: [{ feld: "Adresse", vorher: "Feldweg 66", nachher: "Bahnhofstrasse 5" }] as AuditChange[] | null,
|
||||
...teil,
|
||||
};
|
||||
}
|
||||
|
||||
describe("darfGeloeschtWerden", () => {
|
||||
it("lässt eine wirksame Stammdatenänderung mit Werten zu", () => {
|
||||
expect(darfGeloeschtWerden(eintrag(), HEUTE)).toEqual({ erlaubt: true });
|
||||
});
|
||||
|
||||
it("lässt eine Vertragsänderung ebenso zu", () => {
|
||||
expect(darfGeloeschtWerden(eintrag({ event_type: "Vertragsänderung" }), HEUTE).erlaubt).toBe(true);
|
||||
});
|
||||
|
||||
it("schützt den Eintritt", () => {
|
||||
const u = darfGeloeschtWerden(eintrag({ event_type: "Eintritt", changes: null }), HEUTE);
|
||||
expect(u.erlaubt).toBe(false);
|
||||
expect(u.erlaubt === false && u.grund).toMatch(/Anfang der Zeitleiste/);
|
||||
});
|
||||
|
||||
it.each<HistoryEventType>(["Versetzung", "Beförderung", "Karenz", "Rückkehr", "Austritt", "Wiedereintritt", "Reorganisation"])(
|
||||
"verweist bei %s auf den fachlichen Vorgang",
|
||||
(typ) => {
|
||||
const u = darfGeloeschtWerden(eintrag({ event_type: typ }), HEUTE);
|
||||
expect(u.erlaubt).toBe(false);
|
||||
expect(u.erlaubt === false && u.grund).toMatch(/passenden Vorgang|Planstellen oder den Status/);
|
||||
}
|
||||
);
|
||||
|
||||
it("lässt Zukünftiges in Ruhe — daran hängt ein geplanter Vorgang", () => {
|
||||
const u = darfGeloeschtWerden(eintrag({ event_date: "2026-09-01" }), HEUTE);
|
||||
expect(u.erlaubt).toBe(false);
|
||||
expect(u.erlaubt === false && u.grund).toMatch(/noch nicht wirksam/);
|
||||
});
|
||||
|
||||
it("zählt den heutigen Tag als wirksam", () => {
|
||||
expect(darfGeloeschtWerden(eintrag({ event_date: HEUTE }), HEUTE).erlaubt).toBe(true);
|
||||
});
|
||||
|
||||
it("weist Einträge ohne Feldwerte ab", () => {
|
||||
for (const ohne of [null, []]) {
|
||||
const u = darfGeloeschtWerden(eintrag({ changes: ohne }), HEUTE);
|
||||
expect(u.erlaubt).toBe(false);
|
||||
expect(u.erlaubt === false && u.grund).toMatch(/keine Feldwerte/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("loeschVorschau", () => {
|
||||
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" }],
|
||||
});
|
||||
|
||||
it("lässt ein Feld stehen, das später erneut geändert wurde", () => {
|
||||
// Das ist die Regel, um die es geht: die letztgültige Änderung schlägt.
|
||||
const v = loeschVorschau(alt, [alt, neu]);
|
||||
expect(v.find((x) => x.feld === "Adresse")!.bleibt).toBe(true);
|
||||
});
|
||||
|
||||
it("setzt ein Feld zurück, das seither niemand angefasst hat", () => {
|
||||
const v = loeschVorschau(alt, [alt, neu]);
|
||||
const tel = v.find((x) => x.feld === "Telefon")!;
|
||||
expect(tel.bleibt).toBe(false);
|
||||
expect(tel.auf).toBe("0664 000");
|
||||
});
|
||||
|
||||
it("setzt beim neuesten Eintrag alles zurück", () => {
|
||||
const v = loeschVorschau(neu, [alt, neu]);
|
||||
expect(v.every((x) => !x.bleibt)).toBe(true);
|
||||
expect(v[0].auf).toBe("Zwischenweg 1");
|
||||
});
|
||||
|
||||
it("unterscheidet zwei Einträge am selben Tag über die Erfassungszeit", () => {
|
||||
const frueh = eintrag({ id: "a", event_date: HEUTE, created_at: `${HEUTE}T08:00:00.000Z` });
|
||||
const spaet = eintrag({ id: "b", event_date: HEUTE, created_at: `${HEUTE}T16:00:00.000Z` });
|
||||
expect(loeschVorschau(frueh, [frueh, spaet])[0].bleibt).toBe(true);
|
||||
expect(loeschVorschau(spaet, [frueh, spaet])[0].bleibt).toBe(false);
|
||||
});
|
||||
|
||||
it("nennt die Richtung so, wie sie im Dialog steht", () => {
|
||||
// von = was jetzt drinsteht, auf = worauf zurückgesetzt wird.
|
||||
const [v] = loeschVorschau(neu, [neu]);
|
||||
expect(v).toMatchObject({ feld: "Adresse", von: "Endstrasse 9", auf: "Zwischenweg 1" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user