2f762175d2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
259 lines
12 KiB
TypeScript
259 lines
12 KiB
TypeScript
// 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 nur noch das Pensionsalter -- Name und Alter beschreiben den Haushalt und
|
|
// liegen am Plan, der Planungshorizont liegt seit 0.36 als JAHRESZAHL am Szenario.
|
|
const personCols = await cols("Person");
|
|
expect(personCols).toContain("retirementAge");
|
|
expect(personCols).not.toContain("planningHorizonAge");
|
|
expect(personCols).not.toContain("name");
|
|
expect(personCols).not.toContain("age");
|
|
|
|
const scenCols2 = await cols("Scenario");
|
|
expect(scenCols2).toContain("planningHorizonYears");
|
|
expect(scenCols2).toContain("assistantProgress");
|
|
|
|
// Der Pensionierungs-Entscheid hängt am ELEMENT und bewusst an keiner Phase (0.34) --
|
|
// nur so überlebt er eine Verschiebung der Zeitachse. Die Stammdaten (0.36) ebenso: Sie
|
|
// beschreiben den Bestand bei Planbeginn und existieren, bevor es Phasen gibt.
|
|
expect(await cols("FinancialElement")).toContain("retirementDecision");
|
|
expect(await cols("FinancialElement")).toContain("baseData");
|
|
|
|
// 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);
|
|
});
|