// Spielt ALLE Migrationen gegen ein echtes PostgreSQL (PGlite, in-process) ein und prüft das // Ergebnis. Fängt kaputte oder nicht-idempotente Migrations-SQL ab, bevor sie beim Deploy // gegen die Live-Datenbank laufen -- lokal steht sonst keine Datenbank zur Verfügung. import { describe, it, expect } from "vitest"; import { PGlite } from "@electric-sql/pglite"; import { readFileSync, readdirSync } from "node:fs"; import path from "node:path"; const MIG = path.join(process.cwd(), "prisma", "migrations"); describe("Datenbank-Migrationen", () => { it("laufen vollständig durch und ergeben das erwartete Schema", async () => { const db = await PGlite.create(); const dirs = readdirSync(MIG).filter((d) => !d.endsWith(".toml")).sort(); expect(dirs.length).toBeGreaterThan(0); for (const d of dirs) { await db.exec(readFileSync(path.join(MIG, d, "migration.sql"), "utf8")); } const tables = ( await db.query<{ table_name: string }>( `SELECT table_name FROM information_schema.tables WHERE table_schema='public'` ) ).rows.map((r) => r.table_name); // V6-Struktur: Behälter Plan + berechenbares Scenario. for (const t of ["User", "Plan", "Scenario", "Person", "Phase", "FinancialElement"]) { expect(tables, `Tabelle ${t} fehlt`).toContain(t); } const cols = async (table: string) => ( await db.query<{ column_name: string }>( `SELECT column_name FROM information_schema.columns WHERE table_name=$1`, [table] ) ).rows.map((r) => r.column_name); // Kind-Tabellen hängen am Szenario, nicht mehr am Plan. for (const t of ["Person", "Phase", "FinancialElement"]) { const c = await cols(t); expect(c, `${t}.scenarioId fehlt`).toContain("scenarioId"); expect(c, `${t}.planId hätte entfernt werden müssen`).not.toContain("planId"); } // Herkunfts-Verweise für den Diff. expect(await cols("Phase")).toContain("sourcePhaseId"); expect(await cols("FinancialElement")).toContain("sourceElementId"); // --- V7: der HAUSHALT liegt am Plan, die ANNAHMEN am Szenario --- const planCols = await cols("Plan"); expect(planCols).toContain("userId"); for (const c of ["householdType", "startYear"]) { expect(planCols, `Plan.${c} fehlt`).toContain(c); } expect(tables, "Tabelle PlanPerson fehlt").toContain("PlanPerson"); for (const c of ["planId", "role", "name", "age"]) { expect(await cols("PlanPerson"), `PlanPerson.${c} fehlt`).toContain(c); } // Das Szenario trägt nur noch die Annahmen -- Haushaltsform und Startjahr sind weg. const scenCols = await cols("Scenario"); for (const c of ["planId", "isBase", "parentScenarioId", "initialCash", "inflationRateDefault"]) { expect(scenCols, `Scenario.${c} fehlt`).toContain(c); } expect(scenCols).not.toContain("householdType"); expect(scenCols).not.toContain("startYear"); expect(scenCols).not.toContain("userId"); // Eigentümer hängt am Plan // Person trägt Pensionsalter und Planungshorizont -- Name und Alter beschreiben den // Haushalt und liegen am Plan. const personCols = await cols("Person"); expect(personCols).toContain("retirementAge"); expect(personCols).toContain("planningHorizonAge"); expect(personCols).not.toContain("name"); expect(personCols).not.toContain("age"); // Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) -- // nur so überlebt er eine Verschiebung der Zeitachse. expect(await cols("FinancialElement")).toContain("retirementDecision"); // Er muss NULL zulassen: Ohne erfassten Entscheid gelten die Vorgaben. const rd = await db.query<{ is_nullable: string }>( `SELECT is_nullable FROM information_schema.columns WHERE table_name='FinancialElement' AND column_name='retirementDecision'` ); expect(rd.rows[0]?.is_nullable).toBe("YES"); // Ein Plan ohne Haushaltsform wäre nicht rechenbar. const hh = await db.query<{ is_nullable: string }>( `SELECT is_nullable FROM information_schema.columns WHERE table_name='Plan' AND column_name='householdType'` ); expect(hh.rows[0].is_nullable).toBe("NO"); // --- Versionierung --- expect(tables, "Tabelle ScenarioVersion fehlt").toContain("ScenarioVersion"); expect(scenCols, "Scenario.currentMajor fehlt").toContain("currentMajor"); const verCols = await cols("ScenarioVersion"); for (const c of ["scenarioId", "major", "minor", "comment", "isMajor", "createdById", "snapshot"]) { expect(verCols, `ScenarioVersion.${c} fehlt`).toContain(c); } // Bestehende Szenarien müssen nach der Migration eine gültige Hauptversion tragen -- // sonst stünde ein vor der Migration angelegter Plan ohne Version da. const major = await db.query<{ column_default: string | null; is_nullable: string }>( `SELECT column_default, is_nullable FROM information_schema.columns WHERE table_name='Scenario' AND column_name='currentMajor'` ); expect(major.rows[0].is_nullable).toBe("NO"); // Startwert 0: Ein Szenario laeuft in der 0er-Reihe, bis eine Hauptversion gesetzt wird. expect(major.rows[0].column_default).toContain("0"); // A.B muss je Szenario eindeutig sein, sonst kollidieren zwei Sitzungen auf derselben // Nummer und die Historie wird mehrdeutig. const idx = ( await db.query<{ indexname: string }>( `SELECT indexname FROM pg_indexes WHERE tablename='ScenarioVersion'` ) ).rows.map((r) => r.indexname); expect(idx).toContain("ScenarioVersion_scenarioId_major_minor_key"); // --- Effektive Werte --- expect(tables, "Tabelle ActualsSet fehlt").toContain("ActualsSet"); const actCols = await cols("ActualsSet"); for (const c of ["planId", "recordedOn", "year", "comment", "cash", "values", "createdById"]) { expect(actCols, `ActualsSet.${c} fehlt`).toContain(c); } // Der Ist-Satz hängt am PLAN, nicht am Szenario -- sonst wäre die Realität pro Szenario // verschieden erfasst. expect(actCols).not.toContain("scenarioId"); // `cash` muss leer bleiben dürfen: Wer den Kontostand nicht kennt, soll den Satz trotzdem // erfassen können (Lücken fallen auf die Plandaten zurück). const cashCol = await db.query<{ is_nullable: string }>( `SELECT is_nullable FROM information_schema.columns WHERE table_name='ActualsSet' AND column_name='cash'` ); expect(cashCol.rows[0].is_nullable).toBe("YES"); // --- Berichte: die PDF-Datei selbst muss abgelegt werden (Audit-Trail). --- expect(tables, "Tabelle Report fehlt").toContain("Report"); const repCols = await cols("Report"); for (const c of ["planId", "title", "config", "model", "pdf", "pdfBytes", "createdById"]) { expect(repCols, `Report.${c} fehlt`).toContain(c); } const pdfCol = await db.query<{ data_type: string; is_nullable: string }>( `SELECT data_type, is_nullable FROM information_schema.columns WHERE table_name='Report' AND column_name='pdf'` ); expect(pdfCol.rows[0].data_type).toBe("bytea"); expect(pdfCol.rows[0].is_nullable).toBe("NO"); // --- Gespeicherte Analysen --- expect(tables, "Tabelle SavedAnalysis fehlt").toContain("SavedAnalysis"); for (const c of ["planId", "name", "type", "metric", "source", "inputs", "result", "createdById"]) { expect(await cols("SavedAnalysis"), `SavedAnalysis.${c} fehlt`).toContain(c); } }, 60000); }); // Die V7-Migration verschiebt BESTEHENDE Daten (Haushaltsform, Personen, Startjahr) vom // Szenario auf den Plan. Das Schema allein zu prüfen genügt dafür nicht -- ein Fehler im // UPDATE/INSERT fiele erst auf, wenn live Daten verloren gingen. describe("V7-Datenübernahme", () => { const V7 = "20260720140000_plan_level_profile"; it("holt Haushaltsform, Startjahr und Personen aus dem Basisszenario an den Plan", async () => { const db = await PGlite.create(); const dirs = readdirSync(MIG) .filter((d) => !d.endsWith(".toml")) .sort(); // Alle Migrationen VOR V7 -- danach steht das alte V6-Schema. for (const d of dirs.filter((d) => d < V7)) { await db.exec(readFileSync(path.join(MIG, d, "migration.sql"), "utf8")); } await db.exec(` INSERT INTO "User" ("id","username","passwordHash") VALUES ('u1','kelle','x'); INSERT INTO "Plan" ("id","userId","name","updatedAt") VALUES ('p1','u1','Hauptplan',NOW()); -- Basisszenario: der kanonische Stand. INSERT INTO "Scenario" ("id","planId","name","isBase","householdType","inflationRateDefault","initialCash","startYear","updatedAt") VALUES ('s1','p1','Basis',true,'COUPLE',1.5,5000,2026,NOW()); INSERT INTO "Person" ("id","scenarioId","role","name","age","retirementAge") VALUES ('pa','s1','PERSON_A','Anna',40,65), ('pb','s1','PERSON_B','Beat',38,64); -- Nebenszenario mit ABWEICHENDEN Werten: Die Basis muss gewinnen. INSERT INTO "Scenario" ("id","planId","name","isBase","parentScenarioId","householdType","inflationRateDefault","initialCash","startYear","updatedAt") VALUES ('s2','p1','Frühpension',false,'s1','SINGLE',2.0,5000,2030,NOW()); INSERT INTO "Person" ("id","scenarioId","role","name","age","retirementAge") VALUES ('pa2','s2','PERSON_A','Anna (alt)',99,60); `); await db.exec(readFileSync(path.join(MIG, V7, "migration.sql"), "utf8")); const plan = ( await db.query<{ householdType: string; startYear: number }>( `SELECT "householdType", "startYear" FROM "Plan" WHERE "id"='p1'` ) ).rows[0]; expect(plan.householdType).toBe("COUPLE"); // aus der Basis, nicht aus s2 expect(plan.startYear).toBe(2026); const persons = ( await db.query<{ role: string; name: string; age: number }>( `SELECT "role","name","age" FROM "PlanPerson" WHERE "planId"='p1' ORDER BY "role"` ) ).rows; expect(persons).toEqual([ { role: "PERSON_A", name: "Anna", age: 40 }, { role: "PERSON_B", name: "Beat", age: 38 }, ]); // Die Pensionsalter bleiben SZENARIO-eigen -- das ist der Kern jedes // Frühpensionierungs-Szenarios und darf nicht eingeebnet werden. const retire = ( await db.query<{ scenarioId: string; role: string; retirementAge: number }>( `SELECT "scenarioId","role","retirementAge" FROM "Person" ORDER BY "scenarioId","role"` ) ).rows; expect(retire).toEqual([ { scenarioId: "s1", role: "PERSON_A", retirementAge: 65 }, { scenarioId: "s1", role: "PERSON_B", retirementAge: 64 }, { scenarioId: "s2", role: "PERSON_A", retirementAge: 60 }, ]); }, 60000); it("lässt keinen Plan ohne Haushaltsform zurück", async () => { const db = await PGlite.create(); const dirs = readdirSync(MIG).filter((d) => !d.endsWith(".toml")).sort(); for (const d of dirs.filter((d) => d < V7)) { await db.exec(readFileSync(path.join(MIG, d, "migration.sql"), "utf8")); } // Grenzfall: ein Plan ganz ohne Szenarien (sollte es nicht geben, darf die Migration // aber nicht zum Absturz bringen -- die Spalte wird NOT NULL gesetzt). await db.exec(` INSERT INTO "User" ("id","username","passwordHash") VALUES ('u1','kelle','x'); INSERT INTO "Plan" ("id","userId","name","updatedAt") VALUES ('leer','u1','Leer',NOW()); `); await db.exec(readFileSync(path.join(MIG, V7, "migration.sql"), "utf8")); const row = ( await db.query<{ householdType: string }>(`SELECT "householdType" FROM "Plan" WHERE "id"='leer'`) ).rows[0]; expect(row.householdType).toBe("SINGLE"); }, 60000); });