205 lines
7.5 KiB
JavaScript
205 lines
7.5 KiB
JavaScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
// ── Mock domain seam and provider at module level ───────────
|
|
|
|
const mockSynthesize = vi.fn();
|
|
|
|
vi.mock("@/lib/graph/current-understanding-synthesis.js", () => ({
|
|
synthesizeCurrentUnderstanding: (...args) => mockSynthesize(...args),
|
|
}));
|
|
|
|
vi.mock("@/lib/llm/provider.js", () => ({
|
|
getProvider: () => ({}),
|
|
getProviderModelName: () => "gpt-5.6-terra",
|
|
}));
|
|
|
|
vi.mock("@/lib/supabase/api-auth.js", () => ({
|
|
withAuthenticatedApi: (handler) => handler,
|
|
}));
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────
|
|
|
|
function makeValidGraph() {
|
|
return {
|
|
centralStatement: "Test situation",
|
|
nodes: [{ id: "n1", proposition: "Node prop" }],
|
|
edges: [],
|
|
};
|
|
}
|
|
|
|
function makeRequest(body) {
|
|
return new Request("http://localhost/api/cases/synthesis", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
// ── Route tests — valid POST ────────────────────────────────
|
|
|
|
describe("POST /api/cases/synthesis — valid request", () => {
|
|
beforeEach(() => mockSynthesize.mockClear());
|
|
|
|
it("invokes synthesis domain seam with situationGraph + findings", async () => {
|
|
mockSynthesize.mockResolvedValue({ currentUnderstanding: "Synthesized result" });
|
|
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
|
const res = await POST(makeRequest({ situationGraph: makeValidGraph(), findings: [] }));
|
|
|
|
expect(res.status).toBe(200);
|
|
const data = await res.json();
|
|
expect(data.success).toBe(true);
|
|
expect(data.currentUnderstanding).toBe("Synthesized result");
|
|
expect(mockSynthesize).toHaveBeenCalledTimes(1);
|
|
expect(mockSynthesize.mock.calls[0][1]).toMatchObject({ modelName: "gpt-5.6-terra" });
|
|
});
|
|
|
|
it("returns narrative result on success", async () => {
|
|
mockSynthesize.mockResolvedValue({ currentUnderstanding: "The revenue dropped because of X and Y." });
|
|
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
|
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
|
|
|
expect(res.status).toBe(200);
|
|
const data = await res.json();
|
|
expect(data.success).toBe(true);
|
|
expect(typeof data.currentUnderstanding).toBe("string");
|
|
expect(data.currentUnderstanding.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("passes findings to domain seam for eligibility filtering", async () => {
|
|
mockSynthesize.mockResolvedValue({ currentUnderstanding: "OK" });
|
|
const findings = [
|
|
{ id: "f1", proposition: "agree finding", userDisposition: "agree", evaluation: "considered" },
|
|
{ id: "f2", proposition: "null finding", userDisposition: null, evaluation: "considered" },
|
|
{ id: "f3", proposition: "not_quite finding", userDisposition: "not_quite", evaluation: "considered" },
|
|
];
|
|
|
|
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
|
await POST(makeRequest({ situationGraph: makeValidGraph(), findings }));
|
|
|
|
expect(mockSynthesize).toHaveBeenCalledTimes(1);
|
|
expect(mockSynthesize.mock.calls[0][0].findings).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
// ── Route tests — error handling ────────────────────────────
|
|
|
|
describe("POST /api/cases/synthesis — error cases", () => {
|
|
it("missing situationGraph → 400", async () => {
|
|
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
|
const res = await POST(makeRequest({ findings: [] }));
|
|
|
|
expect(res.status).toBe(400);
|
|
const data = await res.json();
|
|
expect(data.success).toBe(false);
|
|
expect(data.stage).toBe("request_validation");
|
|
});
|
|
|
|
it("invalid JSON body → 400", async () => {
|
|
const req = new Request("http://localhost/api/cases/synthesis", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: "not json",
|
|
});
|
|
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
|
const res = await POST(req);
|
|
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("null body → 400", async () => {
|
|
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
|
const res = await POST(makeRequest(null));
|
|
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("domain seam throws with statusCode → mapped status", async () => {
|
|
const err = Object.assign(
|
|
new Error("Ollama /api/generate returned 500 from private host"),
|
|
{ statusCode: 502 },
|
|
);
|
|
mockSynthesize.mockImplementationOnce(async () => {
|
|
throw err;
|
|
});
|
|
|
|
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
|
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
|
|
|
expect(res.status).toBe(502);
|
|
const data = await res.json();
|
|
expect(data.success).toBe(false);
|
|
expect(data.stage).toBe("provider");
|
|
expect(data).toEqual({
|
|
success: false,
|
|
stage: "provider",
|
|
error: "Reasoning request could not be completed.",
|
|
});
|
|
});
|
|
|
|
it("domain seam throws without statusCode → 500", async () => {
|
|
mockSynthesize.mockImplementationOnce(async () => {
|
|
throw new Error("unknown error");
|
|
});
|
|
|
|
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
|
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
|
|
|
expect(res.status).toBe(500);
|
|
const data = await res.json();
|
|
expect(data.success).toBe(false);
|
|
expect(data.stage).toBe("internal");
|
|
});
|
|
|
|
it("domain seam throws 400 → mapped to 400", async () => {
|
|
const err = Object.assign(new Error("Invalid input"), { statusCode: 400 });
|
|
mockSynthesize.mockImplementationOnce(async () => {
|
|
throw err;
|
|
});
|
|
|
|
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
|
const res = await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
|
|
|
expect(res.status).toBe(400);
|
|
const data = await res.json();
|
|
expect(data.success).toBe(false);
|
|
expect(data.stage).toBe("request_validation");
|
|
});
|
|
});
|
|
|
|
// ── Route thinness — no business logic in route ─────────────
|
|
|
|
describe("Route thinness", () => {
|
|
beforeEach(() => mockSynthesize.mockClear());
|
|
|
|
it("route does not filter eligibility itself (domain seam owns it)", async () => {
|
|
mockSynthesize.mockResolvedValue({ currentUnderstanding: "OK" });
|
|
const findings = [
|
|
{ id: "f1", proposition: "ineligible", userDisposition: "not_quite", evaluation: "considered" },
|
|
];
|
|
|
|
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
|
await POST(makeRequest({ situationGraph: makeValidGraph(), findings }));
|
|
|
|
// Route passes all findings through — domain seam filters
|
|
expect(mockSynthesize.mock.calls[0][0].findings).toHaveLength(1);
|
|
});
|
|
|
|
it("route does not construct prompts", async () => {
|
|
mockSynthesize.mockResolvedValue({ currentUnderstanding: "OK" });
|
|
|
|
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
|
await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
|
|
|
expect(mockSynthesize).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("route does not contain provider logic (delegates to domain seam)", async () => {
|
|
mockSynthesize.mockResolvedValue({ currentUnderstanding: "OK" });
|
|
|
|
const { POST } = await import("@/app/api/cases/synthesis/route.js");
|
|
await POST(makeRequest({ situationGraph: makeValidGraph() }));
|
|
|
|
expect(mockSynthesize).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|