import { beforeEach, describe, expect, it, vi } from "vitest"; import { makeGraph, makeNode } from "@/lib/graph/schema.js"; const mockUpdateCase = vi.fn(); const mockReconsiderCompletedEpisode = vi.fn(); const mockApplyValidatedProposal = vi.fn(); const mockPrepareCompletedEpisode = vi.fn(); vi.mock("@/lib/graph/orchestrator.js", () => ({ updateCase: (...args) => mockUpdateCase(...args), reconsiderCompletedEpisode: (...args) => mockReconsiderCompletedEpisode(...args), })); vi.mock("@/lib/graph/apply-proposal.js", () => ({ applyValidatedProposal: (...args) => mockApplyValidatedProposal(...args), })); vi.mock("@/lib/graph/episode-preparation.js", () => ({ prepareCompletedEpisode: (...args) => mockPrepareCompletedEpisode(...args), })); function makeEpisodeGraph() { return makeGraph({ centralStatement: "Episode scenario", currentSummary: "Episode graph", nodes: [makeNode({ id: "target", label: "Target question", kind: "unknown", status: "unknown" })], edges: [], activeUnknownNodeId: "target", resolvedNodeIds: ["already-resolved"], }); } function mockSuccessfulEpisodeApplication(graph, overrides = {}) { mockPrepareCompletedEpisode.mockReturnValue({ turns: [{ question: "Q?", answer: "A." }] }); mockReconsiderCompletedEpisode.mockResolvedValue({ success: true, proposal: {} }); mockApplyValidatedProposal.mockResolvedValue({ success: true, updatedSituationGraph: graph, ...overrides, }); } 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"], selectedQuestion: null, }, selectedQuestion: null, 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 }); expect(mockUpdateCase.mock.calls[0][0]).not.toHaveProperty("provider"); expect(mockUpdateCase.mock.calls[0][0]).not.toHaveProperty("modelName"); }); it("authoritatively resolves the selected target after a successful no-op episode", async () => { const graph = makeEpisodeGraph(); mockSuccessfulEpisodeApplication(graph); const { POST } = await import("@/app/api/cases/update/route.js"); const response = await POST(new Request("http://localhost/api/cases/update", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ episodeMode: true, situationGraph: graph, targetNodeId: "target", contributions: [{ targetNodeId: "target" }], }), })); const body = await response.json(); expect(response.status).toBe(200); expect(body.updatedSituationGraph.resolvedNodeIds).toEqual( expect.arrayContaining(["already-resolved", "target"]), ); expect(body.updatedSituationGraph.nodes).toEqual(graph.nodes); expect(body.updatedSituationGraph.edges).toEqual(graph.edges); }); it("preserves meaningful episode mutations while authoritatively resolving the target", async () => { const graph = makeEpisodeGraph(); const mutatedGraph = { ...graph, nodes: [...graph.nodes, makeNode({ id: "meaningful", label: "Meaningful change", kind: "observation", status: "known" })], }; mockSuccessfulEpisodeApplication(mutatedGraph); const { POST } = await import("@/app/api/cases/update/route.js"); const response = await POST(new Request("http://localhost/api/cases/update", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ episodeMode: true, situationGraph: graph, targetNodeId: "target", contributions: [{ targetNodeId: "target" }] }), })); const body = await response.json(); expect(body.updatedSituationGraph.nodes).toEqual(mutatedGraph.nodes); expect(body.updatedSituationGraph.resolvedNodeIds).toContain("target"); }); it("does not return an authoritative closure when completed-episode reconsideration fails", async () => { const graph = makeEpisodeGraph(); mockPrepareCompletedEpisode.mockReturnValue({ turns: [{ question: "Q?", answer: "A." }] }); mockReconsiderCompletedEpisode.mockResolvedValue({ success: false, stage: "proposal_compatibility", error: "invalid" }); const { POST } = await import("@/app/api/cases/update/route.js"); const response = await POST(new Request("http://localhost/api/cases/update", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ episodeMode: true, situationGraph: graph, targetNodeId: "target", contributions: [{ targetNodeId: "target" }] }), })); expect(response.status).toBe(422); await expect(response.json()).resolves.not.toHaveProperty("updatedSituationGraph"); }); 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, selectedQuestion: success.selectedQuestion, 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"); }); });