Files
alpenwerk-hr/tests/components/HistorieTab.test.tsx
Maximilian Stubhan b87c8ad64c
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Remove Supabase
The database moved to a container of our own; the platform is gone.
This takes out what was left of it — and, where the leftovers were load
bearing, moves rather than deletes.

Moved, not deleted:

  supabase/migrations/  -> db/migrations/      the schema's source of truth
  supabase/build-org.ts -> scripts/build-org.ts
  lib/supabase/types.ts -> lib/types.ts        52 import sites repointed

The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`,
and simply renaming the schema would have left the runner facing an empty
table: it would have called all 67 migrations pending and replayed them
against a database that is long since current. So the runner now creates
`migrationen.schema_migrations` and, once, copies the old rows across —
guarded so a second run does nothing and a fresh database skips it entirely.
Only then does migration 20260907100000 drop the old schema.

Deleted: the CLI config, the seed, the historical schema/function dumps
(nothing read them), scripts/umzug-von-supabase.sh (the move is done), and
both Supabase packages plus the CLI. Nothing in the application imported
them — the build now succeeds with no environment variables at all, which
is the proof.

Integration tests: six of them signed in through Supabase Auth and asserted
against the anon key and the service role. That model is gone, so the tests
were not portable — they are deleted. session-context and
employee-status-filter already ran on pg and are untouched; om-reporting is
ported to a direct connection because it guards a real risk (the reporting
line rule exists twice, once in SQL and once in TypeScript).

CI: the integration job started a Supabase stack. It now runs a postgres
service, applies deploy/db-init and every migration to an empty database —
that was the valuable part, and it still holds — then checks that a second
run is a no-op, which is what proves the bookkeeping works.

Docs: security-review.md audited a service-role key, a cookie adapter and
auth.users, none of which exist. Restating findings about removed components
would suggest today's system had been reviewed; it has not. It now records
what was removed and says a fresh review is due. data-model.md was already
marked obsolete and described the pre-OM schema; azure-migration.md was a
plan for a route not taken. Both deleted.

Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the
packages. Integration tests skip cleanly with no database. Migration SQL and
the runner are reviewed but NOT executed: no Docker here, and the old
instance no longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:43:22 +02:00

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