Build Phase 1 application foundation
This commit is contained in:
@@ -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/,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user