using System.Net; using System.Net.Http.Headers; using System.Text; using ClaudeDo.Worker.Usage; namespace ClaudeDo.Worker.Tests.Usage; /// /// Tests for using a stubbed /// and a temp credentials file. No real network call and no real credentials file are touched. /// public sealed class ClaudeOAuthUsageClientTests : IDisposable { // Real fixture from the Anthropic OAuth usage endpoint, trimmed to the relevant fields. private const string RealFixture = """ { "five_hour": { "utilization": 30.0, "resets_at": "2026-08-05T12:59:59.62334+02:00", "limit_dollars": null }, "seven_day": { "utilization": 57.0, "resets_at": "2026-08-06T10:59:59.62336+02:00" }, "seven_day_opus": null, "seven_day_sonnet": null, "seven_day_oauth_apps": null, "extra_usage": { "is_enabled": false }, "limits": [ { "kind": "session", "group": "session", "percent": 30, "severity": "normal", "resets_at": "2026-08-05T12:59:59.62334+02:00", "scope": null, "is_active": false }, { "kind": "weekly_all", "group": "weekly", "percent": 57, "severity": "normal", "resets_at": "2026-08-06T10:59:59.62336+02:00", "scope": null, "is_active": true }, { "kind": "weekly_scoped", "group": "weekly", "percent": 14, "severity": "normal", "resets_at": "2026-08-06T10:59:59.623573+02:00", "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, "is_active": false } ], "spend": { "percent": 0, "enabled": false }, "member_dashboard_available": false } """; private readonly string _credentialsPath = Path.Combine(Path.GetTempPath(), $"claudedo-usage-test-{Guid.NewGuid():N}.json"); public void Dispose() { if (File.Exists(_credentialsPath)) File.Delete(_credentialsPath); } private void WriteCredentials(string accessToken = "test-access-token") => File.WriteAllText(_credentialsPath, $$"""{ "claudeAiOauth": { "accessToken": "{{accessToken}}" } }"""); private sealed class StubHandler : HttpMessageHandler { public List Requests { get; } = new(); public HttpStatusCode ResponseStatus { get; set; } = HttpStatusCode.OK; public string ResponseBody { get; set; } = "{}"; public RetryConditionHeaderValue? RetryAfter { get; set; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) { Requests.Add(request); var resp = new HttpResponseMessage(ResponseStatus) { Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json"), }; if (RetryAfter is not null) resp.Headers.RetryAfter = RetryAfter; return Task.FromResult(resp); } } private (ClaudeOAuthUsageClient Client, StubHandler Handler) Build() { var handler = new StubHandler(); var http = new HttpClient(handler); return (new ClaudeOAuthUsageClient(http, _credentialsPath), handler); } [Fact] public async Task GetUsageAsync_ParsesRealFixture() { WriteCredentials(); var (client, handler) = Build(); handler.ResponseBody = RealFixture; var snapshot = await client.GetUsageAsync(); Assert.NotNull(snapshot.FiveHour); Assert.Equal(30, snapshot.FiveHour!.Utilization); Assert.NotNull(snapshot.SevenDay); Assert.Equal(57, snapshot.SevenDay!.Utilization); Assert.Equal(3, snapshot.Limits.Count); var scoped = snapshot.Limits[2]; Assert.Equal("weekly_scoped", scoped.Kind); Assert.Equal("Fable", scoped.ScopeModelDisplayName); } [Fact] public async Task GetUsageAsync_SendsBearerTokenAndBetaHeader() { WriteCredentials("my-secret-token"); var (client, handler) = Build(); handler.ResponseBody = RealFixture; await client.GetUsageAsync(); var request = handler.Requests[0]; Assert.Equal("Bearer", request.Headers.Authorization!.Scheme); Assert.Equal("my-secret-token", request.Headers.Authorization!.Parameter); Assert.Contains("oauth-2025-04-20", request.Headers.GetValues("anthropic-beta")); Assert.Equal("https://api.anthropic.com/api/oauth/usage", request.RequestUri!.ToString()); } [Fact] public async Task GetUsageAsync_MissingFiveHour_ReturnsNull() { WriteCredentials(); var (client, handler) = Build(); handler.ResponseBody = """{ "seven_day": { "utilization": 12.0 } }"""; var snapshot = await client.GetUsageAsync(); Assert.Null(snapshot.FiveHour); Assert.NotNull(snapshot.SevenDay); Assert.Empty(snapshot.Limits); } [Fact] public async Task GetUsageAsync_NullFiveHour_ReturnsNull() { WriteCredentials(); var (client, handler) = Build(); handler.ResponseBody = """{ "five_hour": null, "seven_day": null }"""; var snapshot = await client.GetUsageAsync(); Assert.Null(snapshot.FiveHour); Assert.Null(snapshot.SevenDay); } [Fact] public async Task GetUsageAsync_MissingLimits_ReturnsEmptyList() { WriteCredentials(); var (client, handler) = Build(); handler.ResponseBody = "{}"; var snapshot = await client.GetUsageAsync(); Assert.Empty(snapshot.Limits); } [Fact] public async Task GetUsageAsync_GarbageBody_ThrowsWithoutLeakingToken() { WriteCredentials("super-secret-token"); var (client, handler) = Build(); handler.ResponseBody = "not json at all {{{"; var ex = await Assert.ThrowsAsync(() => client.GetUsageAsync()); Assert.DoesNotContain("super-secret-token", ex.Message); } [Fact] public async Task GetUsageAsync_Http401_ThrowsWithoutLeakingToken() { WriteCredentials("super-secret-token"); var (client, handler) = Build(); handler.ResponseStatus = HttpStatusCode.Unauthorized; handler.ResponseBody = "unauthorized"; var ex = await Assert.ThrowsAsync(() => client.GetUsageAsync()); Assert.DoesNotContain("super-secret-token", ex.Message); } [Fact] public async Task GetUsageAsync_MissingCredentialsFile_Throws() { var (client, _) = Build(); // credentials file was never written await Assert.ThrowsAsync(() => client.GetUsageAsync()); } [Fact] public async Task GetUsageAsync_NoAccessTokenInCredentials_Throws() { File.WriteAllText(_credentialsPath, """{ "claudeAiOauth": {} }"""); var (client, _) = Build(); await Assert.ThrowsAsync(() => client.GetUsageAsync()); } [Fact] public async Task GetUsageAsync_429_ThrowsRateLimitedWithRetryAfterDelta() { WriteCredentials(); var (client, handler) = Build(); handler.ResponseStatus = HttpStatusCode.TooManyRequests; handler.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(45)); var ex = await Assert.ThrowsAsync(() => client.GetUsageAsync()); Assert.Equal(TimeSpan.FromSeconds(45), ex.RetryAfter); } [Fact] public async Task GetUsageAsync_429_WithoutRetryAfter_HasNullRetryAfter() { WriteCredentials(); var (client, handler) = Build(); handler.ResponseStatus = HttpStatusCode.TooManyRequests; var ex = await Assert.ThrowsAsync(() => client.GetUsageAsync()); Assert.Null(ex.RetryAfter); Assert.Contains("429", ex.Message); } [Fact] public async Task GetUsageAsync_429_WithPastRetryAfterDate_IgnoresIt() { WriteCredentials(); var (client, handler) = Build(); handler.ResponseStatus = HttpStatusCode.TooManyRequests; handler.RetryAfter = new RetryConditionHeaderValue(DateTimeOffset.UtcNow.AddMinutes(-5)); var ex = await Assert.ThrowsAsync(() => client.GetUsageAsync()); Assert.Null(ex.RetryAfter); } }