Merge task branch for: Worker: OAuth-Usage-Client + Poller (Usage/)
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using ClaudeDo.Worker.Usage;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Usage;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ClaudeOAuthUsageClient"/> using a stubbed <see cref="HttpMessageHandler"/>
|
||||
/// and a temp credentials file. No real network call and no real credentials file are touched.
|
||||
/// </summary>
|
||||
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<HttpRequestMessage> Requests { get; } = new();
|
||||
public HttpStatusCode ResponseStatus { get; set; } = HttpStatusCode.OK;
|
||||
public string ResponseBody { get; set; } = "{}";
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
Requests.Add(request);
|
||||
var resp = new HttpResponseMessage(ResponseStatus)
|
||||
{
|
||||
Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => client.GetUsageAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetUsageAsync_NoAccessTokenInCredentials_Throws()
|
||||
{
|
||||
File.WriteAllText(_credentialsPath, """{ "claudeAiOauth": {} }""");
|
||||
var (client, _) = Build();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetUsageAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Usage;
|
||||
using ClaudeDo.Worker.Usage.Interfaces;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Usage;
|
||||
|
||||
public sealed class UsageMonitorServiceTests
|
||||
{
|
||||
private sealed class FakeClient : IUsageClient
|
||||
{
|
||||
public Queue<Func<UsageSnapshot>> Results { get; } = new();
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<UsageSnapshot> GetUsageAsync(CancellationToken ct = default)
|
||||
{
|
||||
CallCount++;
|
||||
var next = Results.Count > 0 ? Results.Dequeue() : () => throw new InvalidOperationException("no result queued");
|
||||
return Task.FromResult(next());
|
||||
}
|
||||
}
|
||||
|
||||
private static UsageSnapshot MakeSnapshot() => new(new UsageBucket(1, null), null, [], DateTime.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public async Task TickAsync_Success_UpdatesState()
|
||||
{
|
||||
var client = new FakeClient();
|
||||
client.Results.Enqueue(MakeSnapshot);
|
||||
var state = new UsageState();
|
||||
var service = new UsageMonitorService(client, state, new WorkerConfig(), NullLogger<UsageMonitorService>.Instance);
|
||||
|
||||
await service.TickAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(state.Snapshot);
|
||||
Assert.Null(state.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickAsync_Failure_SetsErrorButDoesNotThrow()
|
||||
{
|
||||
var client = new FakeClient();
|
||||
client.Results.Enqueue(() => throw new InvalidOperationException("network unreachable"));
|
||||
var state = new UsageState();
|
||||
var service = new UsageMonitorService(client, state, new WorkerConfig(), NullLogger<UsageMonitorService>.Instance);
|
||||
|
||||
await service.TickAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal("network unreachable", state.LastError);
|
||||
Assert.Null(state.Snapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickAsync_FailureAfterSuccess_KeepsOldSnapshot()
|
||||
{
|
||||
var client = new FakeClient();
|
||||
client.Results.Enqueue(MakeSnapshot);
|
||||
client.Results.Enqueue(() => throw new InvalidOperationException("down"));
|
||||
var state = new UsageState();
|
||||
var service = new UsageMonitorService(client, state, new WorkerConfig(), NullLogger<UsageMonitorService>.Instance);
|
||||
|
||||
await service.TickAsync(CancellationToken.None);
|
||||
await service.TickAsync(CancellationToken.None);
|
||||
|
||||
Assert.NotNull(state.Snapshot);
|
||||
Assert.Equal("down", state.LastError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using ClaudeDo.Worker.Usage;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Usage;
|
||||
|
||||
public sealed class UsageStateTests
|
||||
{
|
||||
private static UsageSnapshot MakeSnapshot(double fiveHourUtilization) => new(
|
||||
new UsageBucket(fiveHourUtilization, null),
|
||||
null,
|
||||
[],
|
||||
DateTime.UtcNow);
|
||||
|
||||
[Fact]
|
||||
public void ReportSuccess_SetsSnapshotAndClearsError()
|
||||
{
|
||||
var state = new UsageState();
|
||||
state.ReportFailure("boom", DateTime.UtcNow);
|
||||
|
||||
state.ReportSuccess(MakeSnapshot(42));
|
||||
|
||||
Assert.NotNull(state.Snapshot);
|
||||
Assert.Equal(42, state.Snapshot!.FiveHour!.Utilization);
|
||||
Assert.Null(state.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReportFailure_AfterSuccess_KeepsOldSnapshotButSetsError()
|
||||
{
|
||||
var state = new UsageState();
|
||||
state.ReportSuccess(MakeSnapshot(10));
|
||||
|
||||
state.ReportFailure("network down", DateTime.UtcNow);
|
||||
|
||||
Assert.NotNull(state.Snapshot);
|
||||
Assert.Equal(10, state.Snapshot!.FiveHour!.Utilization);
|
||||
Assert.Equal("network down", state.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoAttemptYet_SnapshotAndErrorAreNull()
|
||||
{
|
||||
var state = new UsageState();
|
||||
|
||||
Assert.Null(state.Snapshot);
|
||||
Assert.Null(state.LastError);
|
||||
Assert.Null(state.LastAttemptUtc);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user