'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const os = require('os'); const path = require('path'); const initSqlJs = require('sql.js'); const database = require('../../src/database'); const EXPECTED_MIGRATION_VERSIONS = Array.from({ length: 20 }, (_, index) => index + 1); function withTempDatabase(fn, { root = null } = {}) { const base = root || fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-db-migrations-')); const logger = { info: () => {}, warn: () => {}, error: () => {}, }; database.configure({ dataDir: base, logger }); return Promise.resolve() .then(fn) .finally(() => { database.setTestMigrationFailureVersion(null); database.close(); fs.rmSync(base, { recursive: true, force: true }); }); } test('migrations create schema_migrations records for a new database', async () => { await withTempDatabase(async () => { await database.initialize(); const applied = database.runQuery('SELECT version, status FROM schema_migrations ORDER BY version'); assert.deepEqual(applied.map((row) => row.version), EXPECTED_MIGRATION_VERSIONS); assert.deepEqual(applied.map((row) => row.status), Array(EXPECTED_MIGRATION_VERSIONS.length).fill('applied')); assert.equal(database.runQuery("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'compatibility_findings'")[0]?.name, 'compatibility_findings'); assert.equal(database.getGame('missing.game'), undefined); }); }); test('legacy schema is upgraded with legacy columns and backfills', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-db-migrations-legacy-')); const dbPath = path.join(root, 'chimera.db'); const SQL = await initSqlJs(); const legacy = new SQL.Database(); legacy.run(` CREATE TABLE games ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL DEFAULT '', game_id TEXT UNIQUE NOT NULL, content_id TEXT DEFAULT '', size INTEGER DEFAULT 0, version TEXT DEFAULT '', installed INTEGER DEFAULT 0, backed_up INTEGER DEFAULT 0, ftp_path TEXT DEFAULT '', last_scanned TEXT DEFAULT NULL, created_at TEXT DEFAULT (datetime('now')) ) `); legacy.run(` CREATE TABLE backups ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_id TEXT NOT NULL, backup_path TEXT NOT NULL UNIQUE ) `); legacy.run(` CREATE TABLE backpork_folders ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, path TEXT NOT NULL UNIQUE ) `); legacy.run(` CREATE TABLE game_firmware ( game_id TEXT NOT NULL, folder_name TEXT NOT NULL, PRIMARY KEY (game_id, folder_name) ) `); legacy.run(` CREATE TABLE game_hashes ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_id TEXT NOT NULL, file_hash TEXT NOT NULL, file_size INTEGER NOT NULL, backup_path TEXT NOT NULL UNIQUE, verified INTEGER DEFAULT 1, hashed_at TEXT DEFAULT (datetime('now')) ) `); legacy.run(` CREATE TABLE payload_sources ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, github_url TEXT NOT NULL UNIQUE ) `); legacy.run(` CREATE TABLE payload_files ( id INTEGER PRIMARY KEY AUTOINCREMENT, source_id INTEGER NOT NULL, asset_name TEXT NOT NULL, asset_url TEXT DEFAULT NULL, remote_hash TEXT DEFAULT NULL, version TEXT DEFAULT NULL, UNIQUE(source_id, asset_name) ) `); legacy.run(` CREATE TABLE cheat_files ( id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT UNIQUE NOT NULL, cusa_id TEXT NOT NULL, version TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '', data TEXT NOT NULL DEFAULT '{}', cached_at TEXT DEFAULT (datetime('now')) ) `); legacy.run(` CREATE TABLE automation_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, trigger TEXT NOT NULL DEFAULT 'manual', status TEXT NOT NULL DEFAULT 'running', started_at INTEGER NOT NULL ) `); legacy.run(` INSERT INTO games (game_id, title, content_id, size, version, installed, backed_up, ftp_path, last_scanned) VALUES ('CUSA12345', 'Legacy Example', 'CUSA12345X', 1234, '1.00', 1, 0, '/tmp/example', 1) `); legacy.run(` INSERT INTO backpork_folders (name, path) VALUES ('USB1', '/mnt/usb1') `); legacy.run(` INSERT INTO backups (game_id, backup_path) VALUES ('CUSA12345', '/mnt/usb1/games/example') `); legacy.run(` INSERT INTO game_hashes (game_id, file_hash, file_size, backup_path) VALUES ('CUSA12345', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 100, '/mnt/usb1/games/example/hash.bin') `); const buffer = legacy.export(); fs.writeFileSync(dbPath, Buffer.from(buffer)); legacy.close(); await withTempDatabase(async () => { await database.initialize(); const columns = database.runQuery("PRAGMA table_info(games)").map((row) => row.name); assert.ok(columns.includes('prospero_name')); assert.ok(columns.includes('metadata_failure_count')); const applied = database.runQuery('SELECT version, status FROM schema_migrations ORDER BY version'); assert.deepEqual(applied.map((row) => row.version), EXPECTED_MIGRATION_VERSIONS); assert.deepEqual(applied.map((row) => row.status), Array(EXPECTED_MIGRATION_VERSIONS.length).fill('applied')); assert.equal(database.getGame('CUSA12345').installed, 1); }, { root }); }); test('rerunning initialize is idempotent and keeps migration rows stable', async () => { await withTempDatabase(async () => { await database.initialize(); const first = database.runQuery('SELECT version, status FROM schema_migrations ORDER BY version'); assert.equal(first.length, EXPECTED_MIGRATION_VERSIONS.length); await database.initialize(); const second = database.runQuery('SELECT version, status FROM schema_migrations ORDER BY version'); assert.deepEqual(second.map((row) => row.version), first.map((row) => row.version)); assert.deepEqual(second.map((row) => row.status), first.map((row) => row.status)); }); }); test('forced migration failure restores from pre-migration snapshot', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-fail-migration-')); await withTempDatabase(async () => { const dbPath = path.join(root, 'chimera.db'); const SQL = await initSqlJs(); const db = new SQL.Database(); db.run(`CREATE TABLE games (id INTEGER PRIMARY KEY AUTOINCREMENT, game_id TEXT UNIQUE NOT NULL, title TEXT NOT NULL DEFAULT '', content_id TEXT DEFAULT '', size INTEGER DEFAULT 0, version TEXT DEFAULT '', installed INTEGER DEFAULT 0, backed_up INTEGER DEFAULT 0, ftp_path TEXT DEFAULT '', last_scanned TEXT DEFAULT NULL, created_at TEXT DEFAULT (datetime('now')))`); db.run(`INSERT INTO games (game_id, title, content_id, size, version) VALUES ('CUSA99999', 'Snapshot', 'CUSA99999', 5, '1.0')`); const seed = db.export(); fs.writeFileSync(dbPath, Buffer.from(seed)); db.close(); database.configure({ dataDir: root, logger: { info: () => {}, warn: () => {}, error: () => {} } }); database.setTestMigrationFailureVersion(2); await assert.rejects(() => database.initialize(), /Test-gedwongen migratiefout voor versie 2/); const rowsAfterFailure = database.runQuery('SELECT version, status, recovery_instructions FROM schema_migrations ORDER BY version'); assert.equal(rowsAfterFailure.length, 2); const failedRow = rowsAfterFailure.find((row) => row.version === 2); assert.equal(failedRow.status, 'failed'); assert.ok(typeof failedRow.recovery_instructions === 'string'); assert.ok(database.getGame('CUSA99999')); database.setTestMigrationFailureVersion(null); await database.initialize(); const rowsAfterRetry = database.runQuery('SELECT version, status FROM schema_migrations ORDER BY version'); assert.equal(rowsAfterRetry[1].status, 'applied'); assert.equal(rowsAfterRetry[1].version, 2); assert.equal(rowsAfterRetry[2].status, 'applied'); assert.equal(rowsAfterRetry[2].version, 3); assert.equal(rowsAfterRetry[3].status, 'applied'); assert.equal(rowsAfterRetry[3].version, 4); assert.equal(database.getGame('CUSA99999').title, 'Snapshot'); }, { root }); }); test('v3 schema shape includes title, copy, install, device, volume, and scan tables', async () => { await withTempDatabase(async () => { await database.initialize(); const required = [ 'titles', 'title_aliases', 'storage_devices', 'storage_volumes', 'scan_runs', 'scan_root_coverage', 'local_copies', 'console_installs', 'version_evidence', ]; const existing = database.runQuery(` SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${required.map((table) => `'${table}'`).join(',')}) `).map((row) => row.name); assert.deepEqual(existing.sort(), required.sort()); }); }); test('v3 schema constraints use foreign keys and location/version indexes', async () => { await withTempDatabase(async () => { await database.initialize(); const SQL = await initSqlJs(); const migrated = new SQL.Database(database.exportDatabaseBuffer()); const fkChecks = { local_copies: ['titles', 'storage_volumes', 'scan_runs'], console_installs: ['titles', 'storage_volumes', 'scan_runs', 'version_evidence'], storage_volumes: ['storage_devices'], title_aliases: ['titles'], scan_root_coverage: ['scan_runs', 'storage_volumes'], }; for (const [table, expected] of Object.entries(fkChecks)) { const rows = migrated.exec(`PRAGMA foreign_key_list('${table}')`); const refs = new Set(rows[0]?.values?.map((row) => row[2]) ?? []); for (const ref of expected) { assert.ok(refs.has(ref), `Expected ${table} to reference ${ref}`); } } const indexes = migrated.exec("SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_local_copies_%' OR name LIKE 'idx_console_installs_%'"); const indexNames = new Set(indexes[0]?.values?.map((row) => row[0]) ?? []); assert.ok(indexNames.has('idx_local_copies_title_last_seen')); assert.ok(indexNames.has('idx_console_installs_title_profile')); }); }); test('multi-location inserts are supported for local copies and console installs', async () => { await withTempDatabase(async () => { await database.initialize(); const SQL = await initSqlJs(); const migrated = new SQL.Database(database.exportDatabaseBuffer()); migrated.run(` INSERT INTO titles (platform, title_id, name, sort_name, region) VALUES ('ps5', 'CUSA12345', 'Legacy Example', 'Legacy Example', 'US') `); const titleId = migrated.exec("SELECT last_insert_rowid() AS id")[0]?.values?.[0]?.[0]; assert.ok(typeof titleId === 'number'); migrated.run(` INSERT INTO storage_devices (scope, stable_fingerprint, display_name, device_type) VALUES ('server', 'srv-volume-internal', 'Server Internal', 'internal'), ('server', 'srv-volume-external', 'Server USB', 'usb') `); const deviceRows = migrated.exec('SELECT id, stable_fingerprint FROM storage_devices ORDER BY stable_fingerprint')[0]?.values || []; assert.equal(deviceRows.length, 2); const internalId = deviceRows[0][0]; const usbId = deviceRows[1][0]; migrated.run(` INSERT INTO storage_volumes (device_id, stable_volume_key, mount_path, can_copy, can_play, can_install) VALUES (${internalId}, 'int-vol-1', '/games/ps5/internal', 1, 1, 1), (${usbId}, 'ext-vol-1', '/games/ps5/usb', 1, 1, 1) `); const volumes = migrated.exec('SELECT id, stable_volume_key FROM storage_volumes ORDER BY stable_volume_key')[0]?.values || []; assert.equal(volumes.length, 2); const internalVolumeId = volumes[0][0]; const externalVolumeId = volumes[1][0]; migrated.run(` INSERT INTO local_copies (title_id, volume_id, relative_path, copy_type, base_version, scan_run_id) VALUES (${titleId}, ${internalVolumeId}, 'game-folder', 'folder', '1.00', NULL), (${titleId}, ${externalVolumeId}, 'game-archive/game.pkg', 'archive', '1.00', NULL) `); migrated.run(` INSERT INTO console_installs (title_id, console_profile_id, volume_id, remote_location_key, install_state, installed_version) VALUES (${titleId}, 'console-A', ${internalVolumeId}, 'ux0:/app0/CUSA12345', 'observed', '1.00'), (${titleId}, 'console-A', ${externalVolumeId}, 'ext0:/app0/CUSA12345', 'observed', '1.01') `); const copiesCount = migrated.exec(`SELECT COUNT(*) FROM local_copies WHERE title_id = ${titleId}`)[0]?.values?.[0]?.[0]; const installsCount = migrated.exec(`SELECT COUNT(*) FROM console_installs WHERE title_id = ${titleId}`)[0]?.values?.[0]?.[0]; assert.equal(copiesCount, 2); assert.equal(installsCount, 2); }); }); test('device disconnect state is persisted as unavailable', async () => { await withTempDatabase(async () => { await database.initialize(); const SQL = await initSqlJs(); const migrated = new SQL.Database(database.exportDatabaseBuffer()); migrated.run(` INSERT INTO storage_devices (scope, stable_fingerprint, display_name, device_type) VALUES ('server', 'device-disconnect', 'USB Drive', 'usb') `); migrated.run(`UPDATE storage_devices SET availability_state = 'unavailable' WHERE stable_fingerprint = 'device-disconnect'`); const rows = migrated.exec(` SELECT availability_state FROM storage_devices WHERE stable_fingerprint = 'device-disconnect' `); assert.equal(rows[0].values[0][0], 'unavailable'); }); }); test('legacy games and backups migrate into v3 titles, copies and install records', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-legacy-v3-migrate-')); const dbPath = path.join(root, 'chimera.db'); const SQL = await initSqlJs(); const legacy = new SQL.Database(); legacy.run(` CREATE TABLE games ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL DEFAULT '', game_id TEXT UNIQUE NOT NULL, content_id TEXT DEFAULT '', size INTEGER DEFAULT 0, version TEXT DEFAULT '', installed INTEGER DEFAULT 0, backed_up INTEGER DEFAULT 0, ftp_path TEXT DEFAULT '', last_scanned TEXT DEFAULT NULL, created_at TEXT DEFAULT (datetime('now')) ) `); legacy.run(` CREATE TABLE backups ( id INTEGER PRIMARY KEY AUTOINCREMENT, game_id TEXT NOT NULL, backup_path TEXT NOT NULL UNIQUE ) `); legacy.run(` INSERT INTO games (game_id, title, content_id, size, version, installed, ftp_path) VALUES ('CUSA12345', 'Migrated Example', 'CUSA12345X', 1234, '1.00', 1, '/srv/internal/PS5/CUSA12345') `); legacy.run(` INSERT INTO backups (game_id, backup_path) VALUES ('CUSA12345', '/srv/internal/PS5/CUSA12345/folder') `); const buffer = legacy.export(); fs.writeFileSync(dbPath, Buffer.from(buffer)); legacy.close(); await withTempDatabase(async () => { await database.initialize(); const titleRows = database.runQuery("SELECT * FROM titles WHERE title_id = 'CUSA12345'"); assert.equal(titleRows.length, 1); const copies = database.runQuery("SELECT COUNT(*) AS c FROM local_copies"); const installs = database.runQuery("SELECT COUNT(*) AS c FROM console_installs"); const report = database.runQuery(`SELECT report_json FROM migration_reports WHERE migration_name = 'legacy-to-v3'`)[0]; assert.ok(report); const payload = JSON.parse(report.report_json || '{}'); assert.equal(copies[0].c, 1); assert.equal(installs[0].c, 1); assert.equal(payload.legacyGames, 1); assert.equal(payload.legacyBackups, 1); assert.equal(payload.ambiguousVersions.length, 0); const installState = database.runQuery(` SELECT install_state FROM console_installs ci JOIN titles t ON t.id = ci.title_id WHERE t.title_id = 'CUSA12345' `); assert.equal(installState[0].install_state, 'legacy-unverified'); }, { root }); }); test('legacy migration reports ambiguous version evidence without asserting certainty', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-legacy-v3-ambig-')); const dbPath = path.join(root, 'chimera.db'); const SQL = await initSqlJs(); const legacy = new SQL.Database(); legacy.run(` CREATE TABLE games ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL DEFAULT '', game_id TEXT NOT NULL, content_id TEXT DEFAULT '', size INTEGER DEFAULT 0, version TEXT DEFAULT '', installed INTEGER DEFAULT 0, backed_up INTEGER DEFAULT 0, ftp_path TEXT DEFAULT '', last_scanned TEXT DEFAULT NULL, created_at TEXT DEFAULT (datetime('now')) ) `); legacy.run(`CREATE TABLE backups (id INTEGER PRIMARY KEY AUTOINCREMENT, game_id TEXT NOT NULL, backup_path TEXT NOT NULL UNIQUE)`); legacy.run(` INSERT INTO games (game_id, title, content_id, size, version, installed) VALUES ('CUSA99999', 'Ambiguous Game', 'CUSA99999', 1, '1.00', 0), ('CUSA99999', 'Ambiguous Game', 'CUSA99999', 1, '1.01', 0) `); const buffer = legacy.export(); fs.writeFileSync(dbPath, Buffer.from(buffer)); legacy.close(); await withTempDatabase(async () => { await database.initialize(); const report = database.runQuery(`SELECT report_json FROM migration_reports WHERE migration_name = 'legacy-to-v3'`)[0]; assert.ok(report); const payload = JSON.parse(report.report_json || '{}'); assert.equal(payload.ambiguousVersions.length, 1); assert.equal(payload.ambiguousVersions[0].game_id, 'CUSA99999'); const titles = database.runQuery("SELECT COUNT(*) AS c FROM titles WHERE title_id = 'CUSA99999'"); assert.equal(titles[0].c, 1); }, { root }); }); test('legacy migration reports row counts and supports rollback', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-legacy-v3-report-')); const dbPath = path.join(root, 'chimera.db'); const SQL = await initSqlJs(); const legacy = new SQL.Database(); legacy.run(` CREATE TABLE games ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL DEFAULT '', game_id TEXT UNIQUE NOT NULL, content_id TEXT DEFAULT '', size INTEGER DEFAULT 0, version TEXT DEFAULT '', installed INTEGER DEFAULT 0, backed_up INTEGER DEFAULT 0, ftp_path TEXT DEFAULT '', last_scanned TEXT DEFAULT NULL, created_at TEXT DEFAULT (datetime('now')) ) `); legacy.run(`CREATE TABLE backups (id INTEGER PRIMARY KEY AUTOINCREMENT, game_id TEXT NOT NULL, backup_path TEXT NOT NULL UNIQUE)`); legacy.run(`INSERT INTO games (game_id, title, content_id, version, installed) VALUES ('CUSA12345', 'Report Game', 'CUSA12345', '1.0', 1)`); const buffer = legacy.export(); fs.writeFileSync(dbPath, Buffer.from(buffer)); legacy.close(); await withTempDatabase(async () => { database.configure({ dataDir: root, logger: { info: () => {}, warn: () => {}, error: () => {} } }); await database.initialize(); const report = database.runQuery(`SELECT report_json FROM migration_reports WHERE migration_name = 'legacy-to-v3'`)[0]; assert.ok(report); const payload = JSON.parse(report.report_json || '{}'); assert.equal(payload.migration, '0004-migrate-legacy-games-to-v3'); assert.equal(payload.legacyGames, 1); database.setTestMigrationFailureVersion(4); await assert.rejects(() => database.initialize(), /Test-gedwongen migratiefout voor versie 4/); const rowsAfterFailure = database.runQuery('SELECT version, status FROM schema_migrations ORDER BY version'); assert.equal(rowsAfterFailure.length, 4); assert.equal(rowsAfterFailure[3].status, 'failed'); assert.ok(database.getGame('CUSA12345')); database.setTestMigrationFailureVersion(null); await database.initialize(); const rowsAfterRetry = database.runQuery('SELECT version, status FROM schema_migrations ORDER BY version'); assert.equal(rowsAfterRetry[3].status, 'applied'); assert.equal(rowsAfterRetry[3].version, 4); assert.equal(database.getGame('CUSA12345').title, 'Report Game'); }, { root }); });