Cover the status ledger, the locale collapse, and snapshot re-grounding

The status-provider suite drives the real budget module against an
in-memory stand-in for the day's usage row, so it exercises the atomic
reserve/reconcile path rather than asserting a mock was called: spend
lands on the ledger, an exhausted day is refused before any egress, a
failed generation is refunded, and a chain on the user's own key is
measured against the user-plan ceiling rather than the operator's.

The snapshot suite walks a 60-turn conversation and pins the full
snapshot to exactly two turns — the first and the elision crossing —
including the case where a lost reply shifts the turn parity.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 04:18:16 +02:00
1 parent 0ddc5097e4
commit 7edf79bff3
3 files changed
+354

No files matched your search

@@ -289,3 +289,72 @@ describe("POST /api/ai/test — dropdown-aware override", () => {
expect(body.error).toBe("Anthropic API key not configured");
});
});
/**
* Cost ceiling. The 5/min bucket bounded burst rate but nothing cumulative —
* sustained, it permits ~7 200 probes a day, and with an empty body this route
* resolves the SAME provider chain generation uses, which can be the
* operator's own credential. So the surface could reach the operator's key
* indefinitely with no daily ceiling and no ledger entry at all.
*/
describe("POST /api/ai/test — daily ceiling + ledger", () => {
function okProvider() {
vi.mocked(resolveProviderForTest).mockResolvedValue({
type: "anthropic",
generateCompletion: vi.fn(async () => ({
content: '{"ok":true}',
providerType: "anthropic",
model: "claude-3-5-sonnet-latest",
tokensUsed: 5,
})),
} as never);
}
it("refuses once the per-user daily probe limit is spent", async () => {
const { checkRateLimit } = await import("@/lib/rate-limit");
okProvider();
// Burst bucket fine, cumulative daily bucket exhausted.
vi.mocked(checkRateLimit)
.mockResolvedValueOnce({ allowed: true } as never)
.mockResolvedValueOnce({ allowed: false } as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(429);
// Refused before any provider egress.
expect(resolveProviderForTest).not.toHaveBeenCalled();
});
it("refuses when the day's token budget is already exhausted", async () => {
const { reserveBudget } = await import("@/lib/ai/coach/budget");
const generateCompletion = vi.fn();
vi.mocked(resolveProviderForTest).mockResolvedValue({
type: "admin-key",
generateCompletion,
} as never);
vi.mocked(reserveBudget).mockResolvedValueOnce({
allowed: false,
reserved: 32,
totalAfter: 200_000,
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(429);
expect(generateCompletion).not.toHaveBeenCalled();
});
it("records a successful probe on the ledger", async () => {
const { reconcileSpend } = await import("@/lib/ai/coach/budget");
okProvider();
await POST(emptyRequest() as never);
expect(reconcileSpend).toHaveBeenCalledWith(
"u-1",
32,
expect.any(Number),
"2026-01-01",
);
});
});
@@ -220,3 +220,92 @@ describe("coach chat — snapshot sent once per conversation (C4)", () => {
expect(prompt).toContain("Your BP looks stable.");
});
});
/**
* The erosion guard.
*
* The re-ground condition used to be the open-ended `allTurns.length >
* TURN_CAP`, which is true for EVERY turn past the history cap — not just the
* one that crosses it. So a conversation that ran long re-shipped the full
* ~15k-token snapshot on turn 21, 22, 23, … permanently, reinstating exactly
* the per-turn cost the snapshot-once design removes, and paying it on the
* longest (most expensive) conversations. The intent was always to re-ground
* ONCE, at the boundary where the original snapshot scrolls out of the
* verbatim window.
*/
describe("coach chat — snapshot re-grounds at the elision boundary only", () => {
const TURN_CAP = 20;
beforeEach(() => {
vi.clearAllMocks();
runStreamingRawCompletionWithFallback.mockResolvedValue({
result: { content: "Your BP looks stable.", tokensUsed: 42, model: "m" },
workingProvider: { providerType: "admin-openai" },
});
});
/** Drive one turn against a conversation that already has `n` turns on disk. */
async function turnWithPriorCount(n: number): Promise<string> {
fetchConversationWithMessages.mockResolvedValue({
id: "c1",
summary: "earlier summary",
messages: Array.from({ length: n }, (_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: `turn ${i}`,
})),
});
await postAndDrain({ conversationId: "c1", message: "next?" });
return lastUserPrompt();
}
it("re-grounds on the turn that crosses the cap", async () => {
expect(await turnWithPriorCount(TURN_CAP)).toContain(SNAPSHOT_JSON);
});
it("does NOT re-ship the snapshot on every turn past the cap", async () => {
// Well past the boundary — these are the turns that used to pay the full
// snapshot cost forever.
for (const prior of [TURN_CAP + 2, TURN_CAP + 6, TURN_CAP + 20, 58]) {
expect(await turnWithPriorCount(prior)).not.toContain(SNAPSHOT_JSON);
}
});
it("ships the full snapshot only twice across a 60-turn conversation", async () => {
let shipped = 0;
const shippedAt: number[] = [];
// Turn 1 starts a fresh conversation (no prior turns on disk); every later
// turn loads the growing history. A successful turn persists both the user
// and the assistant message, so the count advances by 2.
fetchConversationWithMessages.mockResolvedValue(null);
await postAndDrain({ message: "How is my BP?" });
if (lastUserPrompt().includes(SNAPSHOT_JSON)) {
shipped++;
shippedAt.push(0);
}
for (let prior = 2; prior <= 60; prior += 2) {
if ((await turnWithPriorCount(prior)).includes(SNAPSHOT_JSON)) {
shipped++;
shippedAt.push(prior);
}
}
// Turn 1 (grounding) + the single crossing at the cap. Under the old
// predicate this was 1 + every turn from 20 to 60 — 21 full snapshots.
expect(shippedAt).toEqual([0, TURN_CAP]);
expect(shipped).toBe(2);
});
/**
* The turn count does not advance in fixed steps: the user message is
* persisted before the provider call, the assistant message only on success,
* so a turn that lost its reply advances the count by 1 instead of 2. An
* exact `=== TURN_CAP` equality would be stepped straight over by such a
* conversation and would then never re-ground at all. The window must catch
* the crossing from either parity.
*/
it("still re-grounds when a lost reply shifts the turn parity", async () => {
expect(await turnWithPriorCount(TURN_CAP + 1)).toContain(SNAPSHOT_JSON);
});
});
@@ -0,0 +1,196 @@
/**
* Cost accounting for the status/reference provider chokepoint.
*
* `runStatusCompletion` is the single provider entry for every status and
* reference generator — the specialised status cards, the generic metric
* cards, biomarker cards, the batched assessment, the derived scores, the
* period narrative, and the off-request Coach memory workers. It previously
* ran with NO budget accounting whatsoever: nothing reserved, nothing
* recorded, no ceiling. Everything behind it spent invisibly, 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.
*/
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;
},
),
user: {
findUnique: vi.fn(async () => ({ aiResponseTimeoutSeconds: null })),
},
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
const { resolveProviderChain, resolveProvider } = vi.hoisted(() => ({
resolveProviderChain: vi.fn(),
resolveProvider: vi.fn(),
}));
vi.mock("@/lib/ai/provider", () => ({ resolveProviderChain, resolveProvider }));
const { runRawCompletionWithFallback } = vi.hoisted(() => ({
runRawCompletionWithFallback: vi.fn(),
}));
vi.mock("@/lib/ai/provider-runner", () => ({
AllProvidersFailedError: class extends Error {},
runRawCompletionWithFallback,
}));
// Consent gate is exercised by its own suite; keep it open here so the budget
// behaviour is what these tests isolate.
vi.mock("@/lib/ai/consent-guard", () => ({
chainRequiresServerManagedConsent: vi.fn(() => false),
hasActiveConsentForSurface: vi.fn(async () => true),
}));
import { runStatusCompletion } from "../status-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", instance: {} }];
const BYOK_CHAIN = [{ providerType: "anthropic", instance: {} }];
function completionArgs(overrides: Record<string, unknown> = {}) {
return {
userId: "u1",
cacheAction: "status:test",
systemPrompt: "system",
userPrompt: "user",
consentSurface: "insights" as const,
...overrides,
};
}
/** A successful provider reply reporting `tokensUsed`. */
function mockProviderReply(tokensUsed: number | null) {
runRawCompletionWithFallback.mockResolvedValue({
result: { content: '{"summary":"ok"}', model: "m", tokensUsed },
workingProvider: { providerType: "admin-openai" },
});
}
beforeEach(() => {
vi.clearAllMocks();
ledgerTotal = 0;
resolveProviderChain.mockResolvedValue(OPERATOR_CHAIN);
resolveProvider.mockResolvedValue({ type: "none" });
});
describe("runStatusCompletion — ledger accounting", () => {
it("lands a successful generation on the day's ledger", async () => {
mockProviderReply(1234);
const result = await runStatusCompletion(completionArgs());
expect(result.kind).toBe("ok");
// 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.
expect(ledgerTotal).toBe(1234);
});
it("charges the reservation when the provider reports no token count", async () => {
mockProviderReply(null);
await runStatusCompletion(completionArgs());
// An unreported generation must not bill as free.
expect(ledgerTotal).toBeGreaterThan(0);
});
it("refuses a generation once the day's cap is already spent", async () => {
ledgerTotal = OPERATOR_COST_CAP;
mockProviderReply(500);
const result = await runStatusCompletion(completionArgs());
// Refused BEFORE any provider egress, and reported as a transient miss
// (`error`) rather than `none` — callers cache `none` as the settled
// "no provider configured" assessment, which a budget refusal is not.
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
expect(result.kind).toBe("error");
expect(annotate).toHaveBeenCalledWith(
expect.objectContaining({
action: { name: "insights.status.budget_exceeded" },
}),
);
// The refused reservation was refunded, not left on the row.
expect(ledgerTotal).toBe(OPERATOR_COST_CAP);
});
it("refunds the reservation when the provider errors", async () => {
runRawCompletionWithFallback.mockRejectedValue(new Error("upstream down"));
const result = await runStatusCompletion(completionArgs());
expect(result.kind).toBe("error");
expect(ledgerTotal).toBe(0);
});
});
describe("runStatusCompletion — cost owner decides the ceiling", () => {
it("measures an operator-key chain against the operator ceiling", async () => {
// Just under the operator ceiling: an operator-key user is refused here.
ledgerTotal = OPERATOR_COST_CAP;
mockProviderReply(100);
const result = await runStatusCompletion(completionArgs());
expect(result.kind).toBe("error");
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.
resolveProviderChain.mockResolvedValue(BYOK_CHAIN);
ledgerTotal = OPERATOR_COST_CAP;
runRawCompletionWithFallback.mockResolvedValue({
result: { content: '{"summary":"ok"}', model: "m", tokensUsed: 100 },
workingProvider: { providerType: "anthropic" },
});
const result = await runStatusCompletion(completionArgs());
expect(result.kind).toBe("ok");
expect(runRawCompletionWithFallback).toHaveBeenCalled();
});
it("still bounds a BYOK chain at the user-plan ceiling", async () => {
resolveProviderChain.mockResolvedValue(BYOK_CHAIN);
ledgerTotal = USER_PLAN_CAP;
mockProviderReply(100);
const result = await runStatusCompletion(completionArgs());
expect(result.kind).toBe("error");
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
});
});