Files
alpenwerk-hr/tests/unit/history.test.ts
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

185 lines
8.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { darfKorrigiertWerden, loeschVorschau } from "@/lib/history";
import type { AuditChange, HistoryEventType } from "@/lib/types";
const HEUTE = "2026-08-13";
function eintrag(teil: Partial<{ id: string; event_type: HistoryEventType; event_date: string; created_at: string; changes: AuditChange[] | null; pending_id: string | 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("darfKorrigiertWerden", () => {
it("lässt eine wirksame Stammdatenänderung mit Werten zu", () => {
expect(darfKorrigiertWerden(eintrag(), HEUTE)).toEqual({ erlaubt: true });
});
it("lässt eine Vertragsänderung ebenso zu", () => {
expect(darfKorrigiertWerden(eintrag({ event_type: "Vertragsänderung" }), HEUTE).erlaubt).toBe(true);
});
it("schützt den Eintritt", () => {
const u = darfKorrigiertWerden(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", "Austritt", "Wiedereintritt", "Reorganisation"])(
"verweist bei %s auf den fachlichen Vorgang",
(typ) => {
const u = darfKorrigiertWerden(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 Abwesenheit und Rückkehr zu", () => {
// Eine versehentlich erfasste Abwesenheit war sonst nur durch eine zweite
// Buchung loszuwerden — und dann stünden zwei Einträge in der Akte, von
// denen der erste nie stattgefunden hat.
expect(darfKorrigiertWerden(eintrag({ event_type: "Karenz" }), HEUTE).erlaubt).toBe(true);
expect(darfKorrigiertWerden(eintrag({ event_type: "Rückkehr" }), HEUTE).erlaubt).toBe(true);
});
it("lässt Zukünftiges zu, sobald der geplante Vorgang bekannt ist", () => {
// Das ist der Sinn von pending_id: ohne Bezug liesse sich die geplante
// Änderung nur über Person und Datum finden, und das ist nicht eindeutig.
expect(darfKorrigiertWerden(eintrag({ event_date: "2026-09-01", pending_id: "p1" }), HEUTE).erlaubt).toBe(true);
});
it("weist Zukünftiges ohne Bezug zum Vorgang ab", () => {
const u = darfKorrigiertWerden(eintrag({ event_date: "2026-09-01", pending_id: null }), HEUTE);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/kein Vorgang hinterlegt/);
});
it("zählt den heutigen Tag als wirksam", () => {
expect(darfKorrigiertWerden(eintrag({ event_date: HEUTE }), HEUTE).erlaubt).toBe(true);
});
it("weist Einträge ohne Feldwerte ab", () => {
for (const ohne of [null, []]) {
const u = darfKorrigiertWerden(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" });
});
});
// Die Reihenfolge zählt: eine Rückkehr setzt eine Abwesenheit voraus. Bliebe
// sie stehen, während die Abwesenheit verschwindet, stünde in der Akte eine
// Rückkehr aus dem Nichts — und der Status ergäbe sich aus einem Eintrag,
// dessen Ausgangslage gelöscht ist.
describe("Reihenfolge von Abwesenheit und Rückkehr", () => {
const karenz = eintrag({ id: "k", event_type: "Karenz", event_date: "2026-07-15", created_at: "2026-07-15T09:00:00.000Z" });
const rueckkehr = eintrag({ id: "r", event_type: "Rückkehr", event_date: "2026-08-10", created_at: "2026-08-10T09:00:00.000Z" });
it("verweigert die Abwesenheit, solange die Rückkehr steht", () => {
const u = darfKorrigiertWerden(karenz, HEUTE, [karenz, rueckkehr]);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/Rückkehr.*zuerst gelöscht/);
});
it("lässt die Rückkehr jederzeit zurücknehmen — sie ist das obere Ende", () => {
expect(darfKorrigiertWerden(rueckkehr, HEUTE, [karenz, rueckkehr]).erlaubt).toBe(true);
});
it("gibt die Abwesenheit frei, sobald die Rückkehr weg ist", () => {
expect(darfKorrigiertWerden(karenz, HEUTE, [karenz]).erlaubt).toBe(true);
});
it("stört sich nicht an einer früheren Rückkehr aus einer anderen Abwesenheit", () => {
const frueher = eintrag({ id: "r0", event_type: "Rückkehr", event_date: "2026-01-05", created_at: "2026-01-05T09:00:00.000Z" });
expect(darfKorrigiertWerden(karenz, HEUTE, [frueher, karenz]).erlaubt).toBe(true);
});
it("bricht eine noch nicht wirksame Abwesenheit ab, wenn ihr Vorgang bekannt ist", () => {
// Vorher hiess es hier „Sie muss über den Vorgang selbst abgebrochen
// werden" — nur gab es diesen Weg nirgends. Die Zeile stand in der Akte,
// der Vorgang lief weiter, und niemand konnte beides aufhalten.
const geplant = eintrag({ event_type: "Karenz", event_date: "2099-01-01", pending_id: "p1", changes: null });
expect(darfKorrigiertWerden(geplant, HEUTE, [geplant]).erlaubt).toBe(true);
});
it("bleibt bei einer geplanten Abwesenheit ohne hinterlegten Vorgang stehen", () => {
// Ohne Verweis liesse sich nur über Person und Datum raten, welcher
// Vorgang gemeint ist — und geraten wird beim Abbrechen nicht.
const geplant = eintrag({ event_type: "Karenz", event_date: "2099-01-01", pending_id: null, changes: null });
const u = darfKorrigiertWerden(geplant, HEUTE, [geplant]);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/kein Vorgang hinterlegt/);
});
it("verlangt bei einer bereits wirksamen Abwesenheit weiterhin Feldwerte", () => {
// Rückwärts geht es nur, wenn irgendwo steht, was vorher galt.
const alt = eintrag({ event_type: "Karenz", event_date: "2026-07-15", changes: null });
const u = darfKorrigiertWerden(alt, HEUTE, [alt]);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/keine Feldwerte/);
});
it("hält die Reihenfolge auch bei geplanten Einträgen ein", () => {
const geplanteKarenz = eintrag({ id: "k2", event_type: "Karenz", event_date: "2099-01-01", pending_id: "p1", changes: null });
const geplanteRueckkehr = eintrag({ id: "r2", event_type: "Rückkehr", event_date: "2099-06-01", pending_id: "p2", changes: null });
const u = darfKorrigiertWerden(geplanteKarenz, HEUTE, [geplanteKarenz, geplanteRueckkehr]);
expect(u.erlaubt).toBe(false);
expect(u.erlaubt === false && u.grund).toMatch(/zuerst gelöscht/);
});
});