feat: implement metadata catalog database management
This commit is contained in:
@@ -0,0 +1,842 @@
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { server } from "../test/msw";
|
||||
import type { CatalogColumn, CatalogDatabase, CatalogSyncRun, CatalogTable } from "../api/catalog-databases";
|
||||
import { DatabaseManagementPage } from "./DatabaseManagementPage";
|
||||
|
||||
const noSecrets = {
|
||||
password: false,
|
||||
apiKey: false,
|
||||
sshPrivateKey: false,
|
||||
sshPrivateKeyPassphrase: false,
|
||||
sshKnownHosts: false,
|
||||
tlsCa: false,
|
||||
};
|
||||
|
||||
function makeDatabase(overrides: Partial<CatalogDatabase> = {}): CatalogDatabase {
|
||||
return {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
workspaceId: "psd-clinical",
|
||||
workspaceName: "Policlinico San Donato",
|
||||
workspaceAvailable: true,
|
||||
configured: true,
|
||||
engine: "postgres",
|
||||
databaseName: "warehouse",
|
||||
schema: "datawarehouse",
|
||||
version: 3,
|
||||
createdAt: "2026-08-27T08:00:00Z",
|
||||
updatedAt: "2026-08-27T09:00:00Z",
|
||||
binding: {
|
||||
transport: "postgres_direct",
|
||||
host: "db.internal",
|
||||
port: 5432,
|
||||
username: "reader",
|
||||
},
|
||||
connectionStatus: "untested",
|
||||
secrets: { ...noSecrets },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const unconfigured = makeDatabase({
|
||||
id: undefined,
|
||||
workspaceId: "lab",
|
||||
workspaceName: "Research laboratory",
|
||||
configured: false,
|
||||
databaseName: "lab_warehouse",
|
||||
schema: "analytics",
|
||||
version: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
binding: { transport: "postgres_direct", port: 5432 },
|
||||
});
|
||||
|
||||
const orphan = makeDatabase({
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
workspaceId: "retired",
|
||||
workspaceName: "Retired workspace",
|
||||
workspaceAvailable: false,
|
||||
});
|
||||
|
||||
function renderPage({
|
||||
rows = [makeDatabase(), unconfigured, orphan],
|
||||
canManage = true,
|
||||
canManageSecrets = true,
|
||||
onNavigationStateChange,
|
||||
}: {
|
||||
rows?: CatalogDatabase[] | (() => CatalogDatabase[]);
|
||||
canManage?: boolean;
|
||||
canManageSecrets?: boolean;
|
||||
onNavigationStateChange?: (state: { dirty: boolean; busy: boolean }) => void;
|
||||
} = {}) {
|
||||
server.use(http.get("/api/catalog/databases", () => HttpResponse.json(
|
||||
typeof rows === "function" ? rows() : rows,
|
||||
)));
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(
|
||||
<QueryClientProvider client={client}>
|
||||
<DatabaseManagementPage
|
||||
canManage={canManage}
|
||||
canManageSecrets={canManageSecrets}
|
||||
onNavigationStateChange={onNavigationStateChange}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...view, client };
|
||||
}
|
||||
|
||||
function makeSyncRun(
|
||||
scope: CatalogSyncRun["scope"],
|
||||
tableIds: string[] = [],
|
||||
): CatalogSyncRun {
|
||||
return {
|
||||
id: `sync-${scope}`,
|
||||
databaseId: "11111111-1111-4111-8111-111111111111",
|
||||
scope,
|
||||
tableIds,
|
||||
state: "queued",
|
||||
phase: "queued",
|
||||
requestedDatabaseVersion: 3,
|
||||
plannedDiff: null,
|
||||
confirmationToken: null,
|
||||
counts: {},
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
cancelRequested: false,
|
||||
createdAt: "2026-08-27T10:00:00Z",
|
||||
startedAt: null,
|
||||
updatedAt: "2026-08-27T10:00:00Z",
|
||||
finishedAt: null,
|
||||
heartbeatAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function registerCompletedSyncRun(run: CatalogSyncRun) {
|
||||
const completed: CatalogSyncRun = {
|
||||
...run,
|
||||
state: "succeeded",
|
||||
phase: "completed",
|
||||
startedAt: "2026-08-27T10:00:00Z",
|
||||
finishedAt: "2026-08-27T10:00:01Z",
|
||||
};
|
||||
server.use(
|
||||
http.get("/api/catalog/sync-runs/:runId", () => HttpResponse.json(completed)),
|
||||
http.get("/api/catalog/sync-runs/:runId/events-list", () => HttpResponse.json([])),
|
||||
http.get("/api/catalog/databases/:databaseId/sync-runs", () => HttpResponse.json([completed])),
|
||||
);
|
||||
}
|
||||
|
||||
const synchronizationScopes = [
|
||||
{ scope: "tables", label: "Synchronize tables" },
|
||||
{ scope: "columns", label: "Synchronize all columns" },
|
||||
{ scope: "relationships", label: "Synchronize relationships" },
|
||||
{ scope: "all", label: "Synchronize all" },
|
||||
] as const;
|
||||
|
||||
test("starts with a full-width list and applies the row action matrix", async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "View Policlinico San Donato" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Edit Policlinico San Donato" })).toBeEnabled();
|
||||
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: "View Retired workspace" })).toBeEnabled();
|
||||
expect(screen.getByRole("button", { name: "Edit Retired workspace" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Delete Retired workspace" })).toBeEnabled();
|
||||
expect(screen.queryByRole("heading", { name: "Database details" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each(synchronizationScopes)(
|
||||
"selected database Actions offers and starts $scope synchronization",
|
||||
async ({ scope, label }) => {
|
||||
const user = userEvent.setup();
|
||||
let startBody: unknown;
|
||||
const run = makeSyncRun(scope);
|
||||
registerCompletedSyncRun(run);
|
||||
server.use(http.post("/api/catalog/databases/:databaseId/sync-runs", async ({ request }) => {
|
||||
startBody = await request.json();
|
||||
return HttpResponse.json(run, { status: 202 });
|
||||
}));
|
||||
renderPage({
|
||||
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
||||
});
|
||||
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(databaseRow).getByRole("checkbox"));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: synchronizationScopes[0].label })).toBeEnabled();
|
||||
for (const option of synchronizationScopes.slice(1)) {
|
||||
expect(screen.getByRole("menuitem", { name: option.label })).toBeEnabled();
|
||||
}
|
||||
await user.click(screen.getByRole("menuitem", { name: label }));
|
||||
|
||||
await waitFor(() => expect(startBody).toEqual({ version: 3, scope, tableIds: [] }));
|
||||
},
|
||||
);
|
||||
|
||||
test("presents completed synchronization steps as success and skips unneeded confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const run = {
|
||||
...makeSyncRun("columns"),
|
||||
counts: { tables: 163, columns: 2_275 },
|
||||
};
|
||||
registerCompletedSyncRun(run);
|
||||
server.use(http.post("/api/catalog/databases/:databaseId/sync-runs", () => (
|
||||
HttpResponse.json(run, { status: 202 })
|
||||
)));
|
||||
renderPage({
|
||||
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
||||
});
|
||||
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(databaseRow).getByRole("checkbox"));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Synchronize all columns" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Succeeded" })).toBeVisible();
|
||||
const steps = screen.getByRole("list", { name: "Synchronization steps" });
|
||||
expect(steps).toHaveClass("grid-cols-2", "sm:grid-cols-3");
|
||||
const connected = within(steps).getByRole("listitem", { name: "Connected, completed" });
|
||||
expect(connected.querySelector("svg")).toHaveClass("text-[oklch(var(--success))]");
|
||||
expect(within(steps).getByText("Tables read")).toBeVisible();
|
||||
const confirmation = within(steps).getByRole("listitem", {
|
||||
name: "Confirmation not required, not required",
|
||||
});
|
||||
expect(confirmation.querySelector("svg")).toHaveClass("text-muted-foreground");
|
||||
const completed = within(steps).getByRole("listitem", { name: "Completed, completed" });
|
||||
expect(completed.querySelector("svg")).toHaveClass("text-[oklch(var(--success))]");
|
||||
expect(steps.querySelectorAll(".text-primary")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("database Overview exposes every synchronization scope", async () => {
|
||||
const user = userEvent.setup();
|
||||
let startBody: unknown;
|
||||
const run = makeSyncRun("relationships");
|
||||
registerCompletedSyncRun(run);
|
||||
server.use(http.post("/api/catalog/databases/:databaseId/sync-runs", async ({ request }) => {
|
||||
startBody = await request.json();
|
||||
return HttpResponse.json(run, { status: 202 });
|
||||
}));
|
||||
renderPage({
|
||||
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||
await user.click(screen.getByRole("button", { name: "Synchronize database schema" }));
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: synchronizationScopes[0].label })).toBeEnabled();
|
||||
for (const option of synchronizationScopes.slice(1)) {
|
||||
expect(screen.getByRole("menuitem", { name: option.label })).toBeEnabled();
|
||||
}
|
||||
await user.click(screen.getByRole("menuitem", { name: "Synchronize relationships" }));
|
||||
|
||||
await waitFor(() => expect(startBody).toEqual({
|
||||
version: 3,
|
||||
scope: "relationships",
|
||||
tableIds: [],
|
||||
}));
|
||||
});
|
||||
|
||||
test("replaces the list with View and returns focus to the originating action", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage({ rows: [makeDatabase()] });
|
||||
const view = await screen.findByRole("button", { name: "View Policlinico San Donato" });
|
||||
|
||||
await user.click(view);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Database details" })).toBeVisible();
|
||||
expect(screen.getByDisplayValue("db.internal")).toHaveAttribute("readonly");
|
||||
expect(screen.getByRole("button", { name: "Test connection" })).toBeEnabled();
|
||||
expect(screen.queryByRole("button", { name: "Save changes" })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back to list" }));
|
||||
|
||||
await waitFor(() => expect(view).toHaveFocus());
|
||||
expect(screen.getByRole("button", { name: "View Policlinico San Donato" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens an unconfigured row as Edit while creating its first saved configuration", async () => {
|
||||
const user = userEvent.setup();
|
||||
let postBody: unknown;
|
||||
const saved = makeDatabase({
|
||||
...unconfigured,
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
configured: true,
|
||||
version: 1,
|
||||
binding: { transport: "rest_api", baseUrl: "https://psd.example/api", restPath: "/health", restAuth: "x-api-key" },
|
||||
});
|
||||
let rows = [unconfigured];
|
||||
server.use(
|
||||
http.post("/api/catalog/databases", async ({ request }) => {
|
||||
postBody = await request.json();
|
||||
rows = [saved];
|
||||
return HttpResponse.json(saved, { status: 201 });
|
||||
}),
|
||||
);
|
||||
renderPage({ rows: () => rows });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Edit Research laboratory" }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Edit database" })).toBeVisible();
|
||||
expect(screen.getByLabelText("Workspace")).toBeDisabled();
|
||||
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 waitFor(() => expect(postBody).toMatchObject({
|
||||
workspaceId: "lab",
|
||||
binding: expect.objectContaining({ transport: "rest_api", baseUrl: "https://psd.example/api" }),
|
||||
}));
|
||||
expect(await screen.findByRole("heading", { name: "Edit database" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("global Add offers only unconfigured workspaces and lets the operator choose one", async () => {
|
||||
const user = userEvent.setup();
|
||||
const second = makeDatabase({
|
||||
...unconfigured,
|
||||
workspaceId: "radiology",
|
||||
workspaceName: "Radiology",
|
||||
databaseName: "radiology_dwh",
|
||||
});
|
||||
renderPage({ rows: [makeDatabase(), unconfigured, second] });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Add database" }));
|
||||
|
||||
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.getByDisplayValue("radiology_dwh")).toBeVisible();
|
||||
});
|
||||
|
||||
test("guards a dirty Edit, disables testing, and reports navigation state", async () => {
|
||||
const user = userEvent.setup();
|
||||
const navigation = vi.fn();
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
renderPage({ rows: [makeDatabase()], onNavigationStateChange: navigation });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
|
||||
const schema = screen.getByLabelText("Schema");
|
||||
await user.clear(schema);
|
||||
await user.type(schema, "reporting");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Test connection" })).toBeDisabled();
|
||||
await waitFor(() => expect(navigation).toHaveBeenLastCalledWith({ dirty: true, busy: false }));
|
||||
await user.click(screen.getByRole("button", { name: "Back to list" }));
|
||||
expect(confirm).toHaveBeenCalledWith("Discard unsaved database changes?");
|
||||
expect(screen.getByRole("heading", { name: "Edit database" })).toBeVisible();
|
||||
|
||||
confirm.mockReturnValue(true);
|
||||
await user.click(screen.getByRole("button", { name: "Back to list" }));
|
||||
expect(await screen.findByRole("button", { name: "Edit Policlinico San Donato" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("uses an in-page destructive form and returns the YAML workspace to Not configured", async () => {
|
||||
const user = userEvent.setup();
|
||||
let row = makeDatabase();
|
||||
let deleteCalled = false;
|
||||
let deleteVersion: string | null = null;
|
||||
server.use(
|
||||
http.delete("/api/catalog/databases/:id", ({ request }) => {
|
||||
deleteCalled = true;
|
||||
deleteVersion = new URL(request.url).searchParams.get("version");
|
||||
row = {
|
||||
...row,
|
||||
id: undefined,
|
||||
configured: false,
|
||||
version: 0,
|
||||
updatedAt: "",
|
||||
connectionStatus: "untested",
|
||||
secrets: { ...noSecrets },
|
||||
};
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}),
|
||||
);
|
||||
renderPage({ rows: () => [row] });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Delete Policlinico San Donato" }));
|
||||
expect(screen.getByRole("heading", { name: "Delete database" })).toBeVisible();
|
||||
expect(screen.getByText("This removes the local database configuration.")).toBeVisible();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Delete database" }));
|
||||
|
||||
await waitFor(() => expect(deleteCalled).toBe(true));
|
||||
expect(deleteVersion).toBe("3");
|
||||
expect(await screen.findByRole("button", { name: "Delete Policlinico San Donato" })).toBeDisabled();
|
||||
expect(screen.getByText("Not configured")).toBeVisible();
|
||||
});
|
||||
|
||||
test("tests only the persisted version and updates the visible connection status", async () => {
|
||||
const user = userEvent.setup();
|
||||
let testBody: unknown;
|
||||
let row = makeDatabase();
|
||||
server.use(http.post("/api/catalog/databases/:id/test", async ({ request }) => {
|
||||
testBody = await request.json();
|
||||
row = makeDatabase({ version: 4, connectionStatus: "reachable", testedVersion: 4 });
|
||||
return HttpResponse.json(row);
|
||||
}));
|
||||
renderPage({ rows: () => [row] });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||
await user.click(screen.getByRole("button", { name: "Test connection" }));
|
||||
|
||||
await waitFor(() => expect(testBody).toEqual({ version: 3 }));
|
||||
expect(within(screen.getByRole("region", { name: "Database details form" })).getByText("Reachable")).toBeVisible();
|
||||
});
|
||||
|
||||
test("retains a stale draft and requires an explicit reload", async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.patch("/api/catalog/databases/:id", () => HttpResponse.json({
|
||||
code: "database_stale",
|
||||
message: "The database changed",
|
||||
}, { status: 409 })),
|
||||
);
|
||||
renderPage({ rows: [makeDatabase()] });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
|
||||
const schema = screen.getByLabelText("Schema");
|
||||
await user.clear(schema);
|
||||
await user.type(schema, "draft_schema");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
expect(await screen.findByText("A newer database configuration is available.")).toBeVisible();
|
||||
expect(schema).toHaveValue("draft_schema");
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Keep editing" }));
|
||||
expect(screen.getByText("Reload the latest values before this configuration can be changed.")).toBeVisible();
|
||||
expect(schema).toHaveValue("draft_schema");
|
||||
});
|
||||
|
||||
test("marks an open form stale when a background refetch advances the catalog version", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { client } = renderPage({ rows: [makeDatabase()] });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
|
||||
act(() => {
|
||||
client.setQueryData(["catalog-databases"], [makeDatabase({ version: 4, schema: "server_schema" })]);
|
||||
});
|
||||
|
||||
expect(await screen.findByText("A newer database configuration is available.")).toBeVisible();
|
||||
expect(screen.getByLabelText("Schema")).toHaveValue("datawarehouse");
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("does not submit credentials hidden by a transport or authentication change", async () => {
|
||||
const user = userEvent.setup();
|
||||
let putCalled = false;
|
||||
let patchBody: Record<string, unknown> | undefined;
|
||||
let row = makeDatabase();
|
||||
server.use(
|
||||
http.patch("/api/catalog/databases/:id", async ({ request }) => {
|
||||
patchBody = await request.json() as Record<string, unknown>;
|
||||
row = makeDatabase({
|
||||
version: 4,
|
||||
binding: { transport: "rest_api", baseUrl: "https://psd.example/api", restPath: "/health", restAuth: "none" },
|
||||
});
|
||||
return HttpResponse.json(row);
|
||||
}),
|
||||
http.put("/api/catalog/databases/:id/secrets", () => {
|
||||
putCalled = true;
|
||||
return HttpResponse.json(row);
|
||||
}),
|
||||
);
|
||||
renderPage({ rows: () => [row] });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
|
||||
await user.type(screen.getByLabelText("Password"), "must-not-be-sent");
|
||||
await user.selectOptions(screen.getByLabelText("Transport"), "rest_api");
|
||||
await user.type(screen.getByLabelText("Base URL"), "https://psd.example/api");
|
||||
await user.selectOptions(screen.getByLabelText("Authentication"), "none");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
await waitFor(() => expect(patchBody).toBeDefined());
|
||||
expect(putCalled).toBe(false);
|
||||
expect(patchBody).toMatchObject({
|
||||
version: 3,
|
||||
binding: {
|
||||
transport: "rest_api",
|
||||
baseUrl: "https://psd.example/api",
|
||||
restPath: "/health",
|
||||
restAuth: "none",
|
||||
},
|
||||
});
|
||||
expect((patchBody?.binding as Record<string, unknown>).host).toBeUndefined();
|
||||
});
|
||||
|
||||
test("preserves typed secrets and offers a retry when the configuration save is only partial", async () => {
|
||||
const user = userEvent.setup();
|
||||
let putCalls = 0;
|
||||
let row = makeDatabase();
|
||||
server.use(
|
||||
http.put("/api/catalog/databases/:id/secrets", async ({ request }) => {
|
||||
putCalls += 1;
|
||||
const body = await request.json();
|
||||
expect(body).toEqual({ version: 3, values: { password: "transient-secret" } });
|
||||
if (putCalls === 1) {
|
||||
return HttpResponse.json({
|
||||
code: "catalog_unavailable",
|
||||
message: "Secret store unavailable",
|
||||
}, { status: 503 });
|
||||
}
|
||||
row = makeDatabase({ version: 4, secrets: { ...noSecrets, password: true } });
|
||||
return HttpResponse.json(row);
|
||||
}),
|
||||
);
|
||||
renderPage({ rows: () => [row] });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
|
||||
await user.type(screen.getByLabelText("Password"), "transient-secret");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
expect(await screen.findByText("Database configuration saved; secret update could not be confirmed.")).toBeVisible();
|
||||
expect(screen.getByLabelText("Password")).toHaveValue("transient-secret");
|
||||
expect(screen.getByRole("button", { name: "Retry secrets" })).toBeEnabled();
|
||||
|
||||
await user.clear(screen.getByLabelText("Schema"));
|
||||
await user.type(screen.getByLabelText("Schema"), "reporting_after_retry");
|
||||
await user.click(screen.getByRole("button", { name: "Retry secrets" }));
|
||||
|
||||
await waitFor(() => expect(putCalls).toBe(2));
|
||||
expect(screen.queryByText("Database configuration saved; secret update could not be confirmed.")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Schema")).toHaveValue("reporting_after_retry");
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("shows secret status without exposing or enabling values when permission is absent", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage({
|
||||
rows: [makeDatabase({ secrets: { ...noSecrets, password: true } })],
|
||||
canManageSecrets: false,
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||
expect(screen.getByLabelText("Password")).toHaveValue("Configured");
|
||||
expect(screen.getByLabelText("Password")).toHaveAttribute("readonly");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Back to list" }));
|
||||
await user.click(screen.getByRole("button", { name: "Edit Policlinico San Donato" }));
|
||||
expect(await screen.findByRole("heading", { name: "Edit database" })).toBeVisible();
|
||||
expect(screen.getByLabelText("Password")).toBeDisabled();
|
||||
expect(screen.getByText(/requires the workspace\.secrets\.manage permission/i)).toBeVisible();
|
||||
});
|
||||
|
||||
const patientsTable: CatalogTable = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
databaseId: "11111111-1111-4111-8111-111111111111",
|
||||
name: "patients",
|
||||
sourceComment: "Patients imported from the clinical source",
|
||||
description: null,
|
||||
generatedDescription: null,
|
||||
version: 1,
|
||||
createdAt: "2026-08-27T08:00:00Z",
|
||||
updatedAt: "2026-08-27T09:00:00Z",
|
||||
};
|
||||
|
||||
test("filters catalog tables as the operator types", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mediciTable: CatalogTable = {
|
||||
...patientsTable,
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
name: "bridge_medici",
|
||||
sourceComment: "Doctors participating in clinical care",
|
||||
};
|
||||
server.use(
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([
|
||||
patientsTable,
|
||||
mediciTable,
|
||||
])),
|
||||
);
|
||||
renderPage({
|
||||
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
||||
const tablesRegion = await screen.findByRole("region", { name: "Tables for Policlinico San Donato" });
|
||||
expect(await screen.findByRole("button", { name: "Edit description for patients" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Edit description for bridge_medici" })).toBeVisible();
|
||||
|
||||
await user.type(screen.getByRole("textbox", { name: "Search tables" }), "medici");
|
||||
|
||||
expect(screen.getByRole("button", { name: "Edit description for bridge_medici" })).toBeVisible();
|
||||
expect(await within(tablesRegion).findByText("1 of 2")).toBeVisible();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: "Edit description for patients" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("selected table exposes a direct Synchronize columns action and sends its id", async () => {
|
||||
const user = userEvent.setup();
|
||||
let startBody: unknown;
|
||||
const run = makeSyncRun("columns", [patientsTable.id]);
|
||||
registerCompletedSyncRun(run);
|
||||
server.use(
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.post("/api/catalog/databases/:databaseId/sync-runs", async ({ request }) => {
|
||||
startBody = await request.json();
|
||||
return HttpResponse.json(run, { status: 202 });
|
||||
}),
|
||||
);
|
||||
renderPage({
|
||||
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
||||
const tableRow = await screen.findByRole("row", { name: /patients/ });
|
||||
await user.click(within(tableRow).getByRole("checkbox"));
|
||||
|
||||
const synchronizeColumns = screen.getByRole("button", { name: "Synchronize columns" });
|
||||
expect(synchronizeColumns).toBeVisible();
|
||||
expect(synchronizeColumns).toBeEnabled();
|
||||
expect(screen.queryByRole("button", { name: "Actions" })).not.toBeInTheDocument();
|
||||
await user.click(synchronizeColumns);
|
||||
|
||||
await waitFor(() => expect(startBody).toEqual({
|
||||
version: 3,
|
||||
scope: "columns",
|
||||
tableIds: [patientsTable.id],
|
||||
}));
|
||||
await waitFor(() => expect(screen.queryByText("1 selected")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test("navigates purely from a database to its tables and edits review metadata", async () => {
|
||||
const user = userEvent.setup();
|
||||
let patchBody: unknown;
|
||||
server.use(
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.patch("/api/catalog/databases/:databaseId/tables/:tableId", async ({ request }) => {
|
||||
patchBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
...patientsTable,
|
||||
description: "Registry used for longitudinal patient analysis",
|
||||
version: 2,
|
||||
});
|
||||
}),
|
||||
);
|
||||
renderPage({
|
||||
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
||||
|
||||
expect(await screen.findByRole("region", { name: "Tables for Policlinico San Donato" })).toBeVisible();
|
||||
expect(screen.getByRole("tab", { name: "Tables" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.queryByLabelText("Database filter")).not.toBeInTheDocument();
|
||||
await user.click(await screen.findByRole("button", { name: "Edit description for patients" }));
|
||||
|
||||
expect(screen.getByLabelText("Physical table name")).toHaveValue("patients");
|
||||
expect(screen.getByLabelText("Physical table name")).toHaveAttribute("readonly");
|
||||
expect(screen.getByLabelText("Source comment")).toHaveAttribute("readonly");
|
||||
await user.type(
|
||||
screen.getByLabelText(/^Description/),
|
||||
"Registry used for longitudinal patient analysis",
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Save metadata" }));
|
||||
|
||||
await waitFor(() => expect(patchBody).toEqual({
|
||||
version: 1,
|
||||
description: "Registry used for longitudinal patient analysis",
|
||||
generatedDescription: null,
|
||||
}));
|
||||
await user.click(screen.getByRole("button", { name: "Back to tables" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Overview" }));
|
||||
expect(await screen.findByRole("heading", { name: "Database details" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("navigates from a table to columns and from the database to physical relationships", async () => {
|
||||
const user = userEvent.setup();
|
||||
let columnPatch: unknown;
|
||||
const idColumn: CatalogColumn = {
|
||||
id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd",
|
||||
tableId: patientsTable.id,
|
||||
name: "id",
|
||||
ordinalPosition: 1,
|
||||
dataType: "bigint",
|
||||
isNullable: false,
|
||||
defaultExpression: null,
|
||||
primaryKeyPosition: 1,
|
||||
isPrimaryKey: true,
|
||||
isForeignKey: true,
|
||||
foreignKeyCount: 1,
|
||||
sourceComment: "Patient identifier",
|
||||
description: null,
|
||||
generatedDescription: null,
|
||||
lastSyncedDatabaseVersion: 3,
|
||||
lastSyncedAt: "2026-08-27T10:00:00Z",
|
||||
version: 1,
|
||||
createdAt: "2026-08-27T10:00:00Z",
|
||||
updatedAt: "2026-08-27T10:00:00Z",
|
||||
};
|
||||
server.use(
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([idColumn])),
|
||||
http.patch("/api/catalog/databases/:databaseId/tables/:tableId/columns/:columnId", async ({ request }) => {
|
||||
columnPatch = await request.json();
|
||||
return HttpResponse.json({ ...idColumn, generatedDescription: "Generated identifier draft", version: 2 });
|
||||
}),
|
||||
http.get("/api/catalog/databases/:databaseId/relationships", () => HttpResponse.json([{
|
||||
id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
||||
databaseId: patientsTable.databaseId,
|
||||
constraintName: "visits_patient_id_fkey",
|
||||
sourceTableId: "ffffffff-ffff-4fff-8fff-ffffffffffff",
|
||||
sourceTableName: "visits",
|
||||
targetTableId: patientsTable.id,
|
||||
targetTableName: "patients",
|
||||
updateRule: "NO ACTION",
|
||||
deleteRule: "CASCADE",
|
||||
deferrable: false,
|
||||
initiallyDeferred: false,
|
||||
columns: [{ position: 1, sourceColumnId: "1", sourceColumnName: "patient_id", targetColumnId: idColumn.id, targetColumnName: "id" }],
|
||||
lastSyncedDatabaseVersion: 3,
|
||||
lastSyncedAt: "2026-08-27T10:00:00Z",
|
||||
createdAt: "2026-08-27T10:00:00Z",
|
||||
updatedAt: "2026-08-27T10:00:00Z",
|
||||
}])),
|
||||
);
|
||||
renderPage({ rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })] });
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
||||
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
|
||||
expect(screen.getByRole("tab", { name: "Columns" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(await screen.findByText("PK")).toBeVisible();
|
||||
expect(screen.getByText("FK")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Edit metadata for id" }));
|
||||
await user.type(screen.getByLabelText("Generated description"), "Generated identifier draft");
|
||||
await user.click(screen.getByRole("button", { name: "Save metadata" }));
|
||||
await waitFor(() => expect(columnPatch).toEqual({
|
||||
version: 1,
|
||||
description: null,
|
||||
generatedDescription: "Generated identifier draft",
|
||||
}));
|
||||
await user.click(screen.getByRole("tab", { name: "Relationships" }));
|
||||
expect(await screen.findByText("visits_patient_id_fkey")).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens the durable job drawer and confirms its exact destructive plan", async () => {
|
||||
const user = userEvent.setup();
|
||||
const startBodies: unknown[] = [];
|
||||
const confirmationBodies: unknown[] = [];
|
||||
const queued: CatalogSyncRun = {
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
databaseId: "11111111-1111-4111-8111-111111111111",
|
||||
scope: "tables",
|
||||
tableIds: [],
|
||||
state: "queued",
|
||||
phase: "queued",
|
||||
requestedDatabaseVersion: 3,
|
||||
plannedDiff: null,
|
||||
confirmationToken: null,
|
||||
counts: {},
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
cancelRequested: false,
|
||||
createdAt: "2026-08-27T10:00:00Z",
|
||||
startedAt: null,
|
||||
updatedAt: "2026-08-27T10:00:00Z",
|
||||
finishedAt: null,
|
||||
heartbeatAt: null,
|
||||
};
|
||||
const waiting: CatalogSyncRun = {
|
||||
...queued,
|
||||
state: "awaiting_confirmation",
|
||||
phase: "awaiting_confirmation",
|
||||
confirmationToken: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
||||
plannedDiff: { deletedTables: ["legacy_visits"], deletedColumns: [], deletedRelationships: [] },
|
||||
counts: { tables: 1 },
|
||||
startedAt: "2026-08-27T10:00:00Z",
|
||||
updatedAt: "2026-08-27T10:00:01Z",
|
||||
};
|
||||
server.use(
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.post("/api/catalog/databases/:databaseId/sync-runs", async ({ request }) => {
|
||||
startBodies.push(await request.json());
|
||||
return HttpResponse.json(queued, { status: 202 });
|
||||
}),
|
||||
http.get("/api/catalog/sync-runs/:runId", () => HttpResponse.json(waiting)),
|
||||
http.get("/api/catalog/sync-runs/:runId/events-list", () => HttpResponse.json([])),
|
||||
http.get("/api/catalog/databases/:databaseId/sync-runs", () => HttpResponse.json([waiting])),
|
||||
http.post("/api/catalog/sync-runs/:runId/confirm", async ({ request }) => {
|
||||
confirmationBodies.push(await request.json());
|
||||
return HttpResponse.json({ ...waiting, state: "queued", phase: "queued", confirmationToken: null });
|
||||
}),
|
||||
);
|
||||
renderPage({
|
||||
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Sync tables" }));
|
||||
|
||||
const synchronizationDrawer = await screen.findByRole("complementary", {
|
||||
name: "Schema synchronization",
|
||||
});
|
||||
expect(synchronizationDrawer).toHaveClass("inset-y-2", "sm:inset-y-4");
|
||||
expect(within(synchronizationDrawer).getByRole("listitem", {
|
||||
name: "Confirmation required, in progress",
|
||||
})).toHaveAttribute("aria-current", "step");
|
||||
expect(await screen.findByText("table · legacy_visits")).toBeVisible();
|
||||
expect(startBodies).toEqual([{ version: 3, scope: "tables", tableIds: [] }]);
|
||||
await user.click(screen.getByRole("button", { name: "Confirm removals" }));
|
||||
await waitFor(() => expect(confirmationBodies).toEqual([
|
||||
{ confirmationToken: "cccccccc-cccc-4ccc-8ccc-cccccccccccc" },
|
||||
]));
|
||||
});
|
||||
|
||||
test("keeps a stale table draft but requires an explicit reload before another save", async () => {
|
||||
const user = userEvent.setup();
|
||||
const current = {
|
||||
...patientsTable,
|
||||
description: "Description saved by another editor",
|
||||
version: 2,
|
||||
};
|
||||
let latest = patientsTable;
|
||||
server.use(
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([latest])),
|
||||
http.patch("/api/catalog/databases/:databaseId/tables/:tableId", () => {
|
||||
latest = current;
|
||||
return HttpResponse.json({
|
||||
code: "table_stale",
|
||||
message: "Table description changed. Reload and try again.",
|
||||
}, { status: 409 });
|
||||
}),
|
||||
);
|
||||
renderPage({
|
||||
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Edit description for patients" }));
|
||||
const description = screen.getByLabelText(/^Description/);
|
||||
await user.type(description, "My unsaved draft");
|
||||
await user.click(screen.getByRole("button", { name: "Save metadata" }));
|
||||
|
||||
expect(await screen.findByText("A newer table description is available.")).toBeVisible();
|
||||
expect(description).toHaveValue("My unsaved draft");
|
||||
expect(screen.getByRole("button", { name: "Save metadata" })).toBeDisabled();
|
||||
await user.click(screen.getByRole("button", { name: "Keep editing" }));
|
||||
expect(screen.getByText("Reload the latest value before this description can be saved.")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Reload latest" }));
|
||||
|
||||
await waitFor(() => expect(description).toHaveValue("Description saved by another editor"));
|
||||
expect(screen.getByRole("button", { name: "Save metadata" })).toBeDisabled();
|
||||
});
|
||||
Reference in New Issue
Block a user