feat: consolidate database management work
Add catalog-owned logical relationships and runtime snapshots, extend the database-management UI and validation coverage, and document the updated operational workflow. Keep active sensitive-generation status in a tooltip and indicator, and update the layout E2E to follow the history action in its new database-scoped location.
This commit is contained in:
@@ -122,6 +122,7 @@ async function setup(env: Record<string, string> = {}) {
|
||||
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,
|
||||
@@ -605,3 +606,125 @@ test("rejects a missing database without partially cleaning valid selections", a
|
||||
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" });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user