Files
Signage/tests/providers.test.ts
T
2026-08-28 16:41:54 -05:00

517 lines
20 KiB
TypeScript

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<string, unknown> = {}) {
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<string, unknown> = {};
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<string> {
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<string, unknown>;
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);
});
});