517 lines
20 KiB
TypeScript
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);
|
|
});
|
|
});
|