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.Runner; using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging.Abstractions; using Xunit; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Hub; /// Covers the two hub methods a worktree-less "list handler" host task (Mission Control's /// "Let Claude handle it") relies on: CreateMergeHelperTask (task creation + HandlerBaseCommit /// stamp) and SubmitTaskForReview's worktree-less branch (HandlerHeadCommit stamp + transition). /// The handler itself merges the tasks it handles directly into the list's working dir -- no /// worktree of its own -- so these hub methods are the whole story for its review range. public sealed class MergeHelperTaskHubTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly List _repos = new(); private readonly RecordingClientProxy _proxy = new(); private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); public MergeHelperTaskHubTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); } public void Dispose() { foreach (var r in _repos) r.Dispose(); _ctx.Dispose(); _db.Dispose(); } private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry { public Task> InstallAsync(string url, CancellationToken ct) => throw new NotImplementedException(); public Task UpdateAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException(); public Task RemoveAsync(string sourceUrl, CancellationToken ct) => throw new NotImplementedException(); public Task> ListAsync(CancellationToken ct) => Task.FromResult>(Array.Empty()); } private WorkerHub CreateHub() { var factory = _db.CreateFactory(); var git = new GitService(); var wtManager = new WorktreeManager(git, factory, new WorkerConfig(), NullLogger.Instance); var interactiveLaunchSpec = new InteractiveLaunchSpecService( factory, new FakeSessionSkillSeeder(), new FakeSessionSkillRegistry(), wtManager, git, new WorkerConfig { ClaudeBin = "claude" }); var built = TaskStateServiceBuilder.Build(factory); var hub = new WorkerHub( null!, null!, null!, null!, null!, factory, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, built.State, null!, null!, null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!, logBuffer: null, interactiveLaunchSpec: interactiveLaunchSpec, worktreeManager: wtManager, git: git); hub.Clients = new FakeHubCallerClients(_proxy); hub.Context = new FakeHubCallerContext(); return hub; } private GitRepoFixture CreateRepo() { var f = new GitRepoFixture(); _repos.Add(f); return f; } private async Task SeedListAsync(string workingDir, string name = "L") { var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = name, WorkingDir = workingDir, CreatedAt = DateTime.UtcNow }); return listId; } private async Task SeedTaskAsync( string listId, TaskStatus status = TaskStatus.Idle, string? handlerBaseCommit = null, string title = "T") { var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = title, Status = status, HandlerBaseCommit = handlerBaseCommit, CreatedAt = DateTime.UtcNow, }; await _tasks.AddAsync(task); return task; } // ── CreateMergeHelperTask ── [Fact] public async Task CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = CreateRepo(); var listId = await SeedListAsync(repo.RepoDir, name: "Alpha"); var t1 = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "First task"); var hub = CreateHub(); var newTaskId = await hub.CreateMergeHelperTask( new[] { t1.Id }, listId, "List handler: Alpha", "Tasks handled by this run:"); var created = await _tasks.GetByIdAsync(newTaskId); Assert.NotNull(created); Assert.Equal(TaskStatus.Idle, created!.Status); Assert.True(created.IsManual); Assert.Equal(repo.BaseCommit, created.HandlerBaseCommit); Assert.Contains("First task", created.Description); Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated"); } [Fact] public async Task CreateMergeHelperTask_UnknownList_Throws() { var hub = CreateHub(); await Assert.ThrowsAsync( () => hub.CreateMergeHelperTask(new[] { "t1" }, "no-such-list", "title", "header")); } // ── SubmitTaskForReview (worktree-less branch) ── [Fact] public async Task SubmitTaskForReview_WorktreeLessHandlerTask_StampsHeadCommit_TransitionsToWaitingForReview() { if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } var repo = CreateRepo(); var listId = await SeedListAsync(repo.RepoDir); var task = await SeedTaskAsync(listId, TaskStatus.Idle, handlerBaseCommit: repo.BaseCommit); // The handler merged a task's worktree into the list's working dir on its own. File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled task merge"); var expectedHead = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim(); var hub = CreateHub(); await hub.SubmitTaskForReview(task.Id); var reloaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.WaitingForReview, reloaded!.Status); Assert.Equal(expectedHead, reloaded.HandlerHeadCommit); } [Fact] public async Task SubmitTaskForReview_NoWorktreeAndNoHandlerBaseCommit_Throws() { var listId = await SeedListAsync(Path.GetTempPath()); var task = await SeedTaskAsync(listId, TaskStatus.Idle); var hub = CreateHub(); await Assert.ThrowsAsync(() => hub.SubmitTaskForReview(task.Id)); } [Fact] public async Task SubmitTaskForReview_RunningTask_Throws_RegardlessOfHandlerState() { var listId = await SeedListAsync(Path.GetTempPath()); var task = await SeedTaskAsync(listId, TaskStatus.Running, handlerBaseCommit: "abc123"); var hub = CreateHub(); await Assert.ThrowsAsync(() => hub.SubmitTaskForReview(task.Id)); } } // RecordingClientProxy / FakeHubCallerClients / FakeHubCallerContext are defined once for the // whole ClaudeDo.Worker.Tests.Hub namespace in PlanningHubTests.cs; reused here as-is.