diff --git a/src/app/api/insights/provider-chain/__tests__/put.test.ts b/src/app/api/insights/provider-chain/__tests__/put.test.ts new file mode 100644 index 000000000..7721d1d46 --- /dev/null +++ b/src/app/api/insights/provider-chain/__tests__/put.test.ts @@ -0,0 +1,166 @@ +/** + * v1.4.16 phase B2 — write endpoint for the user's AI provider chain. + * + * The Settings → AI section drives reorder + enable/disable through a + * single dropdown + arrow controls; the persisted shape lives in + * `User.aiProviderChain` (Json), parsed back through + * `parseProviderChain()` on every read so a malformed payload from a + * stale client cannot poison the chain. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mirror `apiHandler`'s real behaviour: an `HttpError` thrown from the +// inner handler is converted to a JSON envelope. The tests assert on +// `res.status`, so the wrapper must surface the correct status code. +vi.mock("@/lib/api-handler", () => { + class HttpError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + this.name = "HttpError"; + } + } + return { + apiHandler: + unknown>(fn: T) => + async (...args: unknown[]) => { + try { + return await fn(...args); + } catch (e) { + if (e instanceof HttpError) { + return new Response( + JSON.stringify({ data: null, error: e.message }), + { + status: e.status, + headers: { "content-type": "application/json" }, + }, + ); + } + throw e; + } + }, + requireAuth: vi.fn(async () => ({ + user: { id: "u-1" }, + session: { id: "s-1" }, + })), + HttpError, + }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + update: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +import { PUT } from "../route"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; + +interface Envelope { + data?: { saved: true } | null; + error?: string | null; +} + +function jsonRequest(body: unknown): Request { + return new Request("http://t/test", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("PUT /api/insights/provider-chain", () => { + it("persists a valid chain to user.aiProviderChain", async () => { + vi.mocked(prisma.user.update).mockResolvedValueOnce({} as never); + + const res = await (PUT as (req: Request) => Promise)( + jsonRequest({ + chain: [ + { providerType: "openai", priority: 1, enabled: true }, + { providerType: "codex", priority: 2, enabled: false }, + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Envelope; + expect(body.data).toEqual({ saved: true }); + + expect(prisma.user.update).toHaveBeenCalledTimes(1); + const callArg = vi.mocked(prisma.user.update).mock.calls[0][0]; + expect(callArg.where).toEqual({ id: "u-1" }); + expect(callArg.data).toEqual({ + aiProviderChain: [ + { providerType: "openai", priority: 1, enabled: true }, + { providerType: "codex", priority: 2, enabled: false }, + ], + }); + expect(annotate).toHaveBeenCalled(); + }); + + it("rejects unknown provider types with 422", async () => { + const res = await (PUT as (req: Request) => Promise)( + jsonRequest({ + chain: [{ providerType: "bogus", priority: 1, enabled: true }], + }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects an empty chain with 422", async () => { + const res = await (PUT as (req: Request) => Promise)( + jsonRequest({ chain: [] }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("rejects duplicate provider types in the same chain with 422", async () => { + const res = await (PUT as (req: Request) => Promise)( + jsonRequest({ + chain: [ + { providerType: "openai", priority: 1, enabled: true }, + { providerType: "openai", priority: 2, enabled: true }, + ], + }), + ); + expect(res.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("normalises priority to insertion order even when client supplies arbitrary numbers", async () => { + vi.mocked(prisma.user.update).mockResolvedValueOnce({} as never); + + const res = await (PUT as (req: Request) => Promise)( + jsonRequest({ + chain: [ + { providerType: "openai", priority: 99, enabled: true }, + { providerType: "codex", priority: 1, enabled: true }, + ], + }), + ); + expect(res.status).toBe(200); + + // The route MUST normalise priority to the wire order so the UI's + // drag-arrow contract matches what gets persisted: "first row in the + // list always has priority 1, second row priority 2, …". + const callArg = vi.mocked(prisma.user.update).mock.calls[0][0]; + expect(callArg.data.aiProviderChain).toEqual([ + { providerType: "openai", priority: 1, enabled: true }, + { providerType: "codex", priority: 2, enabled: true }, + ]); + }); +}); diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts index 15dc7f63f..5860eea01 100644 --- a/src/app/api/insights/provider-chain/route.ts +++ b/src/app/api/insights/provider-chain/route.ts @@ -1,8 +1,13 @@ -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import type { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, safeJson } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { resolveProviderChain } from "@/lib/ai/provider"; import { getLastWorkingProvider } from "@/lib/ai/provider-runner"; +import { prisma } from "@/lib/db"; +import { PROVIDER_CHAIN_TYPES } from "@/lib/ai/provider-chain"; /** * v1.4.16 phase B5b — read-only chain summary for the Settings → AI @@ -19,10 +24,13 @@ import { getLastWorkingProvider } from "@/lib/ai/provider-runner"; * for the v1.4.17 UX where unconfigured slots should still render * in the list with a "needs setup" pill. * - * The full management UX (drag-to-reorder, enable/disable, add/remove - * provider entries) is owned by B2 (AI provider settings cleanup) — we - * intentionally keep this endpoint and the matching panel small so - * the two phases don't collide. + * v1.4.16 phase B2 added the matching `PUT` for the new dropdown-driven + * settings UX — the user reorders/toggles entries in the section and + * the section persists the chain through this endpoint. The persisted + * shape lives on `User.aiProviderChain` (Json); a malformed payload + * from an out-of-date client cannot poison generation because every + * read goes through `parseProviderChain()`, which falls back to the + * default chain when given garbage. */ export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -47,3 +55,72 @@ export const GET = apiHandler(async () => { })), }); }); + +const chainEntrySchema = z.object({ + providerType: z.enum( + PROVIDER_CHAIN_TYPES as unknown as [string, ...string[]], + ), + // Priority is recomputed server-side from insertion order so a stale + // client cannot persist a chain whose displayed order disagrees with + // its priority field. The number is accepted (and may be present) but + // ignored on the wire. + priority: z.number().int().optional(), + enabled: z.boolean(), +}); + +const chainBodySchema = z.object({ + chain: z + .array(chainEntrySchema) + .min(1, "Chain must contain at least one provider") + .max(PROVIDER_CHAIN_TYPES.length, "Too many providers"), +}); + +export const PUT = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "insights.provider_chain.update" } }); + + const { data: body, error } = await safeJson(request); + if (error) return error; + + const parsed = chainBodySchema.safeParse(body); + if (!parsed.success) { + throw new HttpError(422, "Invalid provider chain payload"); + } + + // Defence-in-depth: reject duplicate provider types so a stale-tab + // resubmit cannot create a chain that walks the same entry twice. + // `parseProviderChain()` already deduplicates on read but we'd rather + // never persist a duplicate to begin with — drift between the two + // representations is the kind of bug that wastes an evening. + const seen = new Set(); + for (const entry of parsed.data.chain) { + if (seen.has(entry.providerType)) { + throw new HttpError(422, "Duplicate provider in chain"); + } + seen.add(entry.providerType); + } + + // Normalise priority to insertion order (1-based). UI hands us the + // visual order it wants persisted; we make priority match so a later + // GET reflects the same order without depending on the client's math. + const normalised = parsed.data.chain.map((entry, idx) => ({ + providerType: entry.providerType, + priority: idx + 1, + enabled: entry.enabled, + })); + + await prisma.user.update({ + where: { id: user.id }, + data: { aiProviderChain: normalised }, + }); + + annotate({ + meta: { + chain_length: normalised.length, + chain_first: normalised[0]?.providerType ?? null, + chain_disabled_count: normalised.filter((e) => !e.enabled).length, + }, + }); + + return apiSuccess({ saved: true }); +});