diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 12a88e790..f30c72fed 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -173,6 +173,11 @@ beforeEach(() => { // need a fresh cache so the previous test's success doesn't reorder // the chain on the next request. clearLastWorkingProviderCache(); + // `clearAllMocks` does not undo a `mockImplementation`, so the ledger stub is + // re-seeded per test — otherwise the over-cap case below leaks into the rest. + vi.mocked(prisma.$queryRaw).mockImplementation((async () => [ + { total_tokens: 0 }, + ]) as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, remaining: 9, @@ -228,6 +233,33 @@ function makeProviderThatThrows( } as unknown as Awaited>); } +describe("POST /api/insights/generate — daily token ceiling", () => { + it("refuses over-cap with 429 before any provider egress", async () => { + // The reservation reports a prior total already at the ceiling. The hourly + // rate limit bounds how OFTEN this route runs; the ledger bounds what it + // costs. Before this wiring the route had only the former, so ten + // full-price briefings an hour was the real ceiling. + // Targeted at the ledger's upsert only — other raw reads on this path + // (the plateau / derived-signal probes) must keep their own shape. + vi.mocked(prisma.$queryRaw).mockImplementation((async ( + strings: TemplateStringsArray, + ) => + String(strings[0]).includes("coach_usage") + ? [{ total_tokens: 10_000_000 }] + : [{ total_tokens: 0 }]) as never); + makeWorkingProvider(); + + const res = await POST(jsonRequest({ force: true }) as never); + expect(res.status).toBe(429); + const body = (await res.json()) as ApiErrorEnvelope; + expect(body.error).toContain("Daily AI token budget"); + // Not a provider failure: no cache row written, so the last good briefing + // stays intact and the card does not blank. + expect(prisma.user.update).not.toHaveBeenCalled(); + expect(enqueueStatusRefillForUser).not.toHaveBeenCalled(); + }); +}); + describe("POST /api/insights/generate — provider error mapping", () => { it("rejects a body over the 16 KB cap with 413 before parsing", async () => { const res = await POST( diff --git a/src/lib/insights/__tests__/briefing-provider-budget.test.ts b/src/lib/insights/__tests__/briefing-provider-budget.test.ts new file mode 100644 index 000000000..b79a4a7ff --- /dev/null +++ b/src/lib/insights/__tests__/briefing-provider-budget.test.ts @@ -0,0 +1,236 @@ +/** + * Cost accounting for the comprehensive-briefing provider chokepoint. + * + * `runBriefingCompletion` is the single provider entry for the briefing tier — + * the shared generator's first pass, its JSON-shape retry, its + * grounding-correction retry, the daily paragraph re-roll, and both calls in + * the on-demand `POST /api/insights/generate` route. That tier previously ran + * with NO budget accounting: nothing reserved, nothing recorded, no ceiling. + * The briefing is the most expensive generation in the product and retries up + * to twice, so an unmetered run could cost three full generations — and on an + * operator-key account that was unmetered operator spend. + * + * These tests drive the REAL budget module (`reserveBudget` / `reconcileSpend` + * / `resolveDailyCap`) against a stateful in-memory stand-in for the + * `coach_usage` row, so they exercise the actual atomic-upsert path rather + * than merely asserting that a mock was called. Mirrors the status tier's + * `status-provider-budget.test.ts` deliberately: one accounting mechanism, + * one set of guarantees, asserted the same way at both chokepoints. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** In-memory stand-in for the day's `coach_usage.total_tokens`. */ +let ledgerTotal = 0; + +vi.mock("@/lib/db", () => ({ + prisma: { + // `reserveBudget`'s atomic upsert-increment. Tagged-template call shape: + // (strings, userId, dateKey, reserved, reserved). + $queryRaw: vi.fn( + async (_strings: TemplateStringsArray, ...v: unknown[]) => { + ledgerTotal += Number(v[2] ?? 0); + return [{ total_tokens: ledgerTotal }]; + }, + ), + // `reconcileSpend` (+delta) and `refundReservation` (-reserved). + $executeRaw: vi.fn( + async (strings: TemplateStringsArray, ...v: unknown[]) => { + const sql = strings.join("?"); + const amount = Number(v[0] ?? 0); + if (sql.includes("total_tokens + ")) ledgerTotal += amount; + else if (sql.includes("total_tokens - ")) ledgerTotal -= amount; + ledgerTotal = Math.max(0, ledgerTotal); + return 1; + }, + ), + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +const { runRawCompletionWithFallback } = vi.hoisted(() => ({ + runRawCompletionWithFallback: vi.fn(), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback, +})); + +import { + runBriefingCompletion, + BriefingBudgetExceededError, +} from "../briefing-provider"; +import { annotate } from "@/lib/logging/context"; +import { OPERATOR_COST_CAP, USER_PLAN_CAP } from "@/lib/ai/coach/budget"; + +const OPERATOR_CHAIN = [ + { providerType: "admin-openai" as const, instance: {} as never }, +]; +const BYOK_CHAIN = [ + { providerType: "anthropic" as const, instance: {} as never }, +]; + +function completionArgs(overrides: Record = {}) { + return { + userId: "u1", + chain: OPERATOR_CHAIN, + systemPrompt: "system", + userPrompt: "user", + temperature: 0.3, + maxTokens: 2500, + timeoutMs: 60_000, + stage: "generate" as const, + ...overrides, + }; +} + +/** A successful provider reply reporting `tokensUsed`. */ +function mockProviderReply( + tokensUsed: number | null, + cachedInputTokens?: number, +) { + runRawCompletionWithFallback.mockResolvedValue({ + result: { + content: '{"dailyBriefing":{"paragraph":"ok"}}', + model: "m", + tokensUsed, + cachedInputTokens, + }, + workingProvider: { providerType: "admin-openai" }, + fallbackHops: [], + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + ledgerTotal = 0; +}); + +describe("runBriefingCompletion — ledger accounting", () => { + it("lands a successful generation on the day's ledger", async () => { + mockProviderReply(1234); + + const outcome = await runBriefingCompletion(completionArgs()); + + expect(outcome.result.tokensUsed).toBe(1234); + // Reserved an estimate, then reconciled down to what the provider actually + // reported. Either way the spend is now ON the ledger — before this wiring + // existed the total stayed at zero forever, no matter how many briefings + // the account generated. + expect(ledgerTotal).toBe(1234); + }); + + it("charges the reservation when the provider reports no token count", async () => { + mockProviderReply(null); + + await runBriefingCompletion(completionArgs()); + + // An unreported generation must not bill as free. + expect(ledgerTotal).toBeGreaterThan(0); + }); + + it("bills net of provider-cached input tokens", async () => { + mockProviderReply(1000, 400); + + await runBriefingCompletion(completionArgs()); + + // The gross count still includes input the prompt cache served cheaply; + // charging the user's meter for input they did not re-pay for is an + // over-charge. + expect(ledgerTotal).toBe(600); + }); + + it("refuses a generation once the day's cap is already spent", async () => { + ledgerTotal = OPERATOR_COST_CAP; + mockProviderReply(500); + + await expect( + runBriefingCompletion(completionArgs()), + ).rejects.toBeInstanceOf(BriefingBudgetExceededError); + + // Refused BEFORE any provider egress — the point of the whole exercise. + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + expect(annotate).toHaveBeenCalledWith( + expect.objectContaining({ + action: { name: "insights.briefing.budget_exceeded" }, + }), + ); + // The refused reservation was refunded, not left on the row. + expect(ledgerTotal).toBe(OPERATOR_COST_CAP); + }); + + it("refunds the reservation in full when the provider errors", async () => { + runRawCompletionWithFallback.mockRejectedValue(new Error("upstream down")); + + await expect(runBriefingCompletion(completionArgs())).rejects.toThrow( + "upstream down", + ); + + // A failed generation must not leave an estimate parked on the ledger, + // which would ration the retry the user is entitled to. + expect(ledgerTotal).toBe(0); + }); + + it("re-throws the provider error unchanged so callers keep their mapping", async () => { + class AllProvidersFailedError extends Error {} + runRawCompletionWithFallback.mockRejectedValue( + new AllProvidersFailedError("chain exhausted"), + ); + + // The route maps `AllProvidersFailedError` to specific user-facing + // statuses; swallowing or re-wrapping it here would silently degrade every + // one of those branches to the generic error. + await expect( + runBriefingCompletion(completionArgs()), + ).rejects.toBeInstanceOf(AllProvidersFailedError); + }); + + it("meters each retry stage separately", async () => { + mockProviderReply(1000); + + await runBriefingCompletion(completionArgs({ stage: "generate" })); + await runBriefingCompletion(completionArgs({ stage: "json-retry" })); + await runBriefingCompletion(completionArgs({ stage: "grounding-retry" })); + + // A correction pass is real spend. If retries rode the first pass's + // reservation, a user at the ceiling would get free retries. + expect(ledgerTotal).toBe(3000); + }); +}); + +describe("runBriefingCompletion — cost owner decides the ceiling", () => { + it("measures an operator-key chain against the operator ceiling", async () => { + ledgerTotal = OPERATOR_COST_CAP; + mockProviderReply(100); + + await expect( + runBriefingCompletion(completionArgs()), + ).rejects.toBeInstanceOf(BriefingBudgetExceededError); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + }); + + it("measures a BYOK chain against the user-plan ceiling, not the operator's", async () => { + // Same spend that locked the operator-key user out above. A self-hoster on + // their own key pays their own bill, so the operator's ceiling is a + // category error for them — they must still be served. + ledgerTotal = OPERATOR_COST_CAP; + mockProviderReply(100); + + const outcome = await runBriefingCompletion( + completionArgs({ chain: BYOK_CHAIN }), + ); + + expect(outcome.result.tokensUsed).toBe(100); + expect(runRawCompletionWithFallback).toHaveBeenCalled(); + }); + + it("still bounds a BYOK chain at the user-plan ceiling", async () => { + ledgerTotal = USER_PLAN_CAP; + mockProviderReply(100); + + await expect( + runBriefingCompletion(completionArgs({ chain: BYOK_CHAIN })), + ).rejects.toBeInstanceOf(BriefingBudgetExceededError); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts new file mode 100644 index 000000000..205fca7b1 --- /dev/null +++ b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts @@ -0,0 +1,224 @@ +/** + * The briefing generator's behaviour when the day's token ceiling refuses it. + * + * `briefing-provider-budget.test.ts` pins the accounting itself. This pins the + * WIRING: that `generateComprehensiveInsight` actually routes through the + * metered chokepoint, and that an over-cap run is reported honestly. + * + * Load-bearing distinction: an over-cap run is `skipped`, NOT `failed`. No + * provider was contacted, so there is no upstream fault to retry against. The + * morning-refresh worker enqueues its 45-minute provider-failure retry on a + * `failed` outcome and holds the day provisional — doing that for a ceiling + * that will not move until the ledger rolls over would be a retry loop against + * an accounting outcome. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +let ledgerTotal = 0; + +const findUnique = vi.fn(); +const userUpdate = vi.fn(); +const resolveProviderChain = vi.fn(); +const resolveProvider = vi.fn(); +const runRawCompletionWithFallback = vi.fn(); +const extractFeatures = vi.fn(); +const recordBriefingFailure = vi.fn(); +const hasActiveConsentForSurface = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: vi.fn( + async (_strings: TemplateStringsArray, ...v: unknown[]) => { + ledgerTotal += Number(v[2] ?? 0); + return [{ total_tokens: ledgerTotal }]; + }, + ), + $executeRaw: vi.fn( + async (strings: TemplateStringsArray, ...v: unknown[]) => { + const sql = strings.join("?"); + const amount = Number(v[0] ?? 0); + if (sql.includes("total_tokens + ")) ledgerTotal += amount; + else if (sql.includes("total_tokens - ")) ledgerTotal -= amount; + ledgerTotal = Math.max(0, ledgerTotal); + return 1; + }, + ), + user: { + findUnique: (...a: unknown[]) => findUnique(...a), + update: (...a: unknown[]) => userUpdate(...a), + }, + auditLog: { deleteMany: vi.fn() }, + }, +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), + resolveProvider: (...a: unknown[]) => resolveProvider(...a), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback: (...a: unknown[]) => + runRawCompletionWithFallback(...a), +})); +vi.mock("@/lib/insights/features", () => ({ + FeaturesPayloadTooLargeError: class extends Error { + sizeBytes = 0; + }, + extractFeatures: (...a: unknown[]) => extractFeatures(...a), + BRIEFING_FEATURE_WINDOW_DAYS: 400, +})); +vi.mock("@/lib/insights/briefing-failure-marker", () => ({ + recordBriefingFailure: (...a: unknown[]) => recordBriefingFailure(...a), + readBriefingFailure: vi.fn(async () => null), +})); +vi.mock("@/lib/insights/illness-cycle-briefing", () => ({ + buildBriefingIllnessCycleContext: vi.fn().mockResolvedValue(null), + buildBriefingIllnessCyclePrompt: vi.fn().mockReturnValue(""), +})); +vi.mock("@/lib/insights/glp1-plateau", () => ({ + detectGlp1Plateau: vi.fn(async () => null), + buildGlp1PlateauPrompt: vi.fn(() => ""), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(async () => null), + buildAboutMeInsightBlock: vi.fn(() => ""), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserInsights: vi.fn(), +})); +// Modelled truthfully rather than stubbed open: only an operator-credential +// chain needs a receipt, which is what lets the consent-ordering test below +// assert the real gate rather than a bypass. +vi.mock("@/lib/ai/consent-guard", () => ({ + chainRequiresServerManagedConsent: (chain: { providerType: string }[] = []) => + chain.some((c) => c.providerType.startsWith("admin-")), + hasActiveConsentForSurface: (...a: unknown[]) => + hasActiveConsentForSurface(...a), +})); + +import { generateComprehensiveInsight } from "../comprehensive-generate"; +import { OPERATOR_COST_CAP, USER_PLAN_CAP } from "@/lib/ai/coach/budget"; + +const VALID = JSON.stringify({ dailyBriefing: { paragraph: "ok" } }); + +beforeEach(() => { + vi.clearAllMocks(); + ledgerTotal = 0; + resolveProviderChain.mockResolvedValue([ + { providerType: "admin-openai", instance: {} }, + ]); + resolveProvider.mockResolvedValue({ type: "none" }); + extractFeatures.mockResolvedValue({ + weight: { count: 12, latest: 81.4, mean30: 82.1 }, + }); + userUpdate.mockResolvedValue({}); + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: null, + insightsCachedText: null, + insightsExcludeMetrics: [], + insightsSnapshotHash: null, + insightsBriefingRerollDate: null, + }); + hasActiveConsentForSurface.mockResolvedValue(true); + runRawCompletionWithFallback.mockResolvedValue({ + result: { content: VALID, tokensUsed: 900, model: "m" }, + workingProvider: { providerType: "admin-openai" }, + fallbackHops: [], + }); +}); + +describe("generateComprehensiveInsight — daily token ceiling", () => { + it("lands the briefing's spend on the ledger", async () => { + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome.status).toBe("generated"); + // The whole point: this path used to spend invisibly. + expect(ledgerTotal).toBe(900); + }); + + it("refuses over-cap as `skipped: budget` without contacting a provider", async () => { + ledgerTotal = OPERATOR_COST_CAP; + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome).toEqual({ status: "skipped", reason: "budget" }); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + // Not a provider failure: no marker, so the read path never claims the + // refresh broke, and the morning-refresh worker does not enqueue its + // provider-failure retry against a ceiling. + expect(recordBriefingFailure).not.toHaveBeenCalled(); + // No cache row written, so the last good briefing stays intact. + expect(userUpdate).not.toHaveBeenCalled(); + expect(ledgerTotal).toBe(OPERATOR_COST_CAP); + }); + + it("serves a BYOK account at the same spend that refuses an operator-key one", async () => { + // A self-hoster on their own key pays their own bill; the operator's + // ceiling is a category error for them. + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + ]); + ledgerTotal = OPERATOR_COST_CAP; + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome.status).toBe("generated"); + expect(runRawCompletionWithFallback).toHaveBeenCalled(); + }); + + it("still bounds a BYOK account at the user-plan ceiling", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "anthropic", instance: {} }, + ]); + ledgerTotal = USER_PLAN_CAP; + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome).toEqual({ status: "skipped", reason: "budget" }); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + }); + + it("refunds in full when the provider fails, leaving no estimate parked", async () => { + runRawCompletionWithFallback.mockRejectedValue(new Error("upstream down")); + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome.status).toBe("failed"); + expect(ledgerTotal).toBe(0); + }); + + it("never charges a consent-blocked user", async () => { + // Ordering guarantee: the reservation lands AFTER the consent gate. + // Reserving first would bill an operator-key user for a snapshot that is + // never sent anywhere, and could lock them out of a tier they are not + // even permitted to use. + hasActiveConsentForSurface.mockResolvedValue(false); + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome).toEqual({ status: "skipped", reason: "no-consent" }); + expect(runRawCompletionWithFallback).not.toHaveBeenCalled(); + expect(ledgerTotal).toBe(0); + }); + + it("charges the JSON-shape retry as its own generation", async () => { + runRawCompletionWithFallback + .mockResolvedValueOnce({ + result: { content: "not json", tokensUsed: 400, model: "m" }, + workingProvider: { providerType: "admin-openai" }, + fallbackHops: [], + }) + .mockResolvedValueOnce({ + result: { content: VALID, tokensUsed: 900, model: "m" }, + workingProvider: { providerType: "admin-openai" }, + fallbackHops: [], + }); + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome.status).toBe("generated"); + // Both passes billed. A retry that rode the first reservation would let a + // user at the ceiling generate for free. + expect(ledgerTotal).toBe(1300); + }); +});