Build Phase 1 application foundation
This commit is contained in:
+24
-5
@@ -1,13 +1,32 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:22.23.2-bookworm-slim AS dependencies
|
||||
|
||||
ARG PNPM_VERSION=10.29.3
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
RUN corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY server/package.json ./server/package.json
|
||||
COPY client/package.json ./client/package.json
|
||||
|
||||
RUN pnpm install --frozen-lockfile --prod --filter @announcements/server...
|
||||
|
||||
COPY server ./server
|
||||
|
||||
FROM node:22.23.2-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY --from=dependencies /app/server ./server
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
EXPOSE 7532
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
CMD ["node", "server/src/index.js"]
|
||||
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
const express = require("express");
|
||||
const http = require("http");
|
||||
const { Server } = require("socket.io");
|
||||
const cors = require("cors");
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: "*",
|
||||
},
|
||||
});
|
||||
|
||||
const PORT = 7532;
|
||||
|
||||
let currentSlide = 0;
|
||||
const slideSeconds = 7;
|
||||
|
||||
const { XMLParser } = require("fast-xml-parser");
|
||||
|
||||
let slides = [];
|
||||
|
||||
async function loadSlides() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"http://10.40.0.2:9000/announcements?list-type=2"
|
||||
);
|
||||
|
||||
const xml = await response.text();
|
||||
|
||||
const parser = new XMLParser();
|
||||
const data = parser.parse(xml);
|
||||
|
||||
const contents = data.ListBucketResult?.Contents || [];
|
||||
|
||||
const files = Array.isArray(contents)
|
||||
? contents
|
||||
: [contents];
|
||||
|
||||
slides = files
|
||||
.map((file) => file.Key)
|
||||
.filter((name) =>
|
||||
/\.(png|jpg|jpeg|webp)$/i.test(name)
|
||||
)
|
||||
.map((name) => {
|
||||
const encodedName = name
|
||||
.split("/")
|
||||
.map((part) => encodeURIComponent(part))
|
||||
.join("/");
|
||||
|
||||
return `http://10.40.0.2:9000/announcements/${encodedName}`;
|
||||
});
|
||||
|
||||
console.log("Slides loaded:", slides.length);
|
||||
|
||||
if (currentSlide >= slides.length) {
|
||||
currentSlide = 0;
|
||||
}
|
||||
|
||||
broadcastState();
|
||||
} catch (err) {
|
||||
console.error("Failed to load slides:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const mqtt = require("mqtt");
|
||||
|
||||
let mode = "on";
|
||||
|
||||
const mqttClient = mqtt.connect("mqtt://10.40.0.2");
|
||||
|
||||
mqttClient.on("connect", () => {
|
||||
console.log("MQTT connected");
|
||||
mqttClient.subscribe("announcements/mode");
|
||||
});
|
||||
|
||||
mqttClient.on("message", (topic, message) => {
|
||||
if (topic === "announcements/mode") {
|
||||
const value = message.toString().trim().toLowerCase();
|
||||
|
||||
if (value === "on" || value === "off") {
|
||||
mode = value;
|
||||
broadcastState();
|
||||
console.log("Announcement mode:", mode);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function broadcastState() {
|
||||
io.emit("slideshow:state", {
|
||||
currentSlide,
|
||||
slides,
|
||||
slideSeconds,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
console.log("Client connected:", socket.id);
|
||||
|
||||
socket.emit("slideshow:state", {
|
||||
currentSlide,
|
||||
slides,
|
||||
slideSeconds,
|
||||
mode,
|
||||
});
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (slides.length === 0) return;
|
||||
|
||||
currentSlide = (currentSlide + 1) % slides.length;
|
||||
broadcastState();
|
||||
}, slideSeconds * 1000);
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.send("Announcement server is running");
|
||||
});
|
||||
|
||||
loadSlides();
|
||||
|
||||
setInterval(() => {
|
||||
loadSlides();
|
||||
}, 30000);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Announcement server running on http://10.40.0.2:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@announcements/server",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "Announcements v2 API and realtime server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "13.0.2",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.2.1",
|
||||
"socket.io": "^4.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
const express = require("express");
|
||||
const {
|
||||
createDetailedHealthRouter,
|
||||
createHealthRouter,
|
||||
} = require("./health/routes");
|
||||
const { createSettingsRouter } = require("./settings/routes");
|
||||
const { createSettingsService } = require("./settings/service");
|
||||
|
||||
function createApp({ config, database, lifecycle, migrations }) {
|
||||
const app = express();
|
||||
const settingsService = createSettingsService(database);
|
||||
|
||||
app.disable("x-powered-by");
|
||||
app.set("trust proxy", config.trustProxy);
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
app.get("/api", (request, response) => {
|
||||
response.json({ name: "Announcements API", version: 2 });
|
||||
});
|
||||
app.use(
|
||||
"/health",
|
||||
createHealthRouter({ database, lifecycle, migrations }),
|
||||
);
|
||||
const detailedHealthRouterOptions = {
|
||||
config,
|
||||
database,
|
||||
lifecycle,
|
||||
migrations,
|
||||
};
|
||||
app.use(
|
||||
"/api/health",
|
||||
createDetailedHealthRouter(detailedHealthRouterOptions),
|
||||
);
|
||||
app.use(
|
||||
"/api/status",
|
||||
createDetailedHealthRouter(detailedHealthRouterOptions),
|
||||
);
|
||||
app.use("/api/settings", createSettingsRouter(settingsService));
|
||||
|
||||
app.use((request, response) => {
|
||||
response.status(404).json({
|
||||
error: {
|
||||
code: "NOT_FOUND",
|
||||
message: "Route not found",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.use((error, request, response, next) => {
|
||||
if (response.headersSent) {
|
||||
next(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof SyntaxError && error.status === 400 && "body" in error) {
|
||||
response.status(400).json({
|
||||
error: {
|
||||
code: "INVALID_JSON",
|
||||
message: "Request body must contain valid JSON",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const statusCode = error.statusCode || 500;
|
||||
response.status(statusCode).json({
|
||||
error: {
|
||||
code: error.code || "INTERNAL_ERROR",
|
||||
message:
|
||||
statusCode >= 500 ? "An internal server error occurred" : error.message,
|
||||
...(error.details ? { details: error.details } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createApp,
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
const path = require("node:path");
|
||||
|
||||
class ConfigError extends Error {
|
||||
constructor(message, field) {
|
||||
super(message);
|
||||
this.name = "ConfigError";
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
|
||||
function requiredString(env, name) {
|
||||
const value = env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new ConfigError(`${name} is required`, name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(env, name) {
|
||||
const value = env[name]?.trim();
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function integer(env, name, defaultValue, { min, max } = {}) {
|
||||
const raw = env[name];
|
||||
if (raw === undefined || raw === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (!/^-?\d+$/.test(raw)) {
|
||||
throw new ConfigError(`${name} must be an integer`, name);
|
||||
}
|
||||
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new ConfigError(`${name} must be a safe integer`, name);
|
||||
}
|
||||
if (min !== undefined && value < min) {
|
||||
throw new ConfigError(`${name} must be at least ${min}`, name);
|
||||
}
|
||||
if (max !== undefined && value > max) {
|
||||
throw new ConfigError(`${name} must be at most ${max}`, name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(env, name, defaultValue) {
|
||||
const raw = env[name];
|
||||
if (raw === undefined || raw === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (["true", "1", "yes", "on"].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (["false", "0", "no", "off"].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
throw new ConfigError(`${name} must be a boolean`, name);
|
||||
}
|
||||
|
||||
function url(env, name, { required = false, protocols } = {}) {
|
||||
const raw = required ? requiredString(env, name) : optionalString(env, name);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new ConfigError(`${name} must be a valid URL`, name);
|
||||
}
|
||||
|
||||
if (protocols && !protocols.includes(parsed.protocol)) {
|
||||
throw new ConfigError(
|
||||
`${name} must use one of these protocols: ${protocols.join(", ")}`,
|
||||
name,
|
||||
);
|
||||
}
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function bucketName(env) {
|
||||
const value = requiredString(env, "S3_BUCKET");
|
||||
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value)) {
|
||||
throw new ConfigError(
|
||||
"S3_BUCKET must be a valid lowercase S3 bucket name",
|
||||
"S3_BUCKET",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nodeEnvironment(env) {
|
||||
const value = env.NODE_ENV?.trim() || "development";
|
||||
if (!["development", "test", "production"].includes(value)) {
|
||||
throw new ConfigError(
|
||||
"NODE_ENV must be development, test, or production",
|
||||
"NODE_ENV",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timeZone(env) {
|
||||
const value = requiredString(env, "APP_TIMEZONE");
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format();
|
||||
} catch {
|
||||
throw new ConfigError("APP_TIMEZONE must be a valid IANA time zone", "APP_TIMEZONE");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function storageCredential(env, name, environment) {
|
||||
const value = optionalString(env, name);
|
||||
if (environment === "production" && value === null) {
|
||||
throw new ConfigError(`${name} is required in production`, name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function loadConfig(env = process.env) {
|
||||
const environment = nodeEnvironment(env);
|
||||
const databasePath = requiredString(env, "DATABASE_PATH");
|
||||
if (databasePath !== ":memory:" && !path.isAbsolute(databasePath)) {
|
||||
throw new ConfigError("DATABASE_PATH must be an absolute path", "DATABASE_PATH");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
environment,
|
||||
host: env.HOST?.trim() || "0.0.0.0",
|
||||
port: integer(env, "PORT", 7532, { min: 0, max: 65535 }),
|
||||
timeZone: timeZone(env),
|
||||
shutdownTimeoutMs: integer(env, "SHUTDOWN_TIMEOUT_MS", 10_000, {
|
||||
min: 1_000,
|
||||
max: 60_000,
|
||||
}),
|
||||
trustProxy: boolean(env, "TRUST_PROXY", true),
|
||||
database: Object.freeze({
|
||||
path: databasePath,
|
||||
busyTimeoutMs: integer(env, "DATABASE_BUSY_TIMEOUT_MS", 5_000, {
|
||||
min: 0,
|
||||
max: 60_000,
|
||||
}),
|
||||
}),
|
||||
storage: Object.freeze({
|
||||
endpoint: url(env, "S3_ENDPOINT", {
|
||||
required: true,
|
||||
protocols: ["http:", "https:"],
|
||||
}),
|
||||
region: env.S3_REGION?.trim() || "us-east-1",
|
||||
bucket: bucketName(env),
|
||||
accessKeyId: storageCredential(env, "S3_ACCESS_KEY_ID", environment),
|
||||
secretAccessKey: storageCredential(env, "S3_SECRET_ACCESS_KEY", environment),
|
||||
forcePathStyle: boolean(env, "S3_FORCE_PATH_STYLE", true),
|
||||
}),
|
||||
mqtt: Object.freeze({
|
||||
url: url(env, "MQTT_URL", {
|
||||
protocols: ["mqtt:", "mqtts:", "ws:", "wss:"],
|
||||
}),
|
||||
username: optionalString(env, "MQTT_USERNAME"),
|
||||
password: optionalString(env, "MQTT_PASSWORD"),
|
||||
}),
|
||||
notifications: Object.freeze({
|
||||
homeAssistantWebhookUrl: url(env, "HA_WEBHOOK_URL", {
|
||||
protocols: ["http:", "https:"],
|
||||
}),
|
||||
homeAssistantWebhookSecret: optionalString(env, "HA_WEBHOOK_SECRET"),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function redactedConfig(config) {
|
||||
return {
|
||||
environment: config.environment,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
timeZone: config.timeZone,
|
||||
trustProxy: config.trustProxy,
|
||||
database: {
|
||||
configured: Boolean(config.database.path),
|
||||
},
|
||||
storage: {
|
||||
configured: Boolean(
|
||||
config.storage.endpoint &&
|
||||
config.storage.bucket &&
|
||||
config.storage.accessKeyId &&
|
||||
config.storage.secretAccessKey,
|
||||
),
|
||||
region: config.storage.region,
|
||||
bucket: config.storage.bucket,
|
||||
forcePathStyle: config.storage.forcePathStyle,
|
||||
},
|
||||
mqtt: {
|
||||
configured: Boolean(config.mqtt.url),
|
||||
},
|
||||
notifications: {
|
||||
configured: Boolean(config.notifications.homeAssistantWebhookUrl),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ConfigError,
|
||||
loadConfig,
|
||||
redactedConfig,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
function openDatabase({ path: filename, busyTimeoutMs = 5_000 }) {
|
||||
if (filename !== ":memory:") {
|
||||
fs.mkdirSync(path.dirname(filename), { recursive: true });
|
||||
}
|
||||
|
||||
const database = new Database(filename, {
|
||||
timeout: busyTimeoutMs,
|
||||
});
|
||||
|
||||
database.pragma("foreign_keys = ON");
|
||||
database.pragma(`busy_timeout = ${busyTimeoutMs}`);
|
||||
if (filename !== ":memory:") {
|
||||
database.pragma("journal_mode = WAL");
|
||||
database.pragma("synchronous = NORMAL");
|
||||
}
|
||||
|
||||
return database;
|
||||
}
|
||||
|
||||
function checkDatabase(database) {
|
||||
database.prepare("SELECT 1 AS ok").get();
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkDatabase,
|
||||
openDatabase,
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const DEFAULT_MIGRATIONS_DIRECTORY = path.join(__dirname, "migrations");
|
||||
const MIGRATION_FILENAME = /^(\d+)_([a-z0-9_-]+)\.sql$/i;
|
||||
|
||||
function discoverMigrations(directory = DEFAULT_MIGRATIONS_DIRECTORY) {
|
||||
const migrations = fs
|
||||
.readdirSync(directory, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".sql"))
|
||||
.map((entry) => {
|
||||
const match = MIGRATION_FILENAME.exec(entry.name);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid migration filename: ${entry.name}`);
|
||||
}
|
||||
return {
|
||||
version: Number(match[1]),
|
||||
name: match[2],
|
||||
filename: entry.name,
|
||||
sql: fs.readFileSync(path.join(directory, entry.name), "utf8"),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.version - right.version);
|
||||
|
||||
const versions = new Set();
|
||||
for (const migration of migrations) {
|
||||
if (!Number.isSafeInteger(migration.version) || migration.version < 1) {
|
||||
throw new Error(`Invalid migration version: ${migration.filename}`);
|
||||
}
|
||||
if (versions.has(migration.version)) {
|
||||
throw new Error(`Duplicate migration version: ${migration.version}`);
|
||||
}
|
||||
versions.add(migration.version);
|
||||
}
|
||||
|
||||
return migrations;
|
||||
}
|
||||
|
||||
function ensureMigrationsTable(database) {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function readAppliedMigrations(database) {
|
||||
return database
|
||||
.prepare(
|
||||
`SELECT version, name, applied_at AS appliedAt
|
||||
FROM schema_migrations
|
||||
ORDER BY version`,
|
||||
)
|
||||
.all();
|
||||
}
|
||||
|
||||
function migrationStatus(database, directory = DEFAULT_MIGRATIONS_DIRECTORY) {
|
||||
ensureMigrationsTable(database);
|
||||
const available = discoverMigrations(directory);
|
||||
const applied = readAppliedMigrations(database);
|
||||
const appliedVersions = new Set(applied.map((migration) => migration.version));
|
||||
|
||||
return {
|
||||
currentVersion: applied.at(-1)?.version ?? 0,
|
||||
latestAvailableVersion: available.at(-1)?.version ?? 0,
|
||||
pendingVersions: available
|
||||
.filter((migration) => !appliedVersions.has(migration.version))
|
||||
.map((migration) => migration.version),
|
||||
applied,
|
||||
};
|
||||
}
|
||||
|
||||
function runMigrations(database, directory = DEFAULT_MIGRATIONS_DIRECTORY) {
|
||||
ensureMigrationsTable(database);
|
||||
const migrations = discoverMigrations(directory);
|
||||
const applied = new Map(
|
||||
readAppliedMigrations(database).map((migration) => [
|
||||
migration.version,
|
||||
migration.name,
|
||||
]),
|
||||
);
|
||||
const availableVersions = new Set(migrations.map((migration) => migration.version));
|
||||
|
||||
for (const [version] of applied) {
|
||||
if (!availableVersions.has(version)) {
|
||||
throw new Error(`Database contains unknown migration version ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
const insertMigration = database.prepare(
|
||||
`INSERT INTO schema_migrations (version, name, applied_at)
|
||||
VALUES (?, ?, ?)`,
|
||||
);
|
||||
const applyMigration = database.transaction((migration) => {
|
||||
database.exec(migration.sql);
|
||||
insertMigration.run(migration.version, migration.name, Date.now());
|
||||
});
|
||||
|
||||
for (const migration of migrations) {
|
||||
const appliedName = applied.get(migration.version);
|
||||
if (appliedName !== undefined) {
|
||||
if (appliedName !== migration.name) {
|
||||
throw new Error(
|
||||
`Migration ${migration.version} was applied as ${appliedName}, not ${migration.name}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
applyMigration(migration);
|
||||
}
|
||||
|
||||
return migrationStatus(database, directory);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_MIGRATIONS_DIRECTORY,
|
||||
discoverMigrations,
|
||||
migrationStatus,
|
||||
runMigrations,
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
CREATE TABLE assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_sha256 TEXT NOT NULL UNIQUE
|
||||
CHECK (length(source_sha256) = 64),
|
||||
display_name TEXT NOT NULL COLLATE NOCASE UNIQUE
|
||||
CHECK (length(trim(display_name)) > 0),
|
||||
original_filename TEXT NOT NULL
|
||||
CHECK (length(trim(original_filename)) > 0),
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN ('processing', 'ready', 'failed', 'deleting')),
|
||||
processing_error TEXT,
|
||||
expiration_at INTEGER,
|
||||
|
||||
original_object_key TEXT,
|
||||
original_content_type TEXT,
|
||||
original_byte_size INTEGER CHECK (original_byte_size IS NULL OR original_byte_size >= 0),
|
||||
original_width INTEGER CHECK (original_width IS NULL OR original_width > 0),
|
||||
original_height INTEGER CHECK (original_height IS NULL OR original_height > 0),
|
||||
|
||||
display_object_key TEXT,
|
||||
display_sha256 TEXT CHECK (display_sha256 IS NULL OR length(display_sha256) = 64),
|
||||
display_content_type TEXT,
|
||||
display_byte_size INTEGER CHECK (display_byte_size IS NULL OR display_byte_size >= 0),
|
||||
display_width INTEGER CHECK (display_width IS NULL OR display_width > 0),
|
||||
display_height INTEGER CHECK (display_height IS NULL OR display_height > 0),
|
||||
|
||||
thumbnail_object_key TEXT,
|
||||
thumbnail_sha256 TEXT CHECK (thumbnail_sha256 IS NULL OR length(thumbnail_sha256) = 64),
|
||||
thumbnail_content_type TEXT,
|
||||
thumbnail_byte_size INTEGER CHECK (thumbnail_byte_size IS NULL OR thumbnail_byte_size >= 0),
|
||||
thumbnail_width INTEGER CHECK (thumbnail_width IS NULL OR thumbnail_width > 0),
|
||||
thumbnail_height INTEGER CHECK (thumbnail_height IS NULL OR thumbnail_height > 0),
|
||||
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX assets_lifecycle_idx ON assets (status, expiration_at);
|
||||
|
||||
CREATE TABLE playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL COLLATE NOCASE UNIQUE
|
||||
CHECK (length(trim(name)) > 0),
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE playlist_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
playlist_id TEXT NOT NULL,
|
||||
asset_id TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
|
||||
duration_override_ms INTEGER
|
||||
CHECK (duration_override_ms IS NULL OR duration_override_ms > 0),
|
||||
background_mode_override TEXT
|
||||
CHECK (
|
||||
background_mode_override IS NULL OR
|
||||
background_mode_override IN ('blurred', 'solid', 'black')
|
||||
),
|
||||
background_color_override TEXT,
|
||||
background_darkened_override INTEGER
|
||||
CHECK (
|
||||
background_darkened_override IS NULL OR
|
||||
background_darkened_override IN (0, 1)
|
||||
),
|
||||
schedule_start_at INTEGER,
|
||||
schedule_end_at INTEGER,
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE RESTRICT,
|
||||
UNIQUE (playlist_id, position),
|
||||
UNIQUE (playlist_id, asset_id),
|
||||
CHECK (
|
||||
schedule_start_at IS NULL OR
|
||||
schedule_end_at IS NULL OR
|
||||
schedule_start_at < schedule_end_at
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX playlist_entries_asset_idx ON playlist_entries (asset_id);
|
||||
CREATE INDEX playlist_entries_schedule_idx
|
||||
ON playlist_entries (playlist_id, enabled, schedule_start_at, schedule_end_at);
|
||||
|
||||
CREATE TABLE app_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
default_duration_ms INTEGER NOT NULL DEFAULT 7000
|
||||
CHECK (default_duration_ms > 0),
|
||||
default_background_mode TEXT NOT NULL DEFAULT 'blurred'
|
||||
CHECK (default_background_mode IN ('blurred', 'solid', 'black')),
|
||||
default_solid_background_color TEXT NOT NULL DEFAULT '#000000'
|
||||
CHECK (length(trim(default_solid_background_color)) > 0),
|
||||
default_background_darkened INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (default_background_darkened IN (0, 1)),
|
||||
default_blur_px INTEGER NOT NULL DEFAULT 20
|
||||
CHECK (default_blur_px >= 0),
|
||||
crossfade_duration_ms INTEGER NOT NULL DEFAULT 1000
|
||||
CHECK (crossfade_duration_ms >= 0),
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO app_settings (id, updated_at)
|
||||
VALUES (1, CAST(strftime('%s', 'now') AS INTEGER) * 1000);
|
||||
|
||||
CREATE TABLE playback_state (
|
||||
output_id TEXT PRIMARY KEY,
|
||||
mode TEXT NOT NULL DEFAULT 'on' CHECK (mode IN ('on', 'off')),
|
||||
active_playlist_id TEXT,
|
||||
pending_playlist_id TEXT,
|
||||
pending_playlist_requested_at INTEGER,
|
||||
current_entry_id TEXT,
|
||||
rotation_paused INTEGER NOT NULL DEFAULT 0 CHECK (rotation_paused IN (0, 1)),
|
||||
paused_remaining_ms INTEGER
|
||||
CHECK (paused_remaining_ms IS NULL OR paused_remaining_ms >= 0),
|
||||
static_asset_id TEXT,
|
||||
slide_started_at INTEGER,
|
||||
next_slide_at INTEGER,
|
||||
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (active_playlist_id) REFERENCES playlists(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (pending_playlist_id) REFERENCES playlists(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (current_entry_id) REFERENCES playlist_entries(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (static_asset_id) REFERENCES assets(id) ON DELETE SET NULL,
|
||||
CHECK (
|
||||
(pending_playlist_id IS NULL AND pending_playlist_requested_at IS NULL) OR
|
||||
(pending_playlist_id IS NOT NULL AND pending_playlist_requested_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
INSERT INTO playback_state (output_id, updated_at)
|
||||
VALUES ('global', CAST(strftime('%s', 'now') AS INTEGER) * 1000);
|
||||
|
||||
CREATE TABLE asset_import_sources (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_system TEXT NOT NULL,
|
||||
source_bucket TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
source_etag TEXT,
|
||||
source_byte_size INTEGER CHECK (source_byte_size IS NULL OR source_byte_size >= 0),
|
||||
source_sha256 TEXT CHECK (source_sha256 IS NULL OR length(source_sha256) = 64),
|
||||
asset_id TEXT,
|
||||
initial_playlist_entry_id TEXT,
|
||||
status TEXT NOT NULL
|
||||
CHECK (status IN ('pending', 'processing', 'imported', 'failed')),
|
||||
error TEXT,
|
||||
imported_at INTEGER,
|
||||
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (initial_playlist_entry_id) REFERENCES playlist_entries(id) ON DELETE SET NULL,
|
||||
UNIQUE (source_system, source_bucket, source_key)
|
||||
);
|
||||
|
||||
CREATE INDEX asset_import_sources_status_idx ON asset_import_sources (status);
|
||||
|
||||
CREATE TABLE notification_incidents (
|
||||
id TEXT PRIMARY KEY,
|
||||
condition_key TEXT NOT NULL,
|
||||
episode INTEGER NOT NULL CHECK (episode > 0),
|
||||
event TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('open', 'recovered')),
|
||||
message TEXT NOT NULL,
|
||||
context_json TEXT NOT NULL DEFAULT '{}',
|
||||
opened_at INTEGER NOT NULL,
|
||||
last_observed_at INTEGER NOT NULL,
|
||||
recovered_at INTEGER,
|
||||
UNIQUE (condition_key, episode),
|
||||
CHECK (
|
||||
(state = 'open' AND recovered_at IS NULL) OR
|
||||
(state = 'recovered' AND recovered_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX notification_incidents_one_open_idx
|
||||
ON notification_incidents (condition_key)
|
||||
WHERE state = 'open';
|
||||
|
||||
CREATE TABLE notification_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
incident_id TEXT NOT NULL,
|
||||
transition TEXT NOT NULL CHECK (transition IN ('failure', 'recovery')),
|
||||
payload_json TEXT NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
|
||||
next_attempt_at INTEGER NOT NULL,
|
||||
delivered_at INTEGER,
|
||||
last_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (incident_id) REFERENCES notification_incidents(id) ON DELETE CASCADE,
|
||||
UNIQUE (incident_id, transition)
|
||||
);
|
||||
|
||||
CREATE INDEX notification_outbox_due_idx
|
||||
ON notification_outbox (next_attempt_at)
|
||||
WHERE delivered_at IS NULL;
|
||||
|
||||
CREATE TABLE display_clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
output_id TEXT NOT NULL DEFAULT 'global',
|
||||
first_seen_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
active_playlist_id TEXT,
|
||||
playlist_revision INTEGER,
|
||||
playback_revision INTEGER,
|
||||
current_entry_id TEXT,
|
||||
current_asset_id TEXT,
|
||||
cached_asset_count INTEGER CHECK (cached_asset_count IS NULL OR cached_asset_count >= 0),
|
||||
playable_asset_count INTEGER CHECK (playable_asset_count IS NULL OR playable_asset_count >= 0),
|
||||
last_error TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX display_clients_last_seen_idx ON display_clients (last_seen_at);
|
||||
@@ -0,0 +1,89 @@
|
||||
const express = require("express");
|
||||
const { checkDatabase } = require("../database");
|
||||
const { redactedConfig } = require("../config");
|
||||
|
||||
function databaseHealth(database) {
|
||||
try {
|
||||
checkDatabase(database);
|
||||
return { status: "ok" };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
message: "SQLite is unavailable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function createHealthRouter({ database, lifecycle, migrations }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/live", (request, response) => {
|
||||
response.json({ status: "ok" });
|
||||
});
|
||||
|
||||
router.get("/ready", (request, response) => {
|
||||
const databaseCheck = databaseHealth(database);
|
||||
const migrationsCurrent = migrations.pendingVersions.length === 0;
|
||||
const ready =
|
||||
lifecycle.ready && databaseCheck.status === "ok" && migrationsCurrent;
|
||||
|
||||
response.status(ready ? 200 : 503).json({
|
||||
status: ready ? "ready" : "not_ready",
|
||||
checks: {
|
||||
database: databaseCheck.status,
|
||||
migrations: migrationsCurrent ? "current" : "pending",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function createDetailedHealthRouter({ config, database, lifecycle, migrations }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (request, response) => {
|
||||
const databaseCheck = databaseHealth(database);
|
||||
const ready =
|
||||
lifecycle.ready &&
|
||||
databaseCheck.status === "ok" &&
|
||||
migrations.pendingVersions.length === 0;
|
||||
|
||||
const configuration = redactedConfig(config);
|
||||
const status = ready ? "degraded" : "not_ready";
|
||||
|
||||
response.status(ready ? 200 : 503).json({
|
||||
status,
|
||||
configuration,
|
||||
services: {
|
||||
database: {
|
||||
...databaseCheck,
|
||||
currentSchemaVersion: migrations.currentVersion,
|
||||
latestSchemaVersion: migrations.latestAvailableVersion,
|
||||
},
|
||||
storage: {
|
||||
status: "not_checked",
|
||||
configured: configuration.storage.configured,
|
||||
},
|
||||
mqtt: {
|
||||
status: "not_started",
|
||||
configured: configuration.mqtt.configured,
|
||||
},
|
||||
notifications: {
|
||||
status: "not_started",
|
||||
configured: configuration.notifications.configured,
|
||||
},
|
||||
playback: { status: "not_started" },
|
||||
displays: { status: "not_started" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createDetailedHealthRouter,
|
||||
createHealthRouter,
|
||||
databaseHealth,
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
const path = require("node:path");
|
||||
require("dotenv").config({ path: path.resolve(__dirname, "../../.env") });
|
||||
|
||||
const { loadConfig, redactedConfig } = require("./config");
|
||||
const { createRuntime } = require("./runtime");
|
||||
|
||||
async function startApplication({ env = process.env, logger = console } = {}) {
|
||||
const config = loadConfig(env);
|
||||
const runtime = createRuntime({ config, logger });
|
||||
const address = await runtime.start();
|
||||
const displayedHost = address.family === "IPv6" ? `[${address.address}]` : address.address;
|
||||
|
||||
logger.info?.(
|
||||
`Announcements API listening on http://${displayedHost}:${address.port}`,
|
||||
);
|
||||
logger.info?.("Configuration loaded", redactedConfig(config));
|
||||
|
||||
let shutdownStarted = false;
|
||||
const shutdown = async (signal) => {
|
||||
if (shutdownStarted) return;
|
||||
shutdownStarted = true;
|
||||
try {
|
||||
await runtime.stop(signal);
|
||||
} catch (error) {
|
||||
logger.error?.("Graceful shutdown failed", error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
};
|
||||
|
||||
process.once("SIGTERM", shutdown);
|
||||
process.once("SIGINT", shutdown);
|
||||
|
||||
return runtime;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await startApplication();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startApplication,
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
const http = require("node:http");
|
||||
const { Server: SocketServer } = require("socket.io");
|
||||
const { createApp } = require("./app");
|
||||
const { openDatabase } = require("./database");
|
||||
const { runMigrations } = require("./database/migrate");
|
||||
|
||||
function listen(httpServer, port, host) {
|
||||
return new Promise((resolve, reject) => {
|
||||
function onError(error) {
|
||||
httpServer.off("listening", onListening);
|
||||
reject(error);
|
||||
}
|
||||
function onListening() {
|
||||
httpServer.off("error", onError);
|
||||
resolve(httpServer.address());
|
||||
}
|
||||
httpServer.once("error", onError);
|
||||
httpServer.once("listening", onListening);
|
||||
httpServer.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
function createRuntime({ config, logger = console }) {
|
||||
const database = openDatabase(config.database);
|
||||
let migrations;
|
||||
try {
|
||||
migrations = runMigrations(database);
|
||||
} catch (error) {
|
||||
database.close();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const lifecycle = {
|
||||
ready: false,
|
||||
stopping: false,
|
||||
};
|
||||
const app = createApp({ config, database, lifecycle, migrations });
|
||||
const httpServer = http.createServer(app);
|
||||
const io = new SocketServer(httpServer, {
|
||||
serveClient: false,
|
||||
});
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
logger.info?.(`Socket.IO client connected: ${socket.id}`);
|
||||
socket.on("disconnect", () => {
|
||||
logger.info?.(`Socket.IO client disconnected: ${socket.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
let address = null;
|
||||
let startPromise = null;
|
||||
let stopPromise = null;
|
||||
|
||||
async function start() {
|
||||
if (stopPromise) {
|
||||
throw new Error("Cannot start a runtime after shutdown has begun");
|
||||
}
|
||||
if (startPromise) {
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
startPromise = listen(httpServer, config.port, config.host)
|
||||
.then((listeningAddress) => {
|
||||
address = listeningAddress;
|
||||
lifecycle.ready = true;
|
||||
return listeningAddress;
|
||||
})
|
||||
.catch((error) => {
|
||||
database.close();
|
||||
throw error;
|
||||
});
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
async function stop(reason = "shutdown") {
|
||||
if (stopPromise) {
|
||||
return stopPromise;
|
||||
}
|
||||
|
||||
lifecycle.ready = false;
|
||||
lifecycle.stopping = true;
|
||||
stopPromise = new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
if (database.open) {
|
||||
database.close();
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
logger.warn?.(
|
||||
`Graceful shutdown timed out after ${config.shutdownTimeoutMs}ms`,
|
||||
);
|
||||
httpServer.closeAllConnections?.();
|
||||
finish();
|
||||
}, config.shutdownTimeoutMs);
|
||||
timeout.unref?.();
|
||||
|
||||
if (!httpServer.listening) {
|
||||
io.close();
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info?.(`Stopping Announcements API (${reason})`);
|
||||
io.close(finish);
|
||||
});
|
||||
return stopPromise;
|
||||
}
|
||||
|
||||
return {
|
||||
app,
|
||||
config,
|
||||
database,
|
||||
httpServer,
|
||||
io,
|
||||
lifecycle,
|
||||
migrations,
|
||||
get address() {
|
||||
return address;
|
||||
},
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createRuntime,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
const express = require("express");
|
||||
|
||||
function createSettingsRouter(settingsService) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (request, response) => {
|
||||
response.json(settingsService.get());
|
||||
});
|
||||
|
||||
router.patch("/", (request, response, next) => {
|
||||
try {
|
||||
response.json(settingsService.update(request.body));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createSettingsRouter,
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
const SETTING_COLUMNS = Object.freeze({
|
||||
defaultDurationMs: "default_duration_ms",
|
||||
defaultBackgroundMode: "default_background_mode",
|
||||
defaultSolidBackgroundColor: "default_solid_background_color",
|
||||
defaultBackgroundDarkened: "default_background_darkened",
|
||||
defaultBlurPx: "default_blur_px",
|
||||
crossfadeDurationMs: "crossfade_duration_ms",
|
||||
});
|
||||
|
||||
class SettingsValidationError extends Error {
|
||||
constructor(message, details = {}) {
|
||||
super(message);
|
||||
this.name = "SettingsValidationError";
|
||||
this.statusCode = 400;
|
||||
this.code = "INVALID_SETTINGS";
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function mapSettings(row) {
|
||||
return {
|
||||
revision: row.revision,
|
||||
defaultDurationMs: row.default_duration_ms,
|
||||
defaultBackgroundMode: row.default_background_mode,
|
||||
defaultSolidBackgroundColor: row.default_solid_background_color,
|
||||
defaultBackgroundDarkened: Boolean(row.default_background_darkened),
|
||||
defaultBlurPx: row.default_blur_px,
|
||||
crossfadeDurationMs: row.crossfade_duration_ms,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function validateSafeInteger(name, value, { positive = false } = {}) {
|
||||
if (!Number.isSafeInteger(value) || (positive ? value <= 0 : value < 0)) {
|
||||
const qualifier = positive ? "a positive" : "a non-negative";
|
||||
throw new SettingsValidationError(`${name} must be ${qualifier} integer`, {
|
||||
field: name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function validatePatch(input) {
|
||||
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
||||
throw new SettingsValidationError("Settings payload must be a JSON object");
|
||||
}
|
||||
|
||||
const keys = Object.keys(input);
|
||||
const unknown = keys.filter((key) => !Object.hasOwn(SETTING_COLUMNS, key));
|
||||
if (unknown.length > 0) {
|
||||
throw new SettingsValidationError("Settings payload contains unknown fields", {
|
||||
fields: unknown,
|
||||
});
|
||||
}
|
||||
if (keys.length === 0) {
|
||||
throw new SettingsValidationError("At least one setting is required");
|
||||
}
|
||||
|
||||
if ("defaultDurationMs" in input) {
|
||||
validateSafeInteger("defaultDurationMs", input.defaultDurationMs, {
|
||||
positive: true,
|
||||
});
|
||||
}
|
||||
if (
|
||||
"defaultBackgroundMode" in input &&
|
||||
!["blurred", "solid", "black"].includes(input.defaultBackgroundMode)
|
||||
) {
|
||||
throw new SettingsValidationError(
|
||||
"defaultBackgroundMode must be blurred, solid, or black",
|
||||
{ field: "defaultBackgroundMode" },
|
||||
);
|
||||
}
|
||||
if (
|
||||
"defaultSolidBackgroundColor" in input &&
|
||||
(typeof input.defaultSolidBackgroundColor !== "string" ||
|
||||
!/^#[0-9a-f]{6}$/i.test(input.defaultSolidBackgroundColor))
|
||||
) {
|
||||
throw new SettingsValidationError(
|
||||
"defaultSolidBackgroundColor must be a six-digit hex color",
|
||||
{ field: "defaultSolidBackgroundColor" },
|
||||
);
|
||||
}
|
||||
if (
|
||||
"defaultBackgroundDarkened" in input &&
|
||||
typeof input.defaultBackgroundDarkened !== "boolean"
|
||||
) {
|
||||
throw new SettingsValidationError(
|
||||
"defaultBackgroundDarkened must be a boolean",
|
||||
{ field: "defaultBackgroundDarkened" },
|
||||
);
|
||||
}
|
||||
if ("defaultBlurPx" in input) {
|
||||
validateSafeInteger("defaultBlurPx", input.defaultBlurPx);
|
||||
}
|
||||
if ("crossfadeDurationMs" in input) {
|
||||
validateSafeInteger("crossfadeDurationMs", input.crossfadeDurationMs);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
function createSettingsService(database, { now = Date.now } = {}) {
|
||||
const read = database.prepare("SELECT * FROM app_settings WHERE id = 1");
|
||||
const update = database.prepare(`
|
||||
UPDATE app_settings
|
||||
SET revision = revision + 1,
|
||||
default_duration_ms = @defaultDurationMs,
|
||||
default_background_mode = @defaultBackgroundMode,
|
||||
default_solid_background_color = @defaultSolidBackgroundColor,
|
||||
default_background_darkened = @defaultBackgroundDarkened,
|
||||
default_blur_px = @defaultBlurPx,
|
||||
crossfade_duration_ms = @crossfadeDurationMs,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = 1
|
||||
`);
|
||||
|
||||
function get() {
|
||||
const row = read.get();
|
||||
if (!row) {
|
||||
throw new Error("The app_settings singleton row is missing");
|
||||
}
|
||||
return mapSettings(row);
|
||||
}
|
||||
|
||||
const updateTransaction = database.transaction((patch) => {
|
||||
validatePatch(patch);
|
||||
const current = get();
|
||||
const next = { ...current, ...patch };
|
||||
const changed = Object.keys(patch).some((key) => current[key] !== next[key]);
|
||||
|
||||
if (!changed) {
|
||||
return current;
|
||||
}
|
||||
|
||||
update.run({
|
||||
defaultDurationMs: next.defaultDurationMs,
|
||||
defaultBackgroundMode: next.defaultBackgroundMode,
|
||||
defaultSolidBackgroundColor: next.defaultSolidBackgroundColor,
|
||||
defaultBackgroundDarkened: next.defaultBackgroundDarkened ? 1 : 0,
|
||||
defaultBlurPx: next.defaultBlurPx,
|
||||
crossfadeDurationMs: next.crossfadeDurationMs,
|
||||
updatedAt: Math.max(now(), current.updatedAt + 1),
|
||||
});
|
||||
return get();
|
||||
});
|
||||
|
||||
return {
|
||||
get,
|
||||
update: updateTransaction,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SettingsValidationError,
|
||||
createSettingsService,
|
||||
mapSettings,
|
||||
validatePatch,
|
||||
};
|
||||
@@ -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