Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
3544 lines
157 KiB
TypeScript
3544 lines
157 KiB
TypeScript
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { setLocale } from "../i18n";
|
|
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,
|
|
CatalogRelationship,
|
|
CatalogSyncRun,
|
|
CatalogTable,
|
|
DescriptionGenerationRun,
|
|
SensitivityAnalysisRun,
|
|
} from "../api/catalog-databases";
|
|
import { Toaster } from "../components/ui/sonner";
|
|
import { DatabaseManagementPage } from "./DatabaseManagementPage";
|
|
import { workspacePreferences } from "../workspaces/preferences";
|
|
import { ComposerFooter } from "./SteerInput";
|
|
import { useSessionStore } from "../store/sessionStore";
|
|
|
|
beforeEach(() => { localStorage.clear(); });
|
|
|
|
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,
|
|
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",
|
|
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,
|
|
workspaceRevision: null,
|
|
workspaceEvidence: { sourceType: null, state: "workspace_unavailable" },
|
|
runtimeBinding: null,
|
|
});
|
|
|
|
function renderPage({
|
|
rows = [makeDatabase(), unconfigured, orphan],
|
|
canManage = true,
|
|
canManageSecrets = true,
|
|
onNavigationStateChange,
|
|
presentation = "legacy",
|
|
}: {
|
|
rows?: CatalogDatabase[] | (() => CatalogDatabase[]);
|
|
canManage?: boolean;
|
|
canManageSecrets?: boolean;
|
|
onNavigationStateChange?: (state: { dirty: boolean; busy: boolean }) => void;
|
|
presentation?: "legacy" | "fleet";
|
|
} = {}) {
|
|
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}
|
|
presentation={presentation}
|
|
/>
|
|
<Toaster duration={Infinity} />
|
|
</QueryClientProvider>,
|
|
);
|
|
return { ...view, client };
|
|
}
|
|
|
|
test("updates database row actions and the open editor when switching EN and IT", async () => {
|
|
renderPage({ presentation: "fleet" });
|
|
await userEvent.click(await screen.findByRole("button", { name: "Edit Policlinico San Donato" }));
|
|
const name = await screen.findByLabelText("Database name");
|
|
const savedName = (name as HTMLInputElement).value;
|
|
try {
|
|
act(() => setLocale("it"));
|
|
expect(screen.getByLabelText("Nome database")).toHaveValue(savedName);
|
|
expect(screen.getByRole("button", { name: "Salva modifiche" })).toBeVisible();
|
|
expect(screen.getByRole("button", { name: "Verifica connessione" })).toBeVisible();
|
|
act(() => setLocale("en"));
|
|
expect(screen.getByLabelText("Database name")).toHaveValue(savedName);
|
|
expect(screen.getByRole("button", { name: "Save changes" })).toBeVisible();
|
|
} finally { act(() => setLocale("en")); }
|
|
});
|
|
|
|
test("fleet entry opens the database list without workspace preparation or an automatic editor", async () => {
|
|
renderPage({ presentation: "fleet" });
|
|
expect(await screen.findByRole("button", { name: "View tables for Policlinico San Donato" })).toBeVisible();
|
|
expect(screen.getByText("Research laboratory")).toBeVisible();
|
|
expect(screen.queryByRole("button", { name: "Back to list" })).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/Workspace preparation/)).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("link", { name: "Open workspace preprocessing" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
test.each(["tables", "relationships"])("fresh entry ignores a previously persisted %s screen", async kind => {
|
|
vi.stubEnv("MODE", "production");
|
|
localStorage.setItem("thothii:database-management-screen", JSON.stringify({ kind, workspaceId: "psd-clinical" }));
|
|
try {
|
|
renderPage({ presentation: "fleet" });
|
|
expect(await screen.findByRole("button", { name: "View tables for Policlinico San Donato" })).toBeVisible();
|
|
expect(screen.queryByRole("button", { name: "Back to databases" })).not.toBeInTheDocument();
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
});
|
|
|
|
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 makeDescriptionGenerationRun(
|
|
overrides: Partial<DescriptionGenerationRun> = {},
|
|
): DescriptionGenerationRun {
|
|
return {
|
|
id: "77777777-7777-4777-8777-777777777777",
|
|
databaseId: "11111111-1111-4111-8111-111111111111",
|
|
modelId: "local/qwen",
|
|
scope: "selected_columns",
|
|
language: "it",
|
|
status: "queued",
|
|
total: 1,
|
|
processed: 0,
|
|
generated: 0,
|
|
nonGeneratable: 0,
|
|
failed: 0,
|
|
createdAt: "2026-08-28T10:00:00Z",
|
|
startedAt: null,
|
|
updatedAt: "2026-08-28T10:00:00Z",
|
|
finishedAt: null,
|
|
errorSummary: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeSensitivityAnalysisRun(
|
|
overrides: Partial<SensitivityAnalysisRun> = {},
|
|
): SensitivityAnalysisRun {
|
|
return {
|
|
id: "99999999-9999-4999-8999-999999999999",
|
|
databaseId: "11111111-1111-4111-8111-111111111111",
|
|
engine: "local",
|
|
modelId: null,
|
|
policyVersion: "sensitivity-v2",
|
|
scope: "selected_columns",
|
|
status: "completed",
|
|
total: 2,
|
|
suggestedSensitive: 1,
|
|
suggestedNonSensitive: 1,
|
|
unknown: 0,
|
|
createdAt: "2026-08-28T11:00:00Z",
|
|
startedAt: "2026-08-28T11:00:00Z",
|
|
updatedAt: "2026-08-28T11:00:01Z",
|
|
finishedAt: "2026-08-28T11:00:01Z",
|
|
errorSummary: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
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: "relationships", label: "Synchronize relationships" },
|
|
{ scope: "all", label: "Synchronize all" },
|
|
] as const;
|
|
|
|
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({
|
|
scope: "global",
|
|
databaseId: null,
|
|
tables: 163,
|
|
columns: 2_275,
|
|
sensitiveColumns: 5,
|
|
relationships: 18,
|
|
descriptionTargets: 2_438,
|
|
describedTargets: 1_828,
|
|
descriptionCoverage: 75,
|
|
updatedAt: "2026-08-31T09:00:00Z",
|
|
})),
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
);
|
|
renderPage({ rows: [makeDatabase()], presentation: "fleet" });
|
|
|
|
const pageHeading = await screen.findByRole("heading", { name: "Database management" });
|
|
expect(pageHeading).toBeVisible();
|
|
const applicationBar = pageHeading.closest("header");
|
|
expect(applicationBar).not.toBeNull();
|
|
expect(screen.getByRole("region", { name: "Catalog status" })).toBeVisible();
|
|
expect(screen.getByRole("group", { name: "Database management actions" })).toBeVisible();
|
|
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: /Catalog status/ })).toBeVisible();
|
|
const summary = screen.getByRole("region", { name: "Fleet summary" });
|
|
expect(await within(summary).findByText("2,275")).toBeVisible();
|
|
expect(within(summary).getByText("75%")).toBeVisible();
|
|
const modelSelector = await screen.findByRole("combobox", { name: "Global 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" });
|
|
const detailsAction = within(databaseRow).getByRole("button", { name: "View Policlinico San Donato" });
|
|
expect(tablesAction.parentElement).toHaveAttribute("data-tooltip", "Tables");
|
|
expect(tablesAction).not.toHaveAttribute("title");
|
|
expect(detailsAction).toHaveAttribute("data-tooltip", "Details");
|
|
|
|
const manager = screen.getByRole("main", { name: "Database management" });
|
|
await user.click(within(databaseRow).getByRole("button", { name: "Edit Policlinico San Donato" }));
|
|
const editDrawer = await screen.findByRole("region", { name: "Edit database form" });
|
|
expect(manager).toContainElement(editDrawer);
|
|
expect(screen.queryByRole("dialog", { name: "Edit database" })).not.toBeInTheDocument();
|
|
await user.click(within(editDrawer).getByRole("button", { name: "Back to list" }));
|
|
|
|
const restoredDatabaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(restoredDatabaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
const actionPicker = screen.getByRole("combobox", { name: "Batch action" });
|
|
expect(within(actionPicker).getByRole("option", { name: /^Synchronize all(?:,|$)/ })).toBeVisible();
|
|
expect(within(actionPicker).queryByRole("option", { name: "Synchronize all columns" })).not.toBeInTheDocument();
|
|
await user.selectOptions(actionPicker, "generate-missing");
|
|
expect(screen.getAllByRole("combobox", { name: "Global LLM model" })).toHaveLength(1);
|
|
expect(modelSelector).toHaveValue("local/qwen");
|
|
|
|
});
|
|
|
|
test("shows synchronization, description, and sensitive-data 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("Not synchronized")).toBeVisible();
|
|
expect(within(configuredRow).getByText(/Descriptions ·/)).toBeVisible();
|
|
expect(within(configuredRow).getByText(/Sensitive ·/)).toBeVisible();
|
|
|
|
const needsConfigurationRow = screen.getByRole("row", { name: /Research laboratory/ });
|
|
expect(within(needsConfigurationRow).getByText("Not synchronized")).toBeVisible();
|
|
expect(within(needsConfigurationRow).getByText(/Descriptions ·/)).toBeVisible();
|
|
|
|
const orphanRow = screen.getByRole("row", { name: /Retired workspace/ });
|
|
expect(within(orphanRow).getByText("Not synchronized")).toBeVisible();
|
|
});
|
|
|
|
test("shows the configured access type and endpoint in catalog status", async () => {
|
|
const direct = makeDatabase();
|
|
const rest = makeDatabase({
|
|
id: "33333333-3333-4333-8333-333333333333",
|
|
workspaceId: "rest-lab",
|
|
workspaceName: "REST laboratory",
|
|
binding: {
|
|
transport: "rest_api",
|
|
baseUrl: "https://203.0.113.18/dwh/",
|
|
restPath: "/health",
|
|
restAuth: "x-api-key",
|
|
},
|
|
});
|
|
const ssh = makeDatabase({
|
|
id: "44444444-4444-4444-8444-444444444444",
|
|
workspaceId: "ssh-lab",
|
|
workspaceName: "SSH laboratory",
|
|
binding: {
|
|
transport: "ssh_tunnel",
|
|
sshHost: "bastion.example.test",
|
|
sshPort: 2222,
|
|
sshUsername: "operator",
|
|
sshTargetHost: "postgres.internal",
|
|
sshTargetPort: 5432,
|
|
username: "reader",
|
|
},
|
|
});
|
|
renderPage({ rows: [direct, rest, ssh], presentation: "fleet" });
|
|
|
|
const directRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
expect(within(directRow).getByText("Direct PostgreSQL connection")).toBeVisible();
|
|
expect(within(directRow).getByText("db.internal:5432")).toBeVisible();
|
|
|
|
const restRow = screen.getByRole("row", { name: /REST laboratory/ });
|
|
expect(within(restRow).getByText("REST")).toBeVisible();
|
|
expect(within(restRow).getByText("203.0.113.18")).toBeVisible();
|
|
|
|
const sshRow = screen.getByRole("row", { name: /SSH laboratory/ });
|
|
expect(within(sshRow).getByText("SSH")).toBeVisible();
|
|
expect(within(sshRow).getByText("bastion.example.test:2222")).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("Not synchronized")).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("refreshes the relationship KPI after building generated relationships", async () => {
|
|
const user = userEvent.setup();
|
|
const generatedRelationship: CatalogRelationship = {
|
|
id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
|
databaseId: "11111111-1111-4111-8111-111111111111",
|
|
constraintName: null,
|
|
sourceTableId: "ffffffff-ffff-4fff-8fff-ffffffffffff",
|
|
sourceTableName: "visits",
|
|
targetTableId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
|
targetTableName: "patients",
|
|
updateRule: null,
|
|
deleteRule: null,
|
|
deferrable: false,
|
|
initiallyDeferred: false,
|
|
columns: [{
|
|
position: 1,
|
|
sourceColumnId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
sourceColumnName: "patient_id",
|
|
targetColumnId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
|
|
targetColumnName: "id",
|
|
}],
|
|
origin: "generated",
|
|
status: "active",
|
|
lastSyncedDatabaseVersion: null,
|
|
lastSyncedAt: null,
|
|
createdAt: "2026-08-27T10:00:00Z",
|
|
updatedAt: "2026-08-27T10:00:00Z",
|
|
};
|
|
let relationships: CatalogRelationship[] = [];
|
|
server.use(
|
|
http.get("/api/catalog/metrics", ({ request }) => HttpResponse.json({
|
|
scope: new URL(request.url).searchParams.has("databaseId") ? "database" : "global",
|
|
databaseId: new URL(request.url).searchParams.get("databaseId"),
|
|
tables: 2,
|
|
columns: 3,
|
|
sensitiveColumns: 0,
|
|
relationships: relationships.length,
|
|
descriptionTargets: 5,
|
|
describedTargets: 0,
|
|
descriptionCoverage: 0,
|
|
updatedAt: "2026-08-27T10:00:00Z",
|
|
})),
|
|
http.get(
|
|
"/api/catalog/databases/:databaseId/relationships",
|
|
() => HttpResponse.json(relationships),
|
|
),
|
|
http.post(
|
|
"/api/catalog/databases/:databaseId/relationships/rebuild-generated",
|
|
() => {
|
|
relationships = [generatedRelationship];
|
|
return HttpResponse.json({ added: 1, alreadyPresent: 0, excluded: 0, ambiguous: 0 });
|
|
},
|
|
),
|
|
);
|
|
renderPage({
|
|
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
|
presentation: "fleet",
|
|
});
|
|
|
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("button", {
|
|
name: "View relationships for Policlinico San Donato",
|
|
}));
|
|
const summary = await screen.findByRole("region", { name: "Database summary" });
|
|
const relationshipMetric = within(summary).getByText("Relationships").nextElementSibling;
|
|
expect(relationshipMetric).toHaveTextContent("0");
|
|
|
|
await user.selectOptions(
|
|
screen.getByRole("combobox", { name: "Relationship action" }),
|
|
"rebuild-generated",
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Run action" }));
|
|
|
|
expect(await screen.findByText("Build complete: 1 added, 0 already present, 0 excluded, 0 ambiguous."))
|
|
.toBeVisible();
|
|
await waitFor(() => expect(relationshipMetric).toHaveTextContent("1"));
|
|
});
|
|
|
|
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("region", { name: "Configure catalog form" });
|
|
expect(within(drawer).getByLabelText("Workspace")).toHaveAttribute("readonly");
|
|
expect(within(drawer).getByLabelText("Workspace")).toHaveValue("Research laboratory");
|
|
});
|
|
|
|
test("keeps the table back action in the Fleet Ledger breadcrumb while browsing columns", 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",
|
|
}));
|
|
expect(await screen.findByRole("button", { name: "Back to databases" })).toBeVisible();
|
|
expect(screen.getByRole("combobox", { name: "Table action" })).toBeDisabled();
|
|
expect(screen.queryByRole("button", { name: "Sync history" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Description history" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Sensitive history" })).not.toBeInTheDocument();
|
|
|
|
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
|
|
const tableBackAction = await screen.findByRole("button", { name: "Back to tables" });
|
|
expect(tableBackAction).toBeVisible();
|
|
expect(screen.getByRole("combobox", { name: "Column action" })).toBeDisabled();
|
|
expect(screen.getByRole("navigation", { name: "Database hierarchy" }).parentElement)
|
|
.toContainElement(tableBackAction);
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole("button", { name: "Back to databases" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByRole("button", { name: "Back to tables" }));
|
|
expect(await screen.findByRole("button", { name: "Back to databases" })).toBeVisible();
|
|
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 = {
|
|
...makeSyncRun("all"),
|
|
state: "succeeded",
|
|
phase: "completed",
|
|
startedAt: "2026-08-27T10:00:00Z",
|
|
finishedAt: "2026-08-27T10:00:01Z",
|
|
};
|
|
server.use(
|
|
http.get("/api/catalog/databases/:databaseId/sync-runs", () => HttpResponse.json([completed])),
|
|
http.get("/api/catalog/sync-runs/:runId", () => HttpResponse.json(completed)),
|
|
http.get("/api/catalog/sync-runs/:runId/events-list", () => 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 Policlinico San Donato",
|
|
}));
|
|
await user.click(screen.getByRole("button", { name: "View schema synchronization logs" }));
|
|
|
|
const drawer = await screen.findByRole("dialog", { name: "Schema synchronization" });
|
|
expect(drawer).toBeVisible();
|
|
expect(screen.getByRole("main", { name: "Database management" })).toContainElement(drawer);
|
|
expect(await screen.findByRole("heading", { name: "Succeeded" })).toBeVisible();
|
|
expect(screen.getByRole("region", { name: "Synchronization history" })).toBeVisible();
|
|
});
|
|
|
|
test("selects the configured metadata-description model by default", async () => {
|
|
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [
|
|
{ id: "openai/mini", label: "OpenAI Mini" },
|
|
{ id: "local/qwen", label: "Local Qwen" },
|
|
],
|
|
default: "local/qwen",
|
|
})));
|
|
renderPage();
|
|
|
|
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
|
await waitFor(() => expect(selector).toHaveValue("local/qwen"));
|
|
});
|
|
|
|
test("Core and Admin share one canonical selection, including models with the same short ID", async () => {
|
|
const models = [{ id: "local/qwen", label: "Local Qwen" }, { id: "remote/qwen", label: "Remote Qwen" }];
|
|
server.use(
|
|
http.get("/api/catalog/databases", () => HttpResponse.json([makeDatabase()])),
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({ models, default: "local/qwen" })),
|
|
http.get("/api/models", () => HttpResponse.json({ models: models.map((m) => ({
|
|
provider: m.id.split("/")[0], id: "qwen", name: m.label, reasoning: false,
|
|
})) })),
|
|
http.get("/api/settings", () => HttpResponse.json({ provider: "local", model: "qwen" })),
|
|
);
|
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
render(<QueryClientProvider client={client}><ComposerFooter /><DatabaseManagementPage canManage canManageSecrets /></QueryClientProvider>);
|
|
const admin = await screen.findByRole("combobox", { name: "Global LLM model" });
|
|
const core = await screen.findByRole("combobox", { name: "Model" });
|
|
await waitFor(() => expect(admin).toHaveValue("local/qwen"));
|
|
act(() => useSessionStore.getState().setAgentActive(true));
|
|
expect(core).toBeDisabled();
|
|
expect(admin).toBeDisabled();
|
|
act(() => useSessionStore.getState().setAgentActive(false));
|
|
act(() => client.setQueryData(["description-generation-runs", 50], [makeDescriptionGenerationRun()]));
|
|
await waitFor(() => expect(core).toBeDisabled());
|
|
await waitFor(() => expect(admin).toBeDisabled());
|
|
act(() => client.setQueryData(["description-generation-runs", 50], []));
|
|
await waitFor(() => expect(core).toBeEnabled());
|
|
await waitFor(() => expect(admin).toBeEnabled());
|
|
await userEvent.selectOptions(admin, "remote/qwen");
|
|
await waitFor(() => expect(core).toHaveValue("remote/qwen"));
|
|
await userEvent.selectOptions(core, "local/qwen");
|
|
await waitFor(() => expect(admin).toHaveValue("local/qwen"));
|
|
});
|
|
|
|
test("keeps both run-history buttons visible beside the metadata-description selector", async () => {
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
|
|
const selector = await screen.findByRole("combobox", { name: "Global 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" });
|
|
const descriptionHistoryButton = within(metadataControls).getByRole("button", {
|
|
name: "View description generation history",
|
|
});
|
|
const suggestionHistoryButton = within(metadataControls).getByRole("button", {
|
|
name: "View sensitivity analysis history",
|
|
});
|
|
|
|
expect(toolbar).toHaveClass("sm:items-end", "sm:justify-between");
|
|
expect(toolbar.firstElementChild).toBe(metadataControls);
|
|
expect(metadataControls).toContainElement(selector);
|
|
expect(descriptionHistoryButton).toBeVisible();
|
|
expect(descriptionHistoryButton).toBeEnabled();
|
|
expect(descriptionHistoryButton).toHaveTextContent("View description generation history");
|
|
expect(descriptionHistoryButton).toHaveClass("disabled:opacity-70");
|
|
expect(suggestionHistoryButton).toBeVisible();
|
|
expect(suggestionHistoryButton).toBeEnabled();
|
|
expect(suggestionHistoryButton).toHaveTextContent("View sensitivity analysis history");
|
|
expect(toolbar.lastElementChild).toBe(actions);
|
|
expect(actions).toHaveClass("sm:justify-end");
|
|
expect(actions).toContainElement(screen.getByRole("button", { name: "Refresh" }));
|
|
expect(within(actions).queryByRole("button", { name: /Add database/i })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("note", {
|
|
name: "Metadata generation source data disclosure",
|
|
})).not.toBeInTheDocument();
|
|
expect(document.getElementById("metadata-generation-source-data-disclosure")).toBeNull();
|
|
|
|
await user.click(descriptionHistoryButton);
|
|
const descriptionDrawer = await screen.findByRole("dialog", { name: "Description generation" });
|
|
expect(within(descriptionDrawer).getByText("No description generation runs yet.")).toBeVisible();
|
|
|
|
await user.click(suggestionHistoryButton);
|
|
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
|
|
const suggestionDrawer = await screen.findByRole("dialog", { name: "Sensitivity analysis history" });
|
|
expect(within(suggestionDrawer).getByText("No sensitivity analysis runs yet.")).toBeVisible();
|
|
});
|
|
|
|
test("changes the global model shared with Core", async () => {
|
|
const user = userEvent.setup();
|
|
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [
|
|
{ id: "openai/mini", label: "OpenAI Mini" },
|
|
{ id: "local/qwen", label: "Local Qwen" },
|
|
],
|
|
default: "local/qwen",
|
|
})));
|
|
renderPage();
|
|
|
|
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
|
await waitFor(() => expect(selector).toHaveValue("local/qwen"));
|
|
await user.selectOptions(selector, "openai/mini");
|
|
|
|
expect(selector).toHaveValue("openai/mini");
|
|
expect(workspacePreferences.load()).toMatchObject({ provider: "openai", model: "mini" });
|
|
});
|
|
|
|
test("explains application setup when no metadata-description model is configured", async () => {
|
|
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [],
|
|
default: null,
|
|
})));
|
|
renderPage();
|
|
|
|
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
|
expect(selector).toBeDisabled();
|
|
expect(await screen.findByText(
|
|
"No metadata-generation LLM model is configured for this installation.",
|
|
)).toBeVisible();
|
|
});
|
|
|
|
test("uses a safe disabled setup state when metadata-description models are unavailable", async () => {
|
|
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
code: "catalog_unavailable",
|
|
message: "internal model registry details",
|
|
}, { status: 503 })));
|
|
renderPage();
|
|
|
|
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
|
await waitFor(() => expect(selector).toHaveDisplayValue("Model choices unavailable"));
|
|
expect(selector).toBeDisabled();
|
|
expect(screen.getByText(
|
|
"Global LLM model choices are unavailable.",
|
|
)).toBeVisible();
|
|
expect(screen.queryByText("internal model registry details")).not.toBeInTheDocument();
|
|
});
|
|
|
|
test.each([null, "missing-model"])(
|
|
"disables metadata-description selection when the public response has no usable default (%s)",
|
|
async (configuredDefault) => {
|
|
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "openai/mini", label: "OpenAI Mini" }],
|
|
default: configuredDefault,
|
|
})));
|
|
renderPage();
|
|
|
|
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
|
await waitFor(() => expect(selector).toHaveDisplayValue("No usable model configured"));
|
|
expect(selector).toBeDisabled();
|
|
expect(screen.getByText(
|
|
"No metadata-generation LLM model is configured for this installation.",
|
|
)).toBeVisible();
|
|
},
|
|
);
|
|
|
|
test("renders only the public metadata-generation model contract", async () => {
|
|
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{
|
|
id: "approved/model",
|
|
label: "Approved model",
|
|
provider: "private-provider",
|
|
model: "private/upstream-model",
|
|
endpoint: "https://private-model.example/v1",
|
|
apiKeyEnv: "PRIVATE_MODEL_API_KEY",
|
|
credential: "raw-private-credential",
|
|
}],
|
|
default: "approved/model",
|
|
})));
|
|
renderPage();
|
|
|
|
const selector = await screen.findByRole("combobox", { name: "Global LLM model" });
|
|
await waitFor(() => expect(selector).toHaveValue("approved/model"));
|
|
expect(within(selector).getAllByRole("option").map((option) => option.textContent))
|
|
.toEqual(["Approved model"]);
|
|
expect(document.body).not.toHaveTextContent(/private-provider|private\/upstream-model/);
|
|
expect(document.body).not.toHaveTextContent(/private-model\.example|PRIVATE_MODEL_API_KEY/);
|
|
expect(document.body).not.toHaveTextContent("raw-private-credential");
|
|
});
|
|
|
|
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: "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();
|
|
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", { name: /toggle row selection/i }));
|
|
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: [] }));
|
|
expect(screen.getByText("1 selected")).toBeVisible();
|
|
},
|
|
);
|
|
|
|
test("discloses source sampling before starting Generate missing descriptions", async () => {
|
|
const user = userEvent.setup();
|
|
const queuedRun = makeDescriptionGenerationRun({ scope: "missing", total: 3 });
|
|
let startBody: unknown;
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", async ({ request }) => {
|
|
startBody = await request.json();
|
|
return HttpResponse.json(queuedRun, { status: 202 });
|
|
}),
|
|
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(queuedRun)),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
|
|
);
|
|
renderPage({ rows: [makeDatabase()] });
|
|
|
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Generate missing descriptions" }));
|
|
|
|
expect(screen.getByText("Generate missing descriptions for Policlinico San Donato?")).toBeVisible();
|
|
expect(screen.getByRole("note", {
|
|
name: "Metadata generation source data disclosure",
|
|
})).toHaveTextContent(
|
|
"When available, up to five real source rows and five representative values from columns not marked sensitive are sent to the selected provider.",
|
|
);
|
|
expect(startBody).toBeUndefined();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Generate missing descriptions" }));
|
|
await waitFor(() => expect(startBody).toEqual({
|
|
modelId: "local/qwen",
|
|
scope: "missing",
|
|
}));
|
|
expect(screen.queryByText(/replace existing generated descriptions/i)).not.toBeInTheDocument();
|
|
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
|
});
|
|
|
|
test("keeps Fleet Generate missing descriptions behind the source-data disclosure", async () => {
|
|
const user = userEvent.setup();
|
|
let generationStarts = 0;
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => {
|
|
generationStarts += 1;
|
|
return HttpResponse.json(makeDescriptionGenerationRun({ scope: "missing" }), { status: 202 });
|
|
}),
|
|
);
|
|
renderPage({ rows: [makeDatabase()], presentation: "fleet" });
|
|
|
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.selectOptions(
|
|
screen.getByRole("combobox", { name: "Batch action" }),
|
|
"generate-missing",
|
|
);
|
|
await waitFor(() => expect(screen.getByRole("combobox", {
|
|
name: "Global LLM model",
|
|
})).toHaveValue("local/qwen"));
|
|
await user.click(screen.getByRole("button", { name: "Run action" }));
|
|
|
|
expect(screen.getByRole("note", {
|
|
name: "Metadata generation source data disclosure",
|
|
})).toBeVisible();
|
|
expect(generationStarts).toBe(0);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
|
expect(screen.queryByRole("note", {
|
|
name: "Metadata generation source data disclosure",
|
|
})).not.toBeInTheDocument();
|
|
expect(generationStarts).toBe(0);
|
|
});
|
|
|
|
test("copies generated descriptions to every database table and column after explicit confirmation", async () => {
|
|
const user = userEvent.setup();
|
|
let consolidationBody: unknown;
|
|
server.use(
|
|
http.post("/api/catalog/databases/:databaseId/descriptions/consolidate", async ({ request }) => {
|
|
consolidationBody = await request.json();
|
|
return HttpResponse.json({ copied: 7, skipped: 2 });
|
|
}),
|
|
);
|
|
const database = makeDatabase();
|
|
const { client } = renderPage({ rows: [database], presentation: "fleet" });
|
|
const tablesQuery = ["catalog-tables", database.id] as const;
|
|
const firstColumnsQuery = ["catalog-columns", database.id, "table-one"] as const;
|
|
const secondColumnsQuery = ["catalog-columns", database.id, "table-two"] as const;
|
|
const unrelatedColumnsQuery = ["catalog-columns", "other-database", "table-one"] as const;
|
|
for (const queryKey of [
|
|
tablesQuery,
|
|
firstColumnsQuery,
|
|
secondColumnsQuery,
|
|
unrelatedColumnsQuery,
|
|
]) client.setQueryData(queryKey, []);
|
|
|
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.selectOptions(
|
|
screen.getByRole("combobox", { name: "Batch action" }),
|
|
"consolidate-descriptions",
|
|
);
|
|
expect(screen.getByRole("option", { name: "Copy generated description to descriptions" }))
|
|
.toBeInTheDocument();
|
|
await user.click(screen.getByRole("button", { name: "Run action" }));
|
|
|
|
expect(screen.getByText(
|
|
"Copy generated descriptions to Description fields in Policlinico San Donato?",
|
|
)).toBeVisible();
|
|
expect(screen.getByText(/table and column description will replace its current Description value/i))
|
|
.toBeVisible();
|
|
expect(consolidationBody).toBeUndefined();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Copy to descriptions" }));
|
|
|
|
await waitFor(() => expect(consolidationBody).toEqual({ target: "database" }));
|
|
expect(await screen.findByText("Copied 7 descriptions; skipped 2")).toBeVisible();
|
|
await waitFor(() => {
|
|
expect(client.getQueryState(tablesQuery)?.isInvalidated).toBe(true);
|
|
expect(client.getQueryState(firstColumnsQuery)?.isInvalidated).toBe(true);
|
|
expect(client.getQueryState(secondColumnsQuery)?.isInvalidated).toBe(true);
|
|
});
|
|
expect(client.getQueryState(unrelatedColumnsQuery)?.isInvalidated).toBe(false);
|
|
expect(screen.getByText("1 selected")).toBeVisible();
|
|
});
|
|
|
|
test("confirms generating all descriptions, supports cancel, and sends the database-wide scope", async () => {
|
|
const user = userEvent.setup();
|
|
const queuedRun = makeDescriptionGenerationRun({ scope: "all", total: 6 });
|
|
let startBody: unknown;
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", async ({ request }) => {
|
|
startBody = await request.json();
|
|
return HttpResponse.json(queuedRun, { status: 202 });
|
|
}),
|
|
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(queuedRun)),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
|
|
);
|
|
renderPage({ rows: [makeDatabase()] });
|
|
|
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Generate all descriptions" }));
|
|
|
|
expect(screen.getByText("Replace generated descriptions for Policlinico San Donato?")).toBeVisible();
|
|
expect(screen.getByText(/existing generated descriptions for eligible tables and columns will be replaced/i)).toBeVisible();
|
|
expect(screen.getByRole("note", {
|
|
name: "Metadata generation source data disclosure",
|
|
})).toHaveTextContent(/real source rows.*selected provider/i);
|
|
expect(startBody).toBeUndefined();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
|
expect(screen.queryByText(/Replace generated descriptions for/)).not.toBeInTheDocument();
|
|
expect(startBody).toBeUndefined();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Generate all descriptions" }));
|
|
await user.click(screen.getByRole("button", { name: "Generate all descriptions" }));
|
|
|
|
await waitFor(() => expect(startBody).toEqual({
|
|
modelId: "local/qwen",
|
|
scope: "all",
|
|
}));
|
|
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
|
});
|
|
|
|
test("keeps the database selected and safely explains when no descriptions are eligible", async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => HttpResponse.json({
|
|
code: "description_generation_no_eligible_targets",
|
|
message: "private provider and catalog detail",
|
|
}, { status: 409 })),
|
|
);
|
|
renderPage({ rows: [makeDatabase()] });
|
|
|
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Generate missing descriptions" }));
|
|
await user.click(screen.getByRole("button", { name: "Generate missing descriptions" }));
|
|
|
|
expect(await screen.findByText(
|
|
"No eligible catalog tables or columns need description generation.",
|
|
)).toBeVisible();
|
|
expect(screen.getByText("1 selected")).toBeVisible();
|
|
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
|
|
expect(document.body).not.toHaveTextContent("private provider and catalog detail");
|
|
});
|
|
|
|
test.each([
|
|
{ scope: "missing" as const, status: "completed" as const },
|
|
{ scope: "all" as const, status: "completed_with_errors" as const },
|
|
])("refreshes every table and column result after a database-wide $scope run is $status", async ({
|
|
scope,
|
|
status,
|
|
}) => {
|
|
const user = userEvent.setup();
|
|
const queuedRun = makeDescriptionGenerationRun({ scope, total: 4 });
|
|
const terminalRun = makeDescriptionGenerationRun({
|
|
scope,
|
|
status,
|
|
total: 4,
|
|
processed: 4,
|
|
generated: 3,
|
|
failed: status === "completed_with_errors" ? 1 : 0,
|
|
startedAt: "2026-08-28T10:00:01Z",
|
|
updatedAt: "2026-08-28T10:00:02Z",
|
|
finishedAt: "2026-08-28T10:00:02Z",
|
|
});
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => (
|
|
HttpResponse.json(queuedRun, { status: 202 })
|
|
)),
|
|
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(terminalRun)),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
|
|
);
|
|
const { client } = renderPage({ rows: [makeDatabase()] });
|
|
const databaseId = queuedRun.databaseId;
|
|
const tablesQuery = ["catalog-tables", databaseId] as const;
|
|
const firstColumnsQuery = ["catalog-columns", databaseId, "table-one"] as const;
|
|
const secondColumnsQuery = ["catalog-columns", databaseId, "table-two"] as const;
|
|
const unrelatedTablesQuery = ["catalog-tables", "other-database"] as const;
|
|
const unrelatedColumnsQuery = ["catalog-columns", "other-database", "table-one"] as const;
|
|
for (const queryKey of [
|
|
tablesQuery,
|
|
firstColumnsQuery,
|
|
secondColumnsQuery,
|
|
unrelatedTablesQuery,
|
|
unrelatedColumnsQuery,
|
|
]) client.setQueryData(queryKey, []);
|
|
|
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", {
|
|
name: scope === "all" ? "Generate all descriptions" : "Generate missing descriptions",
|
|
}));
|
|
await user.click(screen.getByRole("button", {
|
|
name: scope === "all" ? "Generate all descriptions" : "Generate missing descriptions",
|
|
}));
|
|
|
|
expect(await screen.findByRole("heading", {
|
|
name: status === "completed" ? "Completed" : "Completed with errors",
|
|
})).toBeVisible();
|
|
await waitFor(() => {
|
|
expect(client.getQueryState(tablesQuery)?.isInvalidated).toBe(true);
|
|
expect(client.getQueryState(firstColumnsQuery)?.isInvalidated).toBe(true);
|
|
expect(client.getQueryState(secondColumnsQuery)?.isInvalidated).toBe(true);
|
|
});
|
|
expect(client.getQueryState(unrelatedTablesQuery)?.isInvalidated).toBe(false);
|
|
expect(client.getQueryState(unrelatedColumnsQuery)?.isInvalidated).toBe(false);
|
|
});
|
|
|
|
test.each([
|
|
{
|
|
reason: "database.manage is absent",
|
|
canManage: false,
|
|
rows: [makeDatabase()],
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
configuredDefault: "local/qwen",
|
|
selectedRows: 1,
|
|
},
|
|
{
|
|
reason: "no usable model is configured",
|
|
canManage: true,
|
|
rows: [makeDatabase()],
|
|
models: [],
|
|
configuredDefault: null,
|
|
selectedRows: 1,
|
|
},
|
|
{
|
|
reason: "the Workspace Database is not configured",
|
|
canManage: true,
|
|
rows: [unconfigured],
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
configuredDefault: "local/qwen",
|
|
selectedRows: 1,
|
|
},
|
|
{
|
|
reason: "more than one Workspace Database is selected",
|
|
canManage: true,
|
|
rows: [
|
|
makeDatabase(),
|
|
makeDatabase({
|
|
id: "44444444-4444-4444-8444-444444444444",
|
|
workspaceId: "radiology",
|
|
workspaceName: "Radiology",
|
|
}),
|
|
],
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
configuredDefault: "local/qwen",
|
|
selectedRows: 2,
|
|
},
|
|
])("disables database-wide generation when $reason", async ({
|
|
canManage,
|
|
rows,
|
|
models,
|
|
configuredDefault,
|
|
selectedRows,
|
|
}) => {
|
|
const user = userEvent.setup();
|
|
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models,
|
|
default: configuredDefault,
|
|
})));
|
|
renderPage({ rows, canManage });
|
|
|
|
for (const database of rows.slice(0, selectedRows)) {
|
|
const row = await screen.findByRole("row", { name: new RegExp(database.workspaceName) });
|
|
await user.click(within(row).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
}
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
|
|
expect(await screen.findByRole("menuitem", { name: "Generate all descriptions" }))
|
|
.toHaveAttribute("aria-disabled", "true");
|
|
expect(screen.getByRole("menuitem", { name: "Generate missing descriptions" }))
|
|
.toHaveAttribute("aria-disabled", "true");
|
|
});
|
|
|
|
test("disables database-wide generation while a description generation is active", async () => {
|
|
const user = userEvent.setup();
|
|
const queuedRun = makeDescriptionGenerationRun({ scope: "missing" });
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => (
|
|
HttpResponse.json(queuedRun, { status: 202 })
|
|
)),
|
|
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json({
|
|
...queuedRun,
|
|
status: "running",
|
|
startedAt: "2026-08-28T10:00:01Z",
|
|
})),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
|
|
);
|
|
renderPage({ rows: [makeDatabase()] });
|
|
|
|
let databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Generate missing descriptions" }));
|
|
await user.click(screen.getByRole("button", { name: "Generate missing descriptions" }));
|
|
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
|
await user.click(screen.getByRole("button", { name: "Close description generation" }));
|
|
|
|
databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
expect(await screen.findByRole("menuitem", { name: "Generate all descriptions" }))
|
|
.toHaveAttribute("aria-disabled", "true");
|
|
expect(screen.getByRole("menuitem", { name: "Generate missing descriptions" }))
|
|
.toHaveAttribute("aria-disabled", "true");
|
|
});
|
|
|
|
test("selected database Actions confirms and deletes catalog tables for the full selection", async () => {
|
|
const user = userEvent.setup();
|
|
let cleanupBody: unknown;
|
|
const second = makeDatabase({
|
|
id: "44444444-4444-4444-8444-444444444444",
|
|
workspaceId: "radiology",
|
|
workspaceName: "Radiology",
|
|
databaseName: "radiology",
|
|
});
|
|
server.use(http.post("/api/catalog/databases/metadata-cleanup", async ({ request }) => {
|
|
cleanupBody = await request.json();
|
|
return HttpResponse.json({ tables: 8, columns: 42, relationships: 5 });
|
|
}));
|
|
renderPage({ rows: [makeDatabase(), second] });
|
|
|
|
const firstRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
const secondRow = await screen.findByRole("row", { name: /Radiology/ });
|
|
await user.click(within(firstRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(within(secondRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
|
|
expect(await screen.findByRole("menuitem", { name: "Delete all tables" })).toBeEnabled();
|
|
expect(screen.getByRole("menuitem", { name: "Delete all relationships" })).toBeEnabled();
|
|
await user.click(screen.getByRole("menuitem", { name: "Delete all tables" }));
|
|
|
|
expect(screen.getByText("Delete all catalog tables for 2 databases?")).toBeVisible();
|
|
expect(screen.getByText(/database configurations and source databases are unchanged/i)).toBeVisible();
|
|
expect(cleanupBody).toBeUndefined();
|
|
|
|
await user.click(within(secondRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
expect(screen.queryByText(/Delete all catalog tables for/)).not.toBeInTheDocument();
|
|
expect(cleanupBody).toBeUndefined();
|
|
await user.click(within(secondRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Delete all tables" }));
|
|
await waitFor(() => expect(screen.getByRole("button", { name: "Delete catalog tables" })).toHaveFocus());
|
|
await user.keyboard("{Escape}");
|
|
await waitFor(() => expect(screen.getByRole("button", { name: "Actions" })).toHaveFocus());
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Delete all tables" }));
|
|
await user.click(screen.getByRole("button", { name: "Delete catalog tables" }));
|
|
|
|
await waitFor(() => expect(cleanupBody).toEqual({
|
|
databaseIds: [
|
|
"11111111-1111-4111-8111-111111111111",
|
|
"44444444-4444-4444-8444-444444444444",
|
|
],
|
|
target: "tables",
|
|
}));
|
|
expect(screen.getByText("2 selected")).toBeVisible();
|
|
await user.click(screen.getByRole("button", { name: "Clear" }));
|
|
expect(screen.queryByText("2 selected")).not.toBeInTheDocument();
|
|
});
|
|
|
|
test("presents completed database synchronization steps as success and skips unneeded confirmation", async () => {
|
|
const user = userEvent.setup();
|
|
const run = {
|
|
...makeSyncRun("all"),
|
|
counts: { tables: 163, columns: 2_275, relationships: 18 },
|
|
};
|
|
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", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Synchronize all" }));
|
|
|
|
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 the database synchronization scopes", 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("configures the catalog for the selected workspace and locks that workspace", 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: "Configure catalog for Research laboratory" }));
|
|
|
|
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 catalog configuration" }));
|
|
|
|
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("has no global database creation path and scopes configuration to the chosen row", async () => {
|
|
const user = userEvent.setup();
|
|
const second = makeDatabase({
|
|
...unconfigured,
|
|
workspaceId: "radiology",
|
|
workspaceName: "Radiology",
|
|
databaseName: "radiology_dwh",
|
|
});
|
|
renderPage({ rows: [makeDatabase(), unconfigured, second] });
|
|
|
|
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: "Configure catalog" })).toBeVisible();
|
|
const workspace = screen.getByLabelText("Workspace");
|
|
expect(workspace).toHaveAttribute("readonly");
|
|
expect(workspace).toHaveValue("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: "Configure catalog for Policlinico San Donato" })).toBeEnabled();
|
|
expect(screen.queryByRole("button", { name: "Delete Policlinico San Donato" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
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 });
|
|
const testResponse: Partial<CatalogDatabase> = { ...row };
|
|
delete testResponse.workspaceName;
|
|
return HttpResponse.json(testResponse);
|
|
}));
|
|
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 }));
|
|
const resultDialog = await screen.findByRole("dialog", { name: "Connection test successful" });
|
|
expect(resultDialog).toBeVisible();
|
|
expect(within(resultDialog).getByText("Policlinico San Donato is reachable.")).toBeVisible();
|
|
await user.click(within(resultDialog).getByRole("button", { name: "Done" }));
|
|
expect(within(screen.getByRole("region", { name: "Database details form" })).getByText("Reachable")).toBeVisible();
|
|
});
|
|
|
|
test("reports an unsuccessful selected connection test as a failure", async () => {
|
|
const user = userEvent.setup();
|
|
const failed = makeDatabase({
|
|
connectionStatus: "failed",
|
|
testedVersion: 3,
|
|
lastErrorMessage: "Password authentication failed",
|
|
});
|
|
const testResponse: Partial<CatalogDatabase> = { ...failed };
|
|
delete testResponse.workspaceName;
|
|
server.use(http.post("/api/catalog/databases/:id/test", () => HttpResponse.json(testResponse)));
|
|
renderPage({ rows: [makeDatabase()] });
|
|
|
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Test connections" }));
|
|
|
|
const resultDialog = await screen.findByRole("dialog", { name: "Connection test failed" });
|
|
expect(resultDialog).toBeVisible();
|
|
expect(within(resultDialog).getByText(/Policlinico San Donato: Password authentication failed/)).toBeVisible();
|
|
expect(screen.queryByText("1 connection tested")).not.toBeInTheDocument();
|
|
});
|
|
|
|
test("keeps Fleet synchronization available after an informative connection failure", async () => {
|
|
const user = userEvent.setup();
|
|
renderPage({
|
|
rows: [makeDatabase({
|
|
connectionStatus: "failed",
|
|
testedVersion: 3,
|
|
lastErrorCode: "connector_unavailable",
|
|
lastErrorMessage: "Password authentication failed",
|
|
})],
|
|
presentation: "fleet",
|
|
});
|
|
|
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
const actionPicker = screen.getByRole("combobox", { name: "Batch action" });
|
|
await user.selectOptions(actionPicker, "sync-all");
|
|
|
|
expect(within(actionPicker).getByRole("option", { name: "Synchronize all" })).toBeVisible();
|
|
expect(screen.getByRole("button", { name: "Run action" })).toBeEnabled();
|
|
});
|
|
|
|
test("reports a successful selected connection test as a success", async () => {
|
|
const user = userEvent.setup();
|
|
const reachable = makeDatabase({
|
|
connectionStatus: "reachable",
|
|
testedVersion: 3,
|
|
});
|
|
const testResponse: Partial<CatalogDatabase> = { ...reachable };
|
|
delete testResponse.workspaceName;
|
|
server.use(http.post("/api/catalog/databases/:id/test", () => HttpResponse.json(testResponse)));
|
|
renderPage({ rows: [makeDatabase()] });
|
|
|
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Test connections" }));
|
|
|
|
const resultDialog = await screen.findByRole("dialog", { name: "Connection test successful" });
|
|
expect(resultDialog).toBeVisible();
|
|
expect(within(resultDialog).getByText("Policlinico San Donato is 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",
|
|
};
|
|
|
|
const patientIdColumn: 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: false,
|
|
foreignKeyCount: 0,
|
|
sourceComment: "Patient identifier",
|
|
description: null,
|
|
generatedDescription: null,
|
|
sensitive: false,
|
|
lastSyncedDatabaseVersion: 3,
|
|
lastSyncedAt: "2026-08-27T10:00:00Z",
|
|
version: 1,
|
|
createdAt: "2026-08-27T10:00:00Z",
|
|
updatedAt: "2026-08-27T10:00:00Z",
|
|
};
|
|
|
|
test("keeps column synchronization available when the latest connection test failed", 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([patientIdColumn])),
|
|
);
|
|
renderPage({
|
|
rows: [makeDatabase({
|
|
connectionStatus: "failed",
|
|
testedVersion: 3,
|
|
lastErrorCode: "connector_unavailable",
|
|
lastErrorMessage: "The database connector could not be reached or authenticated.",
|
|
})],
|
|
});
|
|
|
|
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" }));
|
|
const rows = await screen.findAllByRole("row", { name: /Patient identifier/ });
|
|
const selectableRow = rows.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
|
expect(selectableRow).toBeDefined();
|
|
await user.click(within(selectableRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
|
|
expect(await screen.findByRole("menuitem", { name: "Synchronize columns for this table" }))
|
|
.not.toHaveAttribute("aria-disabled", "true");
|
|
});
|
|
|
|
test("saves a manual sensitive flag change from the Fleet columns toolbar", async () => {
|
|
const user = userEvent.setup();
|
|
const nameColumn: CatalogColumn = {
|
|
...patientIdColumn,
|
|
id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
|
name: "name",
|
|
ordinalPosition: 2,
|
|
primaryKeyPosition: null,
|
|
isPrimaryKey: false,
|
|
sourceComment: "Patient name",
|
|
};
|
|
const columns = [patientIdColumn, nameColumn];
|
|
const patches: Array<{ columnId: string; body: Record<string, unknown> }> = [];
|
|
let requestInFlight = false;
|
|
server.use(
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
|
http.get(
|
|
"/api/catalog/databases/:databaseId/tables/:tableId/columns",
|
|
() => HttpResponse.json(columns),
|
|
),
|
|
http.patch(
|
|
"/api/catalog/databases/:databaseId/tables/:tableId/columns/:columnId",
|
|
async ({ params, request }) => {
|
|
if (requestInFlight) {
|
|
return HttpResponse.json({
|
|
code: "catalog_operation_active",
|
|
message: "Another catalog operation is in progress.",
|
|
}, { status: 409 });
|
|
}
|
|
requestInFlight = true;
|
|
const body = await request.json() as Record<string, unknown>;
|
|
await new Promise((resolve) => window.setTimeout(resolve, 20));
|
|
patches.push({ columnId: String(params.columnId), body });
|
|
requestInFlight = false;
|
|
const current = columns.find((column) => column.id === params.columnId)!;
|
|
return HttpResponse.json({ ...current, ...body, version: current.version + 1 });
|
|
},
|
|
),
|
|
);
|
|
renderPage({
|
|
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
|
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",
|
|
}));
|
|
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
|
|
|
|
expect(screen.queryByRole("button", { name: "Save sensitive fields" })).not.toBeInTheDocument();
|
|
await user.click(await screen.findByRole("checkbox", { name: "Sensitive data for id" }));
|
|
await user.click(screen.getByRole("checkbox", { name: "Sensitive data for name" }));
|
|
|
|
const saveButton = screen.getByRole("button", { name: "Save sensitive fields" });
|
|
expect(saveButton).toBeVisible();
|
|
await user.click(saveButton);
|
|
|
|
await waitFor(() => expect(patches).toHaveLength(2));
|
|
expect(patches).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({
|
|
columnId: patientIdColumn.id,
|
|
body: expect.objectContaining({ version: patientIdColumn.version, sensitive: true }),
|
|
}),
|
|
expect.objectContaining({
|
|
columnId: nameColumn.id,
|
|
body: expect.objectContaining({ version: nameColumn.version, sensitive: true }),
|
|
}),
|
|
]));
|
|
expect(await screen.findByText("Sensitive fields saved")).toBeVisible();
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole("button", { name: "Save sensitive fields" })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
test("shows a read-only sensitivity reason in the Fleet column metadata matrix", async () => {
|
|
const user = userEvent.setup();
|
|
const sensitiveColumn: CatalogColumn = {
|
|
...patientIdColumn,
|
|
sensitive: true,
|
|
sensitivityReason: "Local assessment matched content rule pii.email.",
|
|
};
|
|
server.use(
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
|
http.get(
|
|
"/api/catalog/databases/:databaseId/tables/:tableId/columns",
|
|
() => HttpResponse.json([sensitiveColumn]),
|
|
),
|
|
);
|
|
renderPage({
|
|
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
|
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",
|
|
}));
|
|
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
|
|
await user.click(await screen.findByRole("button", { name: "Edit metadata for id" }));
|
|
|
|
const drawer = await screen.findByRole("dialog", { name: "Review column description" });
|
|
const matrix = within(drawer).getByRole("group", { name: "Column metadata fields" });
|
|
expect(matrix).toHaveClass("sm:grid-cols-2");
|
|
expect(within(matrix).getByLabelText("Source comment")).toHaveAttribute("readonly");
|
|
expect(within(matrix).getByLabelText("Description")).not.toHaveAttribute("readonly");
|
|
expect(within(matrix).getByLabelText("Generated description")).not.toHaveAttribute("readonly");
|
|
expect(within(matrix).getByLabelText("Sensitive motivation")).toHaveValue(
|
|
"Local assessment matched content rule pii.email.",
|
|
);
|
|
expect(within(matrix).getByLabelText("Sensitive motivation")).toHaveAttribute("readonly");
|
|
});
|
|
|
|
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 Actions expose table commands and send synchronization ids", 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", { name: /toggle row selection/i }));
|
|
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
const synchronizeColumns = await screen.findByRole("menuitem", { name: "Synchronize columns for selected tables" });
|
|
expect(synchronizeColumns).toBeEnabled();
|
|
expect(screen.getByRole("menuitem", { name: "Synchronize database tables" })).toBeEnabled();
|
|
expect(screen.getByRole("menuitem", { name: "Remove synchronized columns" })).toBeEnabled();
|
|
expect(screen.queryByRole("menuitem", { name: "Delete all relationships" })).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("starts description generation for multiple selected tables", async () => {
|
|
const user = userEvent.setup();
|
|
const visitsTable: CatalogTable = {
|
|
...patientsTable,
|
|
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
name: "visits",
|
|
};
|
|
const queuedRun = makeDescriptionGenerationRun({
|
|
scope: "selected_tables",
|
|
total: 2,
|
|
});
|
|
let startBody: unknown;
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([
|
|
patientsTable,
|
|
visitsTable,
|
|
])),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", async ({ request }) => {
|
|
startBody = await request.json();
|
|
return HttpResponse.json(queuedRun, { status: 202 });
|
|
}),
|
|
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(queuedRun)),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
|
|
);
|
|
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(within(await screen.findByRole("row", { name: /patients/ })).getByRole("checkbox"));
|
|
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
|
|
const generate = await screen.findByRole("menuitem", { name: "Generate table description" });
|
|
expect(generate).toBeEnabled();
|
|
expect(screen.getByRole("menuitem", {
|
|
name: "Copy generated descriptions to Description",
|
|
})).toBeEnabled();
|
|
await user.click(generate);
|
|
|
|
await waitFor(() => expect(startBody).toEqual({
|
|
modelId: "local/qwen",
|
|
scope: "selected_tables",
|
|
targetIds: [patientsTable.id, visitsTable.id],
|
|
}));
|
|
expect(await screen.findByRole("dialog", {
|
|
name: "Description generation",
|
|
})).toBeVisible();
|
|
expect(await screen.findByText("Description generation started for 2 tables")).toBeVisible();
|
|
expect(screen.getByText("2 selected")).toBeVisible();
|
|
});
|
|
|
|
test("generates descriptions for every column in selected tables", async () => {
|
|
const user = userEvent.setup();
|
|
const queuedRun = makeDescriptionGenerationRun({ scope: "selected_columns", total: 1 });
|
|
let startBody: unknown;
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({ models: [{ id: "local/qwen", label: "Local Qwen" }], default: "local/qwen" })),
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
|
http.get(`/api/catalog/databases/:databaseId/tables/${patientsTable.id}/columns`, () => HttpResponse.json([patientIdColumn])),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", async ({ request }) => {
|
|
startBody = await request.json();
|
|
return HttpResponse.json(queuedRun, { status: 202 });
|
|
}),
|
|
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(queuedRun)),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
|
|
);
|
|
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(within(await screen.findByRole("row", { name: /patients/ })).getByRole("checkbox"));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Generate descriptions for all columns" }));
|
|
|
|
await waitFor(() => expect(startBody).toEqual({
|
|
modelId: "local/qwen",
|
|
scope: "selected_columns",
|
|
targetIds: [patientIdColumn.id],
|
|
}));
|
|
expect(await screen.findByText("Column description generation started for 1 column")).toBeVisible();
|
|
});
|
|
|
|
test("observes an active run from another browser and reopens a terminal run from history", async () => {
|
|
const user = userEvent.setup();
|
|
const activeRun = makeDescriptionGenerationRun({
|
|
status: "running",
|
|
startedAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
const completedRun = makeDescriptionGenerationRun({
|
|
id: "88888888-8888-4888-8888-888888888888",
|
|
scope: "missing",
|
|
status: "completed",
|
|
processed: 1,
|
|
generated: 1,
|
|
createdAt: "2026-08-28T09:00:00Z",
|
|
startedAt: "2026-08-28T09:00:01Z",
|
|
updatedAt: "2026-08-28T09:00:02Z",
|
|
finishedAt: "2026-08-28T09:00:02Z",
|
|
});
|
|
let requestedLimit: string | null = null;
|
|
server.use(
|
|
http.get("/api/catalog/description-generation-runs", ({ request }) => {
|
|
requestedLimit = new URL(request.url).searchParams.get("limit");
|
|
return HttpResponse.json([activeRun, completedRun]);
|
|
}),
|
|
http.get("/api/catalog/description-generation-runs/:runId", ({ params }) => (
|
|
HttpResponse.json(params.runId === completedRun.id ? completedRun : activeRun)
|
|
)),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
|
|
);
|
|
renderPage();
|
|
|
|
const observe = await screen.findByRole("button", {
|
|
name: "View description generation history",
|
|
});
|
|
expect(observe).toHaveTextContent("View description generation history");
|
|
expect(requestedLimit).toBe("50");
|
|
await user.click(observe);
|
|
|
|
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
|
expect(within(drawer).getByRole("heading", { name: "Running" })).toBeVisible();
|
|
const history = within(drawer).getByRole("region", { name: "Description generation history" });
|
|
await user.click(within(history).getByRole("button", { name: /Completed.*missing/i }));
|
|
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
|
|
});
|
|
|
|
test("keeps the sensitivity analysis history label stable while showing active status separately", async () => {
|
|
const runningRun = makeSensitivityAnalysisRun({
|
|
status: "running",
|
|
finishedAt: null,
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
server.use(http.get(
|
|
"/api/catalog/sensitive-data-suggestion-runs",
|
|
() => HttpResponse.json([runningRun]),
|
|
));
|
|
renderPage();
|
|
|
|
const historyButton = await screen.findByRole("button", {
|
|
name: "View sensitivity analysis history",
|
|
});
|
|
expect(historyButton).toHaveTextContent("View sensitivity analysis history");
|
|
await waitFor(() => expect(historyButton).toHaveAttribute(
|
|
"title",
|
|
"Sensitivity analysis is active",
|
|
));
|
|
expect(historyButton.querySelector("[aria-hidden='true'].bg-primary")).not.toBeNull();
|
|
});
|
|
|
|
test.each([
|
|
"completed",
|
|
"completed_with_errors",
|
|
"failed",
|
|
"cancelled",
|
|
"interrupted",
|
|
] satisfies DescriptionGenerationRun["status"][])(
|
|
"invalidates Catalog Tables and Catalog Columns for terminal status %s",
|
|
async (status) => {
|
|
const user = userEvent.setup();
|
|
const terminalRun = makeDescriptionGenerationRun({
|
|
status,
|
|
finishedAt: "2026-08-28T10:00:02Z",
|
|
});
|
|
server.use(
|
|
http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([terminalRun])),
|
|
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(terminalRun)),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
|
|
);
|
|
const { client } = renderPage();
|
|
const tableKey = ["catalog-tables", terminalRun.databaseId] as const;
|
|
const columnKey = ["catalog-columns", terminalRun.databaseId, "table-id"] as const;
|
|
const unrelatedColumnKey = ["catalog-columns", "other-database", "table-id"] as const;
|
|
client.setQueryData(tableKey, []);
|
|
client.setQueryData(columnKey, []);
|
|
client.setQueryData(unrelatedColumnKey, []);
|
|
|
|
await user.click(await screen.findByRole("button", {
|
|
name: "View description generation history",
|
|
}));
|
|
|
|
await waitFor(() => expect(client.getQueryState(tableKey)?.isInvalidated).toBe(true));
|
|
expect(client.getQueryState(columnKey)?.isInvalidated).toBe(true);
|
|
expect(client.getQueryState(unrelatedColumnKey)?.isInvalidated).toBe(false);
|
|
},
|
|
);
|
|
|
|
test("keeps selected tables when description generation cannot start", async () => {
|
|
const user = userEvent.setup();
|
|
const visitsTable: CatalogTable = {
|
|
...patientsTable,
|
|
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
name: "visits",
|
|
};
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([
|
|
patientsTable,
|
|
visitsTable,
|
|
])),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => HttpResponse.json({
|
|
code: "description_generation_run_active",
|
|
message: "private provider detail",
|
|
}, { 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(within(await screen.findByRole("row", { name: /patients/ })).getByRole("checkbox"));
|
|
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Generate table description" }));
|
|
|
|
expect(await screen.findByText("A description generation run is already active.")).toBeVisible();
|
|
expect(screen.getByText("2 selected")).toBeVisible();
|
|
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
|
|
expect(document.body).not.toHaveTextContent("private provider detail");
|
|
});
|
|
|
|
test("refreshes Catalog Tables and Catalog Columns after table generation completes", async () => {
|
|
const user = userEvent.setup();
|
|
const visitsTable: CatalogTable = {
|
|
...patientsTable,
|
|
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
name: "visits",
|
|
};
|
|
const queuedRun = makeDescriptionGenerationRun({
|
|
scope: "selected_tables",
|
|
total: 2,
|
|
});
|
|
const completedRun = makeDescriptionGenerationRun({
|
|
scope: "selected_tables",
|
|
status: "completed",
|
|
total: 2,
|
|
processed: 2,
|
|
generated: 2,
|
|
startedAt: "2026-08-28T10:00:01Z",
|
|
updatedAt: "2026-08-28T10:00:02Z",
|
|
finishedAt: "2026-08-28T10:00:02Z",
|
|
});
|
|
let tables = [patientsTable, visitsTable];
|
|
let tableReads = 0;
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => {
|
|
tableReads += 1;
|
|
return HttpResponse.json(tables);
|
|
}),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => (
|
|
HttpResponse.json(queuedRun, { status: 202 })
|
|
)),
|
|
http.get("/api/catalog/description-generation-runs/:runId", () => {
|
|
tables = tables.map((table) => ({
|
|
...table,
|
|
generatedDescription: `Generated ${table.name}`,
|
|
version: table.version + 1,
|
|
}));
|
|
return HttpResponse.json(completedRun);
|
|
}),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
|
|
);
|
|
const { client } = renderPage({
|
|
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
|
});
|
|
const unrelatedTableQuery = ["catalog-tables", "other-database"] as const;
|
|
const columnsQuery = [
|
|
"catalog-columns",
|
|
patientsTable.databaseId,
|
|
patientsTable.id,
|
|
] as const;
|
|
client.setQueryData(unrelatedTableQuery, []);
|
|
client.setQueryData(columnsQuery, []);
|
|
|
|
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
|
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
|
await user.click(within(await screen.findByRole("row", { name: /patients/ })).getByRole("checkbox"));
|
|
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Generate table description" }));
|
|
|
|
expect(await screen.findByRole("heading", { name: "Completed" })).toBeVisible();
|
|
await waitFor(() => expect(tableReads).toBe(2));
|
|
expect(within(await screen.findByRole("row", { name: /patients/ }))
|
|
.getByText("Generated patients")).toBeVisible();
|
|
expect(client.getQueryState(unrelatedTableQuery)?.isInvalidated).toBe(false);
|
|
expect(client.getQueryState(columnsQuery)?.isInvalidated).toBe(true);
|
|
});
|
|
|
|
test("moves selected generated table descriptions and reports copied and skipped counts", async () => {
|
|
const user = userEvent.setup();
|
|
const visitsTable: CatalogTable = {
|
|
...patientsTable,
|
|
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
name: "visits",
|
|
description: "Keep curated visits",
|
|
generatedDescription: null,
|
|
};
|
|
let tables = [
|
|
{ ...patientsTable, description: "Old patients", generatedDescription: "Generated patients" },
|
|
visitsTable,
|
|
];
|
|
let consolidationBody: unknown;
|
|
let tableReads = 0;
|
|
server.use(
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => {
|
|
tableReads += 1;
|
|
return HttpResponse.json(tables);
|
|
}),
|
|
http.post("/api/catalog/databases/:databaseId/descriptions/consolidate", async ({ request }) => {
|
|
consolidationBody = await request.json();
|
|
tables = tables.map((table) => table.id === patientsTable.id
|
|
? { ...table, description: table.generatedDescription, version: table.version + 1 }
|
|
: table);
|
|
return HttpResponse.json({ copied: 1, skipped: 1 });
|
|
}),
|
|
);
|
|
const { client } = renderPage({
|
|
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
|
});
|
|
const unrelatedColumnQuery = [
|
|
"catalog-columns",
|
|
patientsTable.databaseId,
|
|
visitsTable.id,
|
|
] as const;
|
|
client.setQueryData(unrelatedColumnQuery, []);
|
|
|
|
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
|
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
|
const patientsRow = await screen.findByRole("row", { name: /patients/ });
|
|
const visitsRow = await screen.findByRole("row", { name: /visits/ });
|
|
await user.click(within(patientsRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(within(visitsRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", {
|
|
name: "Copy generated descriptions to Description",
|
|
}));
|
|
|
|
await waitFor(() => expect(consolidationBody).toEqual({
|
|
target: "tables",
|
|
targetIds: [patientsTable.id, visitsTable.id],
|
|
}));
|
|
expect(await screen.findByText("Copied 1 description; skipped 1")).toBeVisible();
|
|
await waitFor(() => expect(tableReads).toBe(2));
|
|
expect(within(await screen.findByRole("row", { name: /patients/ }))
|
|
.getAllByText("Generated patients")).toHaveLength(2);
|
|
expect(within(await screen.findByRole("row", { name: /visits/ }))
|
|
.getByText("Keep curated visits")).toBeVisible();
|
|
expect(screen.queryByText("2 selected")).not.toBeInTheDocument();
|
|
expect(client.getQueryState(unrelatedColumnQuery)?.isInvalidated).toBe(false);
|
|
});
|
|
|
|
test("selects columns, moves generated descriptions, and refreshes only the affected column query", async () => {
|
|
const user = userEvent.setup();
|
|
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: false,
|
|
foreignKeyCount: 0,
|
|
sourceComment: "Patient identifier",
|
|
description: "Old identifier",
|
|
generatedDescription: "Generated identifier",
|
|
sensitive: false,
|
|
lastSyncedDatabaseVersion: 3,
|
|
lastSyncedAt: "2026-08-27T10:00:00Z",
|
|
version: 1,
|
|
createdAt: "2026-08-27T10:00:00Z",
|
|
updatedAt: "2026-08-27T10:00:00Z",
|
|
};
|
|
const nameColumn: CatalogColumn = {
|
|
...idColumn,
|
|
id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
|
name: "name",
|
|
ordinalPosition: 2,
|
|
dataType: "text",
|
|
primaryKeyPosition: null,
|
|
isPrimaryKey: false,
|
|
description: "Keep curated patient name",
|
|
generatedDescription: "",
|
|
};
|
|
let columns = [idColumn, nameColumn];
|
|
let consolidationBody: unknown;
|
|
let columnReads = 0;
|
|
server.use(
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
|
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => {
|
|
columnReads += 1;
|
|
return HttpResponse.json(columns);
|
|
}),
|
|
http.post("/api/catalog/databases/:databaseId/descriptions/consolidate", async ({ request }) => {
|
|
consolidationBody = await request.json();
|
|
columns = columns.map((column) => column.id === idColumn.id
|
|
? { ...column, description: column.generatedDescription, version: column.version + 1 }
|
|
: column);
|
|
return HttpResponse.json({ copied: 1, skipped: 1 });
|
|
}),
|
|
);
|
|
const { client } = renderPage({
|
|
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
|
});
|
|
const tableQuery = ["catalog-tables", patientsTable.databaseId] as const;
|
|
const selectableRow = async (name: RegExp) => {
|
|
const rows = await screen.findAllByRole("row", { name });
|
|
const row = rows.find((candidate) => within(candidate).queryByRole("checkbox", { name: /toggle row selection/i }));
|
|
expect(row).toBeDefined();
|
|
return row!;
|
|
};
|
|
|
|
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" }));
|
|
const idRow = await selectableRow(/Patient identifier/);
|
|
const nameRow = await selectableRow(/Keep curated patient name/);
|
|
await user.click(within(idRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(within(nameRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
expect(await screen.findByRole("menuitem", { name: "Synchronize columns for this table" })).toBeEnabled();
|
|
await user.click(await screen.findByRole("menuitem", {
|
|
name: "Copy generated descriptions to Description",
|
|
}));
|
|
|
|
await waitFor(() => expect(consolidationBody).toEqual({
|
|
target: "columns",
|
|
targetIds: [idColumn.id, nameColumn.id],
|
|
}));
|
|
expect(await screen.findByText("Copied 1 description; skipped 1")).toBeVisible();
|
|
await waitFor(() => expect(columnReads).toBe(2));
|
|
expect(within(await selectableRow(/Patient identifier/))
|
|
.getAllByText("Generated identifier")).toHaveLength(2);
|
|
expect(within(await selectableRow(/Keep curated patient name/))
|
|
.getByText("Keep curated patient name")).toBeVisible();
|
|
expect(screen.queryByText("2 selected")).not.toBeInTheDocument();
|
|
expect(client.getQueryState(tableQuery)?.isInvalidated).toBe(false);
|
|
});
|
|
|
|
test("starts one selected column with the configured default model", async () => {
|
|
const user = userEvent.setup();
|
|
let startBody: unknown;
|
|
const queuedRun = makeDescriptionGenerationRun();
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [
|
|
{ id: "openai/mini", label: "OpenAI Mini" },
|
|
{ id: "local/qwen", label: "Local Qwen" },
|
|
],
|
|
default: "local/qwen",
|
|
})),
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
|
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([patientIdColumn])),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", async ({ request }) => {
|
|
startBody = await request.json();
|
|
return HttpResponse.json(queuedRun, { status: 202 });
|
|
}),
|
|
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(queuedRun)),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
|
|
);
|
|
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.queryByRole("button", { name: "Sync columns" })).not.toBeInTheDocument();
|
|
expect(screen.getByRole("columnheader", { name: /Select/i })).toBeVisible();
|
|
expect(await screen.findByRole("checkbox", { name: "Sensitive data for id" })).toHaveAttribute(
|
|
"title",
|
|
"Mark this column as sensitive; this does not select it for actions.",
|
|
);
|
|
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
|
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
|
expect(columnRow).toBeDefined();
|
|
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
|
|
|
await waitFor(() => expect(startBody).toEqual({
|
|
modelId: "local/qwen",
|
|
scope: "selected_columns",
|
|
targetIds: [patientIdColumn.id],
|
|
}));
|
|
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
|
expect(drawer).toBeVisible();
|
|
expect(within(drawer).getByRole("heading", { name: "Queued" })).toBeVisible();
|
|
expect(await screen.findByText("Description generation started for 1 column")).toBeVisible();
|
|
});
|
|
|
|
test("shows database sensitivity analysis only for a selection and rejects multiple databases clearly", async () => {
|
|
const user = userEvent.setup();
|
|
let suggestionCalls = 0;
|
|
const radiology = makeDatabase({
|
|
id: "44444444-4444-4444-8444-444444444444",
|
|
workspaceId: "radiology",
|
|
workspaceName: "Radiology",
|
|
});
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", () => {
|
|
suggestionCalls += 1;
|
|
return HttpResponse.json({ suggestions: [] });
|
|
}),
|
|
);
|
|
renderPage({ rows: [makeDatabase(), radiology] });
|
|
|
|
expect(screen.queryByRole("button", { name: "Analyze sensitive fields" })).not.toBeInTheDocument();
|
|
const psdRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
const radiologyRow = await screen.findByRole("row", { name: /Radiology/ });
|
|
await user.click(within(psdRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
expect(screen.getByRole("button", { name: "Analyze sensitive fields" })).toBeVisible();
|
|
await user.click(within(radiologyRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
|
|
|
|
expect(await screen.findByText("Sensitivity analysis can run for only one database at a time. Select one database and try again.")).toBeVisible();
|
|
expect(suggestionCalls).toBe(0);
|
|
});
|
|
|
|
test("opens sensitivity analysis progress while the assessment request is still running", async () => {
|
|
const user = userEvent.setup();
|
|
let analysisStarted = false;
|
|
let releaseAnalysis!: () => void;
|
|
const analysisGate = new Promise<void>((resolve) => {
|
|
releaseAnalysis = resolve;
|
|
});
|
|
const runningRun = makeSensitivityAnalysisRun({
|
|
scope: "all",
|
|
status: "running",
|
|
total: 2,
|
|
suggestedSensitive: 0,
|
|
suggestedNonSensitive: 0,
|
|
unknown: 2,
|
|
finishedAt: null,
|
|
});
|
|
const completedRun = makeSensitivityAnalysisRun({ scope: "all", total: 2 });
|
|
let activityEvents = [{
|
|
runId: runningRun.id,
|
|
sequence: 1,
|
|
level: "info",
|
|
message: "Scanning source columns",
|
|
createdAt: "2026-08-28T11:00:00Z",
|
|
}];
|
|
server.use(
|
|
http.get("/api/catalog/sensitive-data-suggestion-runs", () => (
|
|
HttpResponse.json(analysisStarted ? [runningRun] : [])
|
|
)),
|
|
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId", () => (
|
|
HttpResponse.json(runningRun)
|
|
)),
|
|
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId/events-list", () => (
|
|
HttpResponse.json(activityEvents)
|
|
)),
|
|
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", async () => {
|
|
analysisStarted = true;
|
|
await analysisGate;
|
|
return HttpResponse.json({ run: completedRun, suggestions: [] });
|
|
}),
|
|
);
|
|
renderPage({ rows: [makeDatabase()] });
|
|
|
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
const launch = user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
|
|
|
|
await waitFor(() => expect(analysisStarted).toBe(true));
|
|
const progress = await screen.findByRole("dialog", { name: "Sensitivity analysis history" });
|
|
expect(within(progress).getByRole("heading", { name: "Starting sensitivity analysis" })).toBeVisible();
|
|
expect(within(progress).getByRole("progressbar", { name: "Sensitivity analysis is starting" }))
|
|
.toBeVisible();
|
|
expect(within(progress).getByText("Preparing sensitivity analysis…")).toBeVisible();
|
|
expect(await within(progress).findByRole("heading", { name: "Running" }, { timeout: 2_000 })).toBeVisible();
|
|
expect(await within(progress).findByText("Scanning source columns")).toBeVisible();
|
|
|
|
const eventLog = within(progress).getByRole("log", { name: "Sensitivity analysis events" });
|
|
Object.defineProperties(eventLog, {
|
|
clientHeight: { configurable: true, value: 100 },
|
|
scrollHeight: { configurable: true, get: () => activityEvents.length * 100 },
|
|
scrollTop: { configurable: true, value: 0, writable: true },
|
|
});
|
|
activityEvents = [...activityEvents, {
|
|
runId: runningRun.id,
|
|
sequence: 2,
|
|
level: "info",
|
|
message: "Running local entity detection",
|
|
createdAt: "2026-08-28T11:00:01Z",
|
|
}];
|
|
expect(await within(progress).findByText("Running local entity detection", {}, { timeout: 2_000 })).toBeVisible();
|
|
await waitFor(() => expect(eventLog.scrollTop).toBe(eventLog.scrollHeight));
|
|
|
|
eventLog.scrollTop = 0;
|
|
fireEvent.scroll(eventLog);
|
|
const jumpToLatest = await within(progress).findByRole("button", { name: "Jump to latest" });
|
|
activityEvents = [...activityEvents, {
|
|
runId: runningRun.id,
|
|
sequence: 3,
|
|
level: "info",
|
|
message: "Classifying the next table",
|
|
createdAt: "2026-08-28T11:00:02Z",
|
|
}];
|
|
expect(await within(progress).findByText("Classifying the next table", {}, { timeout: 2_000 })).toBeVisible();
|
|
expect(eventLog.scrollTop).toBe(0);
|
|
await user.click(jumpToLatest);
|
|
expect(eventLog.scrollTop).toBe(eventLog.scrollHeight);
|
|
expect(within(progress).queryByRole("button", { name: "Jump to latest" })).not.toBeInTheDocument();
|
|
|
|
releaseAnalysis();
|
|
await launch;
|
|
expect(await screen.findByRole("dialog", { name: "Sensitive field review" })).toBeVisible();
|
|
});
|
|
|
|
test("requests database-level sensitivity analysis for the only selected database", async () => {
|
|
const user = userEvent.setup();
|
|
let suggestionBody: unknown;
|
|
let suggestionFinished = false;
|
|
let historyCalls = 0;
|
|
const run = makeSensitivityAnalysisRun({ scope: "all", total: 1 });
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.get("/api/catalog/sensitive-data-suggestion-runs", () => {
|
|
historyCalls += 1;
|
|
return HttpResponse.json(suggestionFinished ? [run] : []);
|
|
}),
|
|
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", async ({ request }) => {
|
|
suggestionBody = await request.json();
|
|
suggestionFinished = true;
|
|
return HttpResponse.json({
|
|
run,
|
|
suggestions: [{
|
|
columnId: patientIdColumn.id,
|
|
tableId: patientsTable.id,
|
|
tableName: patientsTable.name,
|
|
columnName: patientIdColumn.name,
|
|
version: patientIdColumn.version,
|
|
currentSensitive: false,
|
|
sensitive: true,
|
|
assessment: "sensitive",
|
|
evidence: [{ kind: "content", ruleId: "pii.email" }],
|
|
observedValues: 1,
|
|
coverage: "sampled",
|
|
}],
|
|
});
|
|
}),
|
|
);
|
|
renderPage({ rows: [makeDatabase()] });
|
|
|
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
|
|
|
|
await waitFor(() => expect(suggestionBody).toEqual({ scope: "all" }));
|
|
expect(await screen.findByRole("dialog", { name: "Sensitive field review" })).toBeVisible();
|
|
await waitFor(() => expect(historyCalls).toBeGreaterThanOrEqual(2));
|
|
});
|
|
|
|
test("refetches sensitivity analysis history after a failed request", async () => {
|
|
const user = userEvent.setup();
|
|
let historyCalls = 0;
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.get("/api/catalog/sensitive-data-suggestion-runs", () => {
|
|
historyCalls += 1;
|
|
return HttpResponse.json([]);
|
|
}),
|
|
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", () => (
|
|
HttpResponse.json({
|
|
code: "sensitivity_source_scan_failed",
|
|
message: "Sensitivity analysis could not read the source. No assessments were applied.",
|
|
}, { status: 502 })
|
|
)),
|
|
);
|
|
renderPage({ rows: [makeDatabase()] });
|
|
|
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
|
|
|
|
await waitFor(() => expect(historyCalls).toBeGreaterThanOrEqual(2));
|
|
expect(screen.queryByRole("dialog", { name: "Sensitive field review" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
test("requests sensitivity analysis only for selected tables", async () => {
|
|
const user = userEvent.setup();
|
|
const visitsTable: CatalogTable = {
|
|
...patientsTable,
|
|
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
|
name: "visits",
|
|
};
|
|
const visitColumn = {
|
|
...patientIdColumn,
|
|
id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
|
tableId: visitsTable.id,
|
|
name: "clinical_note",
|
|
};
|
|
let suggestionBody: unknown;
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([
|
|
patientsTable,
|
|
visitsTable,
|
|
])),
|
|
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", async ({ request }) => {
|
|
suggestionBody = await request.json();
|
|
return HttpResponse.json({
|
|
suggestions: [{
|
|
columnId: visitColumn.id,
|
|
tableId: visitsTable.id,
|
|
tableName: visitsTable.name,
|
|
columnName: visitColumn.name,
|
|
version: visitColumn.version,
|
|
currentSensitive: false,
|
|
sensitive: true,
|
|
assessment: "sensitive",
|
|
evidence: [{ kind: "metadata", ruleId: "metadata.health" }],
|
|
observedValues: 0,
|
|
coverage: "metadata",
|
|
}],
|
|
});
|
|
}),
|
|
);
|
|
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 visitsRow = await screen.findByRole("row", { name: /visits/ });
|
|
await user.click(within(visitsRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
|
|
|
|
await waitFor(() => expect(suggestionBody).toEqual({
|
|
scope: "selected_tables",
|
|
targetIds: [visitsTable.id],
|
|
}));
|
|
expect(await screen.findByRole("dialog", { name: "Sensitive field review" })).toBeVisible();
|
|
});
|
|
|
|
test("allows a human downgrade and saves only explicit sensitivity changes", async () => {
|
|
const user = userEvent.setup();
|
|
const idColumn = { ...patientIdColumn, sensitive: false };
|
|
const nameColumn = {
|
|
...patientIdColumn,
|
|
id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
|
name: "name",
|
|
ordinalPosition: 2,
|
|
primaryKeyPosition: null,
|
|
isPrimaryKey: false,
|
|
description: "Patient name",
|
|
generatedDescription: "Name of the patient",
|
|
sensitive: true,
|
|
};
|
|
const unselectedColumn = {
|
|
...patientIdColumn,
|
|
id: "ffffffff-ffff-4fff-8fff-ffffffffffff",
|
|
name: "address",
|
|
ordinalPosition: 3,
|
|
primaryKeyPosition: null,
|
|
isPrimaryKey: false,
|
|
description: "Patient address",
|
|
generatedDescription: "Address of the patient",
|
|
sensitive: false,
|
|
};
|
|
let columns = [idColumn, nameColumn, unselectedColumn];
|
|
let suggestionBody: unknown;
|
|
const patches: Array<{ columnId: string; body: unknown }> = [];
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
|
http.get(
|
|
"/api/catalog/databases/:databaseId/tables/:tableId/columns",
|
|
() => HttpResponse.json(columns),
|
|
),
|
|
http.post(
|
|
"/api/catalog/databases/:databaseId/sensitive-data-suggestions",
|
|
async ({ request }) => {
|
|
suggestionBody = await request.json();
|
|
return HttpResponse.json({
|
|
suggestions: [
|
|
{
|
|
columnId: idColumn.id,
|
|
tableId: patientsTable.id,
|
|
tableName: patientsTable.name,
|
|
columnName: idColumn.name,
|
|
version: idColumn.version,
|
|
currentSensitive: false,
|
|
sensitive: true,
|
|
assessment: "sensitive",
|
|
evidence: [{ kind: "content", ruleId: "pii.email" }],
|
|
observedValues: 1,
|
|
coverage: "sampled",
|
|
},
|
|
{
|
|
columnId: nameColumn.id,
|
|
tableId: patientsTable.id,
|
|
tableName: patientsTable.name,
|
|
columnName: nameColumn.name,
|
|
version: nameColumn.version,
|
|
currentSensitive: true,
|
|
sensitive: false,
|
|
assessment: "non_sensitive",
|
|
evidence: [{ kind: "coverage", ruleId: "coverage.complete" }],
|
|
observedValues: 2,
|
|
coverage: "complete",
|
|
},
|
|
],
|
|
});
|
|
},
|
|
),
|
|
http.patch(
|
|
"/api/catalog/databases/:databaseId/tables/:tableId/columns/:columnId",
|
|
async ({ params, request }) => {
|
|
const body = await request.json() as {
|
|
version: number;
|
|
sensitive: boolean;
|
|
};
|
|
patches.push({ columnId: String(params.columnId), body });
|
|
const current = columns.find((column) => column.id === params.columnId)!;
|
|
const updated = { ...current, ...body, version: current.version + 1 };
|
|
columns = columns.map((column) => column.id === updated.id ? updated : column);
|
|
return HttpResponse.json(updated);
|
|
},
|
|
),
|
|
);
|
|
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" }));
|
|
|
|
const idSensitive = await screen.findByRole("checkbox", { name: "Sensitive data for id" });
|
|
const nameSensitive = await screen.findByRole("checkbox", { name: "Sensitive data for name" });
|
|
expect(idSensitive).not.toBeChecked();
|
|
expect(nameSensitive).toBeChecked();
|
|
expect(screen.queryByRole("button", { name: "Analyze sensitive fields" })).not.toBeInTheDocument();
|
|
|
|
const selectableRow = async (name: RegExp) => {
|
|
const rows = await screen.findAllByRole("row", { name });
|
|
return rows.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }))!;
|
|
};
|
|
await user.click(within(await selectableRow(/Patient identifier/))
|
|
.getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(within(await selectableRow(/Patient name/))
|
|
.getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Analyze sensitive fields" }));
|
|
|
|
await waitFor(() => expect(suggestionBody).toEqual({
|
|
scope: "selected_columns",
|
|
targetIds: [idColumn.id, nameColumn.id],
|
|
}));
|
|
const review = await screen.findByRole("dialog", { name: "Sensitive field review" });
|
|
expect(within(review).getByRole("checkbox", { name: "Protect patients.id" })).toBeChecked();
|
|
expect(within(review).getByRole("checkbox", { name: "Protect patients.name" })).not.toBeChecked();
|
|
expect(patches).toHaveLength(0);
|
|
|
|
await user.click(within(review).getByRole("checkbox", { name: "Protect patients.id" }));
|
|
await user.click(within(review).getByRole("button", { name: "Save all 1 change" }));
|
|
|
|
await waitFor(() => expect(patches).toEqual([{
|
|
columnId: nameColumn.id,
|
|
body: {
|
|
version: nameColumn.version,
|
|
sensitive: false,
|
|
sensitivityReason: null,
|
|
},
|
|
}]));
|
|
expect(await screen.findByText("Saved 1 sensitive flag")).toBeVisible();
|
|
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sensitive field review" })).not.toBeInTheDocument());
|
|
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Sensitive data for id" })).not.toBeChecked());
|
|
expect(screen.getByRole("checkbox", { name: "Sensitive data for name" })).not.toBeChecked();
|
|
});
|
|
|
|
test("polls a description run and renders its events in sequence order", async () => {
|
|
const user = userEvent.setup();
|
|
const queuedRun = makeDescriptionGenerationRun();
|
|
const runningRun = makeDescriptionGenerationRun({
|
|
status: "running",
|
|
startedAt: "2026-08-28T10:00:01Z",
|
|
updatedAt: "2026-08-28T10:00:01Z",
|
|
});
|
|
const completedRun = makeDescriptionGenerationRun({
|
|
status: "completed",
|
|
processed: 1,
|
|
generated: 1,
|
|
startedAt: "2026-08-28T10:00:01Z",
|
|
updatedAt: "2026-08-28T10:00:03Z",
|
|
finishedAt: "2026-08-28T10:00:03Z",
|
|
});
|
|
let runReads = 0;
|
|
const eventAfters: number[] = [];
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
|
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([patientIdColumn])),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => (
|
|
HttpResponse.json(queuedRun, { status: 202 })
|
|
)),
|
|
http.get("/api/catalog/description-generation-runs/:runId", () => {
|
|
runReads += 1;
|
|
return HttpResponse.json(runReads < 3 ? runningRun : completedRun);
|
|
}),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", ({ request }) => {
|
|
const after = Number(new URL(request.url).searchParams.get("after") ?? 0);
|
|
eventAfters.push(after);
|
|
if (after === 0) return HttpResponse.json([
|
|
{ sequence: 2, level: "info", message: "Calling the selected model", createdAt: "2026-08-28T10:00:02Z" },
|
|
{ sequence: 1, level: "info", message: "Run queued", createdAt: "2026-08-28T10:00:00Z" },
|
|
]);
|
|
if (after === 2 && runReads >= 3) return HttpResponse.json([
|
|
{ sequence: 3, level: "info", message: "Generated description saved", createdAt: "2026-08-28T10:00:03Z" },
|
|
]);
|
|
return HttpResponse.json([]);
|
|
}),
|
|
);
|
|
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" }));
|
|
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
|
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
|
|
|
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
|
expect(within(drawer).getByText(/local[/]qwen/)).toBeVisible();
|
|
expect(within(drawer).getByText(/selected columns/)).toBeVisible();
|
|
expect(await within(drawer).findByRole("heading", { name: "Completed" }, { timeout: 4_000 })).toBeVisible();
|
|
expect(within(drawer).getByText("1", { selector: "[data-count='total']" })).toBeVisible();
|
|
expect(within(drawer).getByText("1", { selector: "[data-count='processed']" })).toBeVisible();
|
|
expect(within(drawer).getByText("1", { selector: "[data-count='generated']" })).toBeVisible();
|
|
expect(within(drawer).getByText("0", { selector: "[data-count='nonGeneratable']" })).toBeVisible();
|
|
expect(within(drawer).getByText("0", { selector: "[data-count='failed']" })).toBeVisible();
|
|
const timestamps = within(drawer).getByRole("region", {
|
|
name: "Description generation timestamps",
|
|
});
|
|
expect(within(timestamps).getByText("Created")).toBeVisible();
|
|
expect(within(timestamps).getByText("Started")).toBeVisible();
|
|
expect(within(timestamps).getByText("Updated")).toBeVisible();
|
|
expect(within(timestamps).getByText("Finished")).toBeVisible();
|
|
const log = within(drawer).getByRole("log", { name: "Description generation events" });
|
|
await waitFor(() => expect(log).toHaveTextContent("Generated description saved"), { timeout: 4_000 });
|
|
expect(log.textContent!.indexOf("Run queued")).toBeLessThan(log.textContent!.indexOf("Calling the selected model"));
|
|
expect(log.textContent!.indexOf("Calling the selected model")).toBeLessThan(log.textContent!.indexOf("Generated description saved"));
|
|
expect(eventAfters).toContain(2);
|
|
});
|
|
|
|
test("refreshes Catalog Tables and Catalog Columns after column generation completes", async () => {
|
|
const user = userEvent.setup();
|
|
const queuedRun = makeDescriptionGenerationRun();
|
|
const completedRun = makeDescriptionGenerationRun({
|
|
status: "completed",
|
|
processed: 1,
|
|
generated: 1,
|
|
startedAt: "2026-08-28T10:00:01Z",
|
|
updatedAt: "2026-08-28T10:00:02Z",
|
|
finishedAt: "2026-08-28T10:00:02Z",
|
|
});
|
|
let columnReads = 0;
|
|
let tableReads = 0;
|
|
let columns = [patientIdColumn];
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => {
|
|
tableReads += 1;
|
|
return HttpResponse.json([patientsTable]);
|
|
}),
|
|
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => {
|
|
columnReads += 1;
|
|
return HttpResponse.json(columns);
|
|
}),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => (
|
|
HttpResponse.json(queuedRun, { status: 202 })
|
|
)),
|
|
http.get("/api/catalog/description-generation-runs/:runId", () => {
|
|
columns = [{
|
|
...patientIdColumn,
|
|
generatedDescription: "Stable patient identifier",
|
|
version: 2,
|
|
}];
|
|
return HttpResponse.json(completedRun);
|
|
}),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
|
|
);
|
|
const { client } = renderPage({
|
|
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
|
});
|
|
const unrelatedColumnsQuery = [
|
|
"catalog-columns",
|
|
patientsTable.databaseId,
|
|
"eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
|
] as const;
|
|
client.setQueryData(unrelatedColumnsQuery, []);
|
|
|
|
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" }));
|
|
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
|
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
|
|
|
expect(await screen.findByRole("heading", { name: "Completed" })).toBeVisible();
|
|
await waitFor(() => expect(columnReads).toBe(2));
|
|
await waitFor(() => expect(tableReads).toBe(2));
|
|
expect(within((await screen.findAllByRole("row", { name: /Stable patient identifier/ }))
|
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }))!).getByText("Stable patient identifier")).toBeVisible();
|
|
expect(client.getQueryState(unrelatedColumnsQuery)?.isInvalidated).toBe(true);
|
|
});
|
|
|
|
test("keeps the selected column and shows a safe message when generation cannot start", async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
|
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([patientIdColumn])),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => HttpResponse.json({
|
|
code: "description_generation_run_active",
|
|
message: "provider=private-provider key=sk-private-key",
|
|
}, { 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: "View columns for patients" }));
|
|
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
|
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
|
|
|
expect(await screen.findByText("A description generation run is already active.")).toBeVisible();
|
|
expect(screen.getByText("1 selected")).toBeVisible();
|
|
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
|
|
expect(document.body).not.toHaveTextContent(/private-provider|sk-private-key/);
|
|
});
|
|
|
|
test("shows a basic failed run without exposing private model or provider fields", async () => {
|
|
const user = userEvent.setup();
|
|
let columnReads = 0;
|
|
const queuedRun = {
|
|
...makeDescriptionGenerationRun({ modelId: "approved/model" }),
|
|
provider: "private-provider",
|
|
apiKey: "sk-private-key",
|
|
};
|
|
const failedRun = {
|
|
...makeDescriptionGenerationRun({
|
|
modelId: "approved/model",
|
|
status: "failed",
|
|
processed: 1,
|
|
failed: 1,
|
|
startedAt: "2026-08-28T10:00:01Z",
|
|
updatedAt: "2026-08-28T10:00:02Z",
|
|
finishedAt: "2026-08-28T10:00:02Z",
|
|
errorSummary: "The model request failed before a description was saved.",
|
|
}),
|
|
provider: "private-provider",
|
|
upstreamModel: "private/upstream-model",
|
|
apiKey: "sk-private-key",
|
|
prompt: "full private prompt",
|
|
samples: ["private-sample-value"],
|
|
providerPayload: { diagnostic: "private provider payload" },
|
|
};
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{
|
|
id: "approved/model",
|
|
label: "Approved model",
|
|
provider: "private-provider",
|
|
upstreamModel: "private/upstream-model",
|
|
apiKeyEnv: "PRIVATE_MODEL_API_KEY",
|
|
}],
|
|
default: "approved/model",
|
|
})),
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
|
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => {
|
|
columnReads += 1;
|
|
return HttpResponse.json([patientIdColumn]);
|
|
}),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => (
|
|
HttpResponse.json(queuedRun, { status: 202 })
|
|
)),
|
|
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(failedRun)),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([
|
|
{ sequence: 1, level: "error", message: "Description generation failed", createdAt: "2026-08-28T10:00:02Z" },
|
|
])),
|
|
);
|
|
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" }));
|
|
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
|
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
|
|
|
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
|
expect(await within(drawer).findByRole("heading", { name: "Failed" })).toBeVisible();
|
|
expect(within(drawer).getByText(/Approved model/)).toBeVisible();
|
|
expect(within(drawer).getByText("The model request failed before a description was saved.")).toBeVisible();
|
|
expect(await within(drawer).findByText("Description generation failed")).toBeVisible();
|
|
await waitFor(() => expect(columnReads).toBe(2));
|
|
expect(document.body).not.toHaveTextContent(
|
|
/private-provider|private\/upstream-model|sk-private-key|full private prompt|private-sample-value|private provider payload|PRIVATE_MODEL_API_KEY/,
|
|
);
|
|
});
|
|
|
|
test("offers generation and consolidation for multiple selected columns", async () => {
|
|
const user = userEvent.setup();
|
|
const patientNameColumn: CatalogColumn = {
|
|
...patientIdColumn,
|
|
id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
|
name: "name",
|
|
ordinalPosition: 2,
|
|
dataType: "text",
|
|
primaryKeyPosition: null,
|
|
isPrimaryKey: false,
|
|
sourceComment: "Patient name",
|
|
};
|
|
const queuedRun = makeDescriptionGenerationRun({ total: 2 });
|
|
let startBody: unknown;
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
|
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => (
|
|
HttpResponse.json([patientIdColumn, patientNameColumn])
|
|
)),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", async ({ request }) => {
|
|
startBody = await request.json();
|
|
return HttpResponse.json(queuedRun, { status: 202 });
|
|
}),
|
|
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(queuedRun)),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
|
|
);
|
|
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" }));
|
|
const idRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
|
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
|
const nameRow = (await screen.findAllByRole("row", { name: /Patient name/ }))
|
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(within(idRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
expect(await screen.findByRole("menuitem", { name: "Generate column description" })).toBeEnabled();
|
|
expect(screen.getByRole("menuitem", {
|
|
name: "Copy generated descriptions to Description",
|
|
})).toBeEnabled();
|
|
await user.keyboard("{Escape}");
|
|
|
|
await user.click(within(nameRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
const generate = await screen.findByRole("menuitem", { name: "Generate column descriptions" });
|
|
expect(generate).toBeEnabled();
|
|
expect(await screen.findByRole("menuitem", {
|
|
name: "Copy generated descriptions to Description",
|
|
})).toBeEnabled();
|
|
await user.click(generate);
|
|
|
|
await waitFor(() => expect(startBody).toEqual({
|
|
modelId: "local/qwen",
|
|
scope: "selected_columns",
|
|
targetIds: [patientIdColumn.id, patientNameColumn.id],
|
|
}));
|
|
expect(await screen.findByText("Description generation started for 2 columns")).toBeVisible();
|
|
expect(screen.getByText("2 selected")).toBeVisible();
|
|
});
|
|
|
|
test.each([
|
|
{ name: "database.manage is absent", canManage: false, models: [{ id: "local/qwen", label: "Local Qwen" }], configuredDefault: "local/qwen" },
|
|
{ name: "no usable model is configured", canManage: true, models: [], configuredDefault: null },
|
|
])("disables selected table and column generation when $name", async ({ canManage, models, configuredDefault }) => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models,
|
|
default: configuredDefault,
|
|
})),
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
|
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([patientIdColumn])),
|
|
);
|
|
renderPage({
|
|
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
|
canManage,
|
|
});
|
|
|
|
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", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
expect(await screen.findByRole("menuitem", { name: "Generate descriptions for all columns" }))
|
|
.toHaveAttribute("aria-disabled", "true");
|
|
await user.keyboard("{Escape}");
|
|
await user.click(screen.getByRole("button", { name: "Clear" }));
|
|
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
|
|
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
|
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
|
|
expect(await screen.findByRole("menuitem", { name: "Generate column description" }))
|
|
.toHaveAttribute("aria-disabled", "true");
|
|
});
|
|
|
|
test("disables another generation start while the selected-column run is active", async () => {
|
|
const user = userEvent.setup();
|
|
const queuedRun = makeDescriptionGenerationRun();
|
|
server.use(
|
|
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
|
models: [{ id: "local/qwen", label: "Local Qwen" }],
|
|
default: "local/qwen",
|
|
})),
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
|
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([patientIdColumn])),
|
|
http.post("/api/catalog/databases/:databaseId/description-generation-runs", () => (
|
|
HttpResponse.json(queuedRun, { status: 202 })
|
|
)),
|
|
http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json({
|
|
...queuedRun,
|
|
status: "running",
|
|
startedAt: "2026-08-28T10:00:01Z",
|
|
})),
|
|
http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])),
|
|
);
|
|
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" }));
|
|
const columnRow = (await screen.findAllByRole("row", { name: /Patient identifier/ }))
|
|
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
|
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
expect(await screen.findByRole("menuitem", { name: "Generate column description" }))
|
|
.toHaveAttribute("aria-disabled", "true");
|
|
expect(screen.getByText("1 selected")).toBeVisible();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Back to tables" }));
|
|
const tableRow = await screen.findByRole("row", { name: /patients/ });
|
|
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
expect(await screen.findByRole("menuitem", { name: "Generate descriptions for all columns" }))
|
|
.toHaveAttribute("aria-disabled", "true");
|
|
});
|
|
|
|
test("disables selected description consolidation without database.manage", async () => {
|
|
const user = userEvent.setup();
|
|
let consolidationCalls = 0;
|
|
server.use(
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([
|
|
{ ...patientsTable, generatedDescription: "Generated patients" },
|
|
])),
|
|
http.post("/api/catalog/databases/:databaseId/descriptions/consolidate", () => {
|
|
consolidationCalls += 1;
|
|
return HttpResponse.json({ copied: 1, skipped: 0 });
|
|
}),
|
|
);
|
|
renderPage({
|
|
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
|
canManage: false,
|
|
});
|
|
|
|
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", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
|
|
expect(await screen.findByRole("menuitem", {
|
|
name: "Copy generated descriptions to Description",
|
|
})).toHaveAttribute("aria-disabled", "true");
|
|
expect(consolidationCalls).toBe(0);
|
|
});
|
|
|
|
test("shows an operation conflict without clearing selected descriptions or refreshing tables", async () => {
|
|
const user = userEvent.setup();
|
|
let tableReads = 0;
|
|
server.use(
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => {
|
|
tableReads += 1;
|
|
return HttpResponse.json([{ ...patientsTable, generatedDescription: "Generated patients" }]);
|
|
}),
|
|
http.post("/api/catalog/databases/:databaseId/descriptions/consolidate", () => HttpResponse.json({
|
|
code: "database_operation_in_progress",
|
|
message: "A database operation is already in progress.",
|
|
}, { 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" }));
|
|
const tableRow = await screen.findByRole("row", { name: /patients/ });
|
|
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", {
|
|
name: "Copy generated descriptions to Description",
|
|
}));
|
|
|
|
expect(await screen.findByText("A database operation is already in progress.")).toBeVisible();
|
|
expect(screen.getByText("1 selected")).toBeVisible();
|
|
expect(tableReads).toBe(1);
|
|
});
|
|
|
|
test("selected table Actions do not expose relationship cleanup", async () => {
|
|
const user = userEvent.setup();
|
|
server.use(
|
|
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
|
);
|
|
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", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
expect(screen.queryByRole("menuitem", { name: "Delete all relationships" })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole("menuitem", { name: "Clear catalog relationships" })).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,
|
|
sensitive: false,
|
|
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" }],
|
|
origin: "physical",
|
|
status: "active",
|
|
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 })],
|
|
});
|
|
|
|
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
|
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
|
await user.click(screen.getByRole("button", { name: "Actions" }));
|
|
await user.click(await screen.findByRole("menuitem", { name: "Synchronize tables" }));
|
|
|
|
const synchronizationDrawer = await screen.findByRole("dialog", {
|
|
name: "Schema synchronization",
|
|
});
|
|
expect(synchronizationDrawer).toHaveAttribute("data-catalog-panel");
|
|
expect(synchronizationDrawer.querySelector(
|
|
':scope > [data-catalog-panel-region="header"]',
|
|
)).toBeInTheDocument();
|
|
expect(synchronizationDrawer.querySelector(
|
|
':scope > [data-catalog-panel-region="body"]',
|
|
)).toBeInTheDocument();
|
|
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();
|
|
});
|