Blame · Line-by-line history
ai-quota-metering.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 489a5ac | 1 | /** |
| 2 | * Regression guard: the AI spend cap must actually be able to trip. | |
| 3 | * | |
| 4 | * assertAiQuota is called at the top of the AI features (ai-review, | |
| 5 | * ai-review-trio, ai-ci-healer). It reads | |
| 6 | * userQuotas.aiTokensUsedThisMonth — and NOTHING ever incremented that | |
| 7 | * column. bumpUsage, the function whose entire job is to increment it, had | |
| 8 | * zero callers anywhere in the codebase. So the counter sat at 0 forever, | |
| 9 | * `allowed` was always true, and the monthly AI cap could never fire. | |
| 10 | * | |
| 11 | * Token counts were being recorded accurately the whole time, just into | |
| 12 | * ai_cost_events via recordAiCost, which the enforcement path does not read. | |
| 13 | * Metering and enforcement were wired to two different stores. Every piece | |
| 14 | * existed — bumpUsage, invalidateQuotaCache, checkAiQuotaCached, | |
| 15 | * assertAiQuota — and none of them were connected. | |
| 16 | * | |
| 17 | * The second half matters as much as the first: getUserQuota does NOT roll | |
| 18 | * the monthly cycle, and resetIfCycleExpired's only other caller is the | |
| 19 | * repo-creation gate. Making the counter live without also rolling it would | |
| 20 | * have permanently locked users out of AI once they hit the cap, released | |
| 21 | * only by the unrelated act of creating a repository. That would be a worse | |
| 22 | * bug than the one being fixed. | |
| 23 | * | |
| 24 | * The rule is asserted directly; the wiring is asserted from source, because | |
| 25 | * the defect was an absent call and no test of the rule alone would catch a | |
| 26 | * regression that simply stops calling it. | |
| 27 | */ | |
| 28 | ||
| 29 | import { describe, it, expect } from "bun:test"; | |
| 30 | import { readFileSync } from "node:fs"; | |
| 31 | import { join } from "node:path"; | |
| 32 | import { quotaChargeForCall } from "../lib/ai-cost-tracker"; | |
| 33 | ||
| 34 | const USER = "11111111-1111-1111-1111-111111111111"; | |
| 35 | ||
| 36 | describe("quotaChargeForCall", () => { | |
| 37 | it("charges the owner for input plus output tokens", () => { | |
| 38 | expect( | |
| 39 | quotaChargeForCall({ ownerUserId: USER, inputTokens: 900, outputTokens: 100 }) | |
| 40 | ).toEqual({ userId: USER, tokens: 1000 }); | |
| 41 | }); | |
| 42 | ||
| 43 | it("charges nobody when the call has no owner", () => { | |
| 44 | // Platform-internal work (cron sweeps, system tasks) has nobody to bill | |
| 45 | // and must not be charged to whichever user happens to be nearby. | |
| 46 | expect( | |
| 47 | quotaChargeForCall({ ownerUserId: null, inputTokens: 500, outputTokens: 500 }) | |
| 48 | ).toBeNull(); | |
| 49 | expect( | |
| 50 | quotaChargeForCall({ ownerUserId: "", inputTokens: 500, outputTokens: 500 }) | |
| 51 | ).toBeNull(); | |
| 52 | expect( | |
| 53 | quotaChargeForCall({ inputTokens: 500, outputTokens: 500 }) | |
| 54 | ).toBeNull(); | |
| 55 | }); | |
| 56 | ||
| 57 | it("skips calls that consumed nothing", () => { | |
| 58 | expect( | |
| 59 | quotaChargeForCall({ ownerUserId: USER, inputTokens: 0, outputTokens: 0 }) | |
| 60 | ).toBeNull(); | |
| 61 | }); | |
| 62 | ||
| 63 | it("never charges a negative or fractional amount", () => { | |
| 64 | // A bad usage payload must not be able to REFUND quota and hand a user | |
| 65 | // unlimited spend. | |
| 66 | expect( | |
| 67 | quotaChargeForCall({ ownerUserId: USER, inputTokens: -5000, outputTokens: 10 }) | |
| 68 | ).toEqual({ userId: USER, tokens: 10 }); | |
| 69 | expect( | |
| 70 | quotaChargeForCall({ ownerUserId: USER, inputTokens: -50, outputTokens: -50 }) | |
| 71 | ).toBeNull(); | |
| 72 | expect( | |
| 73 | quotaChargeForCall({ ownerUserId: USER, inputTokens: 1.9, outputTokens: 0.9 }) | |
| 74 | ).toEqual({ userId: USER, tokens: 1 }); | |
| 75 | }); | |
| 76 | ||
| 77 | it("tolerates missing token fields", () => { | |
| 78 | expect( | |
| 79 | quotaChargeForCall({ | |
| 80 | ownerUserId: USER, | |
| 81 | inputTokens: undefined as unknown as number, | |
| 82 | outputTokens: 7, | |
| 83 | }) | |
| 84 | ).toEqual({ userId: USER, tokens: 7 }); | |
| 85 | }); | |
| 86 | }); | |
| 87 | ||
| 88 | // --- the wiring must stay connected ---------------------------------------- | |
| 89 | // | |
| 90 | // Strip ONLY line comments: a block-comment regex eats path-like text. | |
| 91 | ||
| 92 | function source(...rel: string[]): string { | |
| 93 | const raw = readFileSync(join(import.meta.dir, "..", ...rel), "utf8"); | |
| 94 | const stripped = raw | |
| 95 | .split("\n") | |
| 96 | .filter((l) => !l.trim().startsWith("//") && !l.trim().startsWith("*")) | |
| 97 | .join("\n"); | |
| 98 | expect(stripped.length).toBeGreaterThan(0); | |
| 99 | return stripped; | |
| 100 | } | |
| 101 | ||
| 102 | /** Slice by anchor, never by character count. */ | |
| 103 | function slice(src: string, startAnchor: string, endAnchor: string): string { | |
| 104 | const start = src.indexOf(startAnchor); | |
| 105 | expect(start).toBeGreaterThan(-1); | |
| 106 | const end = src.indexOf(endAnchor, start + startAnchor.length); | |
| 107 | expect(end).toBeGreaterThan(start); | |
| 108 | const body = src.slice(start, end); | |
| 109 | expect(body.length).toBeGreaterThan(0); | |
| 110 | return body; | |
| 111 | } | |
| 112 | ||
| 113 | describe("recordAiCost meters the quota", () => { | |
| 114 | const src = source("lib", "ai-cost-tracker.ts"); | |
| 115 | ||
| 116 | it("awaits the meter — referencing it is not calling it", () => { | |
| 117 | // Asserting the identifier merely APPEARS is too weak: `void | |
| 118 | // meterAiQuota;` satisfies that while metering nothing. Require the | |
| 119 | // awaited invocation. | |
| 120 | const body = slice( | |
| 121 | src, | |
| 122 | "export async function recordAiCost", | |
| 123 | "export async function summarize" | |
| 124 | ); | |
| 125 | expect(body).toContain("await meterAiQuota(args)"); | |
| 126 | }); | |
| 127 | ||
| 128 | it("meters before the ledger insert, so a ledger failure cannot skip it", () => { | |
| 129 | const body = slice( | |
| 130 | src, | |
| 131 | "export async function recordAiCost", | |
| 132 | "export async function summarize" | |
| 133 | ); | |
| 134 | expect(body.indexOf("await meterAiQuota(args)")).toBeLessThan( | |
| 135 | body.indexOf("insert(aiCostEvents)") | |
| 136 | ); | |
| 137 | }); | |
| 138 | ||
| 139 | it("bumps the column the enforcement path actually reads", () => { | |
| 6ae664d | 140 | expect(src).toContain("bumpUsage("); |
| 489a5ac | 141 | expect(src).toContain("aiTokensUsedThisMonth"); |
| 142 | }); | |
| 143 | ||
| 144 | it("invalidates the cached verdict after bumping", () => { | |
| 145 | // Otherwise a burst of calls keeps reusing a stale allow until the cache | |
| 146 | // expires and sails past the cap. | |
| 147 | const meter = slice(src, "async function meterAiQuota", "export interface"); | |
| 6ae664d | 148 | expect(meter).toContain("invalidateQuotaCache("); |
| 149 | expect(meter.indexOf("bumpUsage(")).toBeLessThan( | |
| 150 | meter.indexOf("invalidateQuotaCache(") | |
| 489a5ac | 151 | ); |
| 152 | }); | |
| 153 | }); | |
| 154 | ||
| 155 | describe("the quota gate rolls the monthly cycle", () => { | |
| 156 | const src = source("lib", "billing.ts"); | |
| 157 | ||
| 158 | it("resets an expired cycle before reading usage", () => { | |
| 159 | const body = slice( | |
| 160 | src, | |
| 161 | "export async function checkAiQuotaCached", | |
| 162 | "export function invalidateQuotaCache" | |
| 163 | ); | |
| 6ae664d | 164 | expect(body).toContain("resetIfCycleExpired("); |
| 165 | expect(body.indexOf("resetIfCycleExpired(")).toBeLessThan( | |
| 489a5ac | 166 | body.indexOf("getUserQuota(userId)") |
| 167 | ); | |
| 168 | }); | |
| 169 | ||
| 170 | it("still fails open on billing errors", () => { | |
| 171 | // Billing must never be a hard dependency of the primary path. | |
| 172 | const body = slice( | |
| 173 | src, | |
| 174 | "export async function checkAiQuotaCached", | |
| 175 | "export function invalidateQuotaCache" | |
| 176 | ); | |
| 177 | expect(body).toContain("allowed: true"); | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 181 | describe("AI entry points still assert the quota", () => { | |
| 182 | for (const file of ["ai-review.ts", "ai-review-trio.ts", "ai-ci-healer.ts"]) { | |
| 183 | it(`${file} calls assertAiQuota`, () => { | |
| 184 | expect(source("lib", file)).toContain("assertAiQuota("); | |
| 185 | }); | |
| 186 | } | |
| 187 | }); |