From 17c7ff517a2db750386b14c5359d9a6e435fbb45 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 10 Jun 2026 10:49:49 +0200 Subject: [PATCH] feat(worker,ui): Online Inbox config + auth hub plumbing (Phase 2) Hub: GetOnlineInboxState / SetOnlineInboxConfig / SetOnlineInboxAuth / ClearOnlineInboxAuth. WorkerConfig.SaveOnlineInbox persists only the online_inbox section. OnlineTokenStore + config registered always so hub methods work when sync is disabled. IWorkerClient surface + all test fakes synced. RedirectUri config (default http://localhost:8765/callback). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Services/Interfaces/IWorkerClient.cs | 5 + src/ClaudeDo.Ui/Services/WorkerClient.cs | 30 +++ src/ClaudeDo.Worker/Config/WorkerConfig.cs | 30 +++ src/ClaudeDo.Worker/Hub/WorkerHub.cs | 68 ++++++- .../Online/OnlineInboxConfig.cs | 3 + src/ClaudeDo.Worker/Program.cs | 13 +- tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 5 + .../Hub/ClearMyDayHubTests.cs | 3 +- .../Hub/OnlineInboxHubTests.cs | 174 ++++++++++++++++++ .../Hub/PlanningHubTests.cs | 3 +- .../Hub/WorktreeStateHubTests.cs | 3 +- .../UiVm/TasksIslandViewModelPlanningTests.cs | 4 + 12 files changed, 333 insertions(+), 8 deletions(-) create mode 100644 tests/ClaudeDo.Worker.Tests/Hub/OnlineInboxHubTests.cs diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index 8303fa0..3b7590b 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -98,4 +98,9 @@ public interface IWorkerClient : INotifyPropertyChanged Task> GetWorktreesOverviewAsync(string? listId); Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState); Task ForceRemoveWorktreeAsync(string taskId); + + Task GetOnlineInboxStateAsync(); + Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input); + Task SetOnlineInboxAuthAsync(string refreshToken); + Task ClearOnlineInboxAuthAsync(); } diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 08a274d..6a28bf8 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -504,6 +504,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct); } + public Task GetOnlineInboxStateAsync() + => TryInvokeAsync("GetOnlineInboxState"); + + public async Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) + => await _hub.InvokeAsync("SetOnlineInboxConfig", input); + + public async Task SetOnlineInboxAuthAsync(string refreshToken) + => await _hub.InvokeAsync("SetOnlineInboxAuth", refreshToken); + + public async Task ClearOnlineInboxAuthAsync() + => await _hub.InvokeAsync("ClearOnlineInboxAuth"); + // IWorkerClient explicit implementations (drop typed return values) async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct) => await StartPlanningSessionAsync(taskId, ct); @@ -568,3 +580,21 @@ public sealed record WorktreeOverviewDto( bool PathExistsOnDisk); public sealed record ForceRemoveResultDto(bool Removed, string? Reason); + +public sealed record OnlineInboxStateDto( + bool Enabled, + string ApiBaseUrl, + string Authority, + string ClientId, + string Scopes, + string RedirectUri, + bool SignedIn); + +public sealed record OnlineInboxConfigInputDto( + bool Enabled, + string ApiBaseUrl, + int PollIntervalSeconds, + string Authority, + string ClientId, + string Scopes, + string RedirectUri); diff --git a/src/ClaudeDo.Worker/Config/WorkerConfig.cs b/src/ClaudeDo.Worker/Config/WorkerConfig.cs index 17ca294..afcdff9 100644 --- a/src/ClaudeDo.Worker/Config/WorkerConfig.cs +++ b/src/ClaudeDo.Worker/Config/WorkerConfig.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using ClaudeDo.Data; using ClaudeDo.Worker.Online; @@ -74,9 +75,38 @@ public sealed class WorkerConfig return cfg; } + /// + /// Persists ONLY the online_inbox section back to + /// (defaults to ) without rewriting any other fields. + /// Reads the existing JSON, replaces the online_inbox node, and writes back indented. + /// + public void SaveOnlineInbox(string? path = null) + { + path ??= DefaultConfigPath; + + var root = File.Exists(path) + ? JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject() + : new JsonObject(); + + root["online_inbox"] = JsonSerializer.SerializeToNode(OnlineInbox, InboxSerializerOpts); + + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, root.ToJsonString(WriteOpts)); + } + private static readonly JsonSerializerOptions JsonOpts = new() { ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, }; + + private static readonly JsonSerializerOptions InboxSerializerOpts = new() + { + WriteIndented = false, + }; + + private static readonly JsonSerializerOptions WriteOpts = new() + { + WriteIndented = true, + }; } diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index f87a550..e0afbfe 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -4,7 +4,9 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Agents; +using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Online; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Queue; @@ -65,6 +67,24 @@ public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? S public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); public record SeedResultDto(int Copied, int Skipped); +public record OnlineInboxStateDto( + bool Enabled, + string ApiBaseUrl, + string Authority, + string ClientId, + string Scopes, + string RedirectUri, + bool SignedIn); + +public record OnlineInboxConfigInput( + bool Enabled, + string ApiBaseUrl, + int PollIntervalSeconds, + string Authority, + string ClientId, + string Scopes, + string RedirectUri); + public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub { private static readonly string Version = @@ -89,6 +109,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub private readonly ITaskStateService _state; private readonly IWeekReportService _report; private readonly IRefineRunner _refineRunner; + private readonly WorkerConfig _cfg; + private readonly OnlineInboxConfig _onlineInboxConfig; + private readonly OnlineTokenStore _onlineTokenStore; public WorkerHub( QueueService queue, @@ -109,7 +132,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub IPrimeRunner primeRunner, ITaskStateService state, IWeekReportService report, - IRefineRunner refineRunner) + IRefineRunner refineRunner, + WorkerConfig cfg, + OnlineInboxConfig onlineInboxConfig, + OnlineTokenStore onlineTokenStore) { _queue = queue; _waker = waker; @@ -130,6 +156,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub _state = state; _report = report; _refineRunner = refineRunner; + _cfg = cfg; + _onlineInboxConfig = onlineInboxConfig; + _onlineTokenStore = onlineTokenStore; } // Maps the two exceptions service methods throw into client-facing HubExceptions: @@ -684,4 +713,41 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub return ids.Count; } + +#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI calls are safe here. + public OnlineInboxStateDto GetOnlineInboxState() + { + var signedIn = _onlineTokenStore.Read() is not null; + return new OnlineInboxStateDto( + _onlineInboxConfig.Enabled, + _onlineInboxConfig.ApiBaseUrl, + _onlineInboxConfig.Zitadel.Authority, + _onlineInboxConfig.Zitadel.ClientId, + _onlineInboxConfig.Zitadel.Scopes, + _onlineInboxConfig.RedirectUri, + signedIn); + } + + public void SetOnlineInboxConfig(OnlineInboxConfigInput input) + { + _onlineInboxConfig.Enabled = input.Enabled; + _onlineInboxConfig.ApiBaseUrl = input.ApiBaseUrl ?? ""; + _onlineInboxConfig.PollIntervalSeconds = input.PollIntervalSeconds; + _onlineInboxConfig.RedirectUri = input.RedirectUri ?? "http://localhost:8765/callback"; + _onlineInboxConfig.Zitadel.Authority = input.Authority ?? ""; + _onlineInboxConfig.Zitadel.ClientId = input.ClientId ?? ""; + _onlineInboxConfig.Zitadel.Scopes = input.Scopes ?? "openid offline_access"; + _cfg.SaveOnlineInbox(); + } + + public void SetOnlineInboxAuth(string refreshToken) + { + _onlineTokenStore.Save(refreshToken); + } + + public void ClearOnlineInboxAuth() + { + _onlineTokenStore.Clear(); + } +#pragma warning restore CA1416 } diff --git a/src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs b/src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs index 5dd3ccd..a1ae279 100644 --- a/src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs +++ b/src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs @@ -25,6 +25,9 @@ public sealed class OnlineInboxConfig [JsonPropertyName("poll_interval_seconds")] public int PollIntervalSeconds { get; set; } = 60; + [JsonPropertyName("redirect_uri")] + public string RedirectUri { get; set; } = "http://localhost:8765/callback"; + [JsonPropertyName("zitadel")] public ZitadelClientConfig Zitadel { get; set; } = new(); } diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 1778286..ca7a6d7 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -151,14 +151,19 @@ builder.Services.AddMcpServer() .WithTools() .WithTools(); -// Online Inbox — registered only when enabled. +// OnlineInboxConfig and OnlineTokenStore are always registered so hub methods work +// even when sync is disabled. The sync stack (api client, auth, hosted service) is +// only registered when enabled. +builder.Services.AddSingleton(cfg.OnlineInbox); +#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here. +builder.Services.AddSingleton(); +#pragma warning restore CA1416 + if (cfg.OnlineInbox.Enabled) { OnlineInboxApiClient.ValidateBaseUrl(cfg.OnlineInbox.ApiBaseUrl); - builder.Services.AddSingleton(cfg.OnlineInbox); builder.Services.AddHttpClient(); -#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here. - builder.Services.AddSingleton(); +#pragma warning disable CA1416 builder.Services.AddSingleton(); #pragma warning restore CA1416 builder.Services.AddHttpClient(client => diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 233cfe1..3cb7f2f 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -108,5 +108,10 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task GetLastPrepLogAsync() => Task.FromResult(LastPrepLog); public virtual Task RefineTaskAsync(string taskId) => Task.CompletedTask; + public virtual Task GetOnlineInboxStateAsync() => Task.FromResult(null); + public virtual Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask; + public virtual Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask; + public virtual Task ClearOnlineInboxAuthAsync() => Task.CompletedTask; + protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); } diff --git a/tests/ClaudeDo.Worker.Tests/Hub/ClearMyDayHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/ClearMyDayHubTests.cs index 1fd550c..31e1074 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/ClearMyDayHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/ClearMyDayHubTests.cs @@ -19,7 +19,8 @@ public sealed class ClearMyDayHubTests : IDisposable var broadcaster = new HubBroadcaster(new CapturingHubContext()); var hub = new WorkerHub( null!, null!, null!, null!, broadcaster, _db.CreateFactory(), - null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!); + null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore()); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); return hub; diff --git a/tests/ClaudeDo.Worker.Tests/Hub/OnlineInboxHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/OnlineInboxHubTests.cs new file mode 100644 index 0000000..0ab74d6 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Hub/OnlineInboxHubTests.cs @@ -0,0 +1,174 @@ +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Online; +using ClaudeDo.Worker.Tests.Infrastructure; +using System.Text.Json; +using System.Text.Json.Nodes; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Hub; + +public sealed class OnlineInboxHubTests : IDisposable +{ + private readonly string _configPath = Path.Combine( + Path.GetTempPath(), $"online_inbox_hub_{Guid.NewGuid():N}.json"); + private readonly string _tokenPath = Path.Combine( + Path.GetTempPath(), $"online_inbox_hub_token_{Guid.NewGuid():N}.bin"); + + public void Dispose() + { + try { File.Delete(_configPath); } catch { } + try { File.Delete(_tokenPath); } catch { } + } + + private (WorkerHub hub, OnlineInboxConfig inboxCfg, OnlineTokenStore store) CreateHub( + OnlineInboxConfig? inboxCfg = null, OnlineTokenStore? store = null) + { + var cfg = new WorkerConfig(); + inboxCfg ??= cfg.OnlineInbox; + store ??= new OnlineTokenStore(_tokenPath); + var broadcaster = new HubBroadcaster(new CapturingHubContext()); + var hub = new WorkerHub( + null!, null!, null!, null!, broadcaster, null!, + null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, + cfg, inboxCfg, store); + hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); + hub.Context = new FakeHubCallerContext(); + return (hub, inboxCfg, store); + } + + [Fact] + public void GetOnlineInboxState_ReflectsConfigAndNoToken() + { + var (hub, _, _) = CreateHub(); + + var state = hub.GetOnlineInboxState(); + + Assert.False(state.Enabled); + Assert.False(state.SignedIn); + Assert.Equal("", state.ApiBaseUrl); + } + + [Fact] + public void GetOnlineInboxState_SignedIn_WhenTokenPresent() + { + if (!OperatingSystem.IsWindows()) return; // DPAPI is Windows-only + + var (hub, _, store) = CreateHub(); + store.Save("my-refresh-token"); + + var state = hub.GetOnlineInboxState(); + + Assert.True(state.SignedIn); + } + + [Fact] + public void GetOnlineInboxState_ReflectsConfigFields() + { + var inboxCfg = new OnlineInboxConfig + { + Enabled = true, + ApiBaseUrl = "https://inbox.example.com", + RedirectUri = "http://localhost:9999/callback", + Zitadel = new ZitadelClientConfig + { + Authority = "https://auth.example.com", + ClientId = "client-abc", + Scopes = "openid offline_access", + }, + }; + var (hub, _, _) = CreateHub(inboxCfg); + + var state = hub.GetOnlineInboxState(); + + Assert.True(state.Enabled); + Assert.Equal("https://inbox.example.com", state.ApiBaseUrl); + Assert.Equal("https://auth.example.com", state.Authority); + Assert.Equal("client-abc", state.ClientId); + Assert.Equal("openid offline_access", state.Scopes); + Assert.Equal("http://localhost:9999/callback", state.RedirectUri); + } + + [Fact] + public void SetOnlineInboxConfig_UpdatesConfigAndPersistsToFile() + { + var cfg = new WorkerConfig(); + // point SaveOnlineInbox to our temp file + var inboxCfg = cfg.OnlineInbox; + var store = new OnlineTokenStore(_tokenPath); + var broadcaster = new HubBroadcaster(new CapturingHubContext()); + + // Patch cfg to save to our temp path by writing an initial file there + File.WriteAllText(_configPath, "{}"); + + // We need to use the internal SaveOnlineInbox with our path. + // WorkerHub calls _cfg.SaveOnlineInbox() which uses DefaultConfigPath. + // For this test, directly exercise the round-trip via WorkerConfig.SaveOnlineInbox. + cfg.OnlineInbox.Enabled = true; + cfg.OnlineInbox.ApiBaseUrl = "https://inbox.test.com"; + cfg.OnlineInbox.PollIntervalSeconds = 120; + cfg.OnlineInbox.RedirectUri = "http://localhost:7777/cb"; + cfg.OnlineInbox.Zitadel.Authority = "https://auth.test.com"; + cfg.OnlineInbox.Zitadel.ClientId = "test-client"; + cfg.OnlineInbox.Zitadel.Scopes = "openid offline_access"; + + cfg.SaveOnlineInbox(_configPath); + + // Verify file contains only online_inbox node and preserves others + var json = File.ReadAllText(_configPath); + var root = JsonNode.Parse(json)!.AsObject(); + + Assert.NotNull(root["online_inbox"]); + var inbox = root["online_inbox"]!.AsObject(); + Assert.True(inbox["enabled"]!.GetValue()); + Assert.Equal("https://inbox.test.com", inbox["api_base_url"]!.GetValue()); + Assert.Equal(120, inbox["poll_interval_seconds"]!.GetValue()); + Assert.Equal("http://localhost:7777/cb", inbox["redirect_uri"]!.GetValue()); + + // Verify existing file keys are preserved (not overwritten) + var roundTripped = WorkerConfig.Load(_configPath); + Assert.Equal("https://inbox.test.com", roundTripped.OnlineInbox.ApiBaseUrl); + Assert.Equal(true, roundTripped.OnlineInbox.Enabled); + } + + [Fact] + public void SetOnlineInboxConfig_PreservesOtherConfigKeys() + { + // Write a config with a non-inbox key + var initial = """{"signalr_port":12345}"""; + File.WriteAllText(_configPath, initial); + + var cfg = new WorkerConfig(); + cfg.OnlineInbox.ApiBaseUrl = "https://new.example.com"; + cfg.SaveOnlineInbox(_configPath); + + var root = JsonNode.Parse(File.ReadAllText(_configPath))!.AsObject(); + Assert.Equal(12345, root["signalr_port"]!.GetValue()); + Assert.Equal("https://new.example.com", + root["online_inbox"]!["api_base_url"]!.GetValue()); + } + + [Fact] + public void SetOnlineInboxAuth_WritesToken() + { + if (!OperatingSystem.IsWindows()) return; + + var (hub, _, store) = CreateHub(); + hub.SetOnlineInboxAuth("refresh-abc"); + + Assert.Equal("refresh-abc", store.Read()); + } + + [Fact] + public void ClearOnlineInboxAuth_RemovesToken() + { + if (!OperatingSystem.IsWindows()) return; + + var (hub, _, store) = CreateHub(); + store.Save("some-token"); + + hub.ClearOnlineInboxAuth(); + + Assert.Null(store.Read()); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs index 1579e3c..95167a5 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs @@ -55,7 +55,8 @@ public sealed class PlanningHubTests : IDisposable { var hub = new WorkerHub( null!, null!, null!, null!, null!, null!, null!, null!, null!, - _planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!); + _planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!, + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore()); hub.Clients = new FakeHubCallerClients(_proxy); hub.Context = new FakeHubCallerContext(); return hub; diff --git a/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs index 0043533..5f95f1b 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs @@ -19,7 +19,8 @@ public sealed class WorktreeStateHubTests : IDisposable var broadcaster = new HubBroadcaster(new CapturingHubContext()); var hub = new WorkerHub( null!, null!, null!, null!, broadcaster, _db.CreateFactory(), - null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!); + null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore()); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); return hub; diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index b338a47..639d59d 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -112,6 +112,10 @@ sealed class FakeWorkerClient : IWorkerClient public Task DeleteDailyNoteAsync(string id) => Task.CompletedTask; public Task GetLastPrepLogAsync() => Task.FromResult(string.Empty); public Task RefineTaskAsync(string taskId) => Task.CompletedTask; + public Task GetOnlineInboxStateAsync() => Task.FromResult(null); + public Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input) => Task.CompletedTask; + public Task SetOnlineInboxAuthAsync(string refreshToken) => Task.CompletedTask; + public Task ClearOnlineInboxAuthAsync() => Task.CompletedTask; } // ── Helper to build VM with pre-seeded Items ──────────────────────────────────