import { beforeEach, describe, expect, it } from "vitest"; import { SIGNAGE_PROVIDER_CONTRACT_VERSION } from "../src/server/providers/contract.js"; import { createNativeImageProvider } from "../src/server/providers/native-provider.js"; import { createProviderRegistry, type SignageProviderRegistry, } from "../src/server/providers/registry.js"; import { SignageProviderError } from "../src/server/providers/validation.js"; import { SIGNAGE_NATIVE_PROVIDER_ID } from "../src/shared/native-images.js"; import { createFakeStorage } from "./support/fake-storage.js"; import { announcementsFixture, createFixtureProvider, scriptureFixture, triviaFixture, } from "./support/fixture-provider.js"; import { makeImage } from "./support/images.js"; import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js"; const AT = "2026-08-27T15:00:00.000Z"; function request(overrides: Record = {}) { return { at: AT, audienceKeys: ["default"], channelGroupId: "group-1", selectionScopeId: "scope-1", sourceId: "active-slides", timelineId: "timeline-1", timezone: "America/Chicago", ...overrides, } as never; } describe("content provider registry", () => { let registry: SignageProviderRegistry; let invalidations: { providerId: string; itemId?: string; sourceId?: string }[]; beforeEach(() => { invalidations = []; registry = createProviderRegistry({ onInvalidate: (notice) => invalidations.push(notice), timeoutMs: 200, }); }); it("registers a provider, reports it as healthy, and unregisters it cleanly", () => { const announcements = announcementsFixture(); const handle = registry.capability.register(announcements.registration); expect(registry.capability.contractVersion).toBe(SIGNAGE_PROVIDER_CONTRACT_VERSION); expect(registry.has("fixture.announcements")).toBe(true); expect(registry.health()).toEqual([ { consecutiveFailures: 0, contractVersion: "1.0.0", defaultDurationMs: 5000, lastFailureAt: null, lastFailureCode: null, lastSuccessAt: null, providerId: "fixture.announcements", providerVersion: "1.0.0", registeredAt: expect.any(String), renderer: { contributionId: "fixture.announcements.slide", contributionVersion: "1.0.0" }, }, ]); handle.unregister(); expect(registry.has("fixture.announcements")).toBe(false); expect(registry.health()).toEqual([]); }); it("unregisters idempotently, so a rollback after a partial start is safe", () => { const handle = registry.capability.register(announcementsFixture().registration); handle.unregister(); handle.unregister(); // A late invalidation from a module being torn down is ordinary, not an error. expect(() => handle.invalidate({ itemId: "welcome" })).not.toThrow(); expect(invalidations).toEqual([]); // And the same provider may register again — a module restored after being disabled. expect(() => registry.capability.register(announcementsFixture().registration)).not.toThrow(); }); it("refuses a second live registration of the same provider id", () => { registry.capability.register(announcementsFixture().registration); expect(() => registry.capability.register(announcementsFixture().registration)).toThrowError( expect.objectContaining({ code: "SIGNAGE_PROVIDER_DUPLICATE" }), ); }); it("rejects a malformed registration with a sanitized code carrying none of the value", () => { const cases: readonly [unknown, string][] = [ [undefined, "SIGNAGE_PROVIDER_REGISTRATION_INVALID"], ["announcements", "SIGNAGE_PROVIDER_REGISTRATION_INVALID"], [ { ...announcementsFixture().registration, providerId: "" }, "SIGNAGE_PROVIDER_REGISTRATION_INVALID", ], [ { ...announcementsFixture().registration, providerVersion: "one" }, "SIGNAGE_PROVIDER_REGISTRATION_INVALID", ], [ { ...announcementsFixture().registration, resolveNext: "not a function" }, "SIGNAGE_PROVIDER_REGISTRATION_INVALID", ], [ { ...announcementsFixture().registration, defaultDurationMs: Number.NaN }, "SIGNAGE_PROVIDER_REGISTRATION_INVALID", ], [ { ...announcementsFixture().registration, renderer: { contributionId: "x" } }, "SIGNAGE_PROVIDER_REGISTRATION_INVALID", ], [ { ...announcementsFixture().registration, contractVersion: "2.0.0" }, "SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED", ], ]; for (const [value, code] of cases) { let thrown: unknown; try { registry.capability.register(value); } catch (error) { thrown = error; } expect(thrown).toBeInstanceOf(SignageProviderError); const error = thrown as SignageProviderError; expect(error.code).toBe(code); // The message is fixed, and nothing of the rejected registration is in it. expect(error.message).not.toMatch(/announcements|function|NaN|2\.0\.0/u); } expect(registry.health()).toEqual([]); }); it("separates a contract-version mismatch from a malformed registration, so an operator can act", () => { const outdated = createFixtureProvider({ contractVersion: "2.0.0", items: [{ id: "a", label: "A", payload: {} }], policy: "cycle", providerId: "fixture.future", sourceId: "s", sourceLabel: "S", }); expect(() => registry.capability.register(outdated.registration)).toThrowError( expect.objectContaining({ code: "SIGNAGE_PROVIDER_CONTRACT_UNSUPPORTED", providerId: "fixture.future", }), ); }); it("passes invalidation through as a notification, never as a command", () => { const handle = registry.capability.register(announcementsFixture().registration); handle.invalidate({ itemId: "greeting" }); handle.invalidate({ sourceId: "active-slides" }); handle.invalidate(); expect(invalidations).toEqual([ { itemId: "greeting", providerId: "fixture.announcements" }, { providerId: "fixture.announcements", sourceId: "active-slides" }, { providerId: "fixture.announcements" }, ]); }); }); describe("provider selection", () => { let registry: SignageProviderRegistry; beforeEach(() => { registry = createProviderRegistry({ timeoutMs: 200 }); }); it("supplies a provider with a scope, a time, and an audience — and nothing else", async () => { const announcements = announcementsFixture(); registry.capability.register(announcements.registration); await registry.resolveNext( "fixture.announcements", request({ audienceKeys: ["default", "online"] }), ); expect(announcements.resolveRequests).toHaveLength(1); expect(Object.keys(announcements.resolveRequests[0] ?? {}).sort()).toEqual([ "at", "audienceKeys", "channelGroupId", "selectionScopeId", "sourceId", "timelineId", "timezone", ]); // No principal, no session, no database, no storage, no URL, no permission. }); it("lets an announcements-shaped provider cycle its own active set forever", async () => { const announcements = announcementsFixture(); registry.capability.register(announcements.registration); for (let index = 0; index < 6; index += 1) { await registry.resolveNext("fixture.announcements", request()); } // Signage asked six times and got the provider's own rotation, wrapping where the provider // decided to wrap. Signage implements no repeat policy at all. expect(announcements.served).toEqual([ "welcome", "greeting", "giving", "kids", "welcome", "greeting", ]); }); it("lets trivia and scripture refuse to repeat until their pools are exhausted", async () => { const trivia = triviaFixture(); const scripture = scriptureFixture(); registry.capability.register(trivia.registration); registry.capability.register(scripture.registration); for (let index = 0; index < 4; index += 1) { await registry.resolveNext("fixture.trivia", request({ sourceId: "open-questions" })); await registry.resolveNext("fixture.scripture", request({ sourceId: "reading-plan" })); } // Every question appears once before any repeats; the fourth call starts a new pass. expect(trivia.served).toEqual(["q1", "q2", "q3", "q1"]); expect(scripture.served).toEqual(["psalm-23", "john-3", "psalm-23", "john-3"]); }); it("keeps one rotation per selection scope, and two scopes independent", async () => { const announcements = announcementsFixture(); registry.capability.register(announcements.registration); await registry.resolveNext("fixture.announcements", request({ selectionScopeId: "lobby" })); await registry.resolveNext("fixture.announcements", request({ selectionScopeId: "lobby" })); await registry.resolveNext("fixture.announcements", request({ selectionScopeId: "atrium" })); // Two source instances an operator deliberately created as separate rotations stay separate. expect(announcements.served).toEqual(["welcome", "greeting", "welcome"]); }); it("carries one logical selection with a variant per audience, and a required default", async () => { const announcements = announcementsFixture(); registry.capability.register(announcements.registration); await registry.resolveNext("fixture.announcements", request()); const outcome = await registry.resolveNext("fixture.announcements", request()); expect(outcome.kind).toBe("resolved"); if (outcome.kind !== "resolved" || !outcome.resolution) { throw new Error("Expected a resolution."); } expect(outcome.resolution.itemId).toBe("greeting"); // One resolution, two renderings. The provider was asked once. expect([...outcome.resolution.variants.keys()].sort()).toEqual(["default", "online"]); expect(outcome.resolution.variants.get("online")).toEqual({ headline: "Watching from home" }); expect(outcome.resolution.variants.get("default")).toEqual({ headline: "Good morning" }); expect(announcements.resolveRequests).toHaveLength(2); }); it("treats an empty pool as nothing right now, not as a failure", async () => { const trivia = triviaFixture(); trivia.setItems([]); registry.capability.register(trivia.registration); const outcome = await registry.resolveNext( "fixture.trivia", request({ sourceId: "open-questions" }), ); expect(outcome).toEqual({ kind: "resolved", resolution: null }); // A skip is not a failure, so nothing about provider health changes. expect(registry.health()[0]?.consecutiveFailures).toBe(0); expect(registry.health()[0]?.lastFailureCode).toBeNull(); }); it("reports a provider that throws as temporarily failed, and recovers when it stops", async () => { const announcements = announcementsFixture(); registry.capability.register(announcements.registration); announcements.failNext(2); const first = await registry.resolveNext("fixture.announcements", request()); const second = await registry.resolveNext("fixture.announcements", request()); expect(first).toEqual({ code: "SIGNAGE_PROVIDER_UNAVAILABLE", kind: "failed" }); expect(second).toEqual({ code: "SIGNAGE_PROVIDER_UNAVAILABLE", kind: "failed" }); expect(registry.health()[0]).toMatchObject({ consecutiveFailures: 2, lastFailureCode: "SIGNAGE_PROVIDER_UNAVAILABLE", }); const recovered = await registry.resolveNext("fixture.announcements", request()); expect(recovered.kind).toBe("resolved"); // Restoring a compatible provider allows a safe refresh without restarting the module. expect(registry.health()[0]).toMatchObject({ consecutiveFailures: 0, lastFailureCode: null }); }); it("bounds a provider that never answers, so one provider cannot freeze a channel group", async () => { const announcements = announcementsFixture(); registry.capability.register(announcements.registration); announcements.hang(true); const outcome = await registry.resolveNext("fixture.announcements", request()); expect(outcome).toEqual({ code: "SIGNAGE_PROVIDER_UNAVAILABLE", kind: "failed" }); }); it("distinguishes an explicit withdrawal from an item that is merely unavailable", async () => { const announcements = announcementsFixture(); registry.capability.register(announcements.registration); const missing = await registry.refresh( "fixture.announcements", request({ itemId: "not-a-real-item" }), ); announcements.withdraw("welcome"); const gone = await registry.refresh("fixture.announcements", request({ itemId: "welcome" })); expect(missing).toEqual({ kind: "resolved", resolution: null }); expect(gone.kind).toBe("resolved"); if (gone.kind !== "resolved") { throw new Error("Expected a resolution."); } expect(gone.resolution?.withdrawn).toBe(true); }); it("reports an unregistered provider as unregistered, never as failed", async () => { const outcome = await registry.resolveNext("fixture.nothing", request()); // A temporarily missing registration is not an error, and must not erase anything Signage has // already persisted about what that provider resolved. expect(outcome).toEqual({ kind: "unregistered" }); }); it("refuses a resolution this contract cannot store or render, without echoing it", async () => { const cyclic: Record = {}; cyclic.self = cyclic; const malformed = [ { itemId: "a", itemRevision: "1", variants: [] }, { itemId: "a", itemRevision: "1", variants: [{ audienceKey: "online", payload: {} }] }, { itemId: "a", itemRevision: "1", variants: [{ audienceKey: null, payload: cyclic }] }, { durationMs: Number.POSITIVE_INFINITY, itemId: "a", itemRevision: "1", variants: [{ audienceKey: null, payload: {} }], }, { itemId: "a", itemRevision: "1", variants: [{ audienceKey: null, payload: { when: new Date() } }], }, { itemId: "", itemRevision: "1", variants: [{ audienceKey: null, payload: {} }] }, ]; for (const resolution of malformed) { const provider = createFixtureProvider({ items: [], policy: "cycle", providerId: "fixture.malformed", sourceId: "s", sourceLabel: "S", }); const registration = { ...provider.registration, resolveNext: () => resolution as never, }; const handle = registry.capability.register(registration); const outcome = await registry.resolveNext("fixture.malformed", request({ sourceId: "s" })); expect(outcome).toEqual({ code: "SIGNAGE_PROVIDER_RESULT_INVALID", kind: "failed" }); handle.unregister(); } }); it("returns nothing at all for discovery when a provider is absent or failing", async () => { expect(await registry.listSources("fixture.absent", { at: AT, timezone: "UTC" })).toEqual([]); const announcements = announcementsFixture(); registry.capability.register(announcements.registration); announcements.failNext(1); expect( await registry.listSources("fixture.announcements", { at: AT, timezone: "UTC" }), ).toEqual([]); // The editor says "no sources right now"; provider health says why. expect(registry.health()[0]?.consecutiveFailures).toBe(1); }); it("discovers a provider's own sources and items for the timeline editor", async () => { const announcements = announcementsFixture(); registry.capability.register(announcements.registration); const sources = await registry.listSources("fixture.announcements", { at: AT, timezone: "UTC", }); const items = await registry.listItems("fixture.announcements", { at: AT, timezone: "UTC" }); expect(sources).toEqual([{ id: "active-slides", label: "Active announcements" }]); expect(items.map((item) => item.id)).toEqual(["welcome", "greeting", "giving", "kids"]); }); }); describe("Signage's own image provider", () => { let store: MemorySignageStore; async function seedImage(name: string, width = 1920, height = 1080): Promise { const { ingestNativeImage } = await import("../src/server/images/service.js"); const result = await ingestNativeImage({ bytes: await makeImage({ format: "png", height, width }), displayName: name, expiresAt: null, filename: `${name}.png`, storage: createFakeStorage(), store, }); return result.image.id; } beforeEach(() => { store = createMemorySignageStore(); }); it("registers through the identical capability an external provider uses", async () => { const registry = createProviderRegistry({ timeoutMs: 200 }); const native = createNativeImageProvider({ defaultDurationMs: 7000, store }); registry.capability.register(native.registration); expect(registry.health()).toEqual([ expect.objectContaining({ contractVersion: SIGNAGE_PROVIDER_CONTRACT_VERSION, defaultDurationMs: 7000, providerId: SIGNAGE_NATIVE_PROVIDER_ID, // Not a browser contribution: a display seeing this identity paints an image itself. renderer: { contributionId: "signage.native-image", contributionVersion: "1.0.0" }, }), ]); }); it("cycles the library in the order images were added", async () => { const first = await seedImage("first"); const second = await seedImage("second", 1600, 900); const registry = createProviderRegistry({ timeoutMs: 500 }); registry.capability.register( createNativeImageProvider({ defaultDurationMs: 7000, store }).registration, ); const served: (string | undefined)[] = []; for (let index = 0; index < 3; index += 1) { const outcome = await registry.resolveNext( SIGNAGE_NATIVE_PROVIDER_ID, request({ sourceId: "library" }), ); served.push(outcome.kind === "resolved" ? outcome.resolution?.itemId : undefined); } expect(served).toEqual([first, second, first]); }); it("carries object keys in its payload, never a delivery URL", async () => { const imageId = await seedImage("welcome"); const registry = createProviderRegistry({ timeoutMs: 500 }); registry.capability.register( createNativeImageProvider({ defaultDurationMs: 7000, store }).registration, ); const outcome = await registry.refresh( SIGNAGE_NATIVE_PROVIDER_ID, request({ itemId: imageId }), ); if (outcome.kind !== "resolved" || !outcome.resolution) { throw new Error("Expected a resolution."); } const payload = outcome.resolution.variants.get("default") as Record; expect(payload).toMatchObject({ imageId, kind: "native-image" }); expect(JSON.stringify(payload)).not.toMatch(/https?:|token=/u); }); it("treats a deleted image as an explicit withdrawal and an expired one as nothing right now", async () => { const imageId = await seedImage("welcome"); const registry = createProviderRegistry({ timeoutMs: 500 }); registry.capability.register( createNativeImageProvider({ defaultDurationMs: 7000, store }).registration, ); await store.updateImage(imageId, { expiresAt: new Date("2020-01-01T00:00:00.000Z") }); const expired = await registry.refresh( SIGNAGE_NATIVE_PROVIDER_ID, request({ itemId: imageId }), ); await store.deleteImage(imageId); const deleted = await registry.refresh( SIGNAGE_NATIVE_PROVIDER_ID, request({ itemId: imageId }), ); expect(expired).toEqual({ kind: "resolved", resolution: null }); expect(deleted.kind === "resolved" && deleted.resolution?.withdrawn).toBe(true); }); });