276 lines
7.8 KiB
JavaScript
276 lines
7.8 KiB
JavaScript
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/,
|
|
);
|
|
});
|