The old refusal read: "Diese Abwesenheit ist noch nicht wirksam. Sie muss über den Vorgang selbst abgebrochen werden." There was no such way. The row sat in the file, the scheduled change kept running toward its date, and nothing could stop either one. That is not hypothetical. One person went absent in July, came back in August, and still has a second return booked for the first of September — recorded while they were already working again. The guard added yesterday stops a third from being written; it does not remove the one that exists. Absences are called off whole, not field by field. For a planned contract change the scheduled payload gets the affected fields lifted out of it and runs on with the rest; an absence has no fields in that map, and half an absence is not a thing anyone means. So the whole scheduled change is cancelled, and what it had already noted on the person goes with it: the date they were to be away from, the date they were to come back on. Left behind, the profile would show an absence with no event behind it. If the absence is still running, the return date planned when it began applies again. The link between the row and the scheduled change had to exist first — start_karenz and record_karenz_return now record it. Existing rows get it backfilled, but only where one running change of that kind falls on that person and that day. Where two would match, the row keeps refusing: guessing which process to cancel is worse than refusing to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
340 lines
16 KiB
TypeScript
340 lines
16 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();
|
|
});
|
|
});
|
|
|
|
// 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(<HistorieTab history={ALLE} employeeId="e1" />);
|
|
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(<HistorieTab history={ALLE} employeeId="e1" />);
|
|
expect(screen.getByText("1 anstehend")).toBeInTheDocument();
|
|
});
|
|
|
|
it("blendet mit „Anstehend“ alles Gelaufene aus", async () => {
|
|
const user = userEvent.setup();
|
|
render(<HistorieTab history={ALLE} employeeId="e1" />);
|
|
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(<HistorieTab history={ALLE} employeeId="e1" />);
|
|
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(<HistorieTab history={ALLE} employeeId="e1" />);
|
|
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(<HistorieTab history={ALLE} employeeId="e1" />);
|
|
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(<HistorieTab history={[VERGANGEN]} employeeId="e1" />);
|
|
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(<HistorieTab history={[eintrag({ event_type: "Eintritt", changes: null })]} employeeId="e1" />);
|
|
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(<HistorieTab history={[GEPLANT]} employeeId="e1" />);
|
|
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(<HistorieTab history={[GEPLANT]} employeeId="e1" />);
|
|
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(<HistorieTab history={[eintrag({ ...GEPLANT, pending_id: null })]} employeeId="e1" />);
|
|
expect(screen.queryByRole("button", { name: /löschen/ })).not.toBeInTheDocument();
|
|
});
|
|
});
|