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

140 lines
6.0 KiB
TypeScript

import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import {
parseDrizzleMigrationJournal,
parseModuleManifest,
parseModuleMigrationPolicy,
} from "@ekklesia/module-sdk";
import { getTableConfig, type PgTable } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import moduleSchema from "../src/schema/index.js";
const tables = moduleSchema.tables as Readonly<Record<string, PgTable>>;
async function readSql(): Promise<string> {
return readFile(new URL("../drizzle/0000_initial.sql", import.meta.url), "utf8");
}
describe("Signage schema", () => {
it("declares every table Signage's data model needs, and nothing outside its namespace", () => {
const physicalNames = Object.values(tables)
.map((table) => getTableConfig(table).name)
.sort();
expect(physicalNames).toEqual([
"signage_channel_groups",
"signage_channels",
"signage_image_assets",
"signage_image_renditions",
"signage_playback_states",
"signage_provider_sources",
"signage_selection_variants",
"signage_selections",
"signage_timeline_slots",
"signage_timelines",
"signage_viewers",
]);
for (const table of Object.values(tables)) {
const config = getTableConfig(table);
// The platform-managed default schema, never a custom PostgreSQL schema.
expect(config.schema).toBeUndefined();
expect(config.name.length).toBeLessThanOrEqual(63);
}
expect(moduleSchema.moduleId).toBe("signage");
});
it("never points a foreign key outside this module", () => {
const owned = new Set(Object.values(tables).map((table) => getTableConfig(table).name));
for (const table of Object.values(tables)) {
for (const foreignKey of getTableConfig(table).foreignKeys) {
const reference = foreignKey.reference();
expect(owned).toContain(getTableConfig(reference.foreignTable).name);
}
}
});
it("stores another module's identities as opaque text rather than as a reference", () => {
// A provider, its sources, and its items belong to another module. Signage stores what it was
// told and can never join to it — which is exactly what keeps the two independently installable.
const opaque = [
["providerSources", "provider_id"],
["providerSources", "source_id"],
["timelineSlots", "provider_id"],
["timelineSlots", "item_id"],
["selections", "provider_id"],
["selections", "item_id"],
["viewers", "device_id"],
] as const;
for (const [tableName, columnName] of opaque) {
const table = tables[tableName];
if (!table) {
throw new Error(`Signage's schema is missing ${tableName}.`);
}
const config = getTableConfig(table);
const column = config.columns.find((candidate) => candidate.name === columnName);
expect(column?.getSQLType()).toBe("text");
const referenced = config.foreignKeys.some((foreignKey) =>
foreignKey.reference().columns.some((candidate) => candidate.name === columnName),
);
expect(referenced).toBe(false);
}
});
it("stores logical object keys and never a signed delivery URL", async () => {
const sql = await readSql();
expect(sql).toContain('"original_object_key" text NOT NULL');
expect(sql).toContain('"object_key" text NOT NULL');
// A read URL is a bearer capability with an expiry in it. Nothing in this schema can hold one.
for (const forbidden of ["url", "signed_url", "presigned", "delivery_url"]) {
expect(sql.toLowerCase()).not.toContain(`"${forbidden}"`);
}
});
it("makes the invariants playback depends on the database's job, not a convention", async () => {
const sql = await readSql();
// Content-addressed identity: the same bytes are the same asset.
expect(sql).toContain("signage_image_assets_content_sha256_key");
// A slot position is unique within its timeline.
expect(sql).toContain("signage_timeline_slots_position_key");
// Exactly one default channel per group.
expect(sql).toContain("signage_channels_group_default_key");
// A slot is a provider source or a pinned item, never both and never neither.
expect(sql).toContain("signage_timeline_slots_reference_check");
// History is ordered and gapless per group.
expect(sql).toContain("signage_selections_group_sequence_key");
// One payload per audience per selection.
expect(sql).toContain("signage_selection_variants_selection_audience_key");
// Deleting a slot must not erase what already happened on screen.
expect(sql).toMatch(/signage_selections_slot_id_.*ON DELETE set null/su);
});
it("packages one reviewed, non-destructive, forward-only migration", async () => {
const [journalInput, policyInput, manifestInput, bytes] = await Promise.all([
readFile(new URL("../drizzle/meta/_journal.json", import.meta.url), "utf8"),
readFile(new URL("../drizzle/meta/_ekklesia.json", import.meta.url), "utf8"),
readFile(new URL("../ekklesia.module.json", import.meta.url), "utf8"),
readFile(new URL("../drizzle/0000_initial.sql", import.meta.url)),
]);
const journal = parseDrizzleMigrationJournal(JSON.parse(journalInput) as unknown);
const policy = parseModuleMigrationPolicy(JSON.parse(policyInput) as unknown);
const manifest = parseModuleManifest(JSON.parse(manifestInput) as unknown);
expect(journal.entries.map((entry) => entry.tag)).toEqual(["0000_initial"]);
expect(policy).toMatchObject({
moduleId: "signage",
entries: [{ tag: "0000_initial", destructive: { mode: "none" } }],
});
expect(manifest.entrypoints.migrations).toBe("./drizzle");
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
// Pinned deliberately. Core compares the exact bytes against its applied history and refuses a
// changed migration, so an accidental edit here must fail in this repository first.
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
"fbbc448f9a951818bf37e89849ccdbfb55f3221260db0f59d1a2b245cc126838",
);
});
});