Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

ai-quota-metering.test.tsBlame187 lines · 1 contributor
489a5acccantynz-alt1/**
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
29import { describe, it, expect } from "bun:test";
30import { readFileSync } from "node:fs";
31import { join } from "node:path";
32import { quotaChargeForCall } from "../lib/ai-cost-tracker";
33
34const USER = "11111111-1111-1111-1111-111111111111";
35
36describe("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
92function 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. */
103function 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
113describe("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", () => {
6ae664dccantynz-alt140 expect(src).toContain("bumpUsage(");
489a5acccantynz-alt141 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");
6ae664dccantynz-alt148 expect(meter).toContain("invalidateQuotaCache(");
149 expect(meter.indexOf("bumpUsage(")).toBeLessThan(
150 meter.indexOf("invalidateQuotaCache(")
489a5acccantynz-alt151 );
152 });
153});
154
155describe("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 );
6ae664dccantynz-alt164 expect(body).toContain("resetIfCycleExpired(");
165 expect(body.indexOf("resetIfCycleExpired(")).toBeLessThan(
489a5acccantynz-alt166 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
181describe("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});