Files
ThothII/backend/test/catalog-schema-routes.test.ts
T

726 lines
32 KiB
TypeScript

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 { CatalogOperationCoordinator } from "../src/catalog/operation-coordinator.js";
import type { CatalogSchemaIntrospector } from "../src/catalog/schema-introspector.js";
import type { CatalogSyncRun, ObservedSchemaSnapshot } from "../src/catalog/types.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"] },
};
const revision: WorkspaceRevision = { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd.yaml" };
function snapshot(): ObservedSchemaSnapshot {
return {
schemaVersion: 1,
capabilities: { tables: "available", columns: "available", relationships: "available" },
tables: [
{ name: "patients", sourceComment: "Clinical patients" },
{ name: "visits", sourceComment: "Patient visits" },
],
columns: [
{ tableName: "patients", name: "id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: "Patient key" },
{ tableName: "visits", name: "id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: null },
{ tableName: "visits", name: "patient_id", ordinalPosition: 2, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: null, sourceComment: "Owning patient" },
],
relationships: [{
constraintName: "visits_patient_id_fkey",
sourceTableName: "visits",
targetTableName: "patients",
updateRule: "NO ACTION",
deleteRule: "CASCADE",
deferrable: false,
initiallyDeferred: false,
columns: [{ position: 1, sourceColumnName: "patient_id", targetColumnName: "id" }],
}],
};
}
function expandedSnapshot(): ObservedSchemaSnapshot {
const expanded = snapshot();
expanded.tables.push(
{ name: "encounters", sourceComment: null },
{ name: "departments", sourceComment: null },
);
expanded.columns.push(
{ tableName: "encounters", name: "visit_id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: null, sourceComment: null },
{ tableName: "departments", name: "patient_id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: null, sourceComment: null },
);
expanded.relationships.push(
{
constraintName: "encounters_visit_id_fkey",
sourceTableName: "encounters",
targetTableName: "visits",
updateRule: "NO ACTION",
deleteRule: "CASCADE",
deferrable: false,
initiallyDeferred: false,
columns: [{ position: 1, sourceColumnName: "visit_id", targetColumnName: "id" }],
},
{
constraintName: "departments_patient_id_fkey",
sourceTableName: "departments",
targetTableName: "patients",
updateRule: "NO ACTION",
deleteRule: "CASCADE",
deferrable: false,
initiallyDeferred: false,
columns: [{ position: 1, sourceColumnName: "patient_id", targetColumnName: "id" }],
},
);
return expanded;
}
async function waitFor(repository: MemoryCatalogRepository, runId: string, state: CatalogSyncRun["state"]): Promise<CatalogSyncRun> {
for (let attempt = 0; attempt < 100; attempt += 1) {
const run = await repository.getSyncRun(runId);
if (run?.state === state) return run;
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error(`Run ${runId} did not reach ${state}`);
}
async function setup(env: Record<string, string> = {}) {
const secretRoot = mkdtempSync(join(tmpdir(), "catalog-schema-secret-"));
const runtimeRoot = mkdtempSync(join(tmpdir(), "catalog-schema-runtime-"));
roots.push(secretRoot, runtimeRoot);
const repository = new MemoryCatalogRepository();
const created = 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(created.id, created.version, {
connectionStatus: "reachable", testedVersion: created.version, lastTestedAt: new Date().toISOString(),
});
let observed = snapshot();
const scan = vi.fn(async (_database, _signal, progress) => {
await progress?.("connecting");
await progress?.("scanning_tables", { tables: observed.tables.length });
await progress?.("scanning_columns", { tables: observed.tables.length, columns: observed.columns.length });
await progress?.("scanning_relationships", { relationships: observed.relationships.length });
return structuredClone(observed);
});
const introspector: CatalogSchemaIntrospector = { scan };
const operations = new CatalogOperationCoordinator();
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 app = buildApp(loadConfig({ THT_HARNESS_DIR: "/missing", NODE_ENV: "test", ...env }), {
thtRunner: {} as never,
workspaceRegistry: registry,
workspaceSecretStore: new WorkspaceSecretStore({ root: secretRoot, runtimeRoot, installationId: "test" }),
catalogRepository: repository,
catalogSchemaIntrospector: introspector,
catalogOperationCoordinator: operations,
workspaceDiagnoser: vi.fn(),
});
return {
app, repository, database: (await repository.get(created.id))!, scan, operations,
setObserved(next: ObservedSchemaSnapshot) { observed = next; },
};
}
async function seedCatalog(
repository: MemoryCatalogRepository,
database: { id: string; version: number },
observed: ObservedSchemaSnapshot = snapshot(),
): Promise<void> {
await repository.applySchemaSync(database.id, database.version, "all", [], observed);
}
test("synchronizes a full physical schema and derives primary and foreign key flags", async () => {
const { app, repository, database } = await setup();
const started = await app.inject({
method: "POST", url: `/catalog/databases/${database.id}/sync-runs`,
payload: { version: database.version, scope: "all", tableIds: [] },
});
expect(started.statusCode).toBe(202);
const completed = await waitFor(repository, started.json().id, "succeeded");
expect(completed.counts).toMatchObject({ tables: 2, columns: 3, relationships: 1 });
const tables = await repository.listTables(database.id);
const visits = tables.find((table) => table.name === "visits")!;
const columns = (await app.inject({ method: "GET", url: `/catalog/databases/${database.id}/tables/${visits.id}/columns` })).json();
expect(columns).toMatchObject([
{ name: "id", isPrimaryKey: true, primaryKeyPosition: 1, isForeignKey: false },
{ name: "patient_id", isPrimaryKey: false, isForeignKey: true, foreignKeyCount: 1 },
]);
const relationships = (await app.inject({ method: "GET", url: `/catalog/databases/${database.id}/relationships` })).json();
expect(relationships).toMatchObject([{ constraintName: "visits_patient_id_fkey", columns: [{ sourceColumnName: "patient_id", targetColumnName: "id" }] }]);
expect((await repository.get(database.id))?.schemaSyncedVersion).toBe(database.version);
});
test("synchronizes columns for every catalog table when no table selection is supplied", async () => {
const { app, repository, database, setObserved } = await setup();
const tablesRun = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/sync-runs`,
payload: { version: database.version, scope: "tables", tableIds: [] },
});
expect(tablesRun.statusCode).toBe(202);
await waitFor(repository, tablesRun.json().id, "succeeded");
const tables = await repository.listTables(database.id);
expect(tables.map((table) => table.name)).toEqual(["patients", "visits"]);
const columnsRun = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/sync-runs`,
payload: { version: database.version, scope: "columns", tableIds: [] },
});
expect(columnsRun.statusCode).toBe(202);
await waitFor(repository, columnsRun.json().id, "succeeded");
const patients = tables.find((table) => table.name === "patients")!;
const visits = tables.find((table) => table.name === "visits")!;
expect((await repository.listColumns(database.id, patients.id)).map((column) => column.name)).toEqual(["id"]);
expect((await repository.listColumns(database.id, visits.id)).map((column) => column.name)).toEqual(["id", "patient_id"]);
const next = snapshot();
next.columns = next.columns.filter((column) => column.name !== "id");
setObserved(next);
const selectedDestructiveRun = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/sync-runs`,
payload: { version: database.version, scope: "columns", tableIds: [patients.id] },
});
expect(selectedDestructiveRun.statusCode).toBe(202);
const selectedWaiting = await waitFor(repository, selectedDestructiveRun.json().id, "awaiting_confirmation");
expect(selectedWaiting.plannedDiff?.deletedColumns).toEqual([
{ tableName: "patients", columnName: "id" },
]);
expect((await app.inject({
method: "POST",
url: `/catalog/sync-runs/${selectedWaiting.id}/cancel`,
})).statusCode).toBe(200);
const destructiveRun = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/sync-runs`,
payload: { version: database.version, scope: "columns", tableIds: [] },
});
expect(destructiveRun.statusCode).toBe(202);
const waiting = await waitFor(repository, destructiveRun.json().id, "awaiting_confirmation");
expect(waiting.plannedDiff?.deletedColumns).toEqual([
{ tableName: "patients", columnName: "id" },
{ tableName: "visits", columnName: "id" },
]);
});
test("keeps generated descriptions editable and preserves them across synchronization", async () => {
const { app, repository, database } = await setup();
const first = await app.inject({ method: "POST", url: `/catalog/databases/${database.id}/sync-runs`, payload: { version: database.version, scope: "all", tableIds: [] } });
await waitFor(repository, first.json().id, "succeeded");
const patients = (await repository.listTables(database.id)).find((table) => table.name === "patients")!;
const editedTable = await app.inject({
method: "PATCH", url: `/catalog/databases/${database.id}/tables/${patients.id}`,
payload: { version: patients.version, description: null, generatedDescription: "Generated table draft" },
});
expect(editedTable.json()).toMatchObject({ description: null, generatedDescription: "Generated table draft" });
const idColumn = (await repository.listColumns(database.id, patients.id))[0];
expect(idColumn.sensitive).toBe(false);
const editedColumn = await app.inject({
method: "PATCH", url: `/catalog/databases/${database.id}/tables/${patients.id}/columns/${idColumn.id}`,
payload: {
version: idColumn.version,
description: "Reviewed key",
generatedDescription: "Generated key draft",
sensitive: false,
},
});
expect(editedColumn.json()).toMatchObject({
description: "Reviewed key",
generatedDescription: "Generated key draft",
sensitive: false,
});
const sensitiveOnly = await app.inject({
method: "PATCH", url: `/catalog/databases/${database.id}/tables/${patients.id}/columns/${idColumn.id}`,
payload: { version: editedColumn.json().version, sensitive: true },
});
expect(sensitiveOnly.statusCode).toBe(200);
expect(sensitiveOnly.json()).toMatchObject({
description: "Reviewed key",
generatedDescription: "Generated key draft",
sensitive: true,
});
const emptyPatch = await app.inject({
method: "PATCH", url: `/catalog/databases/${database.id}/tables/${patients.id}/columns/${idColumn.id}`,
payload: { version: sensitiveOnly.json().version },
});
expect(emptyPatch.statusCode).toBe(400);
const second = await app.inject({ method: "POST", url: `/catalog/databases/${database.id}/sync-runs`, payload: { version: database.version, scope: "all", tableIds: [] } });
await waitFor(repository, second.json().id, "succeeded");
expect(await repository.getTable(database.id, patients.id)).toMatchObject({ generatedDescription: "Generated table draft" });
expect(await repository.getColumn(database.id, patients.id, idColumn.id)).toMatchObject({
description: "Reviewed key",
generatedDescription: "Generated key draft",
sensitive: true,
});
});
test("consolidates non-empty generated table descriptions and reports skipped selections", async () => {
const { app, repository, database, scan } = await setup();
await seedCatalog(repository, database);
const tables = await repository.listTables(database.id);
const patients = tables.find((table) => table.name === "patients")!;
const visits = tables.find((table) => table.name === "visits")!;
await repository.updateTableMetadata(
database.id,
patients.id,
patients.version,
"Curated patients",
"Generated patients",
);
await repository.updateTableMetadata(
database.id,
visits.id,
visits.version,
"Keep curated visits",
null,
);
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/descriptions/consolidate`,
payload: { target: "tables", targetIds: [patients.id, visits.id] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ copied: 1, skipped: 1 });
expect(await repository.getTable(database.id, patients.id)).toMatchObject({
description: "Generated patients",
generatedDescription: "Generated patients",
version: patients.version + 2,
});
expect(await repository.getTable(database.id, visits.id)).toMatchObject({
description: "Keep curated visits",
generatedDescription: null,
version: visits.version + 1,
});
expect(scan).not.toHaveBeenCalled();
});
test("consolidates non-empty generated column descriptions and preserves curated text for empty proposals", async () => {
const { app, repository, database, scan } = await setup();
await seedCatalog(repository, database);
const visits = (await repository.listTables(database.id)).find((table) => table.name === "visits")!;
const columns = await repository.listColumns(database.id, visits.id);
const id = columns.find((column) => column.name === "id")!;
const patientId = columns.find((column) => column.name === "patient_id")!;
await repository.updateColumnMetadata(
database.id,
visits.id,
id.id,
id.version,
"Curated visit identifier",
"Generated visit identifier",
);
await repository.updateColumnMetadata(
database.id,
visits.id,
patientId.id,
patientId.version,
"Keep curated patient reference",
"",
);
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/descriptions/consolidate`,
payload: { target: "columns", targetIds: [id.id, patientId.id] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ copied: 1, skipped: 1 });
expect(await repository.getColumn(database.id, visits.id, id.id)).toMatchObject({
description: "Generated visit identifier",
generatedDescription: "Generated visit identifier",
version: id.version + 2,
});
expect(await repository.getColumn(database.id, visits.id, patientId.id)).toMatchObject({
description: "Keep curated patient reference",
generatedDescription: "",
version: patientId.version + 1,
});
expect(scan).not.toHaveBeenCalled();
});
test("rejects description consolidation while the Workspace Database is reserved", async () => {
const { app, repository, database, operations } = await setup();
await seedCatalog(repository, database);
const table = (await repository.listTables(database.id))[0]!;
const edited = await repository.updateTableMetadata(
database.id,
table.id,
table.version,
"Existing curated text",
"Generated text",
);
const release = operations.reserve(database.id);
try {
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/descriptions/consolidate`,
payload: { target: "tables", targetIds: [table.id] },
});
expect(response.statusCode).toBe(409);
expect(response.json()).toEqual({
code: "database_operation_in_progress",
message: "A database operation is already in progress.",
});
expect(await repository.getTable(database.id, table.id)).toMatchObject({
description: "Existing curated text",
generatedDescription: "Generated text",
version: edited!.version,
});
} finally {
release();
}
});
test("requires database.manage for description consolidation", async () => {
const { app, repository, database } = await setup({ AUTH_MODE: "upstream" });
await seedCatalog(repository, database);
const table = (await repository.listTables(database.id))[0]!;
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/descriptions/consolidate`,
headers: {
"x-thoth-principal-issuer": "portal",
"x-thoth-principal-subject": "catalog-reader",
"x-thoth-is-admin": "0",
},
payload: { target: "tables", targetIds: [table.id] },
});
expect(response.statusCode).toBe(403);
expect(response.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
});
test("strictly validates description consolidation database and target ids", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database);
const table = (await repository.listTables(database.id))[0]!;
const responses = await Promise.all([
app.inject({
method: "POST",
url: "/catalog/databases/not-a-uuid/descriptions/consolidate",
payload: { target: "tables", targetIds: [table.id] },
}),
app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/descriptions/consolidate`,
payload: { target: "tables", targetIds: ["not-a-uuid"] },
}),
app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/descriptions/consolidate`,
payload: { target: "tables", targetIds: [table.id], unexpected: true },
}),
]);
expect(responses.map((response) => response.statusCode)).toEqual([400, 400, 400]);
for (const response of responses) {
expect(response.json()).toEqual({
code: "description_consolidation_invalid",
message: "Description consolidation request is invalid.",
});
}
});
test("rejects a missing consolidation target without copying valid selections", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database);
const table = (await repository.listTables(database.id))[0]!;
const edited = await repository.updateTableMetadata(
database.id,
table.id,
table.version,
"Existing curated text",
"Generated text",
);
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/descriptions/consolidate`,
payload: {
target: "tables",
targetIds: [table.id, "99999999-9999-4999-8999-999999999999"],
},
});
expect(response.statusCode).toBe(404);
expect(await repository.getTable(database.id, table.id)).toMatchObject({
description: "Existing curated text",
generatedDescription: "Generated text",
version: edited!.version,
});
});
test("waits for confirmation and rescans before applying destructive changes", async () => {
const { app, repository, database, scan, setObserved } = await setup();
const first = await app.inject({ method: "POST", url: `/catalog/databases/${database.id}/sync-runs`, payload: { version: database.version, scope: "all", tableIds: [] } });
await waitFor(repository, first.json().id, "succeeded");
const next = snapshot();
next.tables = next.tables.filter((table) => table.name !== "visits");
next.columns = next.columns.filter((column) => column.tableName !== "visits");
next.relationships = [];
setObserved(next);
const destructive = await app.inject({ method: "POST", url: `/catalog/databases/${database.id}/sync-runs`, payload: { version: database.version, scope: "all", tableIds: [] } });
const waiting = await waitFor(repository, destructive.json().id, "awaiting_confirmation");
expect(waiting.plannedDiff).toMatchObject({ deletedTables: ["visits"] });
expect(await repository.listTables(database.id)).toHaveLength(2);
const confirmed = await app.inject({
method: "POST", url: `/catalog/sync-runs/${waiting.id}/confirm`, payload: { confirmationToken: waiting.confirmationToken },
});
expect(confirmed.statusCode).toBe(200);
await waitFor(repository, waiting.id, "succeeded");
expect((await repository.listTables(database.id)).map((table) => table.name)).toEqual(["patients"]);
expect(scan).toHaveBeenCalledTimes(3);
});
test("deletes every catalog table for multiple selected databases and cascades dependent metadata", async () => {
const { app, repository, database } = await setup();
const second = await repository.create({
workspaceId: "radiology", engine: "postgres", databaseName: "radiology", schema: "public",
binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" },
});
await seedCatalog(repository, database);
await seedCatalog(repository, second);
const response = await app.inject({
method: "POST",
url: "/catalog/databases/metadata-cleanup",
payload: { databaseIds: [database.id, second.id], target: "tables" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ tables: 4, columns: 6, relationships: 2 });
expect(await repository.get(database.id)).toBeDefined();
expect(await repository.get(second.id)).toBeDefined();
expect(await repository.listTables(database.id)).toEqual([]);
expect(await repository.listTables(second.id)).toEqual([]);
expect(await repository.listRelationships(database.id)).toEqual([]);
expect(await repository.listRelationships(second.id)).toEqual([]);
});
test("deletes relationships for selected databases without deleting their tables or columns", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database);
const tables = await repository.listTables(database.id);
const response = await app.inject({
method: "POST",
url: "/catalog/databases/metadata-cleanup",
payload: { databaseIds: [database.id], target: "relationships" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ tables: 0, columns: 0, relationships: 1 });
expect(await repository.listTables(database.id)).toHaveLength(2);
expect(await repository.listColumns(database.id, tables[0]!.id)).not.toEqual([]);
expect(await repository.listRelationships(database.id)).toEqual([]);
expect((await repository.get(database.id))?.schemaSyncedVersion).toBeUndefined();
});
test("deletes columns from multiple selected catalog tables only", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database, expandedSnapshot());
const tables = await repository.listTables(database.id);
const patients = tables.find((table) => table.name === "patients")!;
const visits = tables.find((table) => table.name === "visits")!;
const encounters = tables.find((table) => table.name === "encounters")!;
const departments = tables.find((table) => table.name === "departments")!;
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/tables/metadata-cleanup`,
payload: { tableIds: [patients.id, visits.id], target: "columns" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ tables: 0, columns: 3, relationships: 0 });
expect(await repository.listColumns(database.id, patients.id)).toEqual([]);
expect(await repository.listColumns(database.id, visits.id)).toEqual([]);
expect((await repository.listColumns(database.id, encounters.id)).map((column) => column.name))
.toEqual(["visit_id"]);
expect((await repository.listColumns(database.id, departments.id)).map((column) => column.name))
.toEqual(["patient_id"]);
expect((await repository.get(database.id))?.schemaSyncedVersion).toBeUndefined();
});
test("deletes incoming and outgoing relationships for multiple selected catalog tables only", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database, expandedSnapshot());
const tables = await repository.listTables(database.id);
const visits = tables.find((table) => table.name === "visits")!;
const encounters = tables.find((table) => table.name === "encounters")!;
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/tables/metadata-cleanup`,
payload: { tableIds: [visits.id, encounters.id], target: "relationships" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ tables: 0, columns: 0, relationships: 2 });
expect((await repository.listRelationships(database.id)).map((relationship) => relationship.constraintName))
.toEqual(["departments_patient_id_fkey"]);
expect(await repository.listTables(database.id)).toHaveLength(4);
});
test("rejects a missing database without partially cleaning valid selections", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database);
const response = await app.inject({
method: "POST",
url: "/catalog/databases/metadata-cleanup",
payload: {
databaseIds: [database.id, "99999999-9999-4999-8999-999999999999"],
target: "tables",
},
});
expect(response.statusCode).toBe(404);
expect(await repository.listTables(database.id)).toHaveLength(2);
});
test("serves one relationship map and supports the manual relationship lifecycle", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database);
const tables = await repository.listTables(database.id);
const patients = tables.find((table) => table.name === "patients")!;
const visits = tables.find((table) => table.name === "visits")!;
const patientId = (await repository.listColumns(database.id, patients.id)).find((item) => item.name === "id")!;
const visitId = (await repository.listColumns(database.id, visits.id)).find((item) => item.name === "id")!;
const created = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/relationships`,
payload: { sourceColumnId: visitId.id, targetColumnId: patientId.id },
});
expect(created.statusCode).toBe(201);
expect(created.json()).toMatchObject({ origin: "manual", status: "active", constraintName: null });
const listed = (await app.inject({
method: "GET", url: `/catalog/databases/${database.id}/relationships`,
})).json();
expect(listed.map((item: { origin: string }) => item.origin).sort()).toEqual(["manual", "physical"]);
const relationshipId = created.json().id;
const excluded = await app.inject({
method: "PATCH",
url: `/catalog/databases/${database.id}/relationships/${relationshipId}`,
payload: { status: "excluded" },
});
expect(excluded.statusCode).toBe(200);
expect(excluded.json()).toMatchObject({ origin: "manual", status: "excluded" });
const restored = await app.inject({
method: "PATCH",
url: `/catalog/databases/${database.id}/relationships/${relationshipId}`,
payload: { status: "active" },
});
expect(restored.json()).toMatchObject({ status: "active" });
expect((await app.inject({
method: "DELETE", url: `/catalog/databases/${database.id}/relationships/${relationshipId}`,
})).statusCode).toBe(204);
});
test("rejects generated inference while the Catalog schema is not current", async () => {
const { app, database } = await setup();
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/relationships/rebuild-generated`,
});
expect(response.statusCode).toBe(409);
expect(response.json()).toEqual({
code: "relationship_schema_stale",
message: "Synchronize the current database schema before managing logical relationships.",
});
});
test("rebuilds generated relationships and returns the exact summary", async () => {
const { app, repository, database } = await setup();
const observed = snapshot();
observed.relationships = [];
await seedCatalog(repository, database, observed);
const first = await app.inject({
method: "POST", url: `/catalog/databases/${database.id}/relationships/rebuild-generated`,
});
expect(first.statusCode).toBe(200);
expect(first.json()).toEqual({ added: 1, alreadyPresent: 0, excluded: 0, ambiguous: 0 });
const generated = (await repository.listLogicalRelationships(database.id))[0]!;
await app.inject({
method: "PATCH",
url: `/catalog/databases/${database.id}/relationships/${generated.id}`,
payload: { status: "excluded" },
});
const second = await app.inject({
method: "POST", url: `/catalog/databases/${database.id}/relationships/rebuild-generated`,
});
expect(second.json()).toEqual({ added: 0, alreadyPresent: 0, excluded: 1, ambiguous: 0 });
});
test("validates relationship requests and keeps physical relationships read-only", async () => {
const { app, repository, database } = await setup();
await seedCatalog(repository, database);
const physical = (await repository.listRelationships(database.id))[0]!;
const invalid = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/relationships`,
payload: { sourceColumnId: physical.columns[0].sourceColumnId, targetColumnId: physical.columns[0].targetColumnId, generated: true },
});
expect(invalid.statusCode).toBe(400);
expect(invalid.json()).toMatchObject({ code: "relationship_request_invalid" });
const readOnly = await app.inject({
method: "PATCH",
url: `/catalog/databases/${database.id}/relationships/${physical.id}`,
payload: { status: "excluded" },
});
expect(readOnly.statusCode).toBe(409);
expect(readOnly.json()).toMatchObject({ code: "relationship_read_only" });
});
test("requires database.manage for relationship map mutations", async () => {
const { app, repository, database } = await setup({ AUTH_MODE: "upstream" });
const observed = snapshot();
observed.relationships = [];
await seedCatalog(repository, database, observed);
const response = await app.inject({
method: "POST",
url: `/catalog/databases/${database.id}/relationships/rebuild-generated`,
headers: {
"x-thoth-principal-issuer": "portal",
"x-thoth-principal-subject": "catalog-reader",
"x-thoth-is-admin": "0",
},
});
expect(response.statusCode).toBe(403);
expect(response.json()).toEqual({ code: "auth_forbidden", error: "This operation is not permitted" });
});