using System.Net; using System.Text; using System.Text.Json; using ClaudeDo.Worker.Online; namespace ClaudeDo.Worker.Tests.Online; /// /// Tests for using a stubbed . /// public sealed class OnlineInboxApiClientTests { private sealed class StubHandler : HttpMessageHandler { public List Requests { get; } = new(); public HttpStatusCode ResponseStatus { get; set; } = HttpStatusCode.OK; public string ResponseBody { 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"), }; return Task.FromResult(resp); } } private static (OnlineInboxApiClient Client, StubHandler Handler) Build(string? token = "test-token") { var handler = new StubHandler(); // Base address carries a path segment (/api) — requests must nest under it, // so the client uses relative paths without a leading slash. var http = new HttpClient(handler) { BaseAddress = new Uri("https://inbox.example.com/api/") }; var auth = new StaticTokenAuthProvider(token); return (new OnlineInboxApiClient(http, auth), handler); } // ---- PutListsAsync ---- [Fact] public async Task PutListsAsync_UsesCorrectVerbAndPath() { var (client, handler) = Build(); await client.PutListsAsync([new RemoteList("id1", "List 1")]); Assert.Single(handler.Requests); Assert.Equal(HttpMethod.Put, handler.Requests[0].Method); Assert.Equal("/api/lists", handler.Requests[0].RequestUri!.AbsolutePath); } [Fact] public async Task PutListsAsync_AttachesBearerToken() { var (client, handler) = Build("my-bearer"); await client.PutListsAsync([]); Assert.Equal("Bearer", handler.Requests[0].Headers.Authorization!.Scheme); Assert.Equal("my-bearer", handler.Requests[0].Headers.Authorization!.Parameter); } // ---- GetUnimportedTasksAsync ---- [Fact] public async Task GetUnimportedTasksAsync_UsesGetWithQueryParam() { var (client, handler) = Build(); handler.ResponseBody = "[]"; await client.GetUnimportedTasksAsync(); Assert.Equal(HttpMethod.Get, handler.Requests[0].Method); Assert.Contains("imported=false", handler.Requests[0].RequestUri!.Query); } [Fact] public async Task GetUnimportedTasksAsync_DeserializesResponse() { var (client, handler) = Build(); var tasks = new[] { new { id = "t1", listId = "l1", title = "Title", description = (string?)null, createdAt = DateTimeOffset.UtcNow }, }; handler.ResponseBody = JsonSerializer.Serialize(tasks); var result = await client.GetUnimportedTasksAsync(); Assert.Single(result); Assert.Equal("t1", result[0].Id); Assert.Equal("l1", result[0].ListId); Assert.Equal("Title", result[0].Title); } // ---- MarkImportedAsync ---- [Fact] public async Task MarkImportedAsync_UsesPostAndCorrectPath() { var (client, handler) = Build(); await client.MarkImportedAsync("task-id-123"); Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); Assert.Equal("/api/tasks/task-id-123/imported", handler.Requests[0].RequestUri!.AbsolutePath); } // ---- PutMirrorAsync ---- [Fact] public async Task PutMirrorAsync_UsesPutAndCorrectPath() { var (client, handler) = Build(); await client.PutMirrorAsync([new MirrorTask("id1", "l1", "T", null)]); Assert.Equal(HttpMethod.Put, handler.Requests[0].Method); Assert.Equal("/api/tasks/mirror", handler.Requests[0].RequestUri!.AbsolutePath); } // ---- 401 handling ---- [Fact] public async Task NonSuccessResponse_Throws_OnlineInboxException() { var (client, handler) = Build(); handler.ResponseStatus = HttpStatusCode.Unauthorized; handler.ResponseBody = "Unauthorized"; var ex = await Assert.ThrowsAsync( () => client.PutListsAsync([])); Assert.Equal(401, ex.StatusCode); } [Fact] public async Task Unauthorized_RetriesOnceWithForcedRefresh_ThenThrowsMissingRole() { var (client, handler) = Build(); handler.ResponseStatus = HttpStatusCode.Unauthorized; handler.ResponseBody = "Unauthorized"; var ex = await Assert.ThrowsAsync( () => client.PutListsAsync([])); Assert.Equal(401, ex.StatusCode); Assert.Equal(OnlineInboxApiClient.MissingRoleMessage, ex.Message); // Original attempt + one forced-refresh retry. Assert.Equal(2, handler.Requests.Count); } [Fact] public async Task Unauthorized_ThenSuccessOnRetry_Succeeds() { var handler = new SequenceHandler(HttpStatusCode.Unauthorized, HttpStatusCode.OK); var http = new HttpClient(handler) { BaseAddress = new Uri("https://inbox.example.com/api/") }; var client = new OnlineInboxApiClient(http, new StaticTokenAuthProvider("test-token")); await client.PutListsAsync([]); Assert.Equal(2, handler.Requests.Count); } private sealed class SequenceHandler : HttpMessageHandler { private readonly Queue _statuses; public List Requests { get; } = new(); public SequenceHandler(params HttpStatusCode[] statuses) => _statuses = new(statuses); protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) { Requests.Add(request); var status = _statuses.Count > 0 ? _statuses.Dequeue() : HttpStatusCode.OK; return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent("[]", Encoding.UTF8, "application/json"), }); } } [Fact] public async Task ServerError_Throws_OnlineInboxException_WithStatusCode() { var (client, handler) = Build(); handler.ResponseStatus = HttpStatusCode.InternalServerError; handler.ResponseBody = "error"; var ex = await Assert.ThrowsAsync( () => client.GetUnimportedTasksAsync()); Assert.Equal(500, ex.StatusCode); } // ---- No token ---- [Fact] public async Task NoToken_SendsRequestWithoutAuthHeader() { var (client, handler) = Build(token: null); await client.PutListsAsync([]); Assert.Null(handler.Requests[0].Headers.Authorization); } // ---- URL validation ---- [Fact] public void ValidateBaseUrl_AcceptsHttps() { OnlineInboxApiClient.ValidateBaseUrl("https://example.com"); } [Fact] public void ValidateBaseUrl_AcceptsLoopback() { OnlineInboxApiClient.ValidateBaseUrl("http://127.0.0.1:5000"); OnlineInboxApiClient.ValidateBaseUrl("http://localhost:5000"); } [Fact] public void ValidateBaseUrl_Rejects_HttpNonLoopback() { Assert.Throws( () => OnlineInboxApiClient.ValidateBaseUrl("http://example.com")); } [Fact] public void ValidateBaseUrl_Rejects_Empty() { Assert.Throws( () => OnlineInboxApiClient.ValidateBaseUrl("")); } }