Files
confidence-engine/tests/app/api/cases-update-route.test.js
T

402 lines
13 KiB
JavaScript

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");
});
});