import { randomBytes } from "node:crypto"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it } from "vitest"; import { createDashboardDrizzleView, createDashboardStore, type DashboardStore, DashboardStoreError, resolveDashboardNavigation, } from "../src/dashboard/index.js"; import { createValidatedPostgresPool } from "../src/database/index.js"; import { applyModuleMigrations, createPostgresMigrationDatabase, discoverAllMigrations, } from "../src/migrations/index.js"; /** * The Dashboard navigation store against real PostgreSQL. * * The unit suites prove the resolution rules; this proves the things only a database can: that the * migration really creates these tables, that configuration survives a completely new connection, * that `on delete set null` really ungroups a deleted group's modules, and that a placement can * never point at a group that does not exist. The in-memory double used by the API route tests * deliberately reproduces only two of those behaviours, so this file is where they are actually * established rather than assumed. */ const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL"; const coreMigrationsDirectory = path.join( path.dirname(fileURLToPath(import.meta.url)), "..", "drizzle", ); const trackedDatabases = new Set(); let databaseCounter = 0; class IntegrationHarnessError extends Error { constructor(message: string) { super(message); this.name = "IntegrationHarnessError"; this.stack = `${this.name}: ${this.message}`; } } function harnessError(message: string): IntegrationHarnessError { return new IntegrationHarnessError(message); } function baseDatabaseUrl(): string { const value = process.env[integrationDatabaseUrlEnvironment]; if (typeof value !== "string" || value.trim() === "") { throw harnessError("The Dashboard integration database is not configured."); } try { const parsed = new URL(value); if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") { throw new TypeError("Unsupported protocol."); } } catch { throw harnessError("The Dashboard integration database configuration is invalid."); } return value; } function quoteDatabaseName(databaseName: string): string { if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) { throw harnessError("The generated integration database name is invalid."); } return `"${databaseName}"`; } function makeDatabaseName(label: string): string { const safeLabel = label .toLowerCase() .replaceAll(/[^a-z0-9]+/gu, "_") .replaceAll(/^_+|_+$/gu, "") .slice(0, 14); databaseCounter += 1; return `ekk_dash_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`; } function databaseUrl(databaseName: string): string { const parsed = new URL(baseDatabaseUrl()); parsed.pathname = `/${databaseName}`; parsed.searchParams.delete("application_name"); return parsed.toString(); } async function openAdministrativeClient(connectionString: string): Promise { const client = new Client({ connectionString }); client.on("error", () => undefined); try { await client.connect(); return client; } catch { try { await client.end(); } catch { // The public harness failure stays static when connection cleanup also fails. } throw harnessError("The Dashboard integration database connection failed."); } } async function safeQuery( client: Client, text: string, values: unknown[] = [], ): Promise { try { const result = await client.query(text, values); return result.rows; } catch { throw harnessError("An integration database administration operation failed."); } } async function safeEnd(client: Client): Promise { try { await client.end(); } catch { throw harnessError("An integration database connection could not be closed."); } } async function dropDatabase(databaseName: string): Promise { const admin = await openAdministrativeClient(baseDatabaseUrl()); try { await safeQuery( admin, "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()", [databaseName], ); await safeQuery( admin, `DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`, ); trackedDatabases.delete(databaseName); } finally { await safeEnd(admin); } } async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> { const name = makeDatabaseName(label); const admin = await openAdministrativeClient(baseDatabaseUrl()); try { await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`); } finally { await safeEnd(admin); } trackedDatabases.add(name); return { name, url: databaseUrl(name) }; } async function applyCoreMigrations(url: string): Promise { const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-dash-no-modules-")); try { const aggregate = await discoverAllMigrations({ coreMigrationsDirectory, moduleDiscovery: { moduleSdkVersion: "0.1.0", modulesDirectory: emptyModulesDirectory, platformVersion: "0.1.0", }, }); expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ "core:0000_identity_core_tables", "core:0001_authorization_core_tables", "core:0002_authorization_integrity", "core:0003_dashboard_navigation_tables", "core:0004_module_lifecycle_state", "core:0005_module_configuration", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { await rm(emptyModulesDirectory, { force: true, recursive: true }); } } afterAll(async () => { for (const databaseName of [...trackedDatabases]) { await dropDatabase(databaseName).catch(() => undefined); } }); describe.sequential("live Core Dashboard navigation store", () => { let database: { name: string; url: string }; let admin: Client; let store: DashboardStore; let close: () => Promise; it("creates exactly the two Dashboard tables, with their constraints", async () => { database = await createDisposableDatabase("fresh"); await applyCoreMigrations(database.url); admin = await openAdministrativeClient(database.url); const tables = await safeQuery<{ table_name: string }>( admin, `select table_name from information_schema.tables where table_schema = 'public' and table_name like 'ekklesia_core_dashboard%' order by table_name`, ); const constraints = await safeQuery<{ constraint_name: string; delete_rule: string }>( admin, `select rc.constraint_name, rc.delete_rule from information_schema.referential_constraints rc join information_schema.table_constraints tc on tc.constraint_name = rc.constraint_name where tc.table_name = 'ekklesia_core_dashboard_placement'`, ); expect(tables.map((table) => table.table_name)).toEqual([ "ekklesia_core_dashboard_group", "ekklesia_core_dashboard_placement", ]); // The database, not the application, is what guarantees a deleted group leaves no dangling // reference. expect(constraints.map((constraint) => constraint.delete_rule)).toEqual(["SET NULL"]); const handle = createValidatedPostgresPool(database.url); close = handle.close; store = createDashboardStore(createDashboardDrizzleView(handle.pool)); }); it("starts empty, so an installation with no configuration is a normal state", async () => { expect(await store.readConfiguration()).toEqual({ groups: [], placements: [] }); }); it("persists groups and placements, and reads them back in deterministic order", async () => { await store.upsertGroup({ id: "planning", label: "Planning", sortOrder: 20 }); await store.upsertGroup({ id: "media", label: "Media", sortOrder: 10 }); await store.setPlacement({ groupId: "media", moduleId: "livestream", sortOrder: 20 }); await store.setPlacement({ groupId: "media", moduleId: "video", sortOrder: 10 }); await store.setPlacement({ groupId: "planning", moduleId: "calendar", sortOrder: 10 }); const configuration = await store.readConfiguration(); expect(configuration.groups.map((group) => group.id)).toEqual(["media", "planning"]); expect(configuration.placements.map((placement) => placement.moduleId)).toEqual([ "calendar", "video", "livestream", ]); }); it("survives a completely new connection, so a church configures its Dashboard once", async () => { // A different pool, as a restarted process would use — nothing is cached in the old one. const reopened = createValidatedPostgresPool(database.url); try { const configuration = await createDashboardStore( createDashboardDrizzleView(reopened.pool), ).readConfiguration(); const view = resolveDashboardNavigation(configuration, [ { id: "livestream", name: "Livestream" }, { id: "video", name: "Video" }, { id: "calendar", name: "Calendar" }, ]); expect( view.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]), ).toEqual([ ["Media", ["Video", "Livestream"]], ["Planning", ["Calendar"]], ]); } finally { await reopened.close(); } }); it("renames a group without disturbing a single placement", async () => { const before = await store.readConfiguration(); await store.upsertGroup({ id: "media", label: "Media & Streaming", sortOrder: 10 }); const after = await store.readConfiguration(); expect(after.groups.find((group) => group.id === "media")?.label).toBe("Media & Streaming"); // The stable id is what placements point at, so a rename can never re-organize the Dashboard. expect(after.placements).toEqual(before.placements); }); it("re-orders a group and a module in place, without creating duplicates", async () => { await store.upsertGroup({ id: "media", label: "Media & Streaming", sortOrder: 30 }); await store.setPlacement({ groupId: "media", moduleId: "video", sortOrder: 99 }); const configuration = await store.readConfiguration(); expect(configuration.groups).toHaveLength(2); expect(configuration.groups.map((group) => group.id)).toEqual(["planning", "media"]); expect(configuration.placements.filter((entry) => entry.moduleId === "video")).toHaveLength(1); expect(configuration.placements.find((entry) => entry.moduleId === "video")?.sortOrder).toBe( 99, ); }); it("moves a module between groups by changing exactly one row", async () => { await store.upsertGroup({ id: "sunday-production", label: "Sunday Production", sortOrder: 5 }); await store.setPlacement({ groupId: "sunday-production", moduleId: "livestream", sortOrder: 10, }); const configuration = await store.readConfiguration(); expect(configuration.placements.find((entry) => entry.moduleId === "livestream")?.groupId).toBe( "sunday-production", ); // Video did not move. expect(configuration.placements.find((entry) => entry.moduleId === "video")?.groupId).toBe( "media", ); }); it("ungroups a module deliberately, which is distinct from having no record", async () => { await store.setPlacement({ groupId: null, moduleId: "calendar", sortOrder: 7 }); const configuration = await store.readConfiguration(); const calendar = configuration.placements.find((entry) => entry.moduleId === "calendar"); expect(calendar).toEqual({ groupId: null, moduleId: "calendar", sortOrder: 7 }); }); it("ungroups a deleted group's modules instead of leaving a dangling reference", async () => { await store.deleteGroup("sunday-production"); const configuration = await store.readConfiguration(); const groupIds = new Set(configuration.groups.map((group) => group.id)); expect(groupIds.has("sunday-production")).toBe(false); expect( configuration.placements.find((entry) => entry.moduleId === "livestream")?.groupId, ).toBeNull(); // No placement anywhere names a group that no longer exists. for (const placement of configuration.placements) { if (placement.groupId !== null) { expect(groupIds.has(placement.groupId)).toBe(true); } } }); it("refuses a placement naming a group that was never created", async () => { await expect( store.setPlacement({ groupId: "not-a-group", moduleId: "video", sortOrder: 10 }), ).rejects.toMatchObject({ code: "DASHBOARD_GROUP_NOT_FOUND" }); }); it("refuses to delete a group that does not exist", async () => { await expect(store.deleteGroup("not-a-group")).rejects.toMatchObject({ code: "DASHBOARD_GROUP_NOT_FOUND", }); }); it("stores a placement for a module that is not installed, and renders nothing for it", async () => { await store.upsertGroup({ id: "media", label: "Media & Streaming", sortOrder: 30 }); await store.setPlacement({ groupId: "media", moduleId: "uninstalled", sortOrder: 1 }); const configuration = await store.readConfiguration(); const view = resolveDashboardNavigation(configuration, [{ id: "video", name: "Video" }]); // Retained, so a reinstall restores the position... expect(configuration.placements.map((entry) => entry.moduleId)).toContain("uninstalled"); // ...and never rendered while the module is absent. expect( [...view.groups.flatMap((group) => group.modules), ...view.ungrouped].map( (entry) => entry.id, ), ).toEqual(["video"]); }); it("rejects malformed input before it reaches the database", async () => { await expect( store.upsertGroup({ id: "Not Kebab Case", label: "Nope", sortOrder: 0 }), ).rejects.toBeInstanceOf(DashboardStoreError); await expect( store.upsertGroup({ id: "media", label: " ", sortOrder: 0 }), ).rejects.toMatchObject({ code: "DASHBOARD_GROUP_LABEL_INVALID" }); await expect( store.upsertGroup({ id: "media", label: "x".repeat(121), sortOrder: 0 }), ).rejects.toMatchObject({ code: "DASHBOARD_GROUP_LABEL_INVALID" }); await expect( store.upsertGroup({ id: "media", label: "Media", sortOrder: -1 }), ).rejects.toMatchObject({ code: "DASHBOARD_SORT_ORDER_INVALID" }); await expect( store.setPlacement({ groupId: null, moduleId: "Not Valid", sortOrder: 0 }), ).rejects.toMatchObject({ code: "DASHBOARD_MODULE_ID_INVALID" }); await expect( store.setPlacement({ groupId: null, moduleId: "video", sortOrder: 1.5 }), ).rejects.toMatchObject({ code: "DASHBOARD_SORT_ORDER_INVALID" }); }); it("trims a stored label rather than persisting surrounding whitespace", async () => { await store.upsertGroup({ id: "planning", label: " Planning ", sortOrder: 20 }); const configuration = await store.readConfiguration(); expect(configuration.groups.find((group) => group.id === "planning")?.label).toBe("Planning"); }); it("leaks no database detail in a sanitized failure", async () => { await close(); const error = await store.readConfiguration().catch((caught: unknown) => caught); const text = `${String(error)}\n${(error as Error).stack ?? ""}`; expect(error).toMatchObject({ code: "DASHBOARD_READ_FAILED" }); expect(text).not.toContain(database.name); expect(text).not.toContain("select"); expect(text).not.toContain("ekklesia_core_dashboard"); await safeEnd(admin); await dropDatabase(database.name); }); });