import assert from "node:assert/strict"; import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { it } from "node:test"; import { spawnSync } from "node:child_process"; import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { createDatabaseContext } from "../src/db/database-context.js"; it("replaces legacy CSV state and resets linked row quantities to their manual share", () => { const source = path.resolve("src", "db", "migrations"); const staging = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-linear-xlsx-migration-")); const journal = JSON.parse(readFileSync(path.join(source, "meta", "_journal.json"), "utf8")); journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 13); mkdirSync(path.join(staging, "meta")); writeFileSync(path.join(staging, "meta", "_journal.json"), JSON.stringify(journal)); for (const entry of journal.entries) { copyFileSync(path.join(source, `${entry.tag}.sql`), path.join(staging, `${entry.tag}.sql`)); } const context = createDatabaseContext(":memory:"); try { migrate(context.db, { migrationsFolder: staging }); context.sqlite.exec(` INSERT INTO projects (id, name) VALUES ('p', 'Projekt'); INSERT INTO distribution_boards (id, project_id, name) VALUES ('b', 'p', 'UV'); INSERT INTO circuit_lists (id, project_id, distribution_board_id, name) VALUES ('l', 'p', 'b', 'Liste'); INSERT INTO circuit_sections (id, circuit_list_id, key, display_name, prefix) VALUES ('s', 'l', 'single_phase', 'Einphasig', '-2F1.'); INSERT INTO circuits (id, circuit_list_id, section_id, equipment_identifier) VALUES ('c', 'l', 's', '-2F1.1'); INSERT INTO circuit_device_rows (id, circuit_id, name, display_name, quantity, manual_quantity, power_per_unit, simultaneity_factor) VALUES ('r', 'c', 'Steckdose', 'Steckdose', 5, 2, 0.2, 1); INSERT INTO external_csv_configurations (id, project_id, configuration_version, configuration) VALUES ('cfg', 'p', 3, '{}'); INSERT INTO external_model_sources (id, project_id, name, source_type) VALUES ('src', 'p', 'Revit', 'revit_csv'); INSERT INTO external_import_batches (id, project_id, source_id, import_kind, imported_at_iso, file_name, sha256, applied_project_revision, configuration_snapshot, original_bytes, document, configuration_version) VALUES ('batch', 'p', 'src', 'initial', '2026-01-01T00:00:00.000Z', 'legacy.csv', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 0, '{}', X'00', '{}', 3); INSERT INTO external_model_objects (id, project_id, source_id, ifc_guid, last_seen_import_batch_id, last_accepted_import_batch_id, accepted_source_values, planning_values, overridden_fields, circuit_device_row_id) VALUES ('object', 'p', 'src', 'Ifc-GUID', 'batch', 'batch', '{}', '{}', '[]', 'r'); `); migrate(context.db, { migrationsFolder: source }); assert.equal( (context.sqlite.prepare("SELECT quantity FROM circuit_device_rows WHERE id = 'r'").get() as { quantity: number }).quantity, 2 ); assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_objects").get()!.count, 0); assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM external_model_configurations").get()!.count, 0); assert.throws(() => context.sqlite.prepare("SELECT * FROM external_csv_configurations").all(), /no such table/); const columns = context.sqlite.prepare("PRAGMA table_info(external_model_objects)").all() as Array<{ name: string; notnull: number }>; assert.equal(columns.find((column) => column.name === "worksharing_id")?.notnull, 1); assert.equal(columns.find((column) => column.name === "ifc_guid")?.notnull, 0); assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []); assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok"); } finally { context.close(); rmSync(staging, { recursive: true, force: true }); } }); it("creates and verifies a backup before the Linear-XLSX migration", () => { const source = path.resolve("src", "db", "migrations"); const root = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-linear-xlsx-backup-")); const staging = path.join(root, "migrations"); const dataDirectory = path.join(root, "data"); mkdirSync(path.join(staging, "meta"), { recursive: true }); mkdirSync(dataDirectory, { recursive: true }); const journal = JSON.parse(readFileSync(path.join(source, "meta", "_journal.json"), "utf8")); journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 13); writeFileSync(path.join(staging, "meta", "_journal.json"), JSON.stringify(journal)); for (const entry of journal.entries) { copyFileSync(path.join(source, `${entry.tag}.sql`), path.join(staging, `${entry.tag}.sql`)); } const databasePath = path.join(dataDirectory, "leistungsbilanz.db"); const context = createDatabaseContext(databasePath); try { migrate(context.db, { migrationsFolder: staging }); } finally { context.close(); } try { const result = spawnSync(process.execPath, ["scripts/run-migrations.js"], { cwd: path.resolve("."), env: { ...process.env, LEISTUNGSBILANZ_DATA_DIR: dataDirectory }, encoding: "utf8", }); assert.equal(result.status, 0, result.stderr || result.stdout); assert.match(result.stdout, /Verified pre-migration backup created/); const backups = readdirSync(path.join(dataDirectory, "backups")); assert.equal(backups.length, 1); const migrated = createDatabaseContext(databasePath); try { assert.ok(migrated.sqlite.prepare( "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'external_model_configurations'" ).get()); } finally { migrated.close(); } } finally { rmSync(root, { recursive: true, force: true }); } });