import { beforeEach, describe, expect, test } from "bun:test"; import { meterDef } from "~/lib/meters"; const { state, accountSql, repoSql, seedSite, points, fakeEnv, captured, quantity, resetMeteringHarness, recordUsage, recordUsageSoon, setGauge, claimScratchUsage, periodOf, MeteringError, reserveUsage, commitUsage, releaseUsage, assertWithinQuota, QuotaExceededError, setPlan, setSpendingLimit, hardCap, billingSettings, ciRunAccount, tokenAccount, repoOwnerAccount, scratchSiteAccount, scratchIpAccount, usageSummary, alertCandidates, publishScopeGauges, runAccountTask, jobSeconds, runnerMeter, aiMeter, onScratchClaimed, ownerWithinSiteQuota, rollupScopeSiteUsage, containsHave, haveDetector, } = await import("./metering.test-helper"); beforeEach(() => resetMeteringHarness()); describe("idempotency", () => { test("a retried event is counted once", async () => { const event = { accountId: "u1", meter: "compute.runner", quantity: 12.5, idempotencyKey: "ci:job1" } as const; expect(await recordUsage(event)).toBe(true); expect(await recordUsage(event)).toBe(false); expect(await recordUsage({ ...event, quantity: 99 })).toBe(false); expect(quantity("u1", "compute.runner")).toBe(12.5); expect(points).toHaveLength(1); }); test("per-second precision is kept, never rounded up to a minute", async () => { await recordUsage({ accountId: "u1", meter: "compute.runner", quantity: jobSeconds(1000, 62_300), idempotencyKey: "a", }); await recordUsage({ accountId: "u1", meter: "compute.runner", quantity: jobSeconds(0, 1), idempotencyKey: "b" }); expect(quantity("u1", "compute.runner")).toBeCloseTo(61.301, 6); expect(jobSeconds(null, 5000)).toBe(0); }); test("validates meter, unit and quantity", async () => { const base = { accountId: "u1", idempotencyKey: "k" }; await expect(recordUsage({ ...base, meter: "nope" as never, quantity: 1 })).rejects.toBeInstanceOf(MeteringError); await expect(recordUsage({ ...base, meter: "storage.lfs", quantity: 1 })).rejects.toBeInstanceOf(MeteringError); await expect(recordUsage({ ...base, meter: "ai.calls", quantity: -1 })).rejects.toBeInstanceOf(MeteringError); await expect(recordUsage({ ...base, meter: "ai.calls", quantity: 1, unit: "bytes" })).rejects.toBeInstanceOf( MeteringError, ); expect(await recordUsage({ ...base, meter: "ai.calls", quantity: 0 })).toBe(false); }); test("recordUsageSoon never throws into the caller", async () => { const pending: Promise[] = []; recordUsageSoon( { waitUntil: (promise) => pending.push(promise) }, { accountId: "u1", meter: "nope" as never, quantity: 1, idempotencyKey: "x" }, ); await Promise.all(pending); expect(pending).toHaveLength(1); }); test("repo and daily rollups follow the event", async () => { const at = Date.UTC(2026, 9, 9, 12); await recordUsage({ accountId: "o1", repoId: "base", meter: "ops.git_ops", quantity: 1, idempotencyKey: "g1", at }); await recordUsage({ accountId: "o1", repoId: "base", meter: "ops.git_ops", quantity: 1, idempotencyKey: "g1", at }); const summary = await usageSummary("o1", "2026-10"); expect(summary.repos).toEqual([{ repoId: "base", name: "web", meter: "ops.git_ops", quantity: 1 }]); expect(summary.days).toEqual([{ day: "2026-10-09", meter: "ops.git_ops", quantity: 1 }]); }); }); describe("period rollover", () => { test("events land in the month of their timestamp", async () => { const lastSecond = Date.UTC(2026, 9, 31, 23, 59, 59, 999); const firstSecond = Date.UTC(2026, 10, 1, 0, 0, 0); await recordUsage({ accountId: "u1", meter: "ai.calls", quantity: 1, idempotencyKey: "a", at: lastSecond }); await recordUsage({ accountId: "u1", meter: "ai.calls", quantity: 1, idempotencyKey: "b", at: firstSecond }); expect(quantity("u1", "ai.calls", "2026-10")).toBe(1); expect(quantity("u1", "ai.calls", "2026-11")).toBe(1); expect(periodOf(lastSecond)).toBe("2026-10"); }); test("gauges bill the period peak and reset each month", async () => { const oct = Date.UTC(2026, 9, 5); await setGauge("u1", "storage.r2", 500, oct); await setGauge("u1", "storage.r2", 200, oct + 1); await setGauge("u1", "storage.r2", 50, Date.UTC(2026, 10, 2)); expect((await usageSummary("u1", "2026-10")).meters.find((m) => m.meter === "storage.r2")?.quantity).toBe(500); expect((await usageSummary("u1", "2026-11")).meters.find((m) => m.meter === "storage.r2")?.quantity).toBe(50); }); }); describe("quotas", () => { test("concurrent reservations never overshoot a hard cap", async () => { fakeEnv.METERING_PLANS = JSON.stringify({ free: { hardCaps: { "ai.calls": 5 } } }); const attempts = await Promise.allSettled( Array.from({ length: 20 }, (_, index) => reserveUsage({ id: `r${index}`, accountId: "u1", meter: "ai.calls", quantity: 1 }), ), ); const granted = attempts.filter((attempt) => attempt.status === "fulfilled"); expect(granted).toHaveLength(5); expect(attempts.filter((a) => a.status === "rejected" && a.reason instanceof QuotaExceededError)).toHaveLength(15); await expect(assertWithinQuota("u1", "ai.calls")).rejects.toBeInstanceOf(QuotaExceededError); fakeEnv.METERING_PLANS = ""; }); test("commit settles once, records the actual amount and frees the hold", async () => { const reservation = await reserveUsage({ id: "e1", accountId: "u1", meter: "compute.editor", quantity: 600 }); expect(await reserveUsage({ id: "e1", accountId: "u1", meter: "compute.editor", quantity: 600 })).toEqual( reservation, ); await commitUsage(reservation, { quantity: 42.25 }); await commitUsage(reservation, { quantity: 42.25 }); const row = accountSql("u1") .query("SELECT quantity, reserved FROM usage_periods WHERE account_id = 'u1' AND meter = 'compute.editor'") .get(); expect(row).toEqual({ quantity: 42.25, reserved: 0 }); }); test("a reservation id only ever matches its own account's hold (INT-A19)", async () => { const mine = await reserveUsage({ id: "shared", accountId: "u1", meter: "compute.editor", quantity: 10 }); const theirs = await reserveUsage({ id: "shared", accountId: "u2", meter: "compute.editor", quantity: 25 }); expect(theirs).toMatchObject({ accountId: "u2", quantity: 25 }); await releaseUsage("shared", "u2"); const held = (id: string) => accountSql(id).query("SELECT reserved FROM usage_periods WHERE meter = 'compute.editor'").get(); expect(held("u1")).toEqual({ reserved: 10 }); expect(held("u2")).toEqual({ reserved: 0 }); expect(await reserveUsage({ id: "shared", accountId: "u1", meter: "compute.editor", quantity: 99 })).toEqual(mine); }); test("released and expired reservations bill nothing", async () => { const held = await reserveUsage({ id: "x1", accountId: "u1", meter: "compute.editor", quantity: 100 }); await releaseUsage(held.id, "u1"); await releaseUsage(held.id, "u1"); await reserveUsage({ id: "x2", accountId: "u1", meter: "compute.editor", quantity: 100, ttlMs: -1 }); await runAccountTask("u1", "reservations"); const row = accountSql("u1") .query("SELECT quantity, reserved FROM usage_periods WHERE account_id = 'u1' AND meter = 'compute.editor'") .get(); expect(row).toEqual({ quantity: 0, reserved: 0 }); }); test("free plan caps AI and cloud runners; pro with a spending limit caps priced meters", async () => { expect(hardCap(await billingSettings("u1"), "ai.calls")).toBe(50); expect(hardCap(await billingSettings("u1"), "compute.runner")).toBe(5 * 3600); expect(hardCap(await billingSettings("u1"), "ops.git_ops")).toBeNull(); await setPlan("u1", "pro", "billing"); const price = meterDef("ai.calls")?.microCentsPerUnit ?? 0; expect(hardCap(await billingSettings("u1"), "ai.calls")).toBeCloseTo(1000 + (5000 * 1e6) / price); await setSpendingLimit("u1", 0); expect(hardCap(await billingSettings("u1"), "ai.calls")).toBe(1000); expect(hardCap(await billingSettings("u1"), "ops.git_ops")).toBeNull(); expect(hardCap(await billingSettings("scratch:site:s1"), "compute.site_requests")).toBe(2_000); }); test("unverified email users get no compute or AI allowance once email can be sent", async () => { state.d1.db.exec(`INSERT INTO users (account_id, email_verified_at, github_id, is_admin) VALUES ('unverified', NULL, NULL, 0), ('verified', 1, NULL, 0), ('github', NULL, 'gh1', 0); INSERT INTO accounts (id, name, kind) VALUES ('unverified', 'unverified', 'user'), ('verified', 'verified', 'user'), ('github', 'github', 'user'), ('farm', 'farm', 'org'), ('real', 'real', 'org'); INSERT INTO org_members (org_id, user_id, role) VALUES ('farm', 'unverified', 'owner'), ('real', 'verified', 'owner'), ('real', 'unverified', 'member')`); expect(hardCap(await billingSettings("unverified"), "ai.calls")).toBe(50); fakeEnv.EMAIL_FROM = "gitfor "; try { expect(hardCap(await billingSettings("unverified"), "ai.calls")).toBe(0); expect(hardCap(await billingSettings("unverified"), "compute.runner")).toBe(0); expect(hardCap(await billingSettings("unverified"), "storage.artifacts")).toBe(1024 ** 3); expect(hardCap(await billingSettings("verified"), "ai.calls")).toBe(50); expect(hardCap(await billingSettings("github"), "compute.runner")).toBe(5 * 3600); expect(hardCap(await billingSettings("farm"), "compute.runner")).toBe(0); expect(hardCap(await billingSettings("real"), "compute.runner")).toBe(5 * 3600); await setPlan("unverified", "pro", "billing"); expect(hardCap(await billingSettings("unverified"), "ai.calls")).toBeGreaterThan(1000); } finally { delete fakeEnv.EMAIL_FROM; } }); test("AI calls stop once the token allowance is spent", async () => { fakeEnv.METERING_PLANS = JSON.stringify({ free: { hardCaps: { "ai.tokens": 100 } } }); const meter = aiMeter({ accountId: "u1", repoId: "base", feature: "mergefix", scope: "tok" }); const index = await meter.beforeCall("glm"); await meter.afterCall(index, "glm", { inputTokens: 90, outputTokens: 20 }); await expect(meter.beforeCall("glm")).rejects.toBeInstanceOf(QuotaExceededError); fakeEnv.METERING_PLANS = ""; }); test("AI calls are reserved before the call and BYOK is free and uncapped", async () => { fakeEnv.METERING_PLANS = JSON.stringify({ free: { hardCaps: { "ai.calls": 1 } } }); const meter = aiMeter({ accountId: "u1", repoId: "base", feature: "mergefix", scope: "run1" }); const index = await meter.beforeCall("glm"); await meter.afterCall(index, "glm", { inputTokens: 100, outputTokens: 20.4 }); await expect(meter.beforeCall("glm")).rejects.toBeInstanceOf(QuotaExceededError); await meter.beforeCall("gpt", true); const retry = aiMeter({ accountId: "u1", repoId: "base", feature: "mergefix", scope: "run1" }); await retry.beforeCall("glm"); expect(quantity("u1", "ai.calls")).toBe(1); expect(quantity("u1", "ai.tokens")).toBe(120); expect(quantity("u1", "ai.byok_calls")).toBe(1); fakeEnv.METERING_PLANS = ""; }); test("every settled AI call reports ai_usage once, platform and BYOK alike", async () => { captured.length = 0; const meter = aiMeter({ accountId: "u2", repoId: "base", feature: "mergefix", scope: "usage" }); const platform = await meter.beforeCall("glm"); await meter.afterCall(platform, "glm", { inputTokens: 10, outputTokens: 5 }); const byok = await meter.beforeCall("my-model", true); await meter.afterCall(byok, "my-model", { inputTokens: 3, outputTokens: 2 }, true); const usage = captured.filter((entry) => entry.event === "ai_usage").map((entry) => entry.properties); expect(usage).toEqual([ { feature: "mergefix", provider_kind: "platform", model: "glm", input_tokens: 10, output_tokens: 5, calls: 1 }, { feature: "mergefix", provider_kind: "byok", model: "byok", input_tokens: 3, output_tokens: 2, calls: 1 }, ]); }); }); describe("attribution", () => { test("fork PR CI is billed to the base repo owner", async () => { expect(await ciRunAccount("run1")).toEqual({ accountId: "o1", repoId: "base" }); expect(await repoOwnerAccount("fork")).toBe("u2"); }); test("agent tokens bill the authorizer, plain tokens their user", async () => { expect(await tokenAccount("t-agent")).toBe("u1"); expect(await tokenAccount("t-plain")).toBe("u2"); }); test("runner sizes map to their meters; self-hosted is free", () => { expect(runnerMeter(["ubuntu-latest"])).toBe("compute.runner"); expect(runnerMeter(["cloudflare-large"])).toBe("compute.runner_large"); expect(runnerMeter(["ubuntu-latest-8-cores"])).toBe("compute.runner_xlarge"); expect(runnerMeter(["self-hosted", "linux"])).toBe("compute.runner_self_hosted"); }); test("claiming scratch usage moves it without double counting and redirects later events", async () => { expect(scratchIpAccount("ab".repeat(40))).toBe(`scratch:ip:${"ab".repeat(16)}`); const scratch = scratchSiteAccount("s1"); await recordUsage({ accountId: scratch, meter: "compute.site_requests", quantity: 7, idempotencyKey: "s:1" }); await recordUsage({ accountId: "u1", meter: "compute.site_requests", quantity: 3, idempotencyKey: "s:2" }); await setGauge(scratch, "storage.sites", 1000); await claimScratchUsage(scratch, "u1"); await claimScratchUsage(scratch, "u1"); expect(quantity("u1", "compute.site_requests")).toBe(10); expect(quantity(scratch, "compute.site_requests")).toBe(0); const days = accountSql("u1") .query("SELECT SUM(quantity) AS total FROM usage_days WHERE meter = 'compute.site_requests'") .get(); expect(days).toEqual({ total: 10 }); await recordUsage({ accountId: scratch, meter: "compute.site_requests", quantity: 1, idempotencyKey: "s:3" }); expect(quantity("u1", "compute.site_requests")).toBe(11); expect( await recordUsage({ accountId: "u1", meter: "compute.site_requests", quantity: 1, idempotencyKey: "s:1" }), ).toBe(false); const gauge = (await usageSummary("u1")).meters.find((m) => m.meter === "storage.sites"); expect(gauge?.quantity).toBe(1000); }); test("site counters roll up once per finished day, and a claim folds scratch-site usage into the claimer", async () => { const owned = seedSite({ id: "owned", ownerId: "u1" }); const scr = seedSite({ id: "scr", ownerId: null }); const now = Date.parse("2026-10-12T12:00:00Z"); owned.exec(`INSERT INTO site_usage VALUES ('owned', '2026-10-11', 'u1', 40, 900, 5000), ('owned', '2026-10-12', 'u1', 999, 0, 0)`); scr.exec("INSERT INTO site_usage VALUES ('scr', '2026-10-11', NULL, 7, 0, 300)"); for (const scope of ["site:owned", "site:scr", "site:owned"]) await rollupScopeSiteUsage(scope, now); expect(quantity("u1", "compute.site_requests", "2026-10")).toBe(40); expect(quantity("u1", "network.sites", "2026-10")).toBe(5000); expect(quantity("u1", "compute.site_cpu", "2026-10")).toBe(900); expect(quantity("scratch:site:scr", "compute.site_requests", "2026-10")).toBe(7); await onScratchClaimed("scr", "u2"); expect(quantity("u2", "compute.site_requests", "2026-10")).toBe(7); expect(quantity("scratch:site:scr", "compute.site_requests", "2026-10")).toBe(0); }); test("free plan stops at 100%: storage gauges block writes and over-cap owners stop serving sites", async () => { accountSql("u1").exec( `INSERT INTO usage_gauges (account_id, meter, period, current, peak, updated_at) VALUES ('u1', 'storage.lfs', '${periodOf()}', ${0.9 * 1024 ** 3}, 0, 0)`, ); await assertWithinQuota("u1", "storage.lfs", 0.05 * 1024 ** 3); await expect(assertWithinQuota("u1", "storage.lfs", 0.2 * 1024 ** 3)).rejects.toBeInstanceOf(QuotaExceededError); await recordUsage({ accountId: "u1", meter: "network.sites", quantity: 10 * 1024 ** 3, idempotencyKey: "nb:1" }); const scope = seedSite({ id: "s", ownerId: "u1" }); const site = { id: "s", ownerId: "u1", scratch: false, repoId: null }; expect(await ownerWithinSiteQuota(site)).toBe(true); const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10); scope.exec(`INSERT INTO site_usage VALUES ('s', '${yesterday}', 'u1', 1, 0, 1)`); await rollupScopeSiteUsage("site:s"); expect(await ownerWithinSiteQuota(site)).toBe(false); await setPlan("u1", "pro", "billing"); expect(await ownerWithinSiteQuota(site)).toBe(true); }); test("LFS storage gauge reuses the refcounted per-account total", async () => { accountSql("u2").exec("INSERT INTO lfs_usage VALUES ('u2', 4096, '', 0)"); repoSql("base") .exec(`INSERT INTO action_caches (id, repo_id, ref, key, version, size, r2_key, created_at, last_used_at) VALUES ('c1', 'base', 'main', 'a', '1', 10, 'k1', 0, 0), ('c2', 'base', 'main', 'b', '1', 5, 'k2', 0, 0), ('c3', 'base', 'dev', 'b', '1', 5, 'k2', 0, 0)`); // The repository reports its storage to its owner; each account sums its scopes and its LFS total. await publishScopeGauges("base"); await publishScopeGauges("base"); await runAccountTask("o1", "scope-gauges"); await runAccountTask("u2", "lfs-gauge"); const summary = await usageSummary("u2"); expect(summary.meters.find((m) => m.meter === "storage.lfs")?.quantity).toBe(4096); expect((await usageSummary("o1")).meters.find((m) => m.meter === "storage.ci_cache")?.quantity).toBe(15); }); }); describe("alerts", () => { test("thresholds fire once per period to the user or org owners", async () => { fakeEnv.METERING_PLANS = JSON.stringify({ free: { hardCaps: { "ai.calls": 10 } } }); for (const index of [0, 1, 2, 3, 4, 5, 6, 7]) { await recordUsage({ accountId: "o1", meter: "ai.calls", quantity: 1, idempotencyKey: `a${index}` }); } expect(alertCandidates(await usageSummary("o1")).map((c) => c.threshold)).toEqual([80]); await runAccountTask("o1", "alerts"); await runAccountTask("o1", "alerts"); const rows = accountSql("u1").query("SELECT user_id, url, reason FROM notifications").all(); expect(rows).toEqual([{ user_id: "u1", url: "https://forge.test/orgs/acme/billing", reason: "billing" }]); fakeEnv.METERING_PLANS = ""; }); test("git have-line detection distinguishes fetch from clone", () => { const encode = (text: string) => new TextEncoder().encode(text); expect(containsHave(encode("0032want abc\n0032have def\n"))).toBe(true); expect(containsHave(encode("0032want abc\n00000009done\n"))).toBe(false); }); test("have detection survives chunk boundaries", () => { const encode = (text: string) => new TextEncoder().encode(text); const detector = haveDetector(); detector.push(encode("0032want abc\n0032ha")); expect(detector.seen).toBe(false); detector.push(encode("ve def\n")); expect(detector.seen).toBe(true); }); });