Files
Signage/tests/manifest.test.ts
ekklesiaandClaude Opus 5.5 a6d553efbb
CI / lint (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 16s
CI / test (pull_request) Successful in 21s
CI / build (pull_request) Successful in 20s
CI / integration (pull_request) Skipped
feat: move Signage to the Ekklesia 0.2 module contract (manifest v8, database ABI v1)
Signage moves from the legacy module contract to Ekklesia 0.2.0's, with no behaviour or schema
change:

- manifest version 8, `database: { abi: 1 }`, compatibility ^0.2.0 for platform and module SDK;
  the runtime declares the same ABI, as Core requires;
- every store reaches PostgreSQL through the platform's Module Database ABI v1 executor, with
  Signage's own Drizzle tables and the module-side adapter (`moduleDrizzle`) from
  @ekklesia/module-database. createSignageStore takes the executor, so no call site changes;
- the schema entrypoint exports only plain ABI v1 metadata (table names and foreign-key targets),
  derived from tables.ts with moduleDrizzleSchemaMetadata; no Drizzle object reaches Core;
- duplicate-image detection reads SQLSTATE 23505 from the stable Ekklesia database error, found
  with findEkklesiaDatabaseError through Drizzle's DrizzleQueryError wrapping. The old
  `error.code === "23505"` check never matched a wrapped error, so a duplicate-upload race
  surfaced as a 500 instead of the content-hash conflict.

tables.ts declarations, the migration, and its pinned checksum are unchanged.

Tests: an ABI v1 executor double that enforces Core's scope, parameter, and abort rules drives all
13 store transactions, their rollbacks, raw int8/timestamptz/jsonb decoding, and the 23505
regression, including generic and driver-shaped errors that must not count as duplicates.

CI, temporarily: the platform pin moves to v0.2.0 (1788d17) with a regenerated ci/pnpm-lock.yaml.
The integration job is disabled: from 0.2.0 the platform image is generic (ADR 0048) and installs
modules only as Module Packages, which Signage cannot be until its sharp dependency goes through
the native package flow. The job and ci/integration.sh stay, and the job returns in that change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 20:36:11 -05:00

300 lines
12 KiB
TypeScript

import { readFile } from "node:fs/promises";
import { EMPTY_MODULE_CONFIGURATION, parseModuleManifest } from "@ekklesia/module-sdk";
import { describe, expect, it } from "vitest";
import moduleRuntime from "../src/server/index.js";
import { readSignageSettings } from "../src/server/settings.js";
import { SIGNAGE_DEFAULT_SETTINGS } from "../src/shared/settings.js";
import {
fakeConfiguration,
fakeHandlerContext,
findRoute,
registerModule,
} from "./support/module-harness.js";
/** Signage's own copy of Core's overlap rule: two routes of one module under one method must never
* be able to match the same request path, because they may carry different permissions. */
function overlappingRoutes(routes: readonly { method: string; path: string }[]): readonly string[] {
const segmentsOf = (path: string) => (path === "/" ? [] : path.slice(1).split("/"));
const collisions: string[] = [];
for (let left = 0; left < routes.length; left += 1) {
for (let right = left + 1; right < routes.length; right += 1) {
const first = routes[left];
const second = routes[right];
if (!first || !second || first.method !== second.method) {
continue;
}
const a = segmentsOf(first.path);
const b = segmentsOf(second.path);
if (a.length !== b.length) {
continue;
}
const overlaps = a.every((segment, index) => {
const other = b[index] as string;
return segment.startsWith(":") || other.startsWith(":") || segment === other;
});
if (overlaps) {
collisions.push(`${first.method} ${first.path} ~ ${second.path}`);
}
}
}
return collisions;
}
async function readManifest() {
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
return parseModuleManifest(JSON.parse(await readFile(manifestUrl, "utf8")) as unknown);
}
describe("Signage module manifest", () => {
it("declares the extension seams other modules reach Signage through", async () => {
const manifest = await readManifest();
// The backend seam and the browser seam are versioned separately on purpose: a provider's
// registration contract and its renderer's input contract have different lifecycles, and
// versioning them together would force every provider to rebuild whenever either moved.
expect(manifest.capabilities).toEqual([
{ id: "signage.content-providers", version: "1.0.0" },
{ id: "signage.slide-renderer", version: "1.0.0" },
{ id: "signage.playback", version: "1.0.0" },
{ id: "signage.displays", version: "1.0.0" },
]);
});
it("declares the module's product identity at the current manifest version", async () => {
const manifest = await readManifest();
expect(manifest.manifestVersion).toBe(8);
expect(manifest.id).toBe("signage");
expect(manifest.name).toBe("Signage");
expect(manifest.version).toBe("0.1.2");
expect(manifest.compatibility).toEqual({ moduleSdk: "^0.2.0", platform: "^0.2.0" });
});
it("declares the Module Database ABI v1, so the platform hands it an executor and no ORM object", async () => {
const manifest = await readManifest();
// Platform ADR 0050: with `database.abi: 1`, `services.database` is the ABI v1 executor. Without
// it, a version 8 manifest would get the legacy Drizzle facade Signage no longer compiles against.
expect(manifest.database).toEqual({ abi: 1 });
});
it("declares every Signage permission, and none of them publicly", async () => {
const manifest = await readManifest();
expect(manifest.permissions.map((permission) => permission.id)).toEqual([
"signage.view",
"signage.assets.manage",
"signage.timelines.manage",
"signage.playback.control",
"signage.displays.manage",
"signage.display.view",
]);
// Nothing Signage owns is readable by an unauthenticated caller: a timeline names a church's
// schedule, and a display is a screen someone paired.
expect(manifest.permissions.every((permission) => permission.baseline === "restricted")).toBe(
true,
);
for (const permission of manifest.permissions) {
expect(permission.description.length).toBeGreaterThan(0);
}
});
it("declares one logical storage bucket and names no storage service anywhere", async () => {
const manifest = await readManifest();
const raw = await readFile(new URL("../ekklesia.module.json", import.meta.url), "utf8");
expect(manifest.storage.buckets).toEqual([
{
id: "native-images",
description:
"Archived original uploads and generated renditions for Signage's native images.",
},
]);
// A module that configured its own object store would be welded to one deployment (ADR 0030).
for (const forbidden of ["s3", "seaweed", "minio", "endpoint", "accessKey", "region"]) {
expect(raw.toLowerCase()).not.toContain(forbidden.toLowerCase());
}
});
it("declares both browser artifacts, with the display gated on a non-public permission", async () => {
const manifest = await readManifest();
expect(manifest.entrypoints.server).toBe("./dist/server/index.js");
expect(manifest.entrypoints.ui).toBe("./dist/ui/index.js");
expect(manifest.entrypoints.display).toEqual({
artifact: "./dist/display/index.js",
permission: "signage.display.view",
});
});
it("declares only operator settings, every one of them with a safe default and no secret", async () => {
const manifest = await readManifest();
expect(manifest.configuration.map((entry) => entry.key)).toEqual([
"timezone",
"nativeImageDurationSeconds",
"crossfadeMilliseconds",
"defaultBackgroundMode",
"defaultBackgroundColor",
"defaultBackgroundDarkened",
"defaultBlurPixels",
"providerTimeoutMilliseconds",
"playbackHistoryLimit",
"viewerHeartbeatWriteSeconds",
]);
// Nothing Signage needs configured has no safe default, so nothing can block it from starting.
expect(manifest.configuration.every((entry) => entry.required === false)).toBe(true);
expect(manifest.configuration.some((entry) => entry.type === "secret")).toBe(false);
});
});
describe("Signage server runtime", () => {
it("exports the typed runtime definition the compiled server entrypoint publishes", async () => {
// The runtime's database declaration must match the manifest's, or Core refuses to start it.
expect(moduleRuntime).toMatchObject({ database: { abi: 1 }, id: "signage", version: "0.1.2" });
});
it("registers its routes declaratively, with no database, storage, or realtime work", async () => {
const registration = await registerModule();
expect(registration.routes.map((route) => `${route.method} ${route.path}`).sort()).toEqual([
"DELETE /channel-groups/:groupId",
"DELETE /channels/:channelId",
"DELETE /images/:imageId",
"DELETE /timelines/:timelineId",
"DELETE /timelines/:timelineId/slots/:slotId",
"DELETE /timelines/:timelineId/sources/:sourceId",
"DELETE /viewers/:viewerId",
"GET /channel-groups",
"GET /channels/:channelId/display-state",
"GET /images",
"GET /images/:imageId",
"GET /playback/:groupId",
"GET /providers",
"GET /providers/:providerId/items",
"GET /providers/:providerId/sources",
"GET /settings",
"GET /timelines",
"GET /timelines/:timelineId",
"GET /viewers",
"PATCH /images/:imageId",
"PATCH /timelines/:timelineId",
"PATCH /timelines/:timelineId/slots/:slotId",
"PATCH /viewers/:viewerId",
"POST /channel-groups",
"POST /channel-groups/:groupId/channels",
"POST /images",
"POST /playback/:groupId/activate",
"POST /playback/:groupId/blackout",
"POST /playback/:groupId/next",
"POST /playback/:groupId/pause",
"POST /playback/:groupId/previous",
"POST /playback/:groupId/reload",
"POST /playback/:groupId/resume",
"POST /playback/:groupId/static",
"POST /timelines",
"POST /timelines/:timelineId/duplicate",
"POST /timelines/:timelineId/slots",
"POST /timelines/:timelineId/sources",
"POST /viewers/:viewerId/heartbeat",
"POST /viewers/session",
"PUT /timelines/:timelineId/slot-order",
]);
// Every route makes the body decision consciously, names a permission Signage's own manifest
// declares, and cannot collide with another — Core refuses a module whose routes overlap, and
// discovering that at API startup is discovering it in production.
for (const route of registration.routes) {
expect(route.permission.startsWith("signage.")).toBe(true);
expect(Number.isSafeInteger(route.maxBodyBytes)).toBe(true);
}
expect(overlappingRoutes(registration.routes)).toEqual([]);
// Every declared capability is registered during `register`, because Core refuses a module that
// declares one and does not.
expect([...registration.provided.keys()].sort()).toEqual([
"signage.content-providers",
"signage.displays",
"signage.playback",
"signage.slide-renderer",
]);
// Two realtime channels, each gated on a permission Signage owns: what a display needs, and
// what the dashboard needs, are different audiences and different permissions.
expect(registration.channels).toEqual([
{ channel: "display-state", permission: "signage.display.view" },
{ channel: "management-state", permission: "signage.view" },
]);
const upload = findRoute(registration, "POST", "/images");
expect(upload).toMatchObject({
bodyMode: "streamed",
maxBodyBytes: 26_214_400,
permission: "signage.assets.manage",
});
// Exactly one route streams. Everything else takes a small buffered body, or none at all.
expect(registration.routes.filter((route) => route.bodyMode === "streamed")).toHaveLength(1);
expect(findRoute(registration, "GET", "/settings").maxBodyBytes).toBe(0);
});
it("serves its own effective settings, and never a configuration value it did not choose to expose", async () => {
const route = findRoute(await registerModule(), "GET", "/settings");
const response = await route.handler(
new Request("http://localhost/settings"),
fakeHandlerContext({
config: fakeConfiguration({
crossfadeMilliseconds: 400,
nativeImageDurationSeconds: 12,
timezone: "America/Denver",
}),
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
crossfadeMs: 400,
moduleVersion: "0.1.2",
nativeImageDurationMs: 12_000,
nativePresentation: SIGNAGE_DEFAULT_SETTINGS.nativePresentation,
rendererInputVersion: "1.0.0",
timezone: "America/Denver",
// The dashboard is told the limits the server enforces rather than hardcoding them: a UI that
// believed a stale limit would offer an upload the server refuses.
uploads: { batchMaxFiles: 20, concurrency: 3, maxBytes: 26_214_400, maxPixels: 40_000_000 },
});
});
});
describe("Signage settings", () => {
it("falls back to exactly the defaults the manifest declares", async () => {
const manifest = await readManifest();
const settings = readSignageSettings(EMPTY_MODULE_CONFIGURATION);
const declared = new Map(manifest.configuration.map((entry) => [entry.key, entry]));
expect(settings).toEqual(SIGNAGE_DEFAULT_SETTINGS);
// The one place these two could drift is exactly the place a stale default would be invisible.
expect(declared.get("timezone")).toMatchObject({ default: settings.timezone });
expect(declared.get("nativeImageDurationSeconds")).toMatchObject({
default: settings.nativeImageDurationMs / 1000,
});
expect(declared.get("crossfadeMilliseconds")).toMatchObject({ default: settings.crossfadeMs });
expect(declared.get("defaultBlurPixels")).toMatchObject({
default: settings.nativePresentation.blurPixels,
});
expect(declared.get("playbackHistoryLimit")).toMatchObject({
default: settings.playbackHistoryLimit,
});
});
it("refuses a background colour that is not a plain six-digit hex value", () => {
const settings = readSignageSettings(
fakeConfiguration({ defaultBackgroundColor: "red; background: url(x)" }),
);
expect(settings.nativePresentation.backgroundColor).toBe("#000000");
});
it("refuses a background mode Signage does not implement", () => {
const settings = readSignageSettings(fakeConfiguration({ defaultBackgroundMode: "rainbow" }));
expect(settings.nativePresentation.backgroundMode).toBe("blurred");
});
});