Build Phase 1 application foundation

This commit is contained in:
Media
2026-08-16 19:16:45 -05:00
parent 9aab48643b
commit eb19675a5c
30 changed files with 3375 additions and 2207 deletions
+275
View File
@@ -0,0 +1,275 @@
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/,
);
});