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