using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Services; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.AspNetCore.SignalR; using Xunit; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Hub; public sealed class PlanningHubTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly string _rootDir; private readonly PlanningSessionManager _planning; private readonly FakePlanningLauncher _launcher; private readonly RecordingClientProxy _proxy; public PlanningHubTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); _rootDir = Path.Combine(Path.GetTempPath(), $"cd_hub_planning_{Guid.NewGuid():N}"); var git = new GitService(); var cfg = new WorkerConfig { CentralWorktreeRoot = Path.Combine(_rootDir, "central") }; var settingsRepo = new AppSettingsRepository(_ctx); settingsRepo.UpdateAsync(new AppSettingsEntity { WorktreeStrategy = "sibling" }).GetAwaiter().GetResult(); _planning = new PlanningSessionManager(_tasks, _lists, settingsRepo, git, cfg, _rootDir); _launcher = new FakePlanningLauncher(); _proxy = new RecordingClientProxy(); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); try { Directory.Delete(_rootDir, recursive: true); } catch { } } private WorkerHub CreateHub() { var hub = new WorkerHub( null!, null!, null!, null!, null!, null!, null!, null!, _planning, _launcher); hub.Clients = new FakeHubCallerClients(_proxy); hub.Context = new FakeHubCallerContext(); return hub; } private async Task<(string listId, string taskId)> SeedAsync() { var listId = Guid.NewGuid().ToString(); var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}"); GitRepoFixture.InitRepoWithInitialCommit(wd); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow, }); var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Do something", Status = TaskStatus.Manual, CreatedAt = DateTime.UtcNow, CommitType = "feat", }; await _tasks.AddAsync(task); return (listId, task.Id); } [Fact] public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher() { var (_, taskId) = await SeedAsync(); var hub = CreateHub(); var ctx = await hub.StartPlanningSessionAsync(taskId); Assert.Equal(taskId, ctx.ParentTaskId); Assert.Equal(1, _launcher.LaunchStartCalls); Assert.Equal(0, _launcher.LaunchResumeCalls); var loaded = await _tasks.GetByIdAsync(taskId); Assert.Equal(TaskStatus.Planning, loaded!.Status); Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated"); } [Fact] public async Task StartPlanningSessionAsync_LauncherFails_Discards() { var (_, taskId) = await SeedAsync(); _launcher.ShouldThrow = true; var hub = CreateHub(); await Assert.ThrowsAsync(() => hub.StartPlanningSessionAsync(taskId)); var loaded = await _tasks.GetByIdAsync(taskId); Assert.Equal(TaskStatus.Manual, loaded!.Status); var sessionDir = Path.Combine(_rootDir, taskId); Assert.False(Directory.Exists(sessionDir)); } [Fact] public async Task DiscardPlanningSessionAsync_ResetsTask_AndBroadcasts() { var (_, taskId) = await SeedAsync(); // Put task into Planning state first await _planning.StartAsync(taskId, CancellationToken.None); _proxy.Sent.Clear(); var hub = CreateHub(); await hub.DiscardPlanningSessionAsync(taskId); var loaded = await _tasks.GetByIdAsync(taskId); Assert.Equal(TaskStatus.Manual, loaded!.Status); Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated"); } [Fact] public async Task FinalizePlanningSessionAsync_PromotesDraftsAndBroadcasts() { var (_, taskId) = await SeedAsync(); await _planning.StartAsync(taskId, CancellationToken.None); await _tasks.CreateChildAsync(taskId, "child 1", null, null, null); await _tasks.CreateChildAsync(taskId, "child 2", null, null, null); _proxy.Sent.Clear(); var hub = CreateHub(); var count = await hub.FinalizePlanningSessionAsync(taskId, queueAgentTasks: false); Assert.Equal(2, count); Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated"); } [Fact] public async Task GetPendingDraftCountAsync_ReturnsCount() { var (_, taskId) = await SeedAsync(); await _planning.StartAsync(taskId, CancellationToken.None); await _tasks.CreateChildAsync(taskId, "c1", null, null, null); await _tasks.CreateChildAsync(taskId, "c2", null, null, null); var hub = CreateHub(); var count = await hub.GetPendingDraftCountAsync(taskId); Assert.Equal(2, count); } } // --------------------------------------------------------------------------- // Fakes // --------------------------------------------------------------------------- internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher { public bool ShouldThrow { get; set; } public int LaunchStartCalls { get; private set; } public int LaunchResumeCalls { get; private set; } public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken) { if (ShouldThrow) throw new PlanningLaunchException("fake launch failure"); LaunchStartCalls++; return Task.CompletedTask; } public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken) { LaunchResumeCalls++; return Task.CompletedTask; } } internal sealed class RecordingClientProxy : IClientProxy { public List<(string method, object?[] args)> Sent { get; } = new(); public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) { Sent.Add((method, args)); return Task.CompletedTask; } } internal sealed class FakeHubCallerClients : IHubCallerClients { private readonly IClientProxy _all; public FakeHubCallerClients(IClientProxy proxy) => _all = proxy; public IClientProxy All => _all; public IClientProxy Caller => _all; public IClientProxy Others => _all; public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => _all; public IClientProxy Client(string connectionId) => _all; public IClientProxy Clients(IReadOnlyList connectionIds) => _all; public IClientProxy Group(string groupName) => _all; public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => _all; public IClientProxy Groups(IReadOnlyList groupNames) => _all; public IClientProxy OthersInGroup(string groupName) => _all; public IClientProxy User(string userId) => _all; public IClientProxy Users(IReadOnlyList userIds) => _all; } internal sealed class FakeHubCallerContext : HubCallerContext { public override string ConnectionId => "test-conn"; public override string? UserIdentifier => null; public override System.Security.Claims.ClaimsPrincipal? User => null; public override IDictionary Items { get; } = new Dictionary(); public override Microsoft.AspNetCore.Http.Features.IFeatureCollection Features { get; } = new Microsoft.AspNetCore.Http.Features.FeatureCollection(); public override CancellationToken ConnectionAborted => CancellationToken.None; public override void Abort() { } }