From 70206b9bf588479616dc02c201ab611c15c34abf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 01:10:19 +0200 Subject: [PATCH] test(insights): integration coverage for PUT /api/insights/provider-chain Two scenarios pinned end-to-end against the postgres testcontainer: saving a valid reordered chain persists the JSON column with priority normalised to 1-indexed insertion order, and saving an empty chain rejects with 422 + leaves the column untouched. No fetch mocks; the route runs against the real Prisma client so a future schema drift on User.aiProviderChain trips the suite immediately. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights-provider-chain-put.test.ts | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 tests/integration/insights-provider-chain-put.test.ts diff --git a/tests/integration/insights-provider-chain-put.test.ts b/tests/integration/insights-provider-chain-put.test.ts new file mode 100644 index 000000000..a152f0b00 --- /dev/null +++ b/tests/integration/insights-provider-chain-put.test.ts @@ -0,0 +1,142 @@ +/** + * v1.4.16 phase B2 — integration coverage for the new PUT + * `/api/insights/provider-chain` endpoint. + * + * Two scenarios pinned end-to-end against the postgres testcontainer: + * 1. Saving a valid chain persists the JSON column and a follow-up + * GET surfaces the new order. priority is normalised so the + * first row of the saved chain always has priority 1. + * 2. Saving an empty chain rejects with 422 and the column is left + * untouched. + * + * No fetch mocks — the route runs against the real Prisma client + + * real testcontainer. The session cookie is forged via `cookieJar` + * because we don't want to recreate the auth flow here; the auth + * helpers already cover that. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function seedUser(): Promise<{ userId: string }> { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: "chain-put-user", + email: "chain-put@example.test", + role: "USER", + }, + }); + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + return { userId: user.id }; +} + +interface PutEnvelope { + data: { saved: true } | null; + error?: string | null; +} + +describe("PUT /api/insights/provider-chain — integration", () => { + it("persists a reordered chain and the follow-up GET surfaces it", async () => { + const { userId } = await seedUser(); + const { PUT } = await import( + "@/app/api/insights/provider-chain/route" + ); + + const res = await (PUT as (req: Request) => Promise)( + new Request("http://localhost/api/insights/provider-chain", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + chain: [ + { providerType: "openai", enabled: true }, + { providerType: "codex", enabled: false }, + { providerType: "admin-openai", enabled: true }, + ], + }), + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as PutEnvelope; + expect(body.data).toEqual({ saved: true }); + + const stored = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: userId }, + select: { aiProviderChain: true }, + }); + expect(stored.aiProviderChain).toEqual([ + { providerType: "openai", priority: 1, enabled: true }, + { providerType: "codex", priority: 2, enabled: false }, + { providerType: "admin-openai", priority: 3, enabled: true }, + ]); + }); + + it("rejects an empty chain with 422 and leaves the column untouched", async () => { + const { userId } = await seedUser(); + const { PUT } = await import( + "@/app/api/insights/provider-chain/route" + ); + + const res = await (PUT as (req: Request) => Promise)( + new Request("http://localhost/api/insights/provider-chain", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ chain: [] }), + }), + ); + expect(res.status).toBe(422); + + const stored = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: userId }, + select: { aiProviderChain: true }, + }); + // Untouched — the user row was created with no chain so the + // column should still be JSON null. + expect(stored.aiProviderChain).toBeNull(); + }); +});