/** * End-to-end check for usage metering against a live dev server running this worktree (default * http://localhost:3000, override with MCP_BASE_URL). It seeds a user and repo, then exercises git clone, * push and fetch, LFS upload and download, a raw and an archive download, and a mergefix AI run (the * server must use MERGEFIX_PROVIDER=mock). It then checks that the usage API and the billing page report * the same numbers, and that a replayed git push never double counts. * * MCP_BASE_URL=http://localhost:3191 bun test ./e2e/metering.test.ts */ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { randomBytes } from "node:crypto"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { api, BASE, body, seedWorld, sql, sqlRows, teardown } from "./mcp-helpers"; import type { World } from "./mcp-helpers"; type Meter = { meter: string; quantity: number }; type Summary = { meters: Meter[]; repos: { name: string | null; meter: string; quantity: number }[]; totalCents: number; }; let world: World; let work: string; const TIMEOUT = 180_000; function git(args: string[], cwd = work): string { const result = Bun.spawnSync(["git", "-c", "credential.helper=", ...args], { cwd, stdout: "pipe", stderr: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_LFS_SKIP_SMUDGE: "0", }, }); if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${result.stderr.toString()}`); return result.stdout.toString(); } function remote(repo: string): string { const url = new URL(`${BASE}/${world.owner}/${repo}.git`); url.username = world.owner; url.password = world.classicToken; return url.toString(); } async function usage(): Promise { const response = await api(world, "/user/usage"); expect(response.status).toBe(200); return body(response); } function quantity(summary: Summary, meter: string): number { return summary.meters.find((row) => row.meter === meter)?.quantity ?? 0; } async function eventually(check: () => Promise, what: string): Promise { const deadline = Date.now() + 60_000; while (Date.now() < deadline) { if (await check()) return; await Bun.sleep(1000); } throw new Error(`timed out waiting for ${what}`); } beforeAll(async () => { world = await seedWorld(); work = mkdtempSync(join(tmpdir(), "metering-e2e-")); }, TIMEOUT); afterAll(async () => { if (world) await teardown(world); }); describe("metering e2e", () => { test( "git clone, push and fetch are counted per operation with bytes", async () => { git(["clone", remote(world.repo), "repo"]); const repoDir = join(work, "repo"); git(["config", "user.email", "e2e@example.test"], repoDir); git(["config", "user.name", "e2e"], repoDir); writeFileSync(join(repoDir, "data.txt"), randomBytes(25_000).toString("hex")); git(["add", "."], repoDir); git(["commit", "-m", "Add data"], repoDir); git(["push", "origin", "HEAD:main"], repoDir); git(["clone", remote(world.repo), "other"]); const otherDir = join(work, "other"); git(["config", "user.email", "e2e@example.test"], otherDir); git(["config", "user.name", "e2e"], otherDir); writeFileSync(join(otherDir, "other.txt"), "from another clone\n"); git(["add", "."], otherDir); git(["commit", "-m", "Other"], otherDir); git(["push", "origin", "HEAD:main"], otherDir); git(["pull", "--rebase", "origin", "main"], repoDir); await eventually(async () => { const rows = sqlRows<{ op: string; n: number }>( `SELECT json_extract(attributes, '$.op') AS op, COUNT(*) AS n FROM usage_events WHERE account_id = '${world.userId}' AND meter = 'ops.git_ops' GROUP BY op`, ); const ops = Object.fromEntries(rows.map((row) => [row.op, row.n])); return (ops.clone ?? 0) >= 2 && (ops.push ?? 0) >= 2 && (ops.fetch ?? 0) >= 1; }, "git ops"); const summary = await usage(); expect(quantity(summary, "ops.git_bytes")).toBeGreaterThan(10_000); expect(summary.repos.some((row) => row.name === world.repo && row.meter === "ops.git_ops")).toBe(true); const estimate = sqlRows<{ pack_bytes: number }>( `SELECT pack_bytes FROM repo_storage_estimates WHERE repo_id = '${world.repoId}'`, ); expect(estimate[0]?.pack_bytes ?? 0).toBeGreaterThan(0); }, TIMEOUT, ); test( "LFS download bandwidth is metered on network.lfs", async () => { const repoDir = join(work, "repo"); git(["lfs", "install", "--local"], repoDir); git(["lfs", "track", "*.bin"], repoDir); const blobBytes = 200_000; writeFileSync(join(repoDir, "blob.bin"), new Uint8Array(blobBytes).fill(7)); git(["add", ".gitattributes", "blob.bin"], repoDir); git(["commit", "-m", "Add LFS blob"], repoDir); git(["push", "origin", "HEAD:main"], repoDir); git(["clone", remote(world.repo), "lfs-clone"]); await eventually(async () => quantity(await usage(), "network.lfs") >= blobBytes, "LFS bandwidth"); }, TIMEOUT, ); test( "raw and archive downloads are metered on network.downloads", async () => { const before = quantity(await usage(), "network.downloads"); const headers = { authorization: `Bearer ${world.classicToken}` }; const raw = await fetch(`${BASE}/${world.owner}/${world.repo}/raw/main/data.txt`, { headers }); expect((await raw.arrayBuffer()).byteLength).toBe(50_000); const archive = await fetch(`${BASE}/${world.owner}/${world.repo}/archive/main.tar.gz`, { headers }); expect(archive.status).toBe(200); const archiveBytes = (await archive.arrayBuffer()).byteLength; await eventually( async () => quantity(await usage(), "network.downloads") >= before + 50_000 + archiveBytes, "download bytes", ); }, TIMEOUT, ); test( "a mergefix AI run is metered through the AI Gateway path", async () => { const repoDir = join(work, "repo"); git(["checkout", "-b", "feature", "HEAD~3"], repoDir); writeFileSync(join(repoDir, "README.md"), "# feature side\n"); git(["commit", "-am", "Feature README"], repoDir); git(["push", "origin", "feature"], repoDir); git(["checkout", "-B", "main", "origin/main"], repoDir); writeFileSync(join(repoDir, "README.md"), "# main side\n"); git(["commit", "-am", "Main README"], repoDir); git(["push", "origin", "main"], repoDir); const pull = await api(world, `/repos/${world.owner}/${world.repo}/pulls`, { method: "POST", body: JSON.stringify({ title: "Conflicting", head: "feature", base: "main" }), }); expect(pull.status).toBe(201); const { number } = await body<{ number: number }>(pull); sql( `INSERT INTO repo_mergefix_settings (repo_id, allow_private, updated_at) VALUES ('${world.repoId}', 1, 0) ON CONFLICT (repo_id) DO UPDATE SET allow_private = 1`, ); const started = await api(world, `/repos/${world.owner}/${world.repo}/pulls/${number}/ai-fix`, { method: "POST", }); expect([202, 200]).toContain(started.status); await eventually(async () => quantity(await usage(), "ai.calls") >= 1, "AI call metering"); const summary = await usage(); expect(quantity(summary, "ai.tokens")).toBeGreaterThanOrEqual(0); }, TIMEOUT, ); test( "replaying an idempotency key never double counts, and the billing page shows the API numbers", async () => { const key = sqlRows<{ idempotency_key: string; quantity: number }>( `SELECT idempotency_key, quantity FROM usage_events WHERE account_id = '${world.userId}' AND meter = 'ops.git_bytes' LIMIT 1`, )[0]; const before = quantity(await usage(), "ops.git_bytes"); sqlRows( `INSERT OR IGNORE INTO usage_events (idempotency_key, account_id, meter, period, quantity, created_at) VALUES ('${key.idempotency_key}', '${world.userId}', 'ops.git_bytes', strftime('%Y-%m', 'now'), ${key.quantity}, 0)`, ); expect(quantity(await usage(), "ops.git_bytes")).toBe(before); const page = await fetch(`${BASE}/settings/billing`, { headers: { cookie: world.sessionCookie } }); expect(page.status).toBe(200); const html = await page.text(); const shown = /data-testid="usage-ai.calls"[^>]*>([^<]+)/.exec(html)?.[1]?.trim(); expect(shown).toBe(String(Math.round(quantity(await usage(), "ai.calls")))); expect(html).toContain("Billing and usage"); }, TIMEOUT, ); });