using ClawdDotNet.Core.Accounting; using ClawdDotNet.Core.Budget; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Storage; using Shouldly; namespace ClawdDotNet.Core.Tests.Budget; /// /// K5 aus der Bestandsaufnahme: Es gab keine Obergrenze. Ein Agent in einer Schleife — /// etwa durch gegenseitige send_message-Aufrufe — konnte unbeaufsichtigt Guthaben /// verbrennen; die Credits-Anzeige war rein informativ. /// public sealed class BudgetGuardTests : IDisposable { private readonly string _directory; private readonly SqliteUsageRepository _usage; private readonly DateTime _heute = new(2026, 7, 28, 14, 0, 0, DateTimeKind.Local); public BudgetGuardTests() { _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); _usage = new SqliteUsageRepository(new SqliteStorage(Path.Combine(_directory, "state.db"))); } public void Dispose() { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); try { Directory.Delete(_directory, recursive: true); } catch { /* Aufräumen ist Nebensache */ } } private BudgetGuard Guard() => new(_usage, () => _heute); private static AgentConfig Agent(decimal costLimit = 0, long tokenLimit = 0, string id = "agent-a") => new() { AgentId = id, Budget = new AgentBudget { DailyCostUsd = costLimit, DailyTokens = tokenLimit } }; private Task Record(decimal cost, int prompt = 1000, int completion = 100, string agentId = "agent-a", bool costKnown = true, DateTime? when = null) => _usage.RecordAsync(new RunUsage { AgentId = agentId, Model = "test/model", PromptTokens = prompt, CompletionTokens = completion, CostUsd = cost, CostIsKnown = costKnown, OccurredAt = when ?? _heute }, default); // ═══════════════════════════════════════════════════════════ // Ohne Grenzen // ═══════════════════════════════════════════════════════════ [Fact] public async Task Ohne_gesetzte_Grenze_darf_immer_gelaufen_werden() { await Record(cost: 1_000m); var status = await Guard().CheckAsync(Agent(), InstanceBudget.Unlimited, default); status.IsAllowed.ShouldBeTrue(); } // ═══════════════════════════════════════════════════════════ // Kostengrenze // ═══════════════════════════════════════════════════════════ [Fact] public async Task Unterhalb_der_Kostengrenze_laeuft_der_Agent_weiter() { await Record(cost: 1.50m); var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); status.IsAllowed.ShouldBeTrue(); } [Fact] public async Task Bei_erreichter_Kostengrenze_wird_kein_Lauf_mehr_gestartet() { await Record(cost: 3m); await Record(cost: 2m); var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); status.IsAllowed.ShouldBeFalse(); status.Limit.ShouldBe(BudgetLimit.AgentCost); status.Reason.ShouldContain("5"); } // ═══════════════════════════════════════════════════════════ // Token-Grenze — greift auch ohne Preisangaben // ═══════════════════════════════════════════════════════════ [Fact] public async Task Die_Tokengrenze_wirkt_auch_wenn_keine_Preise_vorliegen() { // Der eigentliche Zweck der zweiten Grenze: Bei unbekanntem Modell bleibt die // Kostensumme 0 und eine reine Kostengrenze wäre wirkungslos. await Record(cost: 0m, prompt: 60_000, completion: 5_000, costKnown: false); var agent = Agent(costLimit: 10m, tokenLimit: 50_000); var status = await Guard().CheckAsync(agent, InstanceBudget.Unlimited, default); status.IsAllowed.ShouldBeFalse(); status.Limit.ShouldBe(BudgetLimit.AgentTokens); } [Fact] public async Task Fehlende_Preise_werden_in_der_Begruendung_erwaehnt() { // Sonst wirkte ein niedriger Verbrauch wie ein noch offener Spielraum. await Record(cost: 0m, prompt: 10_000, completion: 1_000, costKnown: false); await Record(cost: 5m); var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); status.IsAllowed.ShouldBeFalse(); status.Reason.ShouldContain("keine Preise"); } // ═══════════════════════════════════════════════════════════ // Instanzgrenze // ═══════════════════════════════════════════════════════════ [Fact] public async Task Die_Instanzgrenze_zaehlt_alle_Agenten_zusammen() { await Record(cost: 3m, agentId: "agent-a"); await Record(cost: 3m, agentId: "agent-b"); await Record(cost: 3m, agentId: "agent-c"); var instance = new InstanceBudget { DailyCostUsd = 8m }; var status = await Guard().CheckAsync(Agent(), instance, default); status.IsAllowed.ShouldBeFalse(); status.Limit.ShouldBe(BudgetLimit.InstanceCost); } [Fact] public async Task Ein_einzelner_Agent_bleibt_unter_der_Instanzgrenze_erlaubt() { await Record(cost: 2m, agentId: "agent-a"); var instance = new InstanceBudget { DailyCostUsd = 10m }; var status = await Guard().CheckAsync(Agent(costLimit: 5m), instance, default); status.IsAllowed.ShouldBeTrue(); } // ═══════════════════════════════════════════════════════════ // Tageswechsel // ═══════════════════════════════════════════════════════════ [Fact] public async Task Der_Verbrauch_des_Vortages_zaehlt_nicht_mit() { await Record(cost: 100m, when: _heute.AddDays(-1)); var status = await Guard().CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); status.IsAllowed.ShouldBeTrue(); } [Fact] public async Task Am_naechsten_Tag_steht_das_Budget_wieder_zur_Verfuegung() { await Record(cost: 10m); var morgen = new BudgetGuard(_usage, () => _heute.AddDays(1)); var status = await morgen.CheckAsync(Agent(costLimit: 5m), InstanceBudget.Unlimited, default); status.IsAllowed.ShouldBeTrue(); } // ═══════════════════════════════════════════════════════════ // Restbudget // ═══════════════════════════════════════════════════════════ [Fact] public async Task Das_Restbudget_wird_korrekt_berechnet() { await Record(cost: 1.25m); var remaining = await Guard().GetRemainingCostAsync(Agent(costLimit: 5m), default); remaining.ShouldBe(3.75m); } [Fact] public async Task Ohne_Kostengrenze_gibt_es_kein_Restbudget() { (await Guard().GetRemainingCostAsync(Agent(), default)).ShouldBeNull(); } [Fact] public async Task Das_Restbudget_wird_nicht_negativ() { await Record(cost: 20m); (await Guard().GetRemainingCostAsync(Agent(costLimit: 5m), default)).ShouldBe(0m); } } public sealed class UsageRepositoryTests : IDisposable { private readonly string _directory; private readonly SqliteUsageRepository _usage; private static readonly DateTime Tag = new(2026, 7, 28, 10, 0, 0, DateTimeKind.Local); public UsageRepositoryTests() { _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); _usage = new SqliteUsageRepository(new SqliteStorage(Path.Combine(_directory, "state.db"))); } public void Dispose() { Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); try { Directory.Delete(_directory, recursive: true); } catch { } } private Task Record(decimal cost, string agentId = "a", DateTime? when = null, bool known = true) => _usage.RecordAsync(new RunUsage { AgentId = agentId, Model = "test/model", PromptTokens = 1000, CompletionTokens = 200, CostUsd = cost, CostIsKnown = known, Status = "Completed", OccurredAt = when ?? Tag }, default); [Fact] public async Task Betraege_ueberstehen_den_Rundlauf_ohne_Rundungsfehler() { // Als Fliesskommazahl abgelegt wuerden sich Cent-Betraege ueber tausende // Laeufe zu einem sichtbaren Fehler summieren. for (var i = 0; i < 1000; i++) await Record(0.0001m); var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default); daily.CostUsd.ShouldBe(0.1m); } [Fact] public async Task Der_Tagesverbrauch_summiert_Tokens_und_Laeufe() { await Record(1m); await Record(2m); var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default); daily.RunCount.ShouldBe(2); daily.PromptTokens.ShouldBe(2000); daily.CompletionTokens.ShouldBe(400); daily.TotalTokens.ShouldBe(2400); } [Fact] public async Task Ohne_Agentenfilter_zaehlt_die_ganze_Instanz() { await Record(1m, agentId: "a"); await Record(2m, agentId: "b"); var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), agentId: "", default); daily.CostUsd.ShouldBe(3m); daily.RunCount.ShouldBe(2); } [Fact] public async Task Ein_Lauf_ohne_Preisangabe_macht_den_Tag_unvollstaendig() { await Record(1m); await Record(0m, known: false); var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default); daily.CostIsComplete.ShouldBeFalse(); } [Fact] public async Task Ein_Tag_ohne_Laeufe_ist_leer() { var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), "a", default); daily.RunCount.ShouldBe(0); daily.CostUsd.ShouldBe(0m); daily.CostIsComplete.ShouldBeTrue(); } [Fact] public async Task Alte_Eintraege_lassen_sich_entfernen() { await Record(1m, when: Tag.AddDays(-40)); await Record(2m, when: Tag); var removed = await _usage.PurgeBeforeAsync(DateOnly.FromDateTime(Tag.AddDays(-30)), default); removed.ShouldBe(1); (await _usage.GetRecentAsync(10, default)).Count.ShouldBe(1); } [Fact] public async Task Viele_Laeufe_koennen_gleichzeitig_erfasst_werden() { // Der alte Weg schrieb TokenUsage.json bei jedem Lauf komplett neu, unter einem // globalen Lock — genau das skalierte nicht. await Task.WhenAll(Enumerable.Range(0, 100).Select(i => Record(0.01m, agentId: $"agent-{i % 8}"))); var daily = await _usage.GetDailyAsync(DateOnly.FromDateTime(Tag), agentId: "", default); daily.RunCount.ShouldBe(100); daily.CostUsd.ShouldBe(1.00m); } }