feat: add situation graph update API route
This commit is contained in:
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,17 +4,22 @@
|
|||||||
- Current tracked start-case route for the v0.4 graph orchestration path.
|
- Current tracked start-case route for the v0.4 graph orchestration path.
|
||||||
- Covered by `tests/app/api/cases-start-route.test.js`.
|
- 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`
|
- `app/api/start-case/route.js`
|
||||||
- Earlier experiment / duplicate start route.
|
- Earlier experiment / duplicate start route.
|
||||||
- No repository UI/test references were found.
|
- No repository UI/test references were found.
|
||||||
- Deleted from the working tree during UI connection cleanup.
|
- Deleted from the working tree during UI connection cleanup.
|
||||||
|
|
||||||
- `app/api/update-case/route.js`
|
- `app/api/update-case/route.js`
|
||||||
- Untracked future `updateCase` work.
|
- Earlier experimental duplicate update route.
|
||||||
- Not referenced by the current UI.
|
- Removed from the working tree during route consolidation.
|
||||||
- Leave untracked for the current milestone.
|
|
||||||
|
|
||||||
- Current UI status
|
- Current UI status
|
||||||
- `components/scenario-form.jsx` now calls `/api/cases/start` for the main experimental flow.
|
- `components/scenario-form.jsx` now calls `/api/cases/start` for the main experimental flow.
|
||||||
- `/api/analyse` remains available for compatibility.
|
- `/api/cases/update` is the active tracked update route.
|
||||||
- No active UI path currently calls `/api/update-case`.
|
- `/api/analyse` remains available for legacy one-shot analysis.
|
||||||
|
- No UI changes were required for this route milestone.
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user