diff --git a/src/server/routes/viewers.ts b/src/server/routes/viewers.ts index 436904d..7fcae24 100644 --- a/src/server/routes/viewers.ts +++ b/src/server/routes/viewers.ts @@ -23,9 +23,11 @@ import { const OPAQUE_ID = /^[\w][\w.:-]{0,199}$/u; -function store(context: ModuleRouteHandlerContext): SignageStore { - return createSignageStore(context.services.database); -} +/** How a route reaches Signage's tables. Production uses the module database; the display contract + * test supplies the in-memory store so the real handlers can answer the real display controller. */ +export type ViewerRouteStore = (context: ModuleRouteHandlerContext) => SignageStore; + +const databaseStore: ViewerRouteStore = (context) => createSignageStore(context.services.database); /** * The safe Core device identifier, when the caller is a paired display. @@ -57,6 +59,7 @@ export function registerViewerRoutes( http: ModuleHttpRouteRegistrar, playback: () => SignagePlaybackService, registry: () => SignageProviderRegistry, + store: ViewerRouteStore = databaseStore, ): void { http.route({ method: "POST", diff --git a/tests/browser/display-contract.test.ts b/tests/browser/display-contract.test.ts new file mode 100644 index 0000000..09e3c2a --- /dev/null +++ b/tests/browser/display-contract.test.ts @@ -0,0 +1,223 @@ +// @vitest-environment happy-dom +import type { ModuleRouteDefinitionInput, ModuleRouteHandlerContext } from "@ekklesia/module-sdk"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createMemoryMediaCache, createMemoryStore } from "../../src/browser/storage.js"; +import { createDisplayController } from "../../src/display/controller.js"; +import { ingestNativeImage } from "../../src/server/images/service.js"; +import { + createPlaybackService, + type SignagePlaybackService, +} from "../../src/server/playback/service.js"; +import { createNativeImageProvider } from "../../src/server/providers/native-provider.js"; +import { + createProviderRegistry, + type SignageProviderRegistry, +} from "../../src/server/providers/registry.js"; +import { registerViewerRoutes } from "../../src/server/routes/viewers.js"; +import { addSlot, createTimeline } from "../../src/server/timelines/service.js"; +import { SIGNAGE_NATIVE_PROVIDER_ID } from "../../src/shared/native-images.js"; +import { SIGNAGE_DEFAULT_SETTINGS } from "../../src/shared/settings.js"; +import { createFakeStorage, type FakeStorage } from "../support/fake-storage.js"; +import { makeImage } from "../support/images.js"; +import { createMemorySignageStore, type MemorySignageStore } from "../support/memory-store.js"; +import { fakeConfiguration, fakeHandlerContext } from "../support/module-harness.js"; +import { createFakeHost, type FakeHost } from "./support/fake-host.js"; + +/** + * The display and the server, speaking to each other for real. + * + * Every other display test answers the controller with a hand-written stub, and every server test + * calls the service with a hand-written object. Each side was therefore only ever tested against its + * own idea of the other, and in production every heartbeat a display sent was refused with a 400: + * the display sent `null` for an absent value and the route accepted only a missing field. + * + * Here the **real** display controller's requests are answered by the **real** viewer routes, over + * Signage's in-memory store. A request one side builds and the other refuses fails this suite. + */ + +const START = Date.parse("2026-10-05T15:00:00.000Z"); +const DEVICE_ID = "6f1c2d3e-4b5a-4c6d-8e7f-901a2b3c4d5e"; + +interface RouteCall { + readonly method: string; + readonly path: string; + readonly status: number; + readonly code: string | undefined; +} + +/** Matches a relative module path against the routes the module registered, and runs the handler + * exactly as the platform would: one Request, one handler context. */ +function routeTable(routes: readonly ModuleRouteDefinitionInput[]) { + return routes.map((route) => { + const names: string[] = []; + const pattern = new RegExp( + `^${route.path.replace(/:(\w+)/gu, (_match, name: string) => { + names.push(name); + return "([^/]+)"; + })}$`, + "u", + ); + return { names, pattern, route }; + }); +} + +describe("display ↔ server contract", () => { + let clock: number; + let counter: number; + let store: MemorySignageStore; + let storage: FakeStorage; + let registry: SignageProviderRegistry; + let playback: SignagePlaybackService; + let calls: RouteCall[]; + let host: FakeHost; + let root: HTMLElement; + + const newId = () => { + counter += 1; + return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`; + }; + + beforeEach(async () => { + clock = START; + counter = 0; + calls = []; + store = createMemorySignageStore({ now: () => new Date(clock) }); + storage = createFakeStorage({ now: () => new Date(clock) }); + registry = createProviderRegistry({ now: () => new Date(clock), timeoutMs: 1000 }); + registry.capability.register( + createNativeImageProvider({ defaultDurationMs: 7000, now: () => new Date(clock), store }) + .registration, + ); + playback = createPlaybackService({ + newId, + now: () => clock, + registry, + setTimer: () => undefined, + settings: SIGNAGE_DEFAULT_SETTINGS, + store, + }); + await playback.ensureDefaultGroup(); + + // One native image on a pinned slot: the most ordinary thing a wall shows, and exactly the case + // whose heartbeat carries no contribution id. + const image = await ingestNativeImage({ + bytes: await makeImage({ format: "png", height: 1080, width: 1920 }), + displayName: "Welcome", + expiresAt: null, + filename: "welcome.png", + storage, + store, + }); + const timeline = await createTimeline({ name: "Lobby", newId, store }); + await addSlot({ + newId, + pinned: { itemId: image.image.id, providerId: SIGNAGE_NATIVE_PROVIDER_ID }, + store, + timelineId: timeline.timeline.id, + }); + const groups = await store.listChannelGroups(); + await playback.activate(groups[0]?.group.id as string, timeline.timeline.id, "now"); + + const registered: ModuleRouteDefinitionInput[] = []; + registerViewerRoutes( + { route: (definition) => registered.push(definition) }, + () => playback, + () => registry, + () => store, + ); + const table = routeTable(registered); + + host = createFakeHost({ + permissions: ["signage.display.view"], + principal: { deviceId: DEVICE_ID, kind: "device" } as never, + routes: [], + }); + Object.assign(host.context.api, { + async request(path: string, init?: RequestInit): Promise { + const method = (init?.method ?? "GET").toUpperCase(); + const relative = `/${path.replace(/^\//u, "").split("?")[0]}`; + for (const { names, pattern, route } of table) { + const match = pattern.exec(relative); + if (route.method !== method || !match) { + continue; + } + const params = Object.fromEntries(names.map((name, index) => [name, match[index + 1]])); + const context: ModuleRouteHandlerContext = fakeHandlerContext({ + config: fakeConfiguration({}), + params: Object.freeze(params) as never, + principal: { deviceId: DEVICE_ID, kind: "device" } as never, + storage, + }); + const request = new Request( + `https://api.example.test/api/v1/modules/signage${relative}`, + { + ...(init?.body === undefined ? {} : { body: init.body }), + headers: init?.headers ?? {}, + method, + }, + ); + const response = await route.handler(request, context); + const body = (await response + .clone() + .json() + .catch(() => null)) as { code?: string } | null; + calls.push({ code: body?.code, method, path: route.path, status: response.status }); + return response; + } + throw new Error( + `The display asked for ${method} ${relative}, which Signage does not serve.`, + ); + }, + }); + + root = document.createElement("div"); + document.body.append(root); + }); + + async function settle(): Promise { + for (let index = 0; index < 20; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + function controller() { + return createDisplayController({ + context: host.context, + decodeImage: () => Promise.resolve(), + media: createMemoryMediaCache(), + newViewerKey: () => "kiosk-contract-0001", + now: () => clock, + root, + store: createMemoryStore(), + }); + } + + it("the server accepts every request the display sends", async () => { + const display = controller(); + await display.start(); + await settle(); + await display.stop(); + + const sent = calls.map((call) => `${call.method} ${call.path}`); + expect(sent).toContain("POST /viewers/session"); + expect(sent).toContain("GET /channels/:channelId/display-state"); + expect(sent).toContain("POST /viewers/:viewerId/heartbeat"); + // Not one refusal, of any kind, for anything the display built itself. + expect(calls.filter((call) => call.status >= 400)).toEqual([]); + }); + + it("a heartbeat from a screen showing a native image is recorded", async () => { + const display = controller(); + await display.start(); + await settle(); + await display.stop(); + + const [viewer] = await store.listViewers(); + expect(viewer).toBeDefined(); + // What the display reported is what the roster now holds — which is only true if the server + // took the heartbeat, rather than refusing it and leaving the session's values in place. + expect(viewer?.reportedSelectionId).not.toBeNull(); + expect(viewer?.reportedProviderId).toBe(SIGNAGE_NATIVE_PROVIDER_ID); + expect(viewer?.reportedContributionId).toBeNull(); + }); +});