import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, expect, test, vi } from "vitest"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; import { MemoryCatalogRepository } from "../src/catalog/memory-repository.js"; import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js"; import type { WorkspaceRegistry, WorkspaceRevision } from "../src/workspaces/registry.js"; import type { WorkspaceDescriptor } from "../src/workspaces/schema.js"; const roots: string[] = []; afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); const workspace: WorkspaceDescriptor = { workspace: { schema_version: 4, id: "psd-clinical", name: "Policlinico San Donato", language: "it" }, dwh: { engine: "postgres", database: "warehouse", schema: "datawarehouse", port: 5432, supported_transports: ["postgres_direct", "rest_api"], }, diagnostics: { dwh_rest: { method: "GET", path: "/health", auth: "bearer", response: { database: "database", schema: "schema" } } }, }; const revision: WorkspaceRevision = { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd.yaml", }; async function setup() { const secretRoot = mkdtempSync(join(tmpdir(), "catalog-table-secret-")); const runtimeRoot = mkdtempSync(join(tmpdir(), "catalog-table-runtime-")); roots.push(secretRoot, runtimeRoot); const repository = new MemoryCatalogRepository(); const database = await repository.create({ workspaceId: "psd-clinical", engine: "postgres", databaseName: "warehouse", schema: "datawarehouse", binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" }, }); await repository.recordTest(database.id, database.version, { connectionStatus: "reachable", testedVersion: database.version, lastTestedAt: new Date().toISOString(), }); const scan = vi.fn(async () => [ { name: "patients", sourceComment: "Clinical patients" }, { name: "visits", sourceComment: null }, ]); const registry = { list: vi.fn(async () => [revision]), listCatalog: vi.fn(async () => [{ id: "psd-clinical", name: "Policlinico San Donato", configurationState: "ready", revision }]), read: vi.fn(async () => ({ workspace, revision })), readPinned: vi.fn(async () => ({ workspace, workspaceConfigPath: revision.snapshotPath })), } as unknown as WorkspaceRegistry; const secretStore = new WorkspaceSecretStore({ root: secretRoot, runtimeRoot, installationId: "test" }); const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/missing", NODE_ENV: "test" }), { thtRunner: {} as never, workspaceRegistry: registry, workspaceSecretStore: secretStore, catalogRepository: repository, workspaceDiagnoser: vi.fn(), }); return { app, repository, database: (await repository.get(database.id))!, scan }; } test("lists physical tables and updates only review metadata", async () => { const { app, repository, database, scan } = await setup(); const synchronized = await repository.reconcileTables(database.id, database.version, await scan(), []); expect(synchronized).toMatchObject({ kind: "applied", createdCount: 2, deletedCount: 0 }); expect(scan).toHaveBeenCalledOnce(); const tables = (await app.inject({ method: "GET", url: `/catalog/databases/${database.id}/tables`, })).json(); expect(tables.map((table: { name: string }) => table.name)).toEqual(["patients", "visits"]); const patients = tables[0]; const edited = await app.inject({ method: "PATCH", url: `/catalog/databases/${database.id}/tables/${patients.id}`, payload: { version: patients.version, description: "Curated patient registry" }, }); expect(edited.statusCode).toBe(200); expect(edited.json()).toMatchObject({ name: "patients", sourceComment: "Clinical patients", description: "Curated patient registry", version: 2, }); }); test("requires an exact deletion confirmation before applying the atomic diff", async () => { const { app, repository, database, scan } = await setup(); await repository.reconcileTables(database.id, database.version, await scan(), []); scan.mockResolvedValue([{ name: "patients", sourceComment: "Clinical patients" }]); const preview = await repository.reconcileTables(database.id, database.version, await scan(), []); expect(preview).toEqual({ kind: "confirmation_required", deletedNames: ["visits"] }); expect((await app.inject({ method: "GET", url: `/catalog/databases/${database.id}/tables` })).json()).toHaveLength(2); const applied = await repository.reconcileTables(database.id, database.version, await scan(), ["visits"]); expect(applied).toMatchObject({ kind: "applied", deletedCount: 1 }); expect((await app.inject({ method: "GET", url: `/catalog/databases/${database.id}/tables` })).json()).toMatchObject([ { name: "patients" }, ]); }); test("refuses synchronization until the current binding has passed its connection test", async () => { const { app, repository, database } = await setup(); await repository.update(database.id, database.version, { workspaceId: database.workspaceId, engine: database.engine, databaseName: database.databaseName, schema: database.schema, binding: database.binding, }); const response = await app.inject({ method: "POST", url: `/catalog/databases/${database.id}/sync-runs`, payload: { version: database.version + 1, scope: "tables", tableIds: [] }, }); expect(response.statusCode).toBe(409); expect(response.json()).toMatchObject({ code: "schema_sync_conflict" }); });