feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
type ModuleStorage,
|
||||
type ModuleStorageBucket,
|
||||
ModuleStorageError,
|
||||
type ModuleStorageListing,
|
||||
type ModuleStorageObject,
|
||||
type ModuleStorageObjectMetadata,
|
||||
type ModuleStorageReadUrl,
|
||||
} from "@ekklesia/module-sdk";
|
||||
|
||||
/**
|
||||
* An in-memory `ModuleStorage`, scoped exactly as the real one is.
|
||||
*
|
||||
* `ModuleStorage` is a `@ekklesia/contracts` interface, so a module-layer test may legitimately
|
||||
* construct one — and doing so demonstrates the portability claim directly: the host supplying this
|
||||
* object could be Ekklesia over S3-compatible storage, or a future standalone host over something
|
||||
* else entirely, and Signage cannot tell.
|
||||
*
|
||||
* It refuses an undeclared bucket exactly as the real facade does, so a test cannot accidentally
|
||||
* pass by reaching a bucket the manifest never declared.
|
||||
*/
|
||||
export interface FakeStorage extends ModuleStorage {
|
||||
readonly objects: Map<string, { bytes: Uint8Array; contentType?: string }>;
|
||||
/** Test-only: make the next `put` for this key fail, so cleanup can be exercised. */
|
||||
failPut(key: string): void;
|
||||
readonly readUrlsIssued: string[];
|
||||
}
|
||||
|
||||
export function createFakeStorage(
|
||||
options: { readonly declared?: readonly string[]; readonly now?: () => Date } = {},
|
||||
): FakeStorage {
|
||||
const declared = new Set(options.declared ?? ["native-images"]);
|
||||
const now = options.now ?? (() => new Date());
|
||||
const objects = new Map<string, { bytes: Uint8Array; contentType?: string }>();
|
||||
const failingPuts = new Set<string>();
|
||||
const readUrlsIssued: string[] = [];
|
||||
|
||||
function makeBucket(id: string): ModuleStorageBucket {
|
||||
return {
|
||||
createReadUrl(key: string): Promise<ModuleStorageReadUrl> {
|
||||
readUrlsIssued.push(key);
|
||||
// Opaque by contract: shape, host, and query are the host's business. A module that parsed
|
||||
// one would be welded to a deployment.
|
||||
return Promise.resolve({
|
||||
expiresAt: new Date(now().getTime() + 15 * 60 * 1000),
|
||||
url: `https://storage.invalid/${encodeURIComponent(key)}?token=opaque`,
|
||||
});
|
||||
},
|
||||
|
||||
delete(key: string): Promise<void> {
|
||||
objects.delete(`${id}/${key}`);
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
get(key: string): Promise<ModuleStorageObject> {
|
||||
const stored = objects.get(`${id}/${key}`);
|
||||
if (!stored) {
|
||||
return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key));
|
||||
}
|
||||
return Promise.resolve({
|
||||
body: new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(stored.bytes);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
contentLength: stored.bytes.byteLength,
|
||||
key,
|
||||
...(stored.contentType === undefined ? {} : { contentType: stored.contentType }),
|
||||
});
|
||||
},
|
||||
|
||||
head(key: string): Promise<ModuleStorageObjectMetadata> {
|
||||
const stored = objects.get(`${id}/${key}`);
|
||||
if (!stored) {
|
||||
return Promise.reject(new ModuleStorageError("STORAGE_OBJECT_NOT_FOUND", key));
|
||||
}
|
||||
return Promise.resolve({ contentLength: stored.bytes.byteLength, key });
|
||||
},
|
||||
|
||||
id,
|
||||
|
||||
list(options_?: { readonly prefix?: string }): Promise<ModuleStorageListing> {
|
||||
const prefix = `${id}/${options_?.prefix ?? ""}`;
|
||||
return Promise.resolve({
|
||||
objects: [...objects.entries()]
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.map(([key, value]) => ({
|
||||
contentLength: value.bytes.byteLength,
|
||||
key: key.slice(id.length + 1),
|
||||
})),
|
||||
});
|
||||
},
|
||||
|
||||
put(
|
||||
key: string,
|
||||
body: unknown,
|
||||
putOptions?: { readonly contentType?: string },
|
||||
): Promise<ModuleStorageObjectMetadata> {
|
||||
if (failingPuts.has(key)) {
|
||||
failingPuts.delete(key);
|
||||
return Promise.reject(new ModuleStorageError("STORAGE_PROVIDER_UNAVAILABLE", key));
|
||||
}
|
||||
if (!(body instanceof Uint8Array)) {
|
||||
return Promise.reject(new ModuleStorageError("STORAGE_OPERATION_FAILED", key));
|
||||
}
|
||||
objects.set(`${id}/${key}`, {
|
||||
bytes: body,
|
||||
...(putOptions?.contentType === undefined ? {} : { contentType: putOptions.contentType }),
|
||||
});
|
||||
return Promise.resolve({ contentLength: body.byteLength, key });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bucket(logicalBucketId: string): ModuleStorageBucket {
|
||||
if (!declared.has(logicalBucketId)) {
|
||||
throw new ModuleStorageError("STORAGE_BUCKET_UNDECLARED", logicalBucketId);
|
||||
}
|
||||
return makeBucket(logicalBucketId);
|
||||
},
|
||||
buckets: () => [...declared].sort(),
|
||||
failPut(key: string): void {
|
||||
failingPuts.add(key);
|
||||
},
|
||||
objects,
|
||||
readUrlsIssued,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import type {
|
||||
SignageJsonValue,
|
||||
SignageProviderDiscoveryRequest,
|
||||
SignageProviderItemDescriptor,
|
||||
SignageProviderRefreshRequest,
|
||||
SignageProviderRegistration,
|
||||
SignageProviderResolution,
|
||||
SignageProviderResolveRequest,
|
||||
SignageProviderSourceDescriptor,
|
||||
} from "../../src/server/providers/contract.js";
|
||||
|
||||
/**
|
||||
* A **test-only** content provider, standing in for the real modules that do not exist yet.
|
||||
*
|
||||
* It is not an installed module, it has no manifest, and nothing about it is reachable from a
|
||||
* running Signage: it lives under `tests/`, and the only way it enters the system is a test calling
|
||||
* the same `register` a real provider module would call from its own `start` hook.
|
||||
*
|
||||
* The point is to prove the seam with something that behaves like the three real providers will:
|
||||
* an announcements-shaped provider that cycles continuously, and trivia- and scripture-shaped
|
||||
* providers that refuse to repeat until their pool is exhausted. **Signage implements none of those
|
||||
* policies** — that is the whole demonstration.
|
||||
*/
|
||||
|
||||
export interface FixtureItem {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
/** Payload for the default audience. */
|
||||
readonly payload: Record<string, SignageJsonValue>;
|
||||
/** Payload for one specific audience, when this item has one. */
|
||||
readonly audiencePayloads?: Readonly<Record<string, Record<string, SignageJsonValue>>>;
|
||||
readonly availableUntil?: string | null;
|
||||
readonly durationMs?: number;
|
||||
}
|
||||
|
||||
export type FixtureRepeatPolicy =
|
||||
/** Announcements: cycle the active set forever, in order, wrapping when it runs out. */
|
||||
| "cycle"
|
||||
/** Trivia and Scripture: never repeat until every currently available item has appeared. */
|
||||
| "no-repeat-until-exhausted";
|
||||
|
||||
export interface FixtureProviderOptions {
|
||||
readonly providerId: string;
|
||||
readonly sourceId: string;
|
||||
readonly sourceLabel: string;
|
||||
readonly items: readonly FixtureItem[];
|
||||
readonly policy: FixtureRepeatPolicy;
|
||||
readonly defaultDurationMs?: number;
|
||||
readonly contractVersion?: string;
|
||||
readonly rendererVersion?: string;
|
||||
}
|
||||
|
||||
export interface FixtureProvider {
|
||||
readonly registration: SignageProviderRegistration;
|
||||
/** Every item id this provider has handed out, in order, across every scope. */
|
||||
readonly served: string[];
|
||||
/** Every `resolveNext` request it received, so a test can prove what Signage supplied. */
|
||||
readonly resolveRequests: SignageProviderResolveRequest[];
|
||||
readonly refreshRequests: SignageProviderRefreshRequest[];
|
||||
/** Replace the available set at run time — an announcement expiring, a question pool refilling. */
|
||||
setItems(items: readonly FixtureItem[]): void;
|
||||
/** Make the next `n` calls fail the way a briefly unavailable provider does. */
|
||||
failNext(count: number): void;
|
||||
/** Make every call hang, so the deadline is what ends it. */
|
||||
hang(hanging: boolean): void;
|
||||
/** Declare one item explicitly gone. */
|
||||
withdraw(itemId: string): void;
|
||||
/** Change an item's payload without changing which item it is — a cancellation, a countdown. */
|
||||
reviseItem(itemId: string, payload: Record<string, SignageJsonValue>): void;
|
||||
}
|
||||
|
||||
export function createFixtureProvider(options: FixtureProviderOptions): FixtureProvider {
|
||||
let items = [...options.items];
|
||||
const revisions = new Map<string, number>();
|
||||
const withdrawn = new Set<string>();
|
||||
/** Per selection scope: which items have already been served in the current pass. This is the
|
||||
* provider's own state, exactly where the contract says it belongs — Signage stores none of it. */
|
||||
const seen = new Map<string, Set<string>>();
|
||||
const cursors = new Map<string, number>();
|
||||
const served: string[] = [];
|
||||
const resolveRequests: SignageProviderResolveRequest[] = [];
|
||||
const refreshRequests: SignageProviderRefreshRequest[] = [];
|
||||
let failures = 0;
|
||||
let hanging = false;
|
||||
|
||||
function guard(): void {
|
||||
if (failures > 0) {
|
||||
failures -= 1;
|
||||
throw new Error("fixture provider is briefly unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
function available(at: string): readonly FixtureItem[] {
|
||||
const instant = Date.parse(at);
|
||||
return items.filter((item) => {
|
||||
if (withdrawn.has(item.id)) {
|
||||
return false;
|
||||
}
|
||||
if (typeof item.availableUntil !== "string") {
|
||||
return true;
|
||||
}
|
||||
return instant < Date.parse(item.availableUntil);
|
||||
});
|
||||
}
|
||||
|
||||
function revisionOf(item: FixtureItem): string {
|
||||
return String(revisions.get(item.id) ?? 1);
|
||||
}
|
||||
|
||||
function resolutionFor(item: FixtureItem): SignageProviderResolution {
|
||||
const variants = [
|
||||
{ audienceKey: null, payload: item.payload },
|
||||
...Object.entries(item.audiencePayloads ?? {}).map(([audienceKey, payload]) => ({
|
||||
audienceKey,
|
||||
payload,
|
||||
})),
|
||||
];
|
||||
return {
|
||||
availableUntil: item.availableUntil ?? null,
|
||||
itemId: item.id,
|
||||
itemRevision: revisionOf(item),
|
||||
variants,
|
||||
...(item.durationMs === undefined ? {} : { durationMs: item.durationMs }),
|
||||
};
|
||||
}
|
||||
|
||||
function pickNext(scope: string, at: string): FixtureItem | null {
|
||||
const pool = available(at);
|
||||
if (pool.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (options.policy === "cycle") {
|
||||
const index = cursors.get(scope) ?? 0;
|
||||
const item = pool[index % pool.length] as FixtureItem;
|
||||
cursors.set(scope, index + 1);
|
||||
return item;
|
||||
}
|
||||
// No repeat until exhausted: only when every currently available item has appeared does the
|
||||
// pass reset. An item added mid-pass joins the current pass rather than waiting for the next.
|
||||
let usedInPass = seen.get(scope);
|
||||
if (!usedInPass) {
|
||||
usedInPass = new Set<string>();
|
||||
seen.set(scope, usedInPass);
|
||||
}
|
||||
let remaining = pool.filter((item) => !usedInPass.has(item.id));
|
||||
if (remaining.length === 0) {
|
||||
usedInPass.clear();
|
||||
remaining = [...pool];
|
||||
}
|
||||
const item = remaining[0] as FixtureItem;
|
||||
usedInPass.add(item.id);
|
||||
return item;
|
||||
}
|
||||
|
||||
const registration: SignageProviderRegistration = {
|
||||
contractVersion: options.contractVersion ?? "1.0.0",
|
||||
defaultDurationMs: options.defaultDurationMs ?? 5000,
|
||||
|
||||
listItems(request: SignageProviderDiscoveryRequest): readonly SignageProviderItemDescriptor[] {
|
||||
guard();
|
||||
return available(request.at).map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
...(item.availableUntil === undefined ? {} : { availableUntil: item.availableUntil }),
|
||||
}));
|
||||
},
|
||||
|
||||
listSources(): readonly SignageProviderSourceDescriptor[] {
|
||||
guard();
|
||||
return [{ id: options.sourceId, label: options.sourceLabel }];
|
||||
},
|
||||
|
||||
providerId: options.providerId,
|
||||
providerVersion: "1.0.0",
|
||||
|
||||
async refresh(
|
||||
request: SignageProviderRefreshRequest,
|
||||
): Promise<SignageProviderResolution | null> {
|
||||
refreshRequests.push(request);
|
||||
if (hanging) {
|
||||
await new Promise(() => undefined);
|
||||
}
|
||||
guard();
|
||||
if (withdrawn.has(request.itemId)) {
|
||||
// Explicitly gone — distinct from "nothing right now", and Signage removes it at the next
|
||||
// safe boundary rather than mid-slide.
|
||||
return {
|
||||
itemId: request.itemId,
|
||||
itemRevision: revisionOf({ id: request.itemId } as FixtureItem),
|
||||
variants: [{ audienceKey: null, payload: { withdrawn: true } }],
|
||||
withdrawn: true,
|
||||
};
|
||||
}
|
||||
const item = available(request.at).find((candidate) => candidate.id === request.itemId);
|
||||
return item ? resolutionFor(item) : null;
|
||||
},
|
||||
|
||||
renderer: {
|
||||
contributionId: `${options.providerId}.slide`,
|
||||
contributionVersion: options.rendererVersion ?? "1.0.0",
|
||||
},
|
||||
|
||||
async resolveNext(
|
||||
request: SignageProviderResolveRequest,
|
||||
): Promise<SignageProviderResolution | null> {
|
||||
resolveRequests.push(request);
|
||||
if (hanging) {
|
||||
await new Promise(() => undefined);
|
||||
}
|
||||
guard();
|
||||
if (request.sourceId !== options.sourceId) {
|
||||
return null;
|
||||
}
|
||||
const item = pickNext(request.selectionScopeId, request.at);
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
served.push(item.id);
|
||||
return resolutionFor(item);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
failNext(count: number): void {
|
||||
failures = count;
|
||||
},
|
||||
hang(value: boolean): void {
|
||||
hanging = value;
|
||||
},
|
||||
refreshRequests,
|
||||
registration,
|
||||
resolveRequests,
|
||||
reviseItem(itemId: string, payload: Record<string, SignageJsonValue>): void {
|
||||
items = items.map((item) => (item.id === itemId ? { ...item, payload } : item));
|
||||
revisions.set(itemId, (revisions.get(itemId) ?? 1) + 1);
|
||||
},
|
||||
served,
|
||||
setItems(next: readonly FixtureItem[]): void {
|
||||
items = [...next];
|
||||
},
|
||||
withdraw(itemId: string): void {
|
||||
withdrawn.add(itemId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The three provider shapes the real modules will have, so a test can name them the way an operator
|
||||
* would. Nothing about their policies lives in Signage. */
|
||||
export function announcementsFixture(): FixtureProvider {
|
||||
return createFixtureProvider({
|
||||
items: [
|
||||
{ id: "welcome", label: "Welcome", payload: { headline: "Welcome" } },
|
||||
{
|
||||
audiencePayloads: { online: { headline: "Watching from home" } },
|
||||
id: "greeting",
|
||||
label: "Greeting",
|
||||
payload: { headline: "Good morning" },
|
||||
},
|
||||
{ id: "giving", label: "Giving", payload: { headline: "Giving" } },
|
||||
{ id: "kids", label: "Kids check-in", payload: { headline: "Kids check-in" } },
|
||||
],
|
||||
policy: "cycle",
|
||||
providerId: "fixture.announcements",
|
||||
sourceId: "active-slides",
|
||||
sourceLabel: "Active announcements",
|
||||
});
|
||||
}
|
||||
|
||||
export function triviaFixture(): FixtureProvider {
|
||||
return createFixtureProvider({
|
||||
items: [
|
||||
{ id: "q1", label: "Question 1", payload: { question: "one" } },
|
||||
{ id: "q2", label: "Question 2", payload: { question: "two" } },
|
||||
{ id: "q3", label: "Question 3", payload: { question: "three" } },
|
||||
],
|
||||
policy: "no-repeat-until-exhausted",
|
||||
providerId: "fixture.trivia",
|
||||
sourceId: "open-questions",
|
||||
sourceLabel: "Open questions",
|
||||
});
|
||||
}
|
||||
|
||||
export function scriptureFixture(): FixtureProvider {
|
||||
return createFixtureProvider({
|
||||
items: [
|
||||
{ id: "psalm-23", label: "Psalm 23", payload: { reference: "Psalm 23" } },
|
||||
{ id: "john-3", label: "John 3", payload: { reference: "John 3" } },
|
||||
],
|
||||
policy: "no-repeat-until-exhausted",
|
||||
providerId: "fixture.scripture",
|
||||
sourceId: "reading-plan",
|
||||
sourceLabel: "Reading plan",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import sharp from "sharp";
|
||||
|
||||
/** Real encoded images, generated rather than checked in, so a fixture can never drift from what the
|
||||
* pipeline actually accepts. */
|
||||
export async function makeImage(options: {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly format: "png" | "jpeg" | "webp";
|
||||
readonly alpha?: boolean;
|
||||
readonly colour?: { r: number; g: number; b: number };
|
||||
}): Promise<Uint8Array> {
|
||||
const colour = options.colour ?? { b: 40, g: 120, r: 200 };
|
||||
const pipeline = sharp({
|
||||
create: {
|
||||
background: { ...colour, alpha: options.alpha === true ? 0.5 : 1 },
|
||||
channels: options.alpha === true ? 4 : 3,
|
||||
height: options.height,
|
||||
width: options.width,
|
||||
},
|
||||
});
|
||||
const encoded =
|
||||
options.format === "png"
|
||||
? await pipeline.png().toBuffer()
|
||||
: options.format === "jpeg"
|
||||
? await pipeline.jpeg().toBuffer()
|
||||
: await pipeline.webp().toBuffer();
|
||||
return new Uint8Array(encoded);
|
||||
}
|
||||
|
||||
/** A genuine two-frame animated WebP. Signage refuses animation: a signage slide is a still frame,
|
||||
* and an animated one would loop out of step with the authoritative slot deadline. */
|
||||
export async function makeAnimatedWebp(): Promise<Uint8Array> {
|
||||
const frame = (red: number) =>
|
||||
sharp({
|
||||
create: { background: { alpha: 1, b: 0, g: 0, r: red }, channels: 4, height: 8, width: 8 },
|
||||
})
|
||||
.raw()
|
||||
.toBuffer();
|
||||
const frames = Buffer.concat([await frame(255), await frame(0)]);
|
||||
const encoded = await sharp(frames, {
|
||||
raw: { channels: 4, height: 16, pageHeight: 8, width: 8 },
|
||||
})
|
||||
.webp({ delay: [100, 100], loop: 0 })
|
||||
.toBuffer();
|
||||
return new Uint8Array(encoded);
|
||||
}
|
||||
|
||||
/** A genuine two-frame animated GIF. GIF is refused outright, but animation is detected first, so
|
||||
* this proves the *animation* rule rather than the format rule. */
|
||||
export async function makeAnimatedGif(): Promise<Uint8Array> {
|
||||
const frame = (red: number) =>
|
||||
sharp({
|
||||
create: { background: { alpha: 1, b: 0, g: 0, r: red }, channels: 4, height: 8, width: 8 },
|
||||
})
|
||||
.raw()
|
||||
.toBuffer();
|
||||
const frames = Buffer.concat([await frame(255), await frame(0)]);
|
||||
const encoded = await sharp(frames, {
|
||||
raw: { channels: 4, height: 16, pageHeight: 8, width: 8 },
|
||||
})
|
||||
.gif({ delay: [100, 100], loop: 0 })
|
||||
.toBuffer();
|
||||
return new Uint8Array(encoded);
|
||||
}
|
||||
|
||||
export function bytesOf(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
/** A minimal, genuinely well-formed SVG. Signage refuses it: an SVG is a document with script and
|
||||
* external-reference capability, not a raster image. */
|
||||
export const SVG_DOCUMENT =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>';
|
||||
|
||||
/** The first bytes of a PDF. Refused for the same reason as everything else that is not a still
|
||||
* raster image. */
|
||||
export const PDF_DOCUMENT = "%PDF-1.7\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>";
|
||||
|
||||
/** A GIF89a header with one tiny frame. */
|
||||
export const GIF_DOCUMENT_BASE64 = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
type NewStoredSelection,
|
||||
type PlaybackChanges,
|
||||
type SelectionChanges,
|
||||
type SignagePlaybackStore,
|
||||
SignageStoreConflict,
|
||||
type StoredChannel,
|
||||
type StoredChannelGroup,
|
||||
type StoredChannelGroupDetail,
|
||||
type StoredPlaybackState,
|
||||
type StoredSelection,
|
||||
} from "../../src/server/store/types.js";
|
||||
|
||||
/**
|
||||
* The playback half of the in-memory store.
|
||||
*
|
||||
* It enforces the guarantees the database enforces — one default channel that cannot be removed, a
|
||||
* sequence that is never handed out twice, variants replaced wholesale — so a decision proven here
|
||||
* holds against PostgreSQL too.
|
||||
*/
|
||||
export interface MemoryPlaybackStore extends SignagePlaybackStore {
|
||||
readonly groups: Map<string, StoredChannelGroup>;
|
||||
readonly channels: Map<string, StoredChannel>;
|
||||
readonly playback: Map<string, StoredPlaybackState>;
|
||||
readonly selections: Map<string, StoredSelection>;
|
||||
}
|
||||
|
||||
export function createMemoryPlaybackStore(
|
||||
options: { readonly now?: () => Date } = {},
|
||||
): MemoryPlaybackStore {
|
||||
const now = options.now ?? (() => new Date());
|
||||
const groups = new Map<string, StoredChannelGroup>();
|
||||
const channels = new Map<string, StoredChannel>();
|
||||
const playback = new Map<string, StoredPlaybackState>();
|
||||
const selections = new Map<string, StoredSelection>();
|
||||
|
||||
function detail(groupId: string): StoredChannelGroupDetail | undefined {
|
||||
const group = groups.get(groupId);
|
||||
const state = playback.get(groupId);
|
||||
if (!group || !state) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
channels: [...channels.values()]
|
||||
.filter((channel) => channel.groupId === groupId)
|
||||
.sort((left, right) =>
|
||||
left.isDefault === right.isDefault
|
||||
? left.key.localeCompare(right.key)
|
||||
: left.isDefault
|
||||
? -1
|
||||
: 1,
|
||||
),
|
||||
group,
|
||||
playback: state,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
addChannel(channel: StoredChannel): Promise<void> {
|
||||
channels.set(channel.id, channel);
|
||||
const group = groups.get(channel.groupId);
|
||||
if (group) {
|
||||
groups.set(group.id, { ...group, revision: group.revision + 1 });
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
channels,
|
||||
|
||||
createChannelGroup(input: {
|
||||
readonly groupId: string;
|
||||
readonly name: string;
|
||||
readonly channelId: string;
|
||||
readonly channelName: string;
|
||||
}): Promise<void> {
|
||||
groups.set(input.groupId, { id: input.groupId, name: input.name, revision: 1 });
|
||||
channels.set(input.channelId, {
|
||||
groupId: input.groupId,
|
||||
id: input.channelId,
|
||||
isDefault: true,
|
||||
key: "default",
|
||||
name: input.channelName,
|
||||
});
|
||||
playback.set(input.groupId, {
|
||||
activeTimelineId: null,
|
||||
blackout: false,
|
||||
currentSelectionId: null,
|
||||
currentSlotId: null,
|
||||
groupId: input.groupId,
|
||||
nextSequence: 1,
|
||||
paused: false,
|
||||
pausedRemainingMs: null,
|
||||
pendingRequestedAt: null,
|
||||
pendingTimelineId: null,
|
||||
revision: 1,
|
||||
scheduledActivationAt: null,
|
||||
scheduledTimelineId: null,
|
||||
slotDeadlineAt: null,
|
||||
slotStartedAt: null,
|
||||
staticAssetId: null,
|
||||
updatedAt: now(),
|
||||
});
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
deleteChannelGroup(groupId: string): Promise<void> {
|
||||
groups.delete(groupId);
|
||||
playback.delete(groupId);
|
||||
for (const [id, channel] of channels) {
|
||||
if (channel.groupId === groupId) {
|
||||
channels.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [id, selection] of selections) {
|
||||
if (selection.groupId === groupId) {
|
||||
selections.delete(id);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
|
||||
return Promise.resolve(detail(groupId));
|
||||
},
|
||||
|
||||
findLatestSelectionForScope(
|
||||
groupId: string,
|
||||
selectionScope: string,
|
||||
): Promise<StoredSelection | undefined> {
|
||||
const matching = [...selections.values()]
|
||||
.filter(
|
||||
(selection) =>
|
||||
selection.groupId === groupId && selection.selectionScope === selectionScope,
|
||||
)
|
||||
.sort((left, right) => right.sequence - left.sequence);
|
||||
return Promise.resolve(matching[0]);
|
||||
},
|
||||
|
||||
findSelection(selectionId: string): Promise<StoredSelection | undefined> {
|
||||
return Promise.resolve(selections.get(selectionId));
|
||||
},
|
||||
|
||||
groups,
|
||||
|
||||
insertSelection(selection: NewStoredSelection): Promise<void> {
|
||||
selections.set(selection.id, { ...selection, resolvedAt: now(), revision: 1 });
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
listChannelGroups(): Promise<readonly StoredChannelGroupDetail[]> {
|
||||
return Promise.resolve(
|
||||
[...groups.values()]
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map((group) => detail(group.id))
|
||||
.filter((value): value is StoredChannelGroupDetail => value !== undefined),
|
||||
);
|
||||
},
|
||||
|
||||
listSelections(groupId: string, limit: number): Promise<readonly StoredSelection[]> {
|
||||
return Promise.resolve(
|
||||
[...selections.values()]
|
||||
.filter((selection) => selection.groupId === groupId)
|
||||
.sort((left, right) => right.sequence - left.sequence)
|
||||
.slice(0, limit),
|
||||
);
|
||||
},
|
||||
|
||||
playback,
|
||||
|
||||
pruneSelections(groupId: string, limit: number): Promise<void> {
|
||||
const ordered = [...selections.values()]
|
||||
.filter((selection) => selection.groupId === groupId)
|
||||
.sort((left, right) => right.sequence - left.sequence);
|
||||
for (const selection of ordered.slice(limit)) {
|
||||
selections.delete(selection.id);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
removeChannel(channelId: string): Promise<void> {
|
||||
const channel = channels.get(channelId);
|
||||
if (!channel) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (channel.isDefault) {
|
||||
return Promise.reject(new SignageStoreConflict("channel.default"));
|
||||
}
|
||||
channels.delete(channelId);
|
||||
const group = groups.get(channel.groupId);
|
||||
if (group) {
|
||||
groups.set(group.id, { ...group, revision: group.revision + 1 });
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
selections,
|
||||
|
||||
takeSelectionSequence(groupId: string): Promise<number> {
|
||||
const state = playback.get(groupId);
|
||||
if (!state) {
|
||||
return Promise.reject(new SignageStoreConflict("playback.sequence"));
|
||||
}
|
||||
playback.set(groupId, { ...state, nextSequence: state.nextSequence + 1 });
|
||||
return Promise.resolve(state.nextSequence);
|
||||
},
|
||||
|
||||
updatePlayback(
|
||||
groupId: string,
|
||||
changes: PlaybackChanges,
|
||||
updateOptions?: { readonly bumpRevision?: boolean },
|
||||
): Promise<void> {
|
||||
const state = playback.get(groupId);
|
||||
if (!state) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const next: Record<string, unknown> = { ...state };
|
||||
for (const key of Object.keys(changes)) {
|
||||
next[key] = (changes as Record<string, unknown>)[key] ?? null;
|
||||
}
|
||||
playback.set(groupId, {
|
||||
...(next as unknown as StoredPlaybackState),
|
||||
revision: updateOptions?.bumpRevision === false ? state.revision : state.revision + 1,
|
||||
updatedAt: now(),
|
||||
});
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
updateSelection(selectionId: string, changes: SelectionChanges): Promise<void> {
|
||||
const selection = selections.get(selectionId);
|
||||
if (!selection) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
selections.set(selectionId, {
|
||||
...selection,
|
||||
...(changes.availableUntil === undefined ? {} : { availableUntil: changes.availableUntil }),
|
||||
...(changes.deadlineAt === undefined ? {} : { deadlineAt: changes.deadlineAt }),
|
||||
...(changes.durationMs === undefined ? {} : { durationMs: changes.durationMs }),
|
||||
...(changes.itemRevision === undefined ? {} : { itemRevision: changes.itemRevision }),
|
||||
...(changes.presentation === undefined ? {} : { presentation: changes.presentation }),
|
||||
...(changes.stale === undefined ? {} : { stale: changes.stale }),
|
||||
...(changes.startedAt === undefined ? {} : { startedAt: changes.startedAt }),
|
||||
...(changes.withdrawn === undefined ? {} : { withdrawn: changes.withdrawn }),
|
||||
...(changes.variants === undefined ? {} : { variants: changes.variants }),
|
||||
revision: changes.bumpRevision === true ? selection.revision + 1 : selection.revision,
|
||||
});
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
type NewStoredImage,
|
||||
type SignageStore,
|
||||
SignageStoreConflict,
|
||||
type StoredImage,
|
||||
type StoredImageReferences,
|
||||
} from "../../src/server/store/types.js";
|
||||
import { createMemoryPlaybackStore, type MemoryPlaybackStore } from "./memory-playback.js";
|
||||
import { createMemoryTimelineStore, type MemoryTimelineStore } from "./memory-timelines.js";
|
||||
import { createMemoryViewerStore, type MemoryViewerStore } from "./memory-viewers.js";
|
||||
|
||||
/**
|
||||
* An in-memory implementation of Signage's data port.
|
||||
*
|
||||
* This is what makes Signage's *decisions* testable without PostgreSQL: deduplication, no-op
|
||||
* detection, reference-aware deletion, provider resolution, and playback reconciliation are all
|
||||
* behaviour, and behaviour proven against a real database for every case would be slow, flaky, and
|
||||
* therefore rare. The Drizzle implementation is proven separately, against a live disposable
|
||||
* database, where what is being proven is the row mapping rather than the decision.
|
||||
*
|
||||
* It enforces the same uniqueness guarantees the database does, so a test cannot pass here and fail
|
||||
* there.
|
||||
*/
|
||||
export interface MemorySignageStore
|
||||
extends SignageStore,
|
||||
MemoryTimelineStore,
|
||||
MemoryPlaybackStore,
|
||||
MemoryViewerStore {
|
||||
/** Test-only: seed a reference so reference-aware deletion can be exercised. */
|
||||
addImageReference(imageId: string, kind: "slot" | "static-hold"): void;
|
||||
readonly images: Map<string, StoredImage>;
|
||||
}
|
||||
|
||||
export function createMemorySignageStore(
|
||||
options: { readonly now?: () => Date } = {},
|
||||
): MemorySignageStore {
|
||||
const now = options.now ?? (() => new Date());
|
||||
const images = new Map<string, StoredImage>();
|
||||
const references = new Map<string, StoredImageReferences>();
|
||||
const timelineStore = createMemoryTimelineStore(options);
|
||||
const playbackStore = createMemoryPlaybackStore(options);
|
||||
const viewerStore = createMemoryViewerStore(options);
|
||||
|
||||
return {
|
||||
...timelineStore,
|
||||
...playbackStore,
|
||||
...viewerStore,
|
||||
|
||||
addImageReference(imageId: string, kind: "slot" | "static-hold"): void {
|
||||
const current = references.get(imageId) ?? { slots: 0, staticHolds: 0 };
|
||||
references.set(
|
||||
imageId,
|
||||
kind === "slot"
|
||||
? { slots: current.slots + 1, staticHolds: current.staticHolds }
|
||||
: { slots: current.slots, staticHolds: current.staticHolds + 1 },
|
||||
);
|
||||
},
|
||||
|
||||
countImageReferences(imageId: string): Promise<StoredImageReferences> {
|
||||
return Promise.resolve(references.get(imageId) ?? { slots: 0, staticHolds: 0 });
|
||||
},
|
||||
|
||||
deleteImage(imageId: string): Promise<void> {
|
||||
images.delete(imageId);
|
||||
references.delete(imageId);
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
findImage(imageId: string): Promise<StoredImage | undefined> {
|
||||
return Promise.resolve(images.get(imageId));
|
||||
},
|
||||
|
||||
findReadyImageByContentHash(contentSha256: string): Promise<StoredImage | undefined> {
|
||||
for (const image of images.values()) {
|
||||
if (image.contentSha256 === contentSha256 && image.status === "ready") {
|
||||
return Promise.resolve(image);
|
||||
}
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
|
||||
images,
|
||||
|
||||
insertImage(image: NewStoredImage): Promise<void> {
|
||||
for (const existing of images.values()) {
|
||||
if (existing.contentSha256 === image.contentSha256) {
|
||||
// Exactly what the unique index does, so a race is exercised here and not only in
|
||||
// production.
|
||||
return Promise.reject(new SignageStoreConflict("image.contentSha256"));
|
||||
}
|
||||
}
|
||||
images.set(image.id, { ...image, createdAt: now() });
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
listImages(): Promise<readonly StoredImage[]> {
|
||||
return Promise.resolve(
|
||||
[...images.values()].sort(
|
||||
(left, right) => right.createdAt.getTime() - left.createdAt.getTime(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
updateImage(
|
||||
imageId: string,
|
||||
changes: { readonly displayName?: string; readonly expiresAt?: Date | null },
|
||||
): Promise<void> {
|
||||
const existing = images.get(imageId);
|
||||
if (!existing) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
images.set(imageId, {
|
||||
...existing,
|
||||
...(changes.displayName === undefined ? {} : { displayName: changes.displayName }),
|
||||
...(changes.expiresAt === undefined ? {} : { expiresAt: changes.expiresAt }),
|
||||
});
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
type NewStoredProviderSource,
|
||||
type NewStoredSlot,
|
||||
type NewStoredTimeline,
|
||||
SignageStoreConflict,
|
||||
type SignageTimelineStore,
|
||||
type SlotChanges,
|
||||
type StoredProviderSource,
|
||||
type StoredSlot,
|
||||
type StoredTimeline,
|
||||
type StoredTimelineDetail,
|
||||
type StoredTimelineUsage,
|
||||
type TimelineChanges,
|
||||
} from "../../src/server/store/types.js";
|
||||
|
||||
/**
|
||||
* The timeline half of the in-memory store.
|
||||
*
|
||||
* It enforces the same invariants the database does — contiguous positions, an exhaustive reorder,
|
||||
* a revision that increments once per semantic change — so a test cannot pass here and fail against
|
||||
* PostgreSQL. Where the production store uses a transaction, this uses the fact that JavaScript does
|
||||
* not interleave synchronous work; the property being proven is the same.
|
||||
*/
|
||||
export interface MemoryTimelineStore extends SignageTimelineStore {
|
||||
/** Test-only: pretend a channel group has this timeline active, pending, or scheduled. */
|
||||
setTimelineUsage(timelineId: string, usage: StoredTimelineUsage): void;
|
||||
readonly timelines: Map<string, StoredTimeline>;
|
||||
readonly slots: Map<string, StoredSlot>;
|
||||
readonly sources: Map<string, StoredProviderSource>;
|
||||
}
|
||||
|
||||
export function createMemoryTimelineStore(
|
||||
options: { readonly now?: () => Date } = {},
|
||||
): MemoryTimelineStore {
|
||||
const now = options.now ?? (() => new Date());
|
||||
const timelines = new Map<string, StoredTimeline>();
|
||||
const slots = new Map<string, StoredSlot>();
|
||||
const sources = new Map<string, StoredProviderSource>();
|
||||
const usage = new Map<string, StoredTimelineUsage>();
|
||||
|
||||
function slotsOf(timelineId: string): StoredSlot[] {
|
||||
return [...slots.values()]
|
||||
.filter((slot) => slot.timelineId === timelineId)
|
||||
.sort((left, right) => left.position - right.position);
|
||||
}
|
||||
|
||||
function bump(timelineId: string): void {
|
||||
const timeline = timelines.get(timelineId);
|
||||
if (timeline) {
|
||||
timelines.set(timelineId, {
|
||||
...timeline,
|
||||
revision: timeline.revision + 1,
|
||||
updatedAt: now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renumber(timelineId: string, orderedSlotIds: readonly string[]): void {
|
||||
for (const [index, slotId] of orderedSlotIds.entries()) {
|
||||
const slot = slots.get(slotId);
|
||||
if (slot) {
|
||||
slots.set(slotId, { ...slot, position: index });
|
||||
}
|
||||
}
|
||||
bump(timelineId);
|
||||
}
|
||||
|
||||
return {
|
||||
addSlot(slot: NewStoredSlot, position?: number): Promise<void> {
|
||||
const existing = slotsOf(slot.timelineId).map((entry) => entry.id);
|
||||
const index =
|
||||
position === undefined ? existing.length : Math.max(0, Math.min(position, existing.length));
|
||||
slots.set(slot.id, { ...slot, position: -1 });
|
||||
renumber(slot.timelineId, [...existing.slice(0, index), slot.id, ...existing.slice(index)]);
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
addSource(source: NewStoredProviderSource): Promise<void> {
|
||||
sources.set(source.id, { ...source, createdAt: now() });
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
createTimeline(input: {
|
||||
readonly timeline: NewStoredTimeline;
|
||||
readonly sources: readonly NewStoredProviderSource[];
|
||||
readonly slots: readonly NewStoredSlot[];
|
||||
}): Promise<void> {
|
||||
const createdAt = now();
|
||||
timelines.set(input.timeline.id, {
|
||||
...input.timeline,
|
||||
createdAt,
|
||||
revision: 1,
|
||||
updatedAt: createdAt,
|
||||
});
|
||||
for (const source of input.sources) {
|
||||
sources.set(source.id, { ...source, createdAt });
|
||||
}
|
||||
for (const [index, slot] of input.slots.entries()) {
|
||||
slots.set(slot.id, { ...slot, position: index });
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
deleteTimeline(timelineId: string): Promise<void> {
|
||||
timelines.delete(timelineId);
|
||||
for (const [id, slot] of slots) {
|
||||
if (slot.timelineId === timelineId) {
|
||||
slots.delete(id);
|
||||
}
|
||||
}
|
||||
for (const [id, source] of sources) {
|
||||
if (source.timelineId === timelineId) {
|
||||
sources.delete(id);
|
||||
}
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
findTimeline(timelineId: string): Promise<StoredTimelineDetail | undefined> {
|
||||
const timeline = timelines.get(timelineId);
|
||||
if (!timeline) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return Promise.resolve({
|
||||
slots: slotsOf(timelineId),
|
||||
sources: [...sources.values()]
|
||||
.filter((source) => source.timelineId === timelineId)
|
||||
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()),
|
||||
timeline,
|
||||
});
|
||||
},
|
||||
|
||||
listTimelines(): Promise<readonly StoredTimeline[]> {
|
||||
return Promise.resolve(
|
||||
[...timelines.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
||||
);
|
||||
},
|
||||
|
||||
removeSlot(slotId: string): Promise<void> {
|
||||
const slot = slots.get(slotId);
|
||||
if (!slot) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
slots.delete(slotId);
|
||||
renumber(
|
||||
slot.timelineId,
|
||||
slotsOf(slot.timelineId).map((entry) => entry.id),
|
||||
);
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
removeSource(sourceId: string): Promise<void> {
|
||||
const source = sources.get(sourceId);
|
||||
if (!source) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
sources.delete(sourceId);
|
||||
for (const [id, slot] of slots) {
|
||||
if (slot.sourceId === sourceId) {
|
||||
slots.delete(id);
|
||||
}
|
||||
}
|
||||
renumber(
|
||||
source.timelineId,
|
||||
slotsOf(source.timelineId).map((entry) => entry.id),
|
||||
);
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
reorderSlots(timelineId: string, orderedSlotIds: readonly string[]): Promise<void> {
|
||||
const current = slotsOf(timelineId).map((slot) => slot.id);
|
||||
const given = new Set(orderedSlotIds);
|
||||
if (given.size !== orderedSlotIds.length || given.size !== current.length) {
|
||||
return Promise.reject(new SignageStoreConflict("timeline.slotOrder"));
|
||||
}
|
||||
for (const slotId of current) {
|
||||
if (!given.has(slotId)) {
|
||||
return Promise.reject(new SignageStoreConflict("timeline.slotOrder"));
|
||||
}
|
||||
}
|
||||
renumber(timelineId, orderedSlotIds);
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
setTimelineUsage(timelineId: string, value: StoredTimelineUsage): void {
|
||||
usage.set(timelineId, value);
|
||||
},
|
||||
|
||||
slots,
|
||||
|
||||
sources,
|
||||
|
||||
timelineUsage(timelineId: string): Promise<StoredTimelineUsage> {
|
||||
return Promise.resolve(
|
||||
usage.get(timelineId) ?? { activeGroups: 0, pendingGroups: 0, scheduledGroups: 0 },
|
||||
);
|
||||
},
|
||||
|
||||
timelines,
|
||||
|
||||
updateSlot(slotId: string, changes: SlotChanges): Promise<void> {
|
||||
const slot = slots.get(slotId);
|
||||
if (!slot) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
slots.set(slotId, {
|
||||
...slot,
|
||||
...(changes.enabled === undefined ? {} : { enabled: changes.enabled }),
|
||||
...(changes.durationOverrideMs === undefined
|
||||
? {}
|
||||
: { durationOverrideMs: changes.durationOverrideMs }),
|
||||
...(changes.eligibleFrom === undefined ? {} : { eligibleFrom: changes.eligibleFrom }),
|
||||
...(changes.eligibleUntil === undefined ? {} : { eligibleUntil: changes.eligibleUntil }),
|
||||
...(changes.itemId === undefined ? {} : { itemId: changes.itemId }),
|
||||
presentation: { ...slot.presentation, ...(changes.presentation ?? {}) },
|
||||
});
|
||||
bump(slot.timelineId);
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
updateTimeline(timelineId: string, changes: TimelineChanges): Promise<void> {
|
||||
const timeline = timelines.get(timelineId);
|
||||
if (!timeline) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
timelines.set(timelineId, {
|
||||
...timeline,
|
||||
...(changes.name === undefined ? {} : { name: changes.name }),
|
||||
...(changes.description === undefined ? {} : { description: changes.description }),
|
||||
...(changes.eligibleFrom === undefined ? {} : { eligibleFrom: changes.eligibleFrom }),
|
||||
...(changes.eligibleUntil === undefined ? {} : { eligibleUntil: changes.eligibleUntil }),
|
||||
revision: timeline.revision + 1,
|
||||
updatedAt: now(),
|
||||
});
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type {
|
||||
NewStoredViewer,
|
||||
SignageViewerStore,
|
||||
StoredViewer,
|
||||
ViewerChanges,
|
||||
} from "../../src/server/store/types.js";
|
||||
|
||||
export interface MemoryViewerStore extends SignageViewerStore {
|
||||
readonly viewers: Map<string, StoredViewer>;
|
||||
/** Test-only: how many times a viewer row was actually written, which is what heartbeat
|
||||
* coalescing exists to keep small. */
|
||||
readonly viewerWrites: { count: number };
|
||||
}
|
||||
|
||||
export function createMemoryViewerStore(
|
||||
options: { readonly now?: () => Date } = {},
|
||||
): MemoryViewerStore {
|
||||
const now = options.now ?? (() => new Date());
|
||||
const viewers = new Map<string, StoredViewer>();
|
||||
const viewerWrites = { count: 0 };
|
||||
|
||||
return {
|
||||
deleteViewer(viewerId: string): Promise<void> {
|
||||
viewers.delete(viewerId);
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
findViewer(viewerId: string): Promise<StoredViewer | undefined> {
|
||||
return Promise.resolve(viewers.get(viewerId));
|
||||
},
|
||||
|
||||
findViewerByKey(viewerKey: string): Promise<StoredViewer | undefined> {
|
||||
for (const viewer of viewers.values()) {
|
||||
if (viewer.viewerKey === viewerKey) {
|
||||
return Promise.resolve(viewer);
|
||||
}
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
|
||||
insertViewer(viewer: NewStoredViewer): Promise<void> {
|
||||
viewerWrites.count += 1;
|
||||
viewers.set(viewer.id, { ...viewer, createdAt: now(), lastError: null, lastSeenAt: null });
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
listViewers(): Promise<readonly StoredViewer[]> {
|
||||
return Promise.resolve(
|
||||
[...viewers.values()].sort((left, right) => left.name.localeCompare(right.name)),
|
||||
);
|
||||
},
|
||||
|
||||
updateViewer(viewerId: string, changes: ViewerChanges): Promise<void> {
|
||||
const viewer = viewers.get(viewerId);
|
||||
if (!viewer) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
viewerWrites.count += 1;
|
||||
const next: Record<string, unknown> = { ...viewer };
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value !== undefined) {
|
||||
next[key] = value;
|
||||
}
|
||||
}
|
||||
viewers.set(viewerId, next as unknown as StoredViewer);
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
viewers,
|
||||
viewerWrites,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
type CapabilityId,
|
||||
EMPTY_MODULE_CONFIGURATION,
|
||||
EMPTY_MODULE_STORAGE,
|
||||
type ModuleConfiguration,
|
||||
type ModuleRouteDefinitionInput,
|
||||
type ModuleRouteHandlerContext,
|
||||
type ModuleRuntimeServices,
|
||||
type RegisteredCapability,
|
||||
} from "@ekklesia/module-sdk";
|
||||
import moduleRuntime from "../../src/server/index.js";
|
||||
|
||||
/**
|
||||
* The module-layer test harness: everything a Signage unit test needs to exercise the real runtime
|
||||
* definition without a platform.
|
||||
*
|
||||
* Every object here is a `@ekklesia/contracts` interface constructed by hand, which is exactly what
|
||||
* a module-layer test may legitimately do — a module may never import Core. It also demonstrates the
|
||||
* portability claim directly: the host producing these objects could be Ekklesia, or a future
|
||||
* standalone host reading YAML from disk, and Signage cannot tell the difference.
|
||||
*/
|
||||
export interface CapturedRegistration {
|
||||
readonly channels: { channel: string; permission: string }[];
|
||||
readonly provided: Map<CapabilityId, unknown>;
|
||||
readonly routes: ModuleRouteDefinitionInput[];
|
||||
}
|
||||
|
||||
export async function registerModule(
|
||||
config: ModuleConfiguration = EMPTY_MODULE_CONFIGURATION,
|
||||
): Promise<CapturedRegistration> {
|
||||
const channels: { channel: string; permission: string }[] = [];
|
||||
const provided = new Map<CapabilityId, unknown>();
|
||||
const routes: ModuleRouteDefinitionInput[] = [];
|
||||
await moduleRuntime.register?.({
|
||||
capabilities: {
|
||||
provide(capabilityId, implementation) {
|
||||
provided.set(capabilityId, implementation);
|
||||
},
|
||||
resolve(_capabilityId: CapabilityId): RegisteredCapability | undefined {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
config,
|
||||
http: {
|
||||
route(definition) {
|
||||
routes.push(definition);
|
||||
},
|
||||
},
|
||||
moduleId: "signage",
|
||||
realtime: {
|
||||
channel(definition) {
|
||||
channels.push({ channel: definition.channel, permission: definition.permission });
|
||||
},
|
||||
},
|
||||
});
|
||||
return { channels, provided, routes };
|
||||
}
|
||||
|
||||
export function findRoute(
|
||||
registration: CapturedRegistration,
|
||||
method: string,
|
||||
path: string,
|
||||
): ModuleRouteDefinitionInput {
|
||||
const route = registration.routes.find(
|
||||
(candidate) => candidate.method === method && candidate.path === path,
|
||||
);
|
||||
if (!route) {
|
||||
throw new Error(`Signage did not register ${method} ${path}.`);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
export function fakeConfiguration(values: Readonly<Record<string, unknown>>): ModuleConfiguration {
|
||||
const read = <Value>(key: string, kind: string): Value | undefined => {
|
||||
const value = values[key];
|
||||
return typeof value === kind ? (value as Value) : undefined;
|
||||
};
|
||||
return Object.freeze({
|
||||
get: (key: string) => values[key] as never,
|
||||
getBoolean: (key: string) => read<boolean>(key, "boolean"),
|
||||
getEnum: (key: string) => read<string>(key, "string"),
|
||||
getInteger: (key: string) => read<number>(key, "number"),
|
||||
getSecret: (key: string) => read<string>(key, "string"),
|
||||
getString: (key: string) => read<string>(key, "string"),
|
||||
getUrl: (key: string) => read<string>(key, "string"),
|
||||
has: (key: string) => Object.hasOwn(values, key),
|
||||
keys: () => Object.freeze(Object.keys(values)),
|
||||
});
|
||||
}
|
||||
|
||||
export function fakeHandlerContext(
|
||||
overrides: Partial<ModuleRouteHandlerContext> = {},
|
||||
): ModuleRouteHandlerContext {
|
||||
const services = { database: undefined } as unknown as ModuleRuntimeServices;
|
||||
return Object.freeze({
|
||||
config: EMPTY_MODULE_CONFIGURATION,
|
||||
moduleId: "signage",
|
||||
params: Object.freeze({}),
|
||||
principal: ANONYMOUS_PRINCIPAL,
|
||||
realtime: { publish: () => undefined },
|
||||
services,
|
||||
storage: EMPTY_MODULE_STORAGE,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user