using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; using ClaudeDo.Worker.Usage; using Microsoft.Extensions.Logging.Abstractions; namespace ClaudeDo.Worker.Tests.Usage; public sealed class UsageGateTests : IDisposable { private readonly DbFixture _db = new(); public void Dispose() => _db.Dispose(); private async Task SetThresholdsAsync(int fiveHourPct, int sevenDayPct) { using var ctx = _db.CreateContext(); var repo = new AppSettingsRepository(ctx); var settings = await repo.GetAsync(); settings.UsageGateFiveHourPct = fiveHourPct; settings.UsageGateSevenDayPct = sevenDayPct; await repo.UpdateAsync(settings); } private UsageGate CreateGate(UsageState state) => new(_db.CreateFactory(), state, NullLogger.Instance); private static UsageState StateWithSnapshot(double fiveHourPct, double sevenDayPct) { var state = new UsageState(); state.ReportSuccess(new UsageSnapshot( new UsageBucket(fiveHourPct, null), new UsageBucket(sevenDayPct, null), Array.Empty(), DateTime.UtcNow)); return state; } [Fact] public async Task BothUnderThreshold_NotBlocked() { await SetThresholdsAsync(80, 90); var decision = await CreateGate(StateWithSnapshot(50, 60)).EvaluateAsync(); Assert.False(decision.IsBlocked); } [Fact] public async Task FiveHourAtOrOverThreshold_Blocked() { await SetThresholdsAsync(80, 90); var decision = await CreateGate(StateWithSnapshot(85, 60)).EvaluateAsync(); Assert.True(decision.IsBlocked); Assert.Contains("5h", decision.Reason); } [Fact] public async Task SevenDayAtOrOverThreshold_Blocked() { await SetThresholdsAsync(80, 90); var decision = await CreateGate(StateWithSnapshot(50, 95)).EvaluateAsync(); Assert.True(decision.IsBlocked); Assert.Contains("7d", decision.Reason); } [Fact] public async Task ExactlyOnThreshold_Blocked() { await SetThresholdsAsync(80, 90); var decision = await CreateGate(StateWithSnapshot(80, 60)).EvaluateAsync(); Assert.True(decision.IsBlocked); } [Fact] public async Task ThresholdZero_ThatBucketNeverGates() { await SetThresholdsAsync(0, 90); var decision = await CreateGate(StateWithSnapshot(100, 60)).EvaluateAsync(); Assert.False(decision.IsBlocked); } [Fact] public async Task BothThresholdsZero_AlwaysFree() { await SetThresholdsAsync(0, 0); var decision = await CreateGate(StateWithSnapshot(100, 100)).EvaluateAsync(); Assert.False(decision.IsBlocked); } [Fact] public async Task NoSnapshotYet_FailsOpen() { await SetThresholdsAsync(80, 90); var decision = await CreateGate(new UsageState()).EvaluateAsync(); Assert.False(decision.IsBlocked); } [Fact] public async Task LastPollFailed_FailsOpen() { await SetThresholdsAsync(80, 90); var state = StateWithSnapshot(95, 95); state.ReportFailure("boom", DateTime.UtcNow); var decision = await CreateGate(state).EvaluateAsync(); Assert.False(decision.IsBlocked); } }