mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user