const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { openDatabase } = require("../src/database"); const { migrationStatus, runMigrations } = require("../src/database/migrate"); function migratedDatabase() { const database = openDatabase({ path: ":memory:" }); const migrations = runMigrations(database); return { database, migrations }; } function columnNames(database, table) { return database.pragma(`table_info(${table})`).map((column) => column.name); } test("initial migration creates every approved Phase 0 table and seed row", (t) => { const { database, migrations } = migratedDatabase(); t.after(() => database.close()); const tables = database .prepare( `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`, ) .all() .map((row) => row.name); assert.deepEqual(tables, [ "app_settings", "asset_import_sources", "assets", "display_clients", "notification_incidents", "notification_outbox", "playback_state", "playlist_entries", "playlists", "schema_migrations", ]); assert.equal(migrations.currentVersion, 1); assert.equal(migrations.latestAvailableVersion, 1); assert.deepEqual(migrations.pendingVersions, []); assert.deepEqual(database.prepare("SELECT * FROM app_settings").get(), { id: 1, revision: 1, default_duration_ms: 7000, default_background_mode: "blurred", default_solid_background_color: "#000000", default_background_darkened: 0, default_blur_px: 20, crossfade_duration_ms: 1000, updated_at: database.prepare("SELECT updated_at FROM app_settings").get() .updated_at, }); const playback = database.prepare("SELECT * FROM playback_state").get(); assert.equal(playback.output_id, "global"); assert.equal(playback.mode, "on"); assert.equal(playback.pending_playlist_id, null); assert.equal(playback.pending_playlist_requested_at, null); assert.ok(columnNames(database, "playlist_entries").includes( "background_darkened_override", )); }); test("migrations are idempotent and report their status", (t) => { const { database } = migratedDatabase(); t.after(() => database.close()); const rerun = runMigrations(database); assert.equal(rerun.applied.length, 1); assert.deepEqual(rerun.pendingVersions, []); assert.deepEqual(migrationStatus(database), rerun); }); test("a failed migration rolls back its schema and version record", (t) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), "announcements-migrations-")); const migrationPath = path.join(directory, "001_test.sql"); fs.writeFileSync( migrationPath, "CREATE TABLE should_roll_back (id INTEGER); THIS IS NOT SQL;", ); const database = openDatabase({ path: ":memory:" }); t.after(() => { database.close(); fs.rmSync(directory, { recursive: true, force: true }); }); assert.throws(() => runMigrations(database, directory)); assert.equal( database .prepare( "SELECT COUNT(*) AS count FROM sqlite_master WHERE name = 'should_roll_back'", ) .get().count, 0, ); assert.equal( database.prepare("SELECT COUNT(*) AS count FROM schema_migrations").get().count, 0, ); fs.writeFileSync(migrationPath, "CREATE TABLE recovered (id INTEGER);"); const status = runMigrations(database, directory); assert.equal(status.currentVersion, 1); assert.equal( database .prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE name = 'recovered'") .get().count, 1, ); }); test("a library asset can occur only once in a given playlist", (t) => { const { database } = migratedDatabase(); t.after(() => database.close()); const now = Date.now(); database .prepare( `INSERT INTO assets (id, source_sha256, display_name, original_filename, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'ready', ?, ?)`, ) .run("asset-1", "a".repeat(64), "Welcome", "welcome.png", now, now); database .prepare( `INSERT INTO playlists (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)`, ) .run("playlist-1", "Sunday", now, now); const insert = database.prepare( `INSERT INTO playlist_entries (id, playlist_id, asset_id, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, ); insert.run("entry-1", "playlist-1", "asset-1", 0, now, now); assert.throws( () => insert.run("entry-2", "playlist-1", "asset-1", 1, now, now), /UNIQUE constraint failed/, ); database .prepare( `INSERT INTO playlists (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)`, ) .run("playlist-2", "Midweek", now, now); assert.doesNotThrow(() => insert.run("entry-3", "playlist-2", "asset-1", 0, now, now), ); assert.throws( () => database.prepare("DELETE FROM assets WHERE id = 'asset-1'").run(), /FOREIGN KEY constraint failed/, ); }); test("darkening overrides accept inherited, off, and on only", (t) => { const { database } = migratedDatabase(); t.after(() => database.close()); const now = Date.now(); database .prepare( `INSERT INTO playlists (id, name, created_at, updated_at) VALUES ('playlist-1', 'Sunday', ?, ?)`, ) .run(now, now); const insertAsset = database.prepare( `INSERT INTO assets (id, source_sha256, display_name, original_filename, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'ready', ?, ?)`, ); const insertEntry = database.prepare( `INSERT INTO playlist_entries (id, playlist_id, asset_id, background_darkened_override, position, created_at, updated_at) VALUES (?, 'playlist-1', ?, ?, ?, ?, ?)`, ); for (const [index, override] of [null, 0, 1].entries()) { const assetId = `asset-${index}`; insertAsset.run( assetId, String(index).repeat(64), `Asset ${index}`, `asset-${index}.png`, now, now, ); assert.doesNotThrow(() => insertEntry.run( `entry-${index}`, assetId, override, index, now, now, ), ); } insertAsset.run( "asset-invalid", "f".repeat(64), "Invalid override", "invalid.png", now, now, ); assert.throws( () => insertEntry.run("entry-invalid", "asset-invalid", 2, 3, now, now), /CHECK constraint failed/, ); assert.throws( () => database .prepare("UPDATE app_settings SET default_background_darkened = 2 WHERE id = 1") .run(), /CHECK constraint failed/, ); }); test("pending playlist activation requires a playlist and request time together", (t) => { const { database } = migratedDatabase(); t.after(() => database.close()); const now = Date.now(); database .prepare( `INSERT INTO playlists (id, name, created_at, updated_at) VALUES ('playlist-1', 'Sunday', ?, ?)`, ) .run(now, now); assert.throws( () => database .prepare( "UPDATE playback_state SET pending_playlist_id = 'playlist-1' WHERE output_id = 'global'", ) .run(), /CHECK constraint failed/, ); assert.doesNotThrow(() => database .prepare( `UPDATE playback_state SET pending_playlist_id = 'playlist-1', pending_playlist_requested_at = ? WHERE output_id = 'global'`, ) .run(now), ); assert.throws( () => database.prepare("DELETE FROM playlists WHERE id = 'playlist-1'").run(), /FOREIGN KEY constraint failed/, ); assert.throws( () => database .prepare( `UPDATE playback_state SET pending_playlist_id = 'missing', pending_playlist_requested_at = ? WHERE output_id = 'global'`, ) .run(now), /FOREIGN KEY constraint failed/, ); });