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