From 1ac9ced0bd04adafada46187cd8893b0a2bdcac1 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 10 Jun 2026 09:55:20 +0200 Subject: [PATCH] feat(worker): Online Inbox sync engine (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Repositories/TaskRepository.cs | 16 ++ src/ClaudeDo.Worker/CLAUDE.md | 7 + src/ClaudeDo.Worker/ClaudeDo.Worker.csproj | 1 + src/ClaudeDo.Worker/Config/WorkerConfig.cs | 4 + src/ClaudeDo.Worker/Online/Dtos.cs | 20 ++ src/ClaudeDo.Worker/Online/IOnlineInboxApi.cs | 9 + .../Online/Interfaces/IOnlineAuthProvider.cs | 6 + src/ClaudeDo.Worker/Online/OnlineBacklog.cs | 27 ++ .../Online/OnlineInboxApiClient.cs | 84 ++++++ .../Online/OnlineInboxConfig.cs | 30 +++ .../Online/OnlineInboxException.cs | 12 + .../Online/OnlineSyncService.cs | 121 +++++++++ .../Online/OnlineTokenStore.cs | 54 ++++ .../Online/StaticTokenAuthProvider.cs | 21 ++ .../Online/ZitadelAuthProvider.cs | 13 + src/ClaudeDo.Worker/Program.cs | 18 ++ .../Online/OnlineBacklogTests.cs | 167 ++++++++++++ .../Online/OnlineInboxApiClientTests.cs | 183 +++++++++++++ .../Online/OnlineInboxConfigTests.cs | 78 ++++++ .../Online/OnlineSyncServiceTests.cs | 242 ++++++++++++++++++ .../Online/OnlineTokenStoreTests.cs | 53 ++++ .../Online/StaticTokenAuthProviderTests.cs | 30 +++ 22 files changed, 1196 insertions(+) create mode 100644 src/ClaudeDo.Worker/Online/Dtos.cs create mode 100644 src/ClaudeDo.Worker/Online/IOnlineInboxApi.cs create mode 100644 src/ClaudeDo.Worker/Online/Interfaces/IOnlineAuthProvider.cs create mode 100644 src/ClaudeDo.Worker/Online/OnlineBacklog.cs create mode 100644 src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs create mode 100644 src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs create mode 100644 src/ClaudeDo.Worker/Online/OnlineInboxException.cs create mode 100644 src/ClaudeDo.Worker/Online/OnlineSyncService.cs create mode 100644 src/ClaudeDo.Worker/Online/OnlineTokenStore.cs create mode 100644 src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs create mode 100644 src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Online/OnlineBacklogTests.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Online/OnlineInboxConfigTests.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Online/OnlineTokenStoreTests.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Online/StaticTokenAuthProviderTests.cs diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 75741b4..329c32b 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -87,6 +87,22 @@ public sealed class TaskRepository .ToListAsync(ct); } + /// + /// Returns all tasks that qualify as "real" Idle backlog items for online mirroring: + /// Status==Idle, no parent, PlanningPhase==None, not blocked. + /// + public async Task> GetAllIdleBacklogAsync(CancellationToken ct = default) + { + return await _context.Tasks + .AsNoTracking() + .Where(t => t.Status == TaskStatus.Idle + && t.ParentTaskId == null + && t.PlanningPhase == PlanningPhase.None + && t.BlockedByTaskId == null) + .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) + .ToListAsync(ct); + } + #endregion #region Status transitions diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index cfaeaee..d7de8ad 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -19,6 +19,7 @@ Worker/ Hub/ — WorkerHub, HubBroadcaster Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/ Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster) + Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (stub — TODO(online-inbox) Phase 2), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider) ``` Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace. @@ -165,6 +166,12 @@ Loaded from `~/.todo-app/worker.config.json`: - `queue_backstop_interval_ms` (default 30000) - `signalr_port` (default 47821) - `claude_bin` (path to claude CLI) +- `online_inbox` — Online Inbox config (default: `enabled=false`, zero network when disabled): + - `enabled` (bool, default false) — when false the entire `Online/` stack is not registered + - `api_base_url` (string) — must be HTTPS or loopback; validated at startup when enabled + - `poll_interval_seconds` (int, default 60) + - `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` (Phase 2; not used until ZitadelAuthProvider is wired) + - The refresh token is NOT in this file — stored encrypted via DPAPI at `~/.todo-app/online-inbox.token` Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order). diff --git a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj index aac05c3..dad1691 100644 --- a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj +++ b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj @@ -14,6 +14,7 @@ + diff --git a/src/ClaudeDo.Worker/Config/WorkerConfig.cs b/src/ClaudeDo.Worker/Config/WorkerConfig.cs index 7f35b24..17ca294 100644 --- a/src/ClaudeDo.Worker/Config/WorkerConfig.cs +++ b/src/ClaudeDo.Worker/Config/WorkerConfig.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using ClaudeDo.Data; +using ClaudeDo.Worker.Online; namespace ClaudeDo.Worker.Config; @@ -39,6 +40,9 @@ public sealed class WorkerConfig [JsonPropertyName("external_mcp_api_key")] public string? ExternalMcpApiKey { get; set; } + [JsonPropertyName("online_inbox")] + public OnlineInboxConfig OnlineInbox { get; set; } = new(); + public static string DefaultConfigPath => Path.Combine(Paths.AppDataRoot(), "worker.config.json"); diff --git a/src/ClaudeDo.Worker/Online/Dtos.cs b/src/ClaudeDo.Worker/Online/Dtos.cs new file mode 100644 index 0000000..8eccb61 --- /dev/null +++ b/src/ClaudeDo.Worker/Online/Dtos.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace ClaudeDo.Worker.Online; + +public sealed record RemoteList( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("name")] string Name); + +public sealed record RemoteTask( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("listId")] string ListId, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("description")] string? Description, + [property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt); + +public sealed record MirrorTask( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("listId")] string ListId, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("description")] string? Description); diff --git a/src/ClaudeDo.Worker/Online/IOnlineInboxApi.cs b/src/ClaudeDo.Worker/Online/IOnlineInboxApi.cs new file mode 100644 index 0000000..ee9b208 --- /dev/null +++ b/src/ClaudeDo.Worker/Online/IOnlineInboxApi.cs @@ -0,0 +1,9 @@ +namespace ClaudeDo.Worker.Online; + +public interface IOnlineInboxApi +{ + Task PutListsAsync(IReadOnlyList lists, CancellationToken ct = default); + Task> GetUnimportedTasksAsync(CancellationToken ct = default); + Task MarkImportedAsync(string id, CancellationToken ct = default); + Task PutMirrorAsync(IReadOnlyList tasks, CancellationToken ct = default); +} diff --git a/src/ClaudeDo.Worker/Online/Interfaces/IOnlineAuthProvider.cs b/src/ClaudeDo.Worker/Online/Interfaces/IOnlineAuthProvider.cs new file mode 100644 index 0000000..3e250cb --- /dev/null +++ b/src/ClaudeDo.Worker/Online/Interfaces/IOnlineAuthProvider.cs @@ -0,0 +1,6 @@ +namespace ClaudeDo.Worker.Online.Interfaces; + +public interface IOnlineAuthProvider +{ + Task GetAccessTokenAsync(CancellationToken ct = default); +} diff --git a/src/ClaudeDo.Worker/Online/OnlineBacklog.cs b/src/ClaudeDo.Worker/Online/OnlineBacklog.cs new file mode 100644 index 0000000..3021298 --- /dev/null +++ b/src/ClaudeDo.Worker/Online/OnlineBacklog.cs @@ -0,0 +1,27 @@ +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Online; + +public static class OnlineBacklog +{ + /// + /// Returns the current Idle backlog: Status==Idle, no parent, PlanningPhase==None, not blocked. + /// These are the tasks mirrored to the online store (§2 of the contract). + /// + public static async Task> CurrentAsync( + TaskRepository tasks, + CancellationToken ct = default) + { + var all = await tasks.GetAllIdleBacklogAsync(ct); + return all.Select(t => new MirrorTask(t.Id, t.ListId, t.Title, t.Description)).ToList(); + } + + internal static bool IsBacklogItem(TaskEntity t) => + t.Status == TaskStatus.Idle + && t.ParentTaskId == null + && t.PlanningPhase == PlanningPhase.None + && t.BlockedByTaskId == null; +} diff --git a/src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs b/src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs new file mode 100644 index 0000000..a5a8377 --- /dev/null +++ b/src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs @@ -0,0 +1,84 @@ +using System.Net.Http.Json; +using ClaudeDo.Worker.Online.Interfaces; + +namespace ClaudeDo.Worker.Online; + +public sealed class OnlineInboxApiClient : IOnlineInboxApi +{ + private readonly HttpClient _http; + private readonly IOnlineAuthProvider _auth; + + public OnlineInboxApiClient(HttpClient http, IOnlineAuthProvider auth) + { + _http = http; + _auth = auth; + } + + /// + /// Validates that is HTTPS or a loopback address. + /// Throws for non-HTTPS non-loopback URLs. + /// + public static void ValidateBaseUrl(string baseUrl) + { + if (string.IsNullOrWhiteSpace(baseUrl)) + throw new InvalidOperationException("online_inbox.api_base_url is not configured."); + + var uri = new Uri(baseUrl, UriKind.Absolute); + if (uri.Scheme != "https" && !uri.IsLoopback) + throw new InvalidOperationException( + $"online_inbox.api_base_url must be HTTPS or loopback. Got: {baseUrl}"); + } + + public async Task PutListsAsync(IReadOnlyList lists, CancellationToken ct = default) + { + using var req = await BuildAsync(HttpMethod.Put, "/lists", lists, ct); + using var resp = await _http.SendAsync(req, ct); + await EnsureSuccessAsync(resp, ct); + } + + public async Task> GetUnimportedTasksAsync(CancellationToken ct = default) + { + using var req = await BuildAsync(HttpMethod.Get, "/tasks?imported=false", null, ct); + using var resp = await _http.SendAsync(req, ct); + await EnsureSuccessAsync(resp, ct); + var result = await resp.Content.ReadFromJsonAsync>(ct); + return result ?? []; + } + + public async Task MarkImportedAsync(string id, CancellationToken ct = default) + { + using var req = await BuildAsync(HttpMethod.Post, $"/tasks/{Uri.EscapeDataString(id)}/imported", null, ct); + using var resp = await _http.SendAsync(req, ct); + await EnsureSuccessAsync(resp, ct); + } + + public async Task PutMirrorAsync(IReadOnlyList tasks, CancellationToken ct = default) + { + using var req = await BuildAsync(HttpMethod.Put, "/tasks/mirror", tasks, ct); + using var resp = await _http.SendAsync(req, ct); + await EnsureSuccessAsync(resp, ct); + } + + private async Task BuildAsync( + HttpMethod method, + string path, + object? body, + CancellationToken ct) + { + var token = await _auth.GetAccessTokenAsync(ct); + var req = new HttpRequestMessage(method, path); + if (token is not null) + req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + if (body is not null) + req.Content = JsonContent.Create(body); + return req; + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage resp, CancellationToken ct) + { + if (resp.IsSuccessStatusCode) return; + var body = await resp.Content.ReadAsStringAsync(ct); + throw new OnlineInboxException((int)resp.StatusCode, + $"Online Inbox API error {(int)resp.StatusCode}: {body}"); + } +} diff --git a/src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs b/src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs new file mode 100644 index 0000000..5dd3ccd --- /dev/null +++ b/src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace ClaudeDo.Worker.Online; + +public sealed class ZitadelClientConfig +{ + [JsonPropertyName("authority")] + public string Authority { get; set; } = ""; + + [JsonPropertyName("client_id")] + public string ClientId { get; set; } = ""; + + [JsonPropertyName("scopes")] + public string Scopes { get; set; } = "openid offline_access"; +} + +public sealed class OnlineInboxConfig +{ + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = false; + + [JsonPropertyName("api_base_url")] + public string ApiBaseUrl { get; set; } = ""; + + [JsonPropertyName("poll_interval_seconds")] + public int PollIntervalSeconds { get; set; } = 60; + + [JsonPropertyName("zitadel")] + public ZitadelClientConfig Zitadel { get; set; } = new(); +} diff --git a/src/ClaudeDo.Worker/Online/OnlineInboxException.cs b/src/ClaudeDo.Worker/Online/OnlineInboxException.cs new file mode 100644 index 0000000..142e1f5 --- /dev/null +++ b/src/ClaudeDo.Worker/Online/OnlineInboxException.cs @@ -0,0 +1,12 @@ +namespace ClaudeDo.Worker.Online; + +public sealed class OnlineInboxException : Exception +{ + public int StatusCode { get; } + + public OnlineInboxException(int statusCode, string message) + : base(message) + { + StatusCode = statusCode; + } +} diff --git a/src/ClaudeDo.Worker/Online/OnlineSyncService.cs b/src/ClaudeDo.Worker/Online/OnlineSyncService.cs new file mode 100644 index 0000000..92ac8e6 --- /dev/null +++ b/src/ClaudeDo.Worker/Online/OnlineSyncService.cs @@ -0,0 +1,121 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Online.Interfaces; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Online; + +public sealed class OnlineSyncService : BackgroundService +{ + private readonly IDbContextFactory _dbFactory; + private readonly IOnlineInboxApi _api; + private readonly IOnlineAuthProvider _auth; + private readonly OnlineInboxConfig _config; + private readonly ILogger _logger; + + public OnlineSyncService( + IDbContextFactory dbFactory, + IOnlineInboxApi api, + IOnlineAuthProvider auth, + OnlineInboxConfig config, + ILogger logger) + { + _dbFactory = dbFactory; + _api = api; + _auth = auth; + _config = config; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await TickAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "OnlineSyncService cycle failed; backing off to next interval"); + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(_config.PollIntervalSeconds), stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + } + } + + internal async Task TickAsync(CancellationToken ct) + { + var token = await _auth.GetAccessTokenAsync(ct); + if (token is null) + { + _logger.LogDebug("OnlineSyncService: no access token, skipping cycle"); + return; + } + + await using var ctx = await _dbFactory.CreateDbContextAsync(ct); + var tasks = new TaskRepository(ctx); + var lists = new ListRepository(ctx); + + // Step 1: pull unimported tasks, import them locally, mark each imported. + var unimported = await _api.GetUnimportedTasksAsync(ct); + foreach (var remote in unimported) + { + var existing = await tasks.GetByIdAsync(remote.Id, ct); + if (existing is not null) + { + // Already imported locally; just mark it on the server. + await _api.MarkImportedAsync(remote.Id, ct); + continue; + } + + var list = await lists.GetByIdAsync(remote.ListId, ct); + if (list is null) + { + _logger.LogWarning( + "OnlineSyncService: remote task {Id} references unknown list {ListId}; skipping", + remote.Id, remote.ListId); + continue; + } + + var entity = new TaskEntity + { + Id = remote.Id, + ListId = remote.ListId, + Title = remote.Title, + Description = remote.Description, + Status = TaskStatus.Idle, + CreatedBy = "online", + CreatedAt = remote.CreatedAt.UtcDateTime, + CommitType = CommitTypeRegistry.DefaultType, + }; + await tasks.AddAsync(entity, ct); + await _api.MarkImportedAsync(remote.Id, ct); + + _logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title); + } + + // Step 2: push full list catalog. + var allLists = await lists.GetAllAsync(ct); + var remoteLists = allLists.Select(l => new RemoteList(l.Id, l.Name)).ToList(); + await _api.PutListsAsync(remoteLists, ct); + + // Step 3: push current Idle backlog mirror. + var mirror = await OnlineBacklog.CurrentAsync(tasks, ct); + await _api.PutMirrorAsync(mirror, ct); + } +} diff --git a/src/ClaudeDo.Worker/Online/OnlineTokenStore.cs b/src/ClaudeDo.Worker/Online/OnlineTokenStore.cs new file mode 100644 index 0000000..29a7510 --- /dev/null +++ b/src/ClaudeDo.Worker/Online/OnlineTokenStore.cs @@ -0,0 +1,54 @@ +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using ClaudeDo.Data; + +namespace ClaudeDo.Worker.Online; + +/// +/// Persists the Zitadel refresh token encrypted with DPAPI (CurrentUser scope). +/// Windows-only; the file lives at ~/.todo-app/online-inbox.token. +/// +[SupportedOSPlatform("windows")] +public sealed class OnlineTokenStore +{ + private readonly string _tokenPath; + + public OnlineTokenStore() + : this(Path.Combine(Paths.AppDataRoot(), "online-inbox.token")) { } + + internal OnlineTokenStore(string tokenPath) + { + _tokenPath = tokenPath; + } + + public void Save(string refreshToken) + { + ArgumentException.ThrowIfNullOrEmpty(refreshToken); + var plain = Encoding.UTF8.GetBytes(refreshToken); + var cipher = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser); + Directory.CreateDirectory(Path.GetDirectoryName(_tokenPath)!); + File.WriteAllBytes(_tokenPath, cipher); + } + + public string? Read() + { + if (!File.Exists(_tokenPath)) return null; + try + { + var cipher = File.ReadAllBytes(_tokenPath); + var plain = ProtectedData.Unprotect(cipher, null, DataProtectionScope.CurrentUser); + return Encoding.UTF8.GetString(plain); + } + catch + { + return null; + } + } + + public void Clear() + { + if (File.Exists(_tokenPath)) + File.Delete(_tokenPath); + } +} diff --git a/src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs b/src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs new file mode 100644 index 0000000..bbb7906 --- /dev/null +++ b/src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs @@ -0,0 +1,21 @@ +using ClaudeDo.Worker.Online.Interfaces; + +namespace ClaudeDo.Worker.Online; + +/// +/// Simple that returns a fixed token supplied at construction. +/// Used as the default DI registration until ZitadelAuthProvider is wired (Phase 2). +/// Also serves as the test double. +/// +public sealed class StaticTokenAuthProvider : IOnlineAuthProvider +{ + private readonly string? _token; + + public StaticTokenAuthProvider(string? token = null) + { + _token = token; + } + + public Task GetAccessTokenAsync(CancellationToken ct = default) + => Task.FromResult(_token); +} diff --git a/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs b/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs new file mode 100644 index 0000000..4587b4d --- /dev/null +++ b/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs @@ -0,0 +1,13 @@ +using ClaudeDo.Worker.Online.Interfaces; + +namespace ClaudeDo.Worker.Online; + +// TODO(online-inbox): wire the Zitadel package once client config is known (Phase 2). +// Replace this stub with a real implementation that uses OnlineTokenStore to read the +// refresh token and exchanges it for an access token via the Zitadel OIDC endpoint, +// caching the access token until near expiry. +public sealed class ZitadelAuthProvider : IOnlineAuthProvider +{ + public Task GetAccessTokenAsync(CancellationToken ct = default) + => Task.FromResult(null); +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 7348efe..2b61241 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -11,6 +11,8 @@ using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.State; +using ClaudeDo.Worker.Online; +using ClaudeDo.Worker.Online.Interfaces; using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Refine; using ClaudeDo.Worker.Report; @@ -149,6 +151,22 @@ builder.Services.AddMcpServer() .WithTools() .WithTools(); +// Online Inbox — registered only when enabled. +if (cfg.OnlineInbox.Enabled) +{ + OnlineInboxApiClient.ValidateBaseUrl(cfg.OnlineInbox.ApiBaseUrl); + builder.Services.AddSingleton(cfg.OnlineInbox); + builder.Services.AddSingleton(); +#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here. + builder.Services.AddSingleton(); +#pragma warning restore CA1416 + builder.Services.AddHttpClient(client => + { + client.BaseAddress = new Uri(cfg.OnlineInbox.ApiBaseUrl.TrimEnd('/') + "/"); + }); + builder.Services.AddHostedService(); +} + // Loopback-only bind. Firewall is irrelevant for 127.0.0.1. builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}"); diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineBacklogTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineBacklogTests.cs new file mode 100644 index 0000000..6faf731 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineBacklogTests.cs @@ -0,0 +1,167 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Online; +using ClaudeDo.Worker.Tests.Infrastructure; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Online; + +public sealed class OnlineBacklogTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly TaskRepository _tasks; + private readonly ListRepository _lists; + private string _listId = null!; + + public OnlineBacklogTests() + { + _ctx = _db.CreateContext(); + _tasks = new TaskRepository(_ctx); + _lists = new ListRepository(_ctx); + } + + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + } + + private async Task SeedListAsync() + { + _listId = Guid.NewGuid().ToString(); + await _lists.AddAsync(new ListEntity { Id = _listId, Name = "Test", CreatedAt = DateTime.UtcNow }); + } + + private TaskEntity Make( + TaskStatus status = TaskStatus.Idle, + string? parentId = null, + PlanningPhase planning = PlanningPhase.None, + string? blockedById = null) => new() + { + Id = Guid.NewGuid().ToString(), + ListId = _listId, + Title = "T", + Status = status, + ParentTaskId = parentId, + PlanningPhase = planning, + BlockedByTaskId = blockedById, + CreatedAt = DateTime.UtcNow, + }; + + [Fact] + public async Task CurrentAsync_Returns_OnlyIdleBacklogItems() + { + await SeedListAsync(); + + var idle = Make(TaskStatus.Idle); + var queued = Make(TaskStatus.Queued); + var running = Make(TaskStatus.Running); + var done = Make(TaskStatus.Done); + var failed = Make(TaskStatus.Failed); + + // Idle but with parent → planning child + var parent = Make(TaskStatus.Idle); + await _tasks.AddAsync(parent); + var child = Make(TaskStatus.Idle, parentId: parent.Id); + + // Idle but with PlanningPhase + var planningParent = Make(TaskStatus.Idle, planning: PlanningPhase.Active); + + // Idle but blocked + await _tasks.AddAsync(idle); + var blocker = Make(TaskStatus.Idle); + await _tasks.AddAsync(blocker); + var blocked = Make(TaskStatus.Idle, blockedById: blocker.Id); + + await _tasks.AddAsync(queued); + await _tasks.AddAsync(running); + await _tasks.AddAsync(done); + await _tasks.AddAsync(failed); + await _tasks.AddAsync(child); + await _tasks.AddAsync(planningParent); + await _tasks.AddAsync(blocked); + + var mirror = await OnlineBacklog.CurrentAsync(_tasks); + + // Only the plain idle task and the blocker (which itself is plain idle) should appear + var ids = mirror.Select(m => m.Id).ToHashSet(); + Assert.Contains(idle.Id, ids); + Assert.Contains(blocker.Id, ids); + Assert.Contains(parent.Id, ids); // parent with no parent itself is a backlog item + + Assert.DoesNotContain(queued.Id, ids); + Assert.DoesNotContain(running.Id, ids); + Assert.DoesNotContain(done.Id, ids); + Assert.DoesNotContain(failed.Id, ids); + Assert.DoesNotContain(child.Id, ids); + Assert.DoesNotContain(planningParent.Id, ids); + Assert.DoesNotContain(blocked.Id, ids); + } + + [Fact] + public void IsBacklogItem_Predicate_FiltersCorrectly() + { + Assert.True(OnlineBacklog.IsBacklogItem(new TaskEntity + { + Id = "1", ListId = "l", Title = "T", + Status = TaskStatus.Idle, ParentTaskId = null, + PlanningPhase = PlanningPhase.None, BlockedByTaskId = null, + CreatedAt = DateTime.UtcNow, + })); + Assert.False(OnlineBacklog.IsBacklogItem(new TaskEntity + { + Id = "2", ListId = "l", Title = "T", + Status = TaskStatus.Queued, ParentTaskId = null, + PlanningPhase = PlanningPhase.None, BlockedByTaskId = null, + CreatedAt = DateTime.UtcNow, + })); + Assert.False(OnlineBacklog.IsBacklogItem(new TaskEntity + { + Id = "3", ListId = "l", Title = "T", + Status = TaskStatus.Idle, ParentTaskId = "p", + PlanningPhase = PlanningPhase.None, BlockedByTaskId = null, + CreatedAt = DateTime.UtcNow, + })); + Assert.False(OnlineBacklog.IsBacklogItem(new TaskEntity + { + Id = "4", ListId = "l", Title = "T", + Status = TaskStatus.Idle, ParentTaskId = null, + PlanningPhase = PlanningPhase.Active, BlockedByTaskId = null, + CreatedAt = DateTime.UtcNow, + })); + Assert.False(OnlineBacklog.IsBacklogItem(new TaskEntity + { + Id = "5", ListId = "l", Title = "T", + Status = TaskStatus.Idle, ParentTaskId = null, + PlanningPhase = PlanningPhase.None, BlockedByTaskId = "b", + CreatedAt = DateTime.UtcNow, + })); + } + + [Fact] + public async Task CurrentAsync_EmptyDb_ReturnsEmpty() + { + await SeedListAsync(); + var mirror = await OnlineBacklog.CurrentAsync(_tasks); + Assert.Empty(mirror); + } + + [Fact] + public async Task CurrentAsync_MapsFieldsCorrectly() + { + await SeedListAsync(); + var task = Make(TaskStatus.Idle); + task.Description = "desc"; + await _tasks.AddAsync(task); + + var mirror = await OnlineBacklog.CurrentAsync(_tasks); + + Assert.Single(mirror); + Assert.Equal(task.Id, mirror[0].Id); + Assert.Equal(_listId, mirror[0].ListId); + Assert.Equal("T", mirror[0].Title); + Assert.Equal("desc", mirror[0].Description); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs new file mode 100644 index 0000000..0a87891 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs @@ -0,0 +1,183 @@ +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(); + 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( + () => 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( + () => 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("")); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxConfigTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxConfigTests.cs new file mode 100644 index 0000000..b99e262 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxConfigTests.cs @@ -0,0 +1,78 @@ +using System.Text.Json; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Online; + +namespace ClaudeDo.Worker.Tests.Online; + +public sealed class OnlineInboxConfigTests : IDisposable +{ + private readonly string _configPath = Path.Combine(Path.GetTempPath(), $"worker_cfg_{Guid.NewGuid():N}.json"); + + public void Dispose() + { + try { File.Delete(_configPath); } catch { } + } + + [Fact] + public void MissingSection_Returns_DisabledDefaults() + { + File.WriteAllText(_configPath, "{}"); + var cfg = WorkerConfig.Load(_configPath); + + Assert.False(cfg.OnlineInbox.Enabled); + Assert.Equal("", cfg.OnlineInbox.ApiBaseUrl); + Assert.Equal(60, cfg.OnlineInbox.PollIntervalSeconds); + Assert.Equal("", cfg.OnlineInbox.Zitadel.Authority); + Assert.Equal("", cfg.OnlineInbox.Zitadel.ClientId); + Assert.Equal("openid offline_access", cfg.OnlineInbox.Zitadel.Scopes); + } + + [Fact] + public void MissingFile_Returns_DisabledDefaults() + { + var cfg = WorkerConfig.Load(Path.Combine(Path.GetTempPath(), $"nonexistent_{Guid.NewGuid():N}.json")); + + Assert.False(cfg.OnlineInbox.Enabled); + Assert.Equal(60, cfg.OnlineInbox.PollIntervalSeconds); + } + + [Fact] + public void PopulatedSection_RoundTrips() + { + var json = """ + { + "online_inbox": { + "enabled": true, + "api_base_url": "https://inbox.claudedo.kuns.dev", + "poll_interval_seconds": 120, + "zitadel": { + "authority": "https://auth.example.com", + "client_id": "abc123", + "scopes": "openid offline_access profile" + } + } + } + """; + File.WriteAllText(_configPath, json); + var cfg = WorkerConfig.Load(_configPath); + + Assert.True(cfg.OnlineInbox.Enabled); + Assert.Equal("https://inbox.claudedo.kuns.dev", cfg.OnlineInbox.ApiBaseUrl); + Assert.Equal(120, cfg.OnlineInbox.PollIntervalSeconds); + Assert.Equal("https://auth.example.com", cfg.OnlineInbox.Zitadel.Authority); + Assert.Equal("abc123", cfg.OnlineInbox.Zitadel.ClientId); + Assert.Equal("openid offline_access profile", cfg.OnlineInbox.Zitadel.Scopes); + } + + [Fact] + public void PartialSection_UsesDefaultsForMissingFields() + { + var json = """{"online_inbox": {"enabled": true}}"""; + File.WriteAllText(_configPath, json); + var cfg = WorkerConfig.Load(_configPath); + + Assert.True(cfg.OnlineInbox.Enabled); + Assert.Equal("", cfg.OnlineInbox.ApiBaseUrl); + Assert.Equal(60, cfg.OnlineInbox.PollIntervalSeconds); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs new file mode 100644 index 0000000..a5c2535 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs @@ -0,0 +1,242 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Online; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Online; + +/// +/// Integration tests for using a fake API + real SQLite. +/// +public sealed class OnlineSyncServiceTests : IDisposable +{ + private readonly DbFixture _db = new(); + + public void Dispose() => _db.Dispose(); + + // ---- fake API ---- + + private sealed class FakeApi : IOnlineInboxApi + { + public List UnimportedTasks { get; set; } = []; + public List ReceivedLists { get; } = []; + public List ReceivedMirror { get; } = []; + public List MarkedImported { get; } = []; + public int CallCount { get; private set; } + + public Task PutListsAsync(IReadOnlyList lists, CancellationToken ct = default) + { + CallCount++; + ReceivedLists.AddRange(lists); + return Task.CompletedTask; + } + + public Task> GetUnimportedTasksAsync(CancellationToken ct = default) + { + CallCount++; + return Task.FromResult>(UnimportedTasks); + } + + public Task MarkImportedAsync(string id, CancellationToken ct = default) + { + CallCount++; + MarkedImported.Add(id); + return Task.CompletedTask; + } + + public Task PutMirrorAsync(IReadOnlyList tasks, CancellationToken ct = default) + { + CallCount++; + ReceivedMirror.AddRange(tasks); + return Task.CompletedTask; + } + } + + private OnlineSyncService BuildService(FakeApi api, string? token = "test-token") + { + var config = new OnlineInboxConfig { Enabled = true, PollIntervalSeconds = 60 }; + var auth = new StaticTokenAuthProvider(token); + return new OnlineSyncService( + _db.CreateFactory(), + api, + auth, + config, + NullLogger.Instance); + } + + private async Task<(string ListId, ClaudeDoDbContext Ctx, TaskRepository Tasks, ListRepository Lists)> SeedAsync() + { + var ctx = _db.CreateContext(); + var lists = new ListRepository(ctx); + var tasks = new TaskRepository(ctx); + var listId = Guid.NewGuid().ToString(); + await lists.AddAsync(new ListEntity { Id = listId, Name = "MyList", CreatedAt = DateTime.UtcNow }); + return (listId, ctx, tasks, lists); + } + + // ---- pull → import → flag ---- + + [Fact] + public async Task Tick_Imports_RemoteTask_And_MarksImported() + { + var (listId, ctx, tasks, _) = await SeedAsync(); + using var _ = ctx; + + var remoteId = Guid.NewGuid().ToString(); + var api = new FakeApi + { + UnimportedTasks = [new RemoteTask(remoteId, listId, "From Web", "desc", DateTimeOffset.UtcNow)], + }; + var svc = BuildService(api); + + await svc.TickAsync(CancellationToken.None); + + var imported = await tasks.GetByIdAsync(remoteId); + Assert.NotNull(imported); + Assert.Equal("From Web", imported!.Title); + Assert.Equal("desc", imported.Description); + Assert.Equal(TaskStatus.Idle, imported.Status); + Assert.Equal("online", imported.CreatedBy); + Assert.Contains(remoteId, api.MarkedImported); + } + + [Fact] + public async Task Tick_UnknownList_Skips_And_DoesNotMark() + { + var _ = await SeedAsync(); + var remoteId = Guid.NewGuid().ToString(); + var api = new FakeApi + { + UnimportedTasks = [new RemoteTask(remoteId, "unknown-list-id", "T", null, DateTimeOffset.UtcNow)], + }; + var svc = BuildService(api); + + await svc.TickAsync(CancellationToken.None); + + using var ctx = _db.CreateContext(); + var tasks = new TaskRepository(ctx); + Assert.Null(await tasks.GetByIdAsync(remoteId)); + Assert.DoesNotContain(remoteId, api.MarkedImported); + } + + // ---- mirror == Idle backlog ---- + + [Fact] + public async Task Tick_Mirror_Contains_Idle_Backlog() + { + var (listId, ctx, tasks, _) = await SeedAsync(); + using var _ = ctx; + + // Idle task → should appear in mirror + var idle = new TaskEntity + { + Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Idle Task", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, + }; + await tasks.AddAsync(idle); + + // Queued task → must NOT appear + var queued = new TaskEntity + { + Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Queued", + Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, + }; + await tasks.AddAsync(queued); + + var api = new FakeApi(); + var svc = BuildService(api); + await svc.TickAsync(CancellationToken.None); + + var mirrorIds = api.ReceivedMirror.Select(m => m.Id).ToHashSet(); + Assert.Contains(idle.Id, mirrorIds); + Assert.DoesNotContain(queued.Id, mirrorIds); + } + + [Fact] + public async Task Tick_ImportedTask_IncludedInMirror() + { + // Newly imported tasks must be part of the mirror sent in the same cycle (order matters). + var (listId, ctx, tasks, _) = await SeedAsync(); + using var _ = ctx; + + var remoteId = Guid.NewGuid().ToString(); + var api = new FakeApi + { + UnimportedTasks = [new RemoteTask(remoteId, listId, "New", null, DateTimeOffset.UtcNow)], + }; + var svc = BuildService(api); + await svc.TickAsync(CancellationToken.None); + + // Imported task lands Idle → must be in the mirror payload + Assert.Contains(api.ReceivedMirror, m => m.Id == remoteId); + } + + // ---- lists pushed ---- + + [Fact] + public async Task Tick_Pushes_AllLists() + { + var (listId, ctx, _, lists) = await SeedAsync(); + using var _ = ctx; + + // Add a second list + var listId2 = Guid.NewGuid().ToString(); + await lists.AddAsync(new ListEntity { Id = listId2, Name = "List2", CreatedAt = DateTime.UtcNow }); + + var api = new FakeApi(); + var svc = BuildService(api); + await svc.TickAsync(CancellationToken.None); + + var pushedIds = api.ReceivedLists.Select(l => l.Id).ToHashSet(); + Assert.Contains(listId, pushedIds); + Assert.Contains(listId2, pushedIds); + } + + // ---- no token = no calls ---- + + [Fact] + public async Task Tick_NoToken_SkipsCycle_NoApiCalls() + { + _ = await SeedAsync(); + var api = new FakeApi(); + var svc = BuildService(api, token: null); + + await svc.TickAsync(CancellationToken.None); + + Assert.Equal(0, api.CallCount); + } + + // ---- already-imported task on server ---- + + [Fact] + public async Task Tick_AlreadyLocalTask_MarksImportedWithoutDuplicate() + { + var (listId, ctx, tasks, _) = await SeedAsync(); + using var _ = ctx; + + var existingId = Guid.NewGuid().ToString(); + var existing = new TaskEntity + { + Id = existingId, ListId = listId, Title = "Existing", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, + }; + await tasks.AddAsync(existing); + + var api = new FakeApi + { + // Server thinks this task isn't imported yet (e.g. a retry scenario) + UnimportedTasks = [new RemoteTask(existingId, listId, "Existing", null, DateTimeOffset.UtcNow)], + }; + var svc = BuildService(api); + await svc.TickAsync(CancellationToken.None); + + // Should still mark imported, and not create a duplicate + Assert.Contains(existingId, api.MarkedImported); + using var ctx2 = _db.CreateContext(); + var count = (await new TaskRepository(ctx2).GetByListIdAsync(listId)).Count(t => t.Id == existingId); + Assert.Equal(1, count); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineTokenStoreTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineTokenStoreTests.cs new file mode 100644 index 0000000..404351b --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineTokenStoreTests.cs @@ -0,0 +1,53 @@ +using ClaudeDo.Worker.Online; + +namespace ClaudeDo.Worker.Tests.Online; + +public sealed class OnlineTokenStoreTests : IDisposable +{ + private readonly string _tokenPath = Path.Combine(Path.GetTempPath(), $"online_token_{Guid.NewGuid():N}.bin"); + + public void Dispose() + { + try { File.Delete(_tokenPath); } catch { } + } + + [Fact] + public void Save_Read_RoundTrips() + { + if (!OperatingSystem.IsWindows()) return; // DPAPI is Windows-only + + var store = new OnlineTokenStore(_tokenPath); + store.Save("my-refresh-token"); + var result = store.Read(); + Assert.Equal("my-refresh-token", result); + } + + [Fact] + public void Clear_Removes_Token() + { + if (!OperatingSystem.IsWindows()) return; + + var store = new OnlineTokenStore(_tokenPath); + store.Save("token"); + store.Clear(); + Assert.Null(store.Read()); + } + + [Fact] + public void Read_WhenFileAbsent_Returns_Null() + { + if (!OperatingSystem.IsWindows()) return; + + var store = new OnlineTokenStore(_tokenPath); + Assert.Null(store.Read()); + } + + [Fact] + public void Clear_WhenFileAbsent_DoesNotThrow() + { + if (!OperatingSystem.IsWindows()) return; + + var store = new OnlineTokenStore(_tokenPath); + store.Clear(); // no exception expected + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Online/StaticTokenAuthProviderTests.cs b/tests/ClaudeDo.Worker.Tests/Online/StaticTokenAuthProviderTests.cs new file mode 100644 index 0000000..47537d6 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Online/StaticTokenAuthProviderTests.cs @@ -0,0 +1,30 @@ +using ClaudeDo.Worker.Online; + +namespace ClaudeDo.Worker.Tests.Online; + +public sealed class StaticTokenAuthProviderTests +{ + [Fact] + public async Task WithToken_Returns_Token() + { + var provider = new StaticTokenAuthProvider("my-token"); + var result = await provider.GetAccessTokenAsync(); + Assert.Equal("my-token", result); + } + + [Fact] + public async Task WithNull_Returns_Null() + { + var provider = new StaticTokenAuthProvider(null); + var result = await provider.GetAccessTokenAsync(); + Assert.Null(result); + } + + [Fact] + public async Task Default_Returns_Null() + { + var provider = new StaticTokenAuthProvider(); + var result = await provider.GetAccessTokenAsync(); + Assert.Null(result); + } +}