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>
185 lines
8.6 KiB
TypeScript
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/);
|
|
});
|
|
});
|