feat(worker): Online Inbox sync engine (Phase 1)
Optional, opt-in (online_inbox.enabled, default false → zero network). Worker-side reconcile loop: pull web-created tasks down as Idle, push the list catalog and the Idle backlog mirror up. Auth behind IOnlineAuthProvider (StaticTokenAuthProvider default; ZitadelAuthProvider stubbed for Phase 2). DPAPI refresh-token store. 35 tests, no real network/Zitadel/Claude. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
183
tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs
Normal file
183
tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs
Normal file
@@ -0,0 +1,183 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Worker.Online;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Online;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="OnlineInboxApiClient"/> using a stubbed <see cref="HttpMessageHandler"/>.
|
||||
/// </summary>
|
||||
public sealed class OnlineInboxApiClientTests
|
||||
{
|
||||
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 static (OnlineInboxApiClient Client, StubHandler Handler) Build(string? token = "test-token")
|
||||
{
|
||||
var handler = new StubHandler();
|
||||
var http = new HttpClient(handler) { BaseAddress = new Uri("https://inbox.example.com/") };
|
||||
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("/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("/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("/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<OnlineInboxException>(
|
||||
() => client.PutListsAsync([]));
|
||||
|
||||
Assert.Equal(401, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ServerError_Throws_OnlineInboxException_WithStatusCode()
|
||||
{
|
||||
var (client, handler) = Build();
|
||||
handler.ResponseStatus = HttpStatusCode.InternalServerError;
|
||||
handler.ResponseBody = "error";
|
||||
|
||||
var ex = await Assert.ThrowsAsync<OnlineInboxException>(
|
||||
() => 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<InvalidOperationException>(
|
||||
() => OnlineInboxApiClient.ValidateBaseUrl("http://example.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateBaseUrl_Rejects_Empty()
|
||||
{
|
||||
Assert.Throws<InvalidOperationException>(
|
||||
() => OnlineInboxApiClient.ValidateBaseUrl(""));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user