142 lines
5.2 KiB
JavaScript
142 lines
5.2 KiB
JavaScript
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);
|
|
});
|