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:
@@ -29,6 +29,13 @@ function makeDatabase(overrides: Partial<CatalogDatabase> = {}): CatalogDatabase
|
||||
workspaceId: "psd-clinical",
|
||||
workspaceName: "Policlinico San Donato",
|
||||
workspaceAvailable: true,
|
||||
workspaceRevision: { commit: "a".repeat(40), blob: "b".repeat(40) },
|
||||
workspaceEvidence: { sourceType: "filesystem", state: "materialized_current_revision" },
|
||||
runtimeBinding: {
|
||||
transport: "postgres_direct",
|
||||
configurationState: "ready",
|
||||
sessionTransportSupported: true,
|
||||
},
|
||||
configured: true,
|
||||
engine: "postgres",
|
||||
databaseName: "warehouse",
|
||||
@@ -66,6 +73,9 @@ const orphan = makeDatabase({
|
||||
workspaceId: "retired",
|
||||
workspaceName: "Retired workspace",
|
||||
workspaceAvailable: false,
|
||||
workspaceRevision: null,
|
||||
workspaceEvidence: { sourceType: null, state: "workspace_unavailable" },
|
||||
runtimeBinding: null,
|
||||
});
|
||||
|
||||
function renderPage({
|
||||
@@ -192,7 +202,7 @@ const synchronizationScopes = [
|
||||
{ scope: "all", label: "Synchronize all" },
|
||||
] as const;
|
||||
|
||||
test("renders Fleet Ledger with real metrics, conceptual row tooltips, and contextual model selection", async () => {
|
||||
test("renders Fleet Ledger with real metrics, conceptual row tooltips, and persistent model selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get("/api/catalog/metrics", () => HttpResponse.json({
|
||||
@@ -223,10 +233,15 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and conte
|
||||
expect(screen.queryByText("Thoth catalog · Fleet ledger")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Inspect physical metadata, curate descriptions and control catalog operations.")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Back to workspace" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Add database/i })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("columnheader", { name: /Revision \/ Evidence/ })).toBeVisible();
|
||||
expect(screen.getByRole("columnheader", { name: /NL→SQL runtime/ })).toBeVisible();
|
||||
expect(screen.getByRole("columnheader", { name: /Metadata Catalog/ })).toBeVisible();
|
||||
const summary = screen.getByRole("region", { name: "Fleet summary" });
|
||||
expect(await within(summary).findByText("2,275")).toBeVisible();
|
||||
expect(within(summary).getByText("75%")).toBeVisible();
|
||||
expect(screen.queryByRole("combobox", { name: "Metadata description model" })).not.toBeInTheDocument();
|
||||
const modelSelector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
expect(modelSelector).toHaveValue("local-qwen");
|
||||
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
const tablesAction = within(databaseRow).getByRole("button", { name: "View tables for Policlinico San Donato" });
|
||||
@@ -247,10 +262,103 @@ test("renders Fleet Ledger with real metrics, conceptual row tooltips, and conte
|
||||
const actionPicker = screen.getByRole("combobox", { name: "Batch action" });
|
||||
expect(within(actionPicker).getByRole("option", { name: /^Synchronize all(?:,|$)/ })).toBeVisible();
|
||||
await user.selectOptions(actionPicker, "generate-missing");
|
||||
expect(await screen.findByRole("combobox", { name: "Metadata description model" })).toHaveValue("local-qwen");
|
||||
expect(screen.getAllByRole("combobox", { name: "Metadata-generation LLM model" })).toHaveLength(1);
|
||||
expect(modelSelector).toHaveValue("local-qwen");
|
||||
|
||||
});
|
||||
|
||||
test("shows repository, NL→SQL runtime, and Metadata Catalog states independently", async () => {
|
||||
const configured = makeDatabase({ connectionStatus: "reachable", testedVersion: 3 });
|
||||
const needsRuntimeConfiguration = makeDatabase({
|
||||
...unconfigured,
|
||||
workspaceEvidence: { sourceType: "http", state: "configuration_required" },
|
||||
runtimeBinding: {
|
||||
transport: "rest_api",
|
||||
configurationState: "configuration_required",
|
||||
sessionTransportSupported: true,
|
||||
},
|
||||
});
|
||||
renderPage({ rows: [configured, needsRuntimeConfiguration, orphan], presentation: "fleet" });
|
||||
|
||||
const configuredRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
expect(within(configuredRow).getByText("Active revision aaaaaaa")).toBeVisible();
|
||||
expect(within(configuredRow).getByText("Evidence materialized · filesystem")).toBeVisible();
|
||||
expect(within(configuredRow).getByText("Ready")).toBeVisible();
|
||||
expect(within(configuredRow).getByText("Configured")).toBeVisible();
|
||||
expect(within(configuredRow).getByText("Connection reachable")).toBeVisible();
|
||||
|
||||
const needsConfigurationRow = screen.getByRole("row", { name: /Research laboratory/ });
|
||||
expect(within(needsConfigurationRow).getByText("Evidence credentials required · http")).toBeVisible();
|
||||
expect(within(needsConfigurationRow).getByText("Configuration required")).toBeVisible();
|
||||
expect(within(needsConfigurationRow).getByText("Not configured")).toBeVisible();
|
||||
|
||||
const orphanRow = screen.getByRole("row", { name: /Retired workspace/ });
|
||||
expect(within(orphanRow).getByText("Workspace missing")).toBeVisible();
|
||||
expect(within(orphanRow).getByText("Unavailable")).toBeVisible();
|
||||
expect(within(orphanRow).getByText("Orphaned configuration")).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps Fleet Ledger visible when a catalog response omits the Evidence projection", async () => {
|
||||
const legacyDatabase = makeDatabase();
|
||||
delete (legacyDatabase as Partial<CatalogDatabase>).workspaceEvidence;
|
||||
|
||||
renderPage({ rows: [legacyDatabase], presentation: "fleet" });
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Database management" })).toBeVisible();
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
expect(within(databaseRow).getByText("Evidence state unavailable")).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens the relationship map directly from a Fleet database and restores focus on return", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get("/api/catalog/databases/:databaseId/relationships", () => HttpResponse.json([])),
|
||||
);
|
||||
renderPage({
|
||||
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
||||
presentation: "fleet",
|
||||
});
|
||||
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
const relationshipsAction = within(databaseRow).getByRole("button", {
|
||||
name: "View relationships for Policlinico San Donato",
|
||||
});
|
||||
expect(relationshipsAction.parentElement).toHaveAttribute("data-tooltip", "Relationships");
|
||||
expect(relationshipsAction).not.toHaveAttribute("title");
|
||||
|
||||
await user.click(relationshipsAction);
|
||||
expect(await screen.findByRole("region", {
|
||||
name: "Relationships for Policlinico San Donato",
|
||||
})).toBeVisible();
|
||||
expect(screen.getByText("Relationship map")).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back to databases" }));
|
||||
await waitFor(() => expect(screen.getByRole("button", {
|
||||
name: "View relationships for Policlinico San Donato",
|
||||
})).toHaveFocus());
|
||||
});
|
||||
|
||||
test("offers catalog configuration directly on an unconfigured Fleet workspace", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage({ rows: [unconfigured], presentation: "fleet" });
|
||||
|
||||
const databaseRow = await screen.findByRole("row", { name: /Research laboratory/ });
|
||||
expect(within(databaseRow).queryByRole("button", {
|
||||
name: "View relationships for Research laboratory",
|
||||
})).not.toBeInTheDocument();
|
||||
expect(within(databaseRow).queryByRole("button", {
|
||||
name: "View tables for Research laboratory",
|
||||
})).not.toBeInTheDocument();
|
||||
|
||||
await user.click(within(databaseRow).getByRole("button", {
|
||||
name: "Configure catalog for Research laboratory",
|
||||
}));
|
||||
|
||||
const drawer = await screen.findByRole("dialog", { name: "Configure catalog" });
|
||||
expect(within(drawer).getByLabelText("Workspace")).toHaveAttribute("readonly");
|
||||
expect(within(drawer).getByLabelText("Workspace")).toHaveValue("Research laboratory");
|
||||
});
|
||||
|
||||
test("keeps only the local back action while browsing Fleet Ledger columns", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
@@ -276,6 +384,32 @@ test("keeps only the local back action while browsing Fleet Ledger columns", asy
|
||||
expect(screen.queryByRole("button", { name: "Back to tables" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("uses the database back-action background treatment for the table back action", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([])),
|
||||
);
|
||||
renderPage({ rows: [makeDatabase()], presentation: "fleet" });
|
||||
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(databaseRow).getByRole("button", {
|
||||
name: "View tables for Policlinico San Donato",
|
||||
}));
|
||||
const databaseBackAction = await screen.findByRole("button", { name: "Back to databases" });
|
||||
expect(databaseBackAction).toHaveClass("thot-fleet-back-action");
|
||||
const backgroundTreatment = (element: HTMLElement) => element.className
|
||||
.split(/\s+/)
|
||||
.filter((className) => /(^|:)bg-/.test(className))
|
||||
.sort();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
|
||||
|
||||
const tableBackAction = await screen.findByRole("button", { name: "Back to tables" });
|
||||
expect(tableBackAction).toHaveClass("thot-fleet-back-action");
|
||||
expect(backgroundTreatment(tableBackAction)).toEqual(backgroundTreatment(databaseBackAction));
|
||||
});
|
||||
|
||||
test("reopens database synchronization history when no run is active", async () => {
|
||||
const user = userEvent.setup();
|
||||
const completed: CatalogSyncRun = {
|
||||
@@ -312,7 +446,7 @@ test("selects the configured metadata-description model by default", async () =>
|
||||
})));
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
await waitFor(() => expect(selector).toHaveValue("local-qwen"));
|
||||
});
|
||||
|
||||
@@ -320,7 +454,7 @@ test("keeps both run-history buttons visible beside the metadata-description sel
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
const toolbar = screen.getByRole("group", { name: "Database management controls" });
|
||||
const metadataControls = screen.getByRole("group", { name: "Metadata description controls" });
|
||||
const actions = screen.getByRole("group", { name: "Database management actions" });
|
||||
@@ -344,7 +478,7 @@ test("keeps both run-history buttons visible beside the metadata-description sel
|
||||
expect(toolbar.lastElementChild).toBe(actions);
|
||||
expect(actions).toHaveClass("sm:justify-end");
|
||||
expect(actions).toContainElement(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(actions).toContainElement(screen.getByRole("button", { name: "Add database" }));
|
||||
expect(within(actions).queryByRole("button", { name: /Add database/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("note", {
|
||||
name: "Metadata generation source data disclosure",
|
||||
})).not.toBeInTheDocument();
|
||||
@@ -371,7 +505,7 @@ test("changes the metadata-description model in page-local state", async () => {
|
||||
})));
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
await waitFor(() => expect(selector).toHaveValue("local-qwen"));
|
||||
await user.selectOptions(selector, "openai-mini");
|
||||
|
||||
@@ -385,10 +519,10 @@ test("explains application setup when no metadata-description model is configure
|
||||
})));
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
expect(selector).toBeDisabled();
|
||||
expect(await screen.findByText(
|
||||
"Configure a metadata-generation model in application setup to enable description generation.",
|
||||
"No metadata-generation LLM model is configured for this installation.",
|
||||
)).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -399,11 +533,11 @@ test("uses a safe disabled setup state when metadata-description models are unav
|
||||
}, { status: 503 })));
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
await waitFor(() => expect(selector).toHaveDisplayValue("Model choices unavailable"));
|
||||
expect(selector).toBeDisabled();
|
||||
expect(screen.getByText(
|
||||
"Configure a metadata-generation model in application setup to enable description generation.",
|
||||
"Metadata-generation LLM model choices are unavailable.",
|
||||
)).toBeVisible();
|
||||
expect(screen.queryByText("internal model registry details")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -417,11 +551,11 @@ test.each([null, "missing-model"])(
|
||||
})));
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
await waitFor(() => expect(selector).toHaveDisplayValue("No usable model configured"));
|
||||
expect(selector).toBeDisabled();
|
||||
expect(screen.getByText(
|
||||
"Configure a metadata-generation model in application setup to enable description generation.",
|
||||
"No metadata-generation LLM model is configured for this installation.",
|
||||
)).toBeVisible();
|
||||
},
|
||||
);
|
||||
@@ -441,7 +575,7 @@ test("renders only the public metadata-generation model contract", async () => {
|
||||
})));
|
||||
renderPage();
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
|
||||
const selector = await screen.findByRole("combobox", { name: "Metadata-generation LLM model" });
|
||||
await waitFor(() => expect(selector).toHaveValue("approved-model"));
|
||||
expect(within(selector).getAllByRole("option").map((option) => option.textContent))
|
||||
.toEqual(["Approved model"]);
|
||||
@@ -458,8 +592,9 @@ test("starts with a full-width list and applies the row action matrix", async ()
|
||||
expect(screen.getByRole("button", { name: "Delete Policlinico San Donato" })).toBeEnabled();
|
||||
|
||||
expect(screen.getByRole("button", { name: "View Research laboratory" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Edit Research laboratory" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Delete Research laboratory" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Configure catalog for Research laboratory" })).toBeEnabled();
|
||||
expect(screen.queryByRole("button", { name: "Edit Research laboratory" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Delete Research laboratory" })).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole("button", { name: "View Retired workspace" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Edit Retired workspace" })).toBeDisabled();
|
||||
@@ -558,7 +693,7 @@ test("keeps Fleet Generate Missing behind the source-data disclosure", async ()
|
||||
"generate-missing",
|
||||
);
|
||||
await waitFor(() => expect(screen.getByRole("combobox", {
|
||||
name: "Metadata description model",
|
||||
name: "Metadata-generation LLM model",
|
||||
})).toHaveValue("local-qwen"));
|
||||
await user.click(screen.getByRole("button", { name: "Run action" }));
|
||||
|
||||
@@ -951,7 +1086,7 @@ test("replaces the list with View and returns focus to the originating action",
|
||||
expect(screen.getByRole("button", { name: "View Policlinico San Donato" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens an unconfigured row as Edit while creating its first saved configuration", async () => {
|
||||
test("configures the catalog for the selected workspace and locks that workspace", async () => {
|
||||
const user = userEvent.setup();
|
||||
let postBody: unknown;
|
||||
const saved = makeDatabase({
|
||||
@@ -971,16 +1106,16 @@ test("opens an unconfigured row as Edit while creating its first saved configura
|
||||
);
|
||||
renderPage({ rows: () => rows });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Edit Research laboratory" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Configure catalog for Research laboratory" }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Edit database" })).toBeVisible();
|
||||
expect(screen.getByLabelText("Workspace")).toBeDisabled();
|
||||
expect(screen.getByRole("heading", { name: "Configure catalog" })).toBeVisible();
|
||||
expect(screen.getByLabelText("Workspace")).toHaveAttribute("readonly");
|
||||
await user.selectOptions(screen.getByLabelText("Transport"), "rest_api");
|
||||
await user.type(screen.getByLabelText("Base URL"), "https://psd.example/api");
|
||||
expect(screen.getByLabelText("Diagnostic endpoint")).toHaveValue("/health");
|
||||
expect(screen.getByLabelText("Diagnostic endpoint")).toHaveAttribute("readonly");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save database" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save catalog configuration" }));
|
||||
|
||||
await waitFor(() => expect(postBody).toMatchObject({
|
||||
workspaceId: "lab",
|
||||
@@ -989,7 +1124,7 @@ test("opens an unconfigured row as Edit while creating its first saved configura
|
||||
expect(await screen.findByRole("heading", { name: "Edit database" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("global Add offers only unconfigured workspaces and lets the operator choose one", async () => {
|
||||
test("has no global database creation path and scopes configuration to the chosen row", async () => {
|
||||
const user = userEvent.setup();
|
||||
const second = makeDatabase({
|
||||
...unconfigured,
|
||||
@@ -999,14 +1134,13 @@ test("global Add offers only unconfigured workspaces and lets the operator choos
|
||||
});
|
||||
renderPage({ rows: [makeDatabase(), unconfigured, second] });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Add database" }));
|
||||
expect(screen.queryByRole("button", { name: /Add database/i })).not.toBeInTheDocument();
|
||||
await user.click(await screen.findByRole("button", { name: "Configure catalog for Radiology" }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Add database" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Add database" })).toBeVisible();
|
||||
const selector = screen.getByLabelText("Workspace");
|
||||
expect(selector).toBeEnabled();
|
||||
expect(screen.queryByRole("option", { name: "Policlinico San Donato" })).not.toBeInTheDocument();
|
||||
await user.selectOptions(selector, "radiology");
|
||||
expect(screen.getByRole("heading", { name: "Configure catalog" })).toBeVisible();
|
||||
const workspace = screen.getByLabelText("Workspace");
|
||||
expect(workspace).toHaveAttribute("readonly");
|
||||
expect(workspace).toHaveValue("Radiology");
|
||||
expect(screen.getByDisplayValue("radiology_dwh")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1064,7 +1198,8 @@ test("uses an in-page destructive form and returns the YAML workspace to Not con
|
||||
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
expect(deleteVersion).toBe("3");
|
||||
expect(await screen.findByRole("button", { name: "Delete Policlinico San Donato" })).toBeDisabled();
|
||||
expect(await screen.findByRole("button", { name: "Configure catalog for Policlinico San Donato" })).toBeEnabled();
|
||||
expect(screen.queryByRole("button", { name: "Delete Policlinico San Donato" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Not configured")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -2677,6 +2812,8 @@ test("navigates from a table to columns and from the database to physical relati
|
||||
deferrable: false,
|
||||
initiallyDeferred: false,
|
||||
columns: [{ position: 1, sourceColumnId: "1", sourceColumnName: "patient_id", targetColumnId: idColumn.id, targetColumnName: "id" }],
|
||||
origin: "physical",
|
||||
status: "active",
|
||||
lastSyncedDatabaseVersion: 3,
|
||||
lastSyncedAt: "2026-08-27T10:00:00Z",
|
||||
createdAt: "2026-08-27T10:00:00Z",
|
||||
|
||||
Reference in New Issue
Block a user