test(display): run the real display controller against the real viewer routes
Every display test answered the controller with a hand-written stub, and every viewer test called the service with a hand-written object, so neither side was ever tested against the other. In production every heartbeat was refused with a 400. This suite answers the real controller's requests with the real viewer route handlers over the in-memory store. The routes gain an optional store factory for that; production still uses the module database. Fails today: POST /viewers/:viewerId/heartbeat -> 400 SIGNAGE_INVALID_REQUEST, because the display sends reportedContributionId: null and the route accepts only a missing field. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5e418bf941
commit
7483d7cbbe
@@ -23,9 +23,11 @@ import {
|
|||||||
|
|
||||||
const OPAQUE_ID = /^[\w][\w.:-]{0,199}$/u;
|
const OPAQUE_ID = /^[\w][\w.:-]{0,199}$/u;
|
||||||
|
|
||||||
function store(context: ModuleRouteHandlerContext): SignageStore {
|
/** How a route reaches Signage's tables. Production uses the module database; the display contract
|
||||||
return createSignageStore(context.services.database);
|
* 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.
|
* The safe Core device identifier, when the caller is a paired display.
|
||||||
@@ -57,6 +59,7 @@ export function registerViewerRoutes(
|
|||||||
http: ModuleHttpRouteRegistrar,
|
http: ModuleHttpRouteRegistrar,
|
||||||
playback: () => SignagePlaybackService,
|
playback: () => SignagePlaybackService,
|
||||||
registry: () => SignageProviderRegistry,
|
registry: () => SignageProviderRegistry,
|
||||||
|
store: ViewerRouteStore = databaseStore,
|
||||||
): void {
|
): void {
|
||||||
http.route({
|
http.route({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -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<Response> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user