From 52ba538339ba06d2109874d060b03d7636224942 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Wed, 30 Sep 2026 08:14:26 +0200 Subject: [PATCH] test(worker): cover WorktreeUpdated broadcast on interactive review submission --- ...InteractiveReviewSubmissionServiceTests.cs | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 tests/ClaudeDo.Worker.Tests/Lifecycle/InteractiveReviewSubmissionServiceTests.cs diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/InteractiveReviewSubmissionServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/InteractiveReviewSubmissionServiceTests.cs new file mode 100644 index 00000000..c1bb7310 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/InteractiveReviewSubmissionServiceTests.cs @@ -0,0 +1,125 @@ +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.Lifecycle; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Lifecycle; + +// The detail pane gates its whole REVIEW/MERGE block (Open diff included) on the worktree row it +// last read, and it only re-reads that on WorktreeUpdated -- TaskUpdated refreshes the task row +// alone. An interactive session creates/commits its worktree entirely outside the autonomous +// pipeline, so without a WorktreeUpdated here the task lands in WaitingForReview with no diff +// button at all until it is re-selected. +public sealed class InteractiveReviewSubmissionServiceTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + private readonly List _repos = new(); + private readonly CapturingHubContext _hub = new(); + + public InteractiveReviewSubmissionServiceTests() => _ctx = _db.CreateContext(); + + public void Dispose() + { + foreach (var r in _repos) r.Dispose(); + _ctx.Dispose(); + _db.Dispose(); + } + + private GitRepoFixture CreateRepo() + { + var f = new GitRepoFixture(); + _repos.Add(f); + return f; + } + + private InteractiveReviewSubmissionService BuildSut() + { + var cfg = new WorkerConfig + { + SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"), + LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"), + }; + var git = new GitService(); + var dbFactory = _db.CreateFactory(); + var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger.Instance); + var state = TaskStateServiceBuilder.Build(dbFactory).State; + return new InteractiveReviewSubmissionService( + dbFactory, git, state, wtManager, new HubBroadcaster(_hub)); + } + + private async Task<(TaskEntity Task, ListEntity List)> SeedAsync(string workingDir, string? handlerBaseCommit) + { + var lists = new ListRepository(_ctx); + var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", WorkingDir = workingDir, CreatedAt = DateTime.UtcNow }; + await lists.AddAsync(list); + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "t", + Status = TaskStatus.Idle, HandlerBaseCommit = handlerBaseCommit, + CreatedAt = DateTime.UtcNow, CommitType = "chore", + }; + await new TaskRepository(_ctx).AddAsync(task); + return (task, list); + } + + private bool BroadcastWorktreeUpdatedFor(string taskId) => + _hub.Proxy.Calls.Any(c => c.Method == "WorktreeUpdated" && c.Args.Length > 0 && (string?)c.Args[0] == taskId); + + [Fact] + public async Task Submit_WorktreeTask_BroadcastsWorktreeUpdated() + { + if (!GitRepoFixture.IsGitAvailable()) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = CreateRepo(); + var (task, list) = await SeedAsync(repo.RepoDir, handlerBaseCommit: null); + + var sut = BuildSut(); + var cfg = new WorkerConfig + { + SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"), + LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"), + }; + var wtManager = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); + var wt = await wtManager.CreateAsync(task, list, CancellationToken.None); + File.WriteAllText(Path.Combine(wt.WorktreePath, "hand-driven.txt"), "content"); + + await sut.SubmitAsync(task.Id, CancellationToken.None); + + Assert.True(BroadcastWorktreeUpdatedFor(task.Id)); + } + + [Fact] + public async Task Submit_WorktreeLessHandlerTask_BroadcastsWorktreeUpdated() + { + if (!GitRepoFixture.IsGitAvailable()) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = CreateRepo(); + var (task, _) = await SeedAsync(repo.RepoDir, handlerBaseCommit: repo.BaseCommit); + File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content"); + GitRepoFixture.RunGit(repo.RepoDir, "add", "-A"); + GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled"); + + await BuildSut().SubmitAsync(task.Id, CancellationToken.None); + + Assert.True(BroadcastWorktreeUpdatedFor(task.Id)); + } + + [Fact] + public async Task Submit_RejectedTask_DoesNotBroadcastWorktreeUpdated() + { + var (task, _) = await SeedAsync(Path.GetTempPath(), handlerBaseCommit: null); + + await Assert.ThrowsAsync(() => + BuildSut().SubmitAsync(task.Id, CancellationToken.None)); + + Assert.False(BroadcastWorktreeUpdatedFor(task.Id)); + } +}