diff --git a/app/api/cases/update/route.js b/app/api/cases/update/route.js new file mode 100644 index 0000000..adcc5af --- /dev/null +++ b/app/api/cases/update/route.js @@ -0,0 +1,68 @@ +import { updateCase } from "@/lib/graph/orchestrator.js"; + +function mapFailureStatus(result) { + switch (result?.stage) { + case "request_validation": + case "graph_validation": + return 400; + case "provider": + return 502; + case "proposal_validation": + case "proposal_compatibility": + case "application": + return 422; + case "result_validation": + return 500; + default: + return 500; + } +} + +function buildFailureResponse(result) { + return { + success: false, + stage: result?.stage ?? "internal", + error: result?.error ?? "Update case failed", + validationErrors: result?.validationErrors, + graphValidationErrors: result?.graphValidationErrors, + proposalErrors: result?.proposalErrors, + providerErrors: result?.providerErrors, + errors: result?.errors, + diagnostics: result?.diagnostics, + }; +} + +export async function POST(request) { + try { + const body = await request.json(); + const result = await updateCase(body, { applyProposal: true }); + + if (result.success) { + return Response.json(result, { status: 200 }); + } + + return Response.json(buildFailureResponse(result), { + status: mapFailureStatus(result), + }); + } catch (error) { + if (error instanceof SyntaxError) { + return Response.json( + { + success: false, + stage: "request_validation", + error: "Invalid JSON request body", + }, + { status: 400 }, + ); + } + + return Response.json( + { + success: false, + stage: "internal", + error: "Internal server error", + }, + { status: 500 }, + ); + } +} diff --git a/docs/v0.4-route-status.md b/docs/v0.4-route-status.md index f423afa..eff9c34 100644 --- a/docs/v0.4-route-status.md +++ b/docs/v0.4-route-status.md @@ -4,17 +4,22 @@ - Current tracked start-case route for the v0.4 graph orchestration path. - Covered by `tests/app/api/cases-start-route.test.js`. +- `app/api/cases/update/route.js` + - Current tracked update-case route for the v0.4 graph orchestration path. + - Delegates to `updateCase(body, { applyProposal: true })`. + - Covered by `tests/app/api/cases-update-route.test.js`. + - `app/api/start-case/route.js` - Earlier experiment / duplicate start route. - No repository UI/test references were found. - Deleted from the working tree during UI connection cleanup. - `app/api/update-case/route.js` - - Untracked future `updateCase` work. - - Not referenced by the current UI. - - Leave untracked for the current milestone. + - Earlier experimental duplicate update route. + - Removed from the working tree during route consolidation. - Current UI status - `components/scenario-form.jsx` now calls `/api/cases/start` for the main experimental flow. - - `/api/analyse` remains available for compatibility. - - No active UI path currently calls `/api/update-case`. + - `/api/cases/update` is the active tracked update route. + - `/api/analyse` remains available for legacy one-shot analysis. + - No UI changes were required for this route milestone. diff --git a/tests/app/api/cases-update-route.test.js b/tests/app/api/cases-update-route.test.js new file mode 100644 index 0000000..bcca0ad --- /dev/null +++ b/tests/app/api/cases-update-route.test.js @@ -0,0 +1,301 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockUpdateCase = vi.fn(); + +vi.mock("@/lib/graph/orchestrator.js", () => ({ + updateCase: (...args) => mockUpdateCase(...args), +})); + +function makeSuccessResult() { + return { + success: true, + stage: "update_applied", + updatedSituationGraph: { + centralStatement: "Scenario", + nodes: [{ id: "n1" }], + edges: [], + activeUnknownNodeId: null, + resolvedNodeIds: ["n1"], + currentSummary: "Updated summary", + }, + proposal: { + addedNodes: [], + updatedNodes: [], + addedEdges: [], + removedEdgeIds: [], + resolvedUnknownNodeIds: ["n1"], + affectedNodeIds: ["n1"], + }, + affectedNodeIds: ["n1"], + resolvedUnknownNodeIds: ["n1"], + previousActiveUnknownNodeId: "n0", + newActiveUnknownNodeId: null, + changesApplied: { updatedNodeCount: 1 }, + diagnostics: { promptVersion: "v0.4" }, + }; +} + +describe("app/api/cases/update route", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("valid update returns HTTP 200", async () => { + mockUpdateCase.mockResolvedValue(makeSuccessResult()); + + const { POST } = await import("@/app/api/cases/update/route.js"); + const response = await POST( + new Request("http://localhost/api/cases/update", { + method: "POST", + body: JSON.stringify({ answer: "A" }), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBe(200); + }); + + it("route calls updateCase with applyProposal: true", async () => { + mockUpdateCase.mockResolvedValue(makeSuccessResult()); + + const { POST } = await import("@/app/api/cases/update/route.js"); + const body = { situationGraph: {}, previousQuestion: "Q", answer: "A" }; + await POST( + new Request("http://localhost/api/cases/update", { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }), + ); + + expect(mockUpdateCase).toHaveBeenCalledWith(body, { applyProposal: true }); + }); + + it("invalid JSON returns 400", async () => { + const { POST } = await import("@/app/api/cases/update/route.js"); + const request = { + json: vi.fn().mockRejectedValue(new SyntaxError("Unexpected token")), + }; + + const response = await POST(request); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + success: false, + stage: "request_validation", + error: "Invalid JSON request body", + }); + }); + + it("request validation failure returns 400", async () => { + mockUpdateCase.mockResolvedValue({ + success: false, + stage: "request_validation", + error: "Invalid update-case request", + validationErrors: [{ message: "Required" }], + }); + + const { POST } = await import("@/app/api/cases/update/route.js"); + const response = await POST( + new Request("http://localhost/api/cases/update", { + method: "POST", + body: JSON.stringify({}), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBe(400); + }); + + it("graph validation failure returns 400", async () => { + mockUpdateCase.mockResolvedValue({ + success: false, + stage: "graph_validation", + error: "Invalid situation graph", + graphValidationErrors: ["bad graph"], + }); + + const { POST } = await import("@/app/api/cases/update/route.js"); + const response = await POST( + new Request("http://localhost/api/cases/update", { + method: "POST", + body: JSON.stringify({ answer: "A" }), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBe(400); + }); + + it("provider failure returns 502", async () => { + mockUpdateCase.mockResolvedValue({ + success: false, + stage: "provider", + error: "Graph update proposal generation failed", + providerErrors: ["provider offline"], + }); + + const { POST } = await import("@/app/api/cases/update/route.js"); + const response = await POST( + new Request("http://localhost/api/cases/update", { + method: "POST", + body: JSON.stringify({ answer: "A" }), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBe(502); + }); + + it("proposal validation failure returns 422", async () => { + mockUpdateCase.mockResolvedValue({ + success: false, + stage: "proposal_validation", + error: "Invalid graph update proposal", + proposalErrors: [{ message: "bad proposal" }], + }); + + const { POST } = await import("@/app/api/cases/update/route.js"); + const response = await POST( + new Request("http://localhost/api/cases/update", { + method: "POST", + body: JSON.stringify({ answer: "A" }), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBe(422); + }); + + it("proposal compatibility failure returns 422", async () => { + mockUpdateCase.mockResolvedValue({ + success: false, + stage: "proposal_compatibility", + error: "Update case failed", + errors: ["incompatible proposal"], + }); + + const { POST } = await import("@/app/api/cases/update/route.js"); + const response = await POST( + new Request("http://localhost/api/cases/update", { + method: "POST", + body: JSON.stringify({ answer: "A" }), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBe(422); + }); + + it("application failure returns 422", async () => { + mockUpdateCase.mockResolvedValue({ + success: false, + stage: "application", + error: "Update case failed", + errors: ["could not apply"], + }); + + const { POST } = await import("@/app/api/cases/update/route.js"); + const response = await POST( + new Request("http://localhost/api/cases/update", { + method: "POST", + body: JSON.stringify({ answer: "A" }), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBe(422); + }); + + it("result validation failure returns 500", async () => { + mockUpdateCase.mockResolvedValue({ + success: false, + stage: "result_validation", + error: "Update case failed", + errors: ["invalid result"], + }); + + const { POST } = await import("@/app/api/cases/update/route.js"); + const response = await POST( + new Request("http://localhost/api/cases/update", { + method: "POST", + body: JSON.stringify({ answer: "A" }), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBe(500); + }); + + it("unknown failure returns 500", async () => { + mockUpdateCase.mockRejectedValue(new Error("boom")); + + const { POST } = await import("@/app/api/cases/update/route.js"); + const response = await POST( + new Request("http://localhost/api/cases/update", { + method: "POST", + body: JSON.stringify({ answer: "A" }), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ + success: false, + stage: "internal", + error: "Internal server error", + }); + }); + + it("success response preserves updated graph fields", async () => { + const success = makeSuccessResult(); + mockUpdateCase.mockResolvedValue(success); + + const { POST } = await import("@/app/api/cases/update/route.js"); + const response = await POST( + new Request("http://localhost/api/cases/update", { + method: "POST", + body: JSON.stringify({ answer: "A" }), + headers: { "content-type": "application/json" }, + }), + ); + + await expect(response.json()).resolves.toMatchObject({ + updatedSituationGraph: success.updatedSituationGraph, + proposal: success.proposal, + affectedNodeIds: success.affectedNodeIds, + resolvedUnknownNodeIds: success.resolvedUnknownNodeIds, + previousActiveUnknownNodeId: success.previousActiveUnknownNodeId, + newActiveUnknownNodeId: success.newActiveUnknownNodeId, + changesApplied: success.changesApplied, + diagnostics: success.diagnostics, + }); + }); + + it("stack traces and raw provider output are not exposed", async () => { + mockUpdateCase.mockResolvedValue({ + success: false, + stage: "provider", + error: "Graph update proposal generation failed", + providerErrors: ["provider offline"], + rawResponse: "secret", + stack: "trace", + diagnostics: {}, + }); + + const { POST } = await import("@/app/api/cases/update/route.js"); + const response = await POST( + new Request("http://localhost/api/cases/update", { + method: "POST", + body: JSON.stringify({ answer: "A" }), + headers: { "content-type": "application/json" }, + }), + ); + + const payload = await response.json(); + + expect(payload).not.toHaveProperty("stack"); + expect(payload).not.toHaveProperty("rawResponse"); + }); +});