import Fastify from "fastify"; import { expect, test, vi } from "vitest"; import type { PrincipalContext } from "../src/auth/principal.js"; import type { CatalogRepository, WorkspaceDatabase } from "../src/catalog/types.js"; import { readWorkspacePreprocessingStatus, workspacePreprocessingRoutes, type WorkspacePreprocessingRouteDeps, } from "../src/routes/workspace-preprocessing.js"; import type { WorkspaceRegistry } from "../src/workspaces/registry.js"; const admin: PrincipalContext = { issuer: "test", subject: "admin", roles: ["admin"], permissions: ["session.use", "database.manage"], isAdmin: true, }; function database(overrides: Partial = {}): WorkspaceDatabase { return { id: "49b6491a-78bb-4cee-b27b-0efaf4419774", workspaceId: "catalog-workspace", engine: "postgres", databaseName: "warehouse", schema: "analytics", version: 4, createdAt: "2026-09-05T10:00:00.000Z", updatedAt: "2026-09-05T10:00:00.000Z", binding: { transport: "postgres_direct", host: "db", port: 5432, username: "reader" }, connectionStatus: "reachable", schemaSyncedVersion: 4, metadataContentRevision: 18, preprocessingStatus: "failed", ...overrides, }; } function routeDeps( current: () => WorkspaceDatabase | undefined, overrides: Partial = {}, ): WorkspacePreprocessingRouteDeps { const repository = { getByWorkspace: vi.fn(async () => current()), listSyncRuns: vi.fn(async () => []), getActiveDescriptionGenerationRun: vi.fn(async () => undefined), listSensitivityAnalysisRuns: vi.fn(async () => []), } as unknown as CatalogRepository; const registry = { read: vi.fn(async () => ({ workspace: { workspace: { schema_version: 4, id: "catalog-workspace", name: "Catalog workspace", language: "en", }, }, revision: { id: "catalog-workspace", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/data/workspaces/catalog-workspace.yaml", }, })), } as unknown as WorkspaceRegistry; return { repository, registry, service: { run: vi.fn(async () => ({ status: "succeeded" })), clear: vi.fn(async () => ({ status: "succeeded" })), } as never, inputFingerprint: { workspaceInputFingerprint: vi.fn(async () => "sha256:current"), } as never, ...overrides, }; } test("reports ready only when the Catalog revision and runtime fingerprint are current", async () => { const ready = database({ preprocessingStatus: "succeeded", preprocessedMetadataRevision: 18, preprocessingInputFingerprint: "sha256:current", preprocessingFinishedAt: "2026-09-05T10:04:00.000Z", }); await expect(readWorkspacePreprocessingStatus( "catalog-workspace", routeDeps(() => ready), )).resolves.toMatchObject({ state: "ready", actionable: true, clearable: true, detail: "Catalog revision 18 is indexed.", }); ready.preprocessingInputFingerprint = "sha256:old"; await expect(readWorkspacePreprocessingStatus( "catalog-workspace", routeDeps(() => ready), )).resolves.toMatchObject({ state: "required", actionable: true, clearable: true, detail: "Catalog revision 18 is not indexed.", }); }); test("explains a current blocked prerequisite without inventing a run log", async () => { const status = await readWorkspacePreprocessingStatus( "catalog-workspace", routeDeps(() => database({ schemaSyncedVersion: 3 })), ); expect(status).toMatchObject({ state: "blocked", actionable: false, clearable: true, detail: "Catalog synchronization is required.", reason: "Database configuration v4 is newer than the latest Catalog synchronization v3.", nextStep: "Open Database management and run Synchronize schema.", }); expect(status).not.toHaveProperty("lastFailure"); }); test("exposes only the latest sanitized failed-run diagnostic", async () => { const status = await readWorkspacePreprocessingStatus( "catalog-workspace", routeDeps(() => database({ preprocessingStatus: "failed", preprocessingErrorCode: "schema_index_failed", preprocessingFinishedAt: "2026-09-05T10:04:00.000Z", })), ); expect(status).toMatchObject({ state: "failed", actionable: true, clearable: true, reason: expect.stringContaining("schema indexing worker"), lastFailure: { stage: "schema_index", errorCode: "schema_index_failed", finishedAt: "2026-09-05T10:04:00.000Z", }, }); expect(status).not.toHaveProperty("history"); }); test("reports the durable phase of the active preprocessing run", async () => { const status = await readWorkspacePreprocessingStatus( "catalog-workspace", routeDeps( () => database({ preprocessingStatus: "running" }), { readLatestJob: () => ({ status: "active", completedStages: ["catalog_snapshot"], }) as never, }, ), ); expect(status).toMatchObject({ state: "running", detail: "Building schema vectors and LSH indexes.", progress: { stage: "schema_index", step: 2, totalSteps: 4 }, }); }); test("explicitly reruns preprocessing when the current Catalog input is already ready", async () => { const ready = database({ preprocessingStatus: "succeeded", preprocessedMetadataRevision: 18, preprocessingInputFingerprint: "sha256:current", }); const deps = routeDeps(() => ready); const app = Fastify(); app.addHook("preHandler", async (request) => { request.principal = admin; }); workspacePreprocessingRoutes(app, deps); const response = await app.inject({ method: "POST", url: "/workspaces/catalog-workspace/preprocessing", }); expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ state: "ready", actionable: true }); expect(deps.service.run).toHaveBeenCalledTimes(1); await app.close(); }); test("runs preprocessing once from the sanctioned admin endpoint and returns the new state", async () => { let current = database(); const deps = routeDeps(() => current, { service: { run: vi.fn(async () => { current = database({ preprocessingStatus: "succeeded", preprocessedMetadataRevision: 18, preprocessingInputFingerprint: "sha256:current", preprocessingFinishedAt: "2026-09-05T10:04:00.000Z", }); return { status: "succeeded" } as never; }), clear: vi.fn(async () => ({ status: "succeeded" } as never)), }, }); const app = Fastify(); app.addHook("preHandler", async (request) => { request.principal = admin; }); workspacePreprocessingRoutes(app, deps); const response = await app.inject({ method: "POST", url: "/workspaces/catalog-workspace/preprocessing", }); expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ state: "ready", actionable: true }); expect(deps.service.run).toHaveBeenCalledTimes(1); await app.close(); }); test("clears only derived preprocessing data from the sanctioned admin endpoint", async () => { let current = database({ preprocessingStatus: "succeeded", preprocessedMetadataRevision: 18, preprocessingInputFingerprint: "sha256:current", }); const deps = routeDeps(() => current, { service: { run: vi.fn(), clear: vi.fn(async () => { current = database({ preprocessingStatus: "failed", preprocessingErrorCode: "derived_data_cleared", preprocessingFinishedAt: "2026-09-05T10:05:00.000Z", }); return { status: "succeeded" } as never; }), }, }); const app = Fastify(); app.addHook("preHandler", async (request) => { request.principal = admin; }); workspacePreprocessingRoutes(app, deps); const response = await app.inject({ method: "DELETE", url: "/workspaces/catalog-workspace/preprocessing", }); expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ state: "required", clearable: false, detail: "Reference vectors and LSH are empty. Memory is preserved.", }); expect(deps.service.clear).toHaveBeenCalledTimes(1); await app.close(); });