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
+87
View File
@@ -0,0 +1,87 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { ConfigError, loadConfig, redactedConfig } = require("../src/config");
function validEnvironment(overrides = {}) {
return {
NODE_ENV: "production",
HOST: "127.0.0.1",
PORT: "7532",
DATABASE_PATH: "/data/db/announcements.sqlite",
APP_TIMEZONE: "America/Chicago",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_REGION: "us-east-1",
S3_BUCKET: "announcements",
S3_ACCESS_KEY_ID: "test-access-key",
S3_SECRET_ACCESS_KEY: "test-secret-key",
S3_FORCE_PATH_STYLE: "true",
MQTT_URL: "mqtt://broker:1883",
MQTT_USERNAME: "mqtt-user",
MQTT_PASSWORD: "mqtt-password",
HA_WEBHOOK_URL: "http://home-assistant:8123/api/webhook/example",
HA_WEBHOOK_SECRET: "webhook-secret",
...overrides,
};
}
test("loadConfig validates and normalizes the supported environment", () => {
const config = loadConfig(validEnvironment());
assert.equal(config.environment, "production");
assert.equal(config.port, 7532);
assert.equal(config.timeZone, "America/Chicago");
assert.equal(config.storage.endpoint, "http://seaweedfs:8333");
assert.equal(config.storage.forcePathStyle, true);
});
test("production requires private S3 credentials", () => {
assert.throws(
() => loadConfig(validEnvironment({ S3_ACCESS_KEY_ID: "" })),
(error) =>
error instanceof ConfigError && error.field === "S3_ACCESS_KEY_ID",
);
const development = loadConfig(
validEnvironment({
NODE_ENV: "development",
S3_ACCESS_KEY_ID: "",
S3_SECRET_ACCESS_KEY: "",
}),
);
assert.equal(development.storage.accessKeyId, null);
assert.equal(development.storage.secretAccessKey, null);
});
test("loadConfig rejects invalid typed values", () => {
assert.throws(
() => loadConfig(validEnvironment({ PORT: "7532.5" })),
/PORT must be an integer/,
);
assert.throws(
() => loadConfig(validEnvironment({ APP_TIMEZONE: "Church/Local" })),
/valid IANA time zone/,
);
assert.throws(
() => loadConfig(validEnvironment({ S3_FORCE_PATH_STYLE: "sometimes" })),
/must be a boolean/,
);
assert.throws(
() => loadConfig(validEnvironment({ DATABASE_PATH: "relative.sqlite" })),
/must be an absolute path/,
);
});
test("redactedConfig never returns credentials, passwords, paths, or webhook URLs", () => {
const summary = redactedConfig(loadConfig(validEnvironment()));
const serialized = JSON.stringify(summary);
assert.equal(summary.storage.configured, true);
assert.equal(summary.mqtt.configured, true);
assert.equal(summary.notifications.configured, true);
assert.doesNotMatch(serialized, /test-access-key/);
assert.doesNotMatch(serialized, /test-secret-key/);
assert.doesNotMatch(serialized, /mqtt-password/);
assert.doesNotMatch(serialized, /webhook-secret/);
assert.doesNotMatch(serialized, /announcements\.sqlite/);
assert.doesNotMatch(serialized, /home-assistant/);
});
+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/,
);
});
+141
View File
@@ -0,0 +1,141 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { loadConfig } = require("../src/config");
const { createRuntime } = require("../src/runtime");
function testConfig() {
return loadConfig({
NODE_ENV: "test",
HOST: "127.0.0.1",
PORT: "0",
DATABASE_PATH: ":memory:",
APP_TIMEZONE: "America/Chicago",
SHUTDOWN_TIMEOUT_MS: "1000",
S3_ENDPOINT: "http://seaweedfs:8333",
S3_REGION: "us-east-1",
S3_BUCKET: "announcements",
S3_ACCESS_KEY_ID: "access-key-that-must-not-leak",
S3_SECRET_ACCESS_KEY: "secret-key-that-must-not-leak",
S3_FORCE_PATH_STYLE: "true",
MQTT_URL: "mqtt://broker:1883",
MQTT_PASSWORD: "mqtt-password-that-must-not-leak",
HA_WEBHOOK_URL: "http://home-assistant:8123/api/webhook/private-id",
});
}
const silentLogger = {
info() {},
warn() {},
error() {},
};
async function responseJson(baseUrl, path, init) {
const response = await fetch(`${baseUrl}${path}`, init);
return { response, body: await response.json() };
}
test("HTTP foundation exposes live, ready, detailed health, and persisted settings", async (t) => {
const runtime = createRuntime({ config: testConfig(), logger: silentLogger });
await runtime.start();
t.after(() => runtime.stop("test cleanup"));
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
const live = await responseJson(baseUrl, "/health/live");
assert.equal(live.response.status, 200);
assert.deepEqual(live.body, { status: "ok" });
const ready = await responseJson(baseUrl, "/health/ready");
assert.equal(ready.response.status, 200);
assert.equal(ready.body.status, "ready");
const status = await responseJson(baseUrl, "/api/health");
assert.equal(status.response.status, 200);
assert.equal(status.body.status, "degraded");
assert.equal(status.body.services.database.currentSchemaVersion, 1);
assert.equal(status.body.services.storage.status, "not_checked");
const statusText = JSON.stringify(status.body);
assert.doesNotMatch(statusText, /access-key-that-must-not-leak/);
assert.doesNotMatch(statusText, /secret-key-that-must-not-leak/);
assert.doesNotMatch(statusText, /mqtt-password-that-must-not-leak/);
assert.doesNotMatch(statusText, /private-id/);
const statusAlias = await responseJson(baseUrl, "/api/status");
assert.equal(statusAlias.response.status, 200);
assert.deepEqual(statusAlias.body, status.body);
const initial = await responseJson(baseUrl, "/api/settings");
assert.equal(initial.body.defaultBackgroundDarkened, false);
const changed = await responseJson(baseUrl, "/api/settings", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
defaultBackgroundDarkened: true,
defaultDurationMs: 9000,
}),
});
assert.equal(changed.response.status, 200);
assert.equal(changed.body.revision, 2);
assert.equal(changed.body.defaultBackgroundDarkened, true);
const reread = await responseJson(baseUrl, "/api/settings");
assert.equal(reread.body.defaultDurationMs, 9000);
});
test("invalid settings receive a structured client error", async (t) => {
const runtime = createRuntime({ config: testConfig(), logger: silentLogger });
await runtime.start();
t.after(() => runtime.stop("test cleanup"));
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
const result = await responseJson(baseUrl, "/api/settings", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ defaultBackgroundDarkened: 0.25 }),
});
assert.equal(result.response.status, 400);
assert.equal(result.body.error.code, "INVALID_SETTINGS");
});
test("liveness stays up while critical readiness failures return 503", async (t) => {
const runtime = createRuntime({ config: testConfig(), logger: silentLogger });
await runtime.start();
t.after(() => runtime.stop("test cleanup"));
const baseUrl = `http://127.0.0.1:${runtime.address.port}`;
runtime.lifecycle.ready = false;
let live = await responseJson(baseUrl, "/health/live");
let ready = await responseJson(baseUrl, "/health/ready");
assert.equal(live.response.status, 200);
assert.equal(ready.response.status, 503);
assert.equal(ready.body.status, "not_ready");
runtime.lifecycle.ready = true;
runtime.migrations.pendingVersions.push(2);
ready = await responseJson(baseUrl, "/health/ready");
assert.equal(ready.response.status, 503);
assert.equal(ready.body.checks.migrations, "pending");
runtime.migrations.pendingVersions.length = 0;
runtime.database.close();
live = await responseJson(baseUrl, "/health/live");
ready = await responseJson(baseUrl, "/health/ready");
assert.equal(live.response.status, 200);
assert.equal(ready.response.status, 503);
assert.equal(ready.body.checks.database, "error");
});
test("runtime startup and shutdown are idempotent", async () => {
const runtime = createRuntime({ config: testConfig(), logger: silentLogger });
const [firstAddress, secondAddress] = await Promise.all([
runtime.start(),
runtime.start(),
]);
assert.deepEqual(firstAddress, secondAddress);
assert.equal(runtime.lifecycle.ready, true);
await Promise.all([runtime.stop("test"), runtime.stop("test")]);
assert.equal(runtime.lifecycle.ready, false);
assert.equal(runtime.database.open, false);
});
+81
View File
@@ -0,0 +1,81 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { openDatabase } = require("../src/database");
const { runMigrations } = require("../src/database/migrate");
const {
SettingsValidationError,
createSettingsService,
} = require("../src/settings/service");
function createService() {
const database = openDatabase({ path: ":memory:" });
runMigrations(database);
return {
database,
service: createSettingsService(database, { now: () => 2_000_000_000_000 }),
};
}
test("settings are persisted with the approved defaults", (t) => {
const { database, service } = createService();
t.after(() => database.close());
assert.deepEqual(service.get(), {
revision: 1,
defaultDurationMs: 7000,
defaultBackgroundMode: "blurred",
defaultSolidBackgroundColor: "#000000",
defaultBackgroundDarkened: false,
defaultBlurPx: 20,
crossfadeDurationMs: 1000,
updatedAt: database.prepare("SELECT updated_at FROM app_settings").get()
.updated_at,
});
});
test("settings updates persist boolean darkening and increment revision", (t) => {
const { database, service } = createService();
t.after(() => database.close());
const updated = service.update({
defaultBackgroundDarkened: true,
defaultBlurPx: 24,
});
assert.equal(updated.revision, 2);
assert.equal(updated.defaultBackgroundDarkened, true);
assert.equal(updated.defaultBlurPx, 24);
assert.equal(
database.prepare("SELECT default_background_darkened FROM app_settings").get()
.default_background_darkened,
1,
);
});
test("settings reject strength values, unknown fields, and invalid values", (t) => {
const { database, service } = createService();
t.after(() => database.close());
const original = service.get();
assert.throws(
() => service.update({ defaultBlurPx: 24, darkeningOverride: 0.25 }),
(error) => error instanceof SettingsValidationError,
);
assert.deepEqual(service.get(), original);
assert.throws(
() => service.update({ defaultBlurPx: 24, defaultBackgroundDarkened: 1 }),
/must be a boolean/,
);
assert.deepEqual(service.get(), original);
assert.throws(
() => service.update({ defaultDurationMs: 0 }),
/positive integer/,
);
assert.deepEqual(service.get(), original);
assert.throws(
() => service.update({ toString: "not a setting" }),
/unknown fields/,
);
assert.deepEqual(service.get(), original);
});