package main import ( "net/http" "testing" "time" ) func testRates() map[string]aiRates { return map[string]aiRates{ // Round numbers so the arithmetic below reads at a glance: // €10/M in, €50/M out, €12.50/M cache-write, €1/M cache-read. "claude-sonnet-5": {In: 10, Out: 50, CacheWrite: 12.5, CacheRead: 1}, } } func TestParseAIRates(t *testing.T) { rates, err := parseAIRates(`{"claude-sonnet-5":{"in":3,"out":15,"cw":3.75,"cr":0.3}}`) if err != nil || rates["claude-sonnet-5"].Out != 15 { t.Fatalf("parse: %v %+v", err, rates) } if r, err := parseAIRates(""); err != nil || r != nil { t.Fatalf("empty must be off: %v %v", r, err) } if _, err := parseAIRates(`{"m":{"in":-1}}`); err == nil { t.Fatal("negative rates must be refused") } if _, err := parseAIRates(`nonsense`); err == nil { t.Fatal("malformed JSON must be refused") } } func TestUsageCostEUR(t *testing.T) { rows := []botUsageRow{ {Model: "claude-sonnet-5", Dimension: "llm_input_tokens", Quantity: 1_000_000}, // €10 {Model: "claude-sonnet-5", Dimension: "llm_output_tokens", Quantity: 200_000}, // €10 {Model: "claude-sonnet-5", Dimension: "llm_cache_read_tokens", Quantity: 3_000_000}, // €3 {Model: "claude-sonnet-5", Dimension: "llm_requests", Quantity: 42}, // request counts are free {Dimension: "charter_runs", Quantity: 7}, // activity, not money {Model: "unpriced-model", Dimension: "llm_output_tokens", Quantity: 1_000_000}, // no rate → 0 + a log line } if got := usageCostEUR(rows, testRates()); got != 23 { t.Fatalf("cost = %v, want 23", got) } } // The state machine end to end through the ingest handler: under allowance is // ok, over allowance is exhausted, and a drained fleet pool exhausts a space // that is itself under its allowance. func TestBudgetStateThroughIngest(t *testing.T) { db := openTestCloudDB(t) a := insertSpaceRow(t, db, "a.11ch.at", 1000) b := insertSpaceRow(t, db, "b.11ch.at", 1000) for id, tok := range map[int64]string{a: "tok-a", b: "tok-b"} { if err := db.setMeterTokenHash(id, hashToken(tok)); err != nil { t.Fatal(err) } } s := &cloudServer{db: db, aiRates: testRates(), aiAllowanceEUR: 5, aiPoolEUR: 12} day := time.Now().UTC().Format("2006-01-02") report := func(tok, reportID string, outTokens int64) map[string]any { t.Helper() code, body := ingestUsageReport(t, s, tok, reportID, []botUsageRow{ {Day: day, Model: "claude-sonnet-5", Dimension: "llm_output_tokens", Quantity: outTokens}}) if code != http.StatusOK { t.Fatalf("ingest: %d %v", code, body) } return body["budget"].(map[string]any) } // €2 of output: comfortably inside the €5 allowance. if st := report("tok-a", "aaaaaaaaaaaaaaa1", 40_000); st["state"] != "ok" { t.Fatalf("under allowance: %v", st) } // Another €4 → €6 total: over the per-space allowance. if st := report("tok-a", "aaaaaaaaaaaaaaa2", 80_000); st["state"] != "exhausted" { t.Fatalf("over allowance: %v", st) } // Space B is fresh (€4 < €5 allowance)… but the fleet pool (€12) is now // €6 + €4 = €10; one more €4 pushes past it and B exhausts too. if st := report("tok-b", "bbbbbbbbbbbbbbb1", 80_000); st["state"] != "ok" { t.Fatalf("pool not yet drained: %v", st) } if st := report("tok-b", "bbbbbbbbbbbbbbb2", 80_000); st["state"] != "exhausted" { t.Fatalf("pool drained must exhaust: %v", st) } // Unconfigured stays measure-only whatever the ledger says. s2 := &cloudServer{db: db} if st := s2.budgetState(a); st != "ok" { t.Fatalf("unconfigured: %q", st) } }