import { afterEach, describe, expect, it } from "vitest"; import { buildApp } from "../src/app.js"; import { loadEnv } from "../src/config/env.js"; const ownerPassword = "correct-horse-battery-staple"; const appEnv = () => loadEnv({ NODE_ENV: "test", LOG_LEVEL: "silent", PROJECTBRAIN_DATA_SOURCE: "fixture", PROJECTBRAIN_OWNER_USERNAME: "owner", PROJECTBRAIN_OWNER_PASSWORD: ownerPassword, PROJECTBRAIN_AUTH_SECRET: "test-secret-with-at-least-thirty-two-characters", PROJECTBRAIN_SESSION_TTL_HOURS: "1", PROJECTBRAIN_TRUST_PROXY: "false", }); const apps: Awaited>[] = []; afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); describe("capture and review state machines", () => { it("classifies once, queues once, and keeps final review decisions immutable", async () => { const app = await buildApp({ env: appEnv() }); apps.push(app); const login = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "owner", password: ownerPassword }, }); const sessionCookie = login.cookies.find((item) => item.name === "projectbrain_session"); const cookie = `${sessionCookie!.name}=${sessionCookie!.value}`; const csrfToken = login.json().data.csrfToken as string; let operation = 0; const mutate = (method: "POST" | "PATCH", url: string, payload: Record = {}) => app.inject({ method, url, payload, headers: { cookie, "x-csrf-token": csrfToken, "idempotency-key": `review-state-machine-${++operation}`, }, }); const capture = await mutate("POST", "/api/projects/widefrog/capture-items", { type: "note", content: "A decision that must pass through one governed review.", source: "state-machine-test", }); expect(capture.statusCode).toBe(201); const captureId = capture.json().data.id as string; const prematureQueue = await mutate("POST", `/api/projects/widefrog/capture-items/${captureId}/send-to-review`); expect(prematureQueue.statusCode).toBe(409); expect(prematureQueue.json().error.code).toBe("CAPTURE_NOT_CLASSIFIED"); expect((await mutate("PATCH", `/api/projects/widefrog/capture-items/${captureId}/classify`)).statusCode).toBe(200); const queued = await mutate("POST", `/api/projects/widefrog/capture-items/${captureId}/send-to-review`); expect(queued.statusCode).toBe(201); const reviewId = queued.json().data.createdEntityId as string; const duplicateQueue = await mutate("POST", `/api/projects/widefrog/capture-items/${captureId}/send-to-review`); expect(duplicateQueue.statusCode).toBe(409); expect(duplicateQueue.json().error.code).toBe("CAPTURE_ALREADY_IN_REVIEW"); const reclassify = await mutate("PATCH", `/api/projects/widefrog/capture-items/${captureId}/classify`); expect(reclassify.statusCode).toBe(409); expect(reclassify.json().error.code).toBe("CAPTURE_ALREADY_IN_REVIEW"); const edited = await mutate("PATCH", `/api/projects/widefrog/review-queue/${reviewId}`, { description: "Edited review content that still needs a final decision.", }); expect(edited.statusCode).toBe(200); expect(edited.json().data.description).toContain("Edited review content"); const invalidMerge = await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/merge`, { targetEntityId: "does-not-exist", }); expect(invalidMerge.statusCode).toBe(409); expect(invalidMerge.json().error.code).toBe("REVIEW_TARGET_NOT_FOUND"); const unsupportedConversion = await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/convert`, { target: "decision", }); expect(unsupportedConversion.statusCode).toBe(501); expect(unsupportedConversion.json().error.code).toBe("DATABASE_MODE_REQUIRED"); expect((await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/accept`)).statusCode).toBe(200); const overwrite = await mutate("POST", `/api/projects/widefrog/review-queue/${reviewId}/reject`); expect(overwrite.statusCode).toBe(409); expect(overwrite.json().error.code).toBe("REVIEW_ALREADY_DECIDED"); const editFinal = await mutate("PATCH", `/api/projects/widefrog/review-queue/${reviewId}`, { notes: "overwrite" }); expect(editFinal.statusCode).toBe(409); expect(editFinal.json().error.code).toBe("REVIEW_ALREADY_DECIDED"); }); });