Public: anyone can read this; the crew can change it.
A test proves spend reported from outside is a claim kept apart: recorded as said, shown apart, never added into the crew's returns or read by a limit #7611
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7593-a-test-proves-spend-reported-from-outsid → main 1 commit fork
1 file
changed +226
−0
test/outside-spend.test.ts added +226
| @@ -0,0 +1,226 @@ | ||
| 1 | /** | |
| 2 | * Spend reported from outside is a claim kept apart (#7593): `reportSpend` | |
| 3 | * records it as said, `readReturns` shows it in `outside` only, and no | |
| 4 | * limit the crew is paced by — the repository's daily ceiling, the forge's, | |
| 5 | * the account's, the subscription's budget and standing, the governor — | |
| 6 | * reads it. Every read is the real one, snapshotted before and after a | |
| 7 | * report big enough to trip each limit were it ever added in. | |
| 8 | */ | |
| 9 | ||
| 10 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 11 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 16 | import { openIssue } from "../src/actions/issues.ts"; | |
| 17 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 18 | import { readReturns, reportSpend } from "../src/actions/returns.ts"; | |
| 19 | import { daySpent, dailyLeft, setAccountCeiling } from "../src/core/account_ceiling.ts"; | |
| 20 | import type { Issue } from "../src/core/issues.ts"; | |
| 21 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 22 | import { createRun } from "../src/core/runs.ts"; | |
| 23 | import { standings } from "../src/core/standing.ts"; | |
| 24 | import { addSubscription, budgetsOf, setBudget } from "../src/core/subscriptions.ts"; | |
| 25 | import { recordUsage, usageTotals } from "../src/core/usage.ts"; | |
| 26 | import { createUser, type User } from "../src/core/users.ts"; | |
| 27 | import { autonomyStatus, spentToday, spentTodayEverywhere } from "../src/crew/autonomy.ts"; | |
| 28 | import { governorFor } from "../src/crew/governed.ts"; | |
| 29 | import { quietLogs } from "./helpers/globals.ts"; | |
| 30 | ||
| 31 | quietLogs(); | |
| 32 | ||
| 33 | let dataDir = ""; | |
| 34 | let app: App; | |
| 35 | let alice: User; | |
| 36 | let bob: User; | |
| 37 | let repo: Repo; | |
| 38 | let secret: Repo; | |
| 39 | let issue: Issue; | |
| 40 | let subscriptionId = ""; | |
| 41 | /** The crew's own spend: one $2 run, today. */ | |
| 42 | const RUN_USD = 2; | |
| 43 | /** A claim far past every limit below; were it added in, each would move. */ | |
| 44 | const CLAIM_USD = 9_999.5; | |
| 45 | ||
| 46 | async function person(username: string, admin = false): Promise<User> { | |
| 47 | const made = await createUser(app.db, { | |
| 48 | origin: "seed", | |
| 49 | trusted: true, | |
| 50 | username, | |
| 51 | email: `${username}@forge.local`, | |
| 52 | password: "a-good-passphrase", | |
| 53 | adminIfFirst: admin, | |
| 54 | }); | |
| 55 | if (!made.ok) throw new Error(made.error); | |
| 56 | return made.value; | |
| 57 | } | |
| 58 | ||
| 59 | /** Every spend read a limit or a page takes, at one instant. */ | |
| 60 | function reads(now: Date) { | |
| 61 | const since = new Date(now.getTime() - 86_400_000).toISOString(); | |
| 62 | const returns = readReturns(app, alice, { repo: "alice/mill", days: 1, now }); | |
| 63 | if (!returns.ok) throw new Error(returns.error); | |
| 64 | const { outside: _outside, ...crew } = returns.value; | |
| 65 | return { | |
| 66 | returns: crew, | |
| 67 | spentToday: spentToday(app.db, repo.id, now), | |
| 68 | spentEverywhere: spentTodayEverywhere(app.db, now), | |
| 69 | status: autonomyStatus(app, getRepoById(app.db, repo.id)!, now), | |
| 70 | daySpent: daySpent(app.db, alice.id, now), | |
| 71 | dailyLeft: dailyLeft(app.db, alice.id, now), | |
| 72 | usage: usageTotals(app.db, "repo", { since, repoId: repo.id }), | |
| 73 | budget: budgetsOf(app.db).get(subscriptionId), | |
| 74 | standing: standings(app.db, [subscriptionId], now).get(subscriptionId), | |
| 75 | governor: governorFor(app, getRepoById(app.db, repo.id)!, now), | |
| 76 | }; | |
| 77 | } | |
| 78 | ||
| 79 | beforeAll(async () => { | |
| 80 | dataDir = await mkdtemp(join(tmpdir(), "forge-outside-spend-")); | |
| 81 | app = createApp({ dataDir, port: 0 }); | |
| 82 | alice = await person("alice", true); | |
| 83 | bob = await person("bob"); | |
| 84 | const made = await createRepo(app, alice, { name: "mill", visibility: "public" }); | |
| 85 | const hidden = await createRepo(app, alice, { name: "vault", visibility: "private" }); | |
| 86 | if (!made.ok || !hidden.ok) throw new Error("repos"); | |
| 87 | repo = made.value; | |
| 88 | secret = hidden.value; | |
| 89 | const agent = await createAgent(app, alice, { username: "bot" }); | |
| 90 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 91 | expect(addToCrew(app, alice, repo, "bot").ok).toBe(true); | |
| 92 | const on = await updateRepoSettings(app, alice, repo, { | |
| 93 | autonomy: "autonomous", | |
| 94 | autonomyDirection: "Grind the grain", | |
| 95 | autonomyCeilingUsd: 5, | |
| 96 | autonomySlots: 3, | |
| 97 | autonomyBy: alice.id, | |
| 98 | }); | |
| 99 | expect(on.ok).toBe(true); | |
| 100 | const opened = openIssue(app, alice, repo, { title: "Mill the flour" }); | |
| 101 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 102 | issue = opened.value; | |
| 103 | const sub = addSubscription(app.db, dataDir, { name: "work-plan", token: "sk-ant-api-test-token" }); | |
| 104 | if (!sub.ok) throw new Error(sub.error); | |
| 105 | subscriptionId = sub.value.id; | |
| 106 | expect(setBudget(app.db, subscriptionId, 10)).toBe(true); | |
| 107 | expect(setAccountCeiling(app.db, alice.id, 5, 5, alice.id, new Date())).toBe(true); | |
| 108 | ||
| 109 | const run = createRun(app.db, { | |
| 110 | repoId: repo.id, | |
| 111 | kind: "agent", | |
| 112 | issueId: issue.id, | |
| 113 | branch: "", | |
| 114 | actorId: agent.value.user.id, | |
| 115 | reason: "assigned", | |
| 116 | }); | |
| 117 | const at = new Date().toISOString(); | |
| 118 | app.db | |
| 119 | .query(`UPDATE run SET state = 'ok', cost_usd = ?2, summary = 'ok', started_at = ?3, finished_at = ?3 WHERE id = ?1`) | |
| 120 | .run(run.id, RUN_USD, at); | |
| 121 | recordUsage(app.db, { | |
| 122 | runId: run.id, | |
| 123 | repoId: repo.id, | |
| 124 | issueId: issue.id, | |
| 125 | agentId: agent.value.user.id, | |
| 126 | subscriptionId, | |
| 127 | tokensIn: 5_000, | |
| 128 | tokensOut: 1_000, | |
| 129 | costUsd: RUN_USD, | |
| 130 | wallMs: 60_000, | |
| 131 | outcome: "ok", | |
| 132 | at, | |
| 133 | }); | |
| 134 | }); | |
| 135 | ||
| 136 | afterAll(async () => { | |
| 137 | app.close?.(); | |
| 138 | await rm(dataDir, { recursive: true, force: true }); | |
| 139 | }); | |
| 140 | ||
| 141 | test("who may report: no one signed in is unauthorized; a reader and a stranger are not_found", () => { | |
| 142 | const claim = { who: "the build team", usd: 1 }; | |
| 143 | const none = reportSpend(app, undefined, repo, claim); | |
| 144 | expect(none).toEqual({ ok: false, error: "unauthorized" }); | |
| 145 | // bob reads the public mill, and is a stranger to the private vault. | |
| 146 | expect(reportSpend(app, bob, repo, claim)).toEqual({ ok: false, error: "not_found" }); | |
| 147 | expect(reportSpend(app, bob, secret, claim)).toEqual({ ok: false, error: "not_found" }); | |
| 148 | const kept = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM outside_spend").get()!.n; | |
| 149 | expect(kept).toBe(0); | |
| 150 | }); | |
| 151 | ||
| 152 | test("a bad amount, a missing who or an unknown issue is invalid", () => { | |
| 153 | for (const input of [ | |
| 154 | { who: "the build team", usd: -1 }, | |
| 155 | { who: "the build team", usd: 10_000.01 }, | |
| 156 | { who: "the build team", usd: Number.NaN }, | |
| 157 | { who: "the build team", usd: "3" }, | |
| 158 | { who: "the build team" }, | |
| 159 | { who: "the build team", usd: 1, tokensIn: 1.5 }, | |
| 160 | { who: "", usd: 1 }, | |
| 161 | { who: "the build team", usd: 1, issue: 999 }, | |
| 162 | ]) { | |
| 163 | expect(reportSpend(app, alice, repo, input)).toEqual({ ok: false, error: "invalid" }); | |
| 164 | } | |
| 165 | const kept = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM outside_spend").get()!.n; | |
| 166 | expect(kept).toBe(0); | |
| 167 | }); | |
| 168 | ||
| 169 | test("a report is kept as said, shown apart, and moves no total and no limit", () => { | |
| 170 | const now = new Date(Date.now() + 1_000); | |
| 171 | const before = reads(now); | |
| 172 | // The fixture sits under every limit, so a claim added in would trip each. | |
| 173 | expect(before.spentToday).toBe(RUN_USD); | |
| 174 | expect(before.status.paused).toBeNull(); | |
| 175 | expect(before.dailyLeft).toBe(3); | |
| 176 | expect(before.returns.spent.usd).toBe(RUN_USD); | |
| 177 | ||
| 178 | const made = reportSpend(app, alice, repo, { | |
| 179 | who: "the build team", | |
| 180 | model: "claude-opus-5-5", | |
| 181 | tokensIn: 120_000, | |
| 182 | tokensOut: 30_000, | |
| 183 | usd: CLAIM_USD, | |
| 184 | issue: issue.number, | |
| 185 | note: "a long session at home", | |
| 186 | }); | |
| 187 | if (!made.ok) throw new Error(made.error); | |
| 188 | expect(made.value).toMatchObject({ | |
| 189 | repoId: repo.id, | |
| 190 | issueId: issue.id, | |
| 191 | reporterId: alice.id, | |
| 192 | who: "the build team", | |
| 193 | usd: CLAIM_USD, | |
| 194 | }); | |
| 195 | ||
| 196 | // Read back from the table exactly as said: amount, issue, who said it. | |
| 197 | const row = app.db | |
| 198 | .query<Record<string, unknown>, [string]>( | |
| 199 | `SELECT repo_id, issue_id, reporter_id, who, model, tokens_in, tokens_out, usd, note | |
| 200 | FROM outside_spend WHERE id = ?1`, | |
| 201 | ) | |
| 202 | .get(made.value.id); | |
| 203 | expect(row).toEqual({ | |
| 204 | repo_id: repo.id, | |
| 205 | issue_id: issue.id, | |
| 206 | reporter_id: alice.id, | |
| 207 | who: "the build team", | |
| 208 | model: "claude-opus-5-5", | |
| 209 | tokens_in: 120_000, | |
| 210 | tokens_out: 30_000, | |
| 211 | usd: CLAIM_USD, | |
| 212 | note: "a long session at home", | |
| 213 | }); | |
| 214 | ||
| 215 | const after = reads(now); | |
| 216 | expect(after).toEqual(before); | |
| 217 | expect(after.status.paused).toBeNull(); | |
| 218 | ||
| 219 | // The claim appears only in its own field. | |
| 220 | const returns = readReturns(app, alice, { repo: "alice/mill", days: 1, now }); | |
| 221 | if (!returns.ok) throw new Error(returns.error); | |
| 222 | expect(returns.value.outside).toEqual([ | |
| 223 | { who: "the build team", reports: 1, tokensIn: 120_000, tokensOut: 30_000, usd: CLAIM_USD }, | |
| 224 | ]); | |
| 225 | expect(returns.value.spent).toEqual({ runs: 1, usd: RUN_USD }); | |
| 226 | }); |
new above
offline