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(""));
}
}