140 lines
6.0 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|