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