using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.External; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Tests.Infrastructure; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.External; public sealed class HandoffMcpToolsTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly CapturingHubContext _hubContext = new(); public HandoffMcpToolsTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private HandoffMcpTools BuildSut(HandoffRoundTracker? tracker = null) => new(_tasks, new HubBroadcaster(_hubContext), tracker ?? new HandoffRoundTracker()); private async Task SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle, string title = "t") { var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = title, Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(task); return task; } private async Task SeedListAsync() { var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); return listId; } [Fact] public async Task HandoffListHandler_ValidIds_BroadcastsAndReturnsCount() { var listId = await SeedListAsync(); var handlerTask = await SeedTaskAsync(listId, title: "List handler: Alpha"); var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor"); var sut = BuildSut(); var result = await sut.HandoffListHandler(handlerTask.Id, new[] { survivor.Id }, cancellationToken: CancellationToken.None); Assert.True(result.Requested); Assert.Equal(handlerTask.Id, result.TaskId); Assert.Equal(1, result.SurvivingCount); Assert.Equal("wait", result.NextPhase); var call = Assert.Single(_hubContext.Proxy.Calls); Assert.Equal("HandoffRequested", call.Method); Assert.Equal(handlerTask.Id, call.Args[0]); Assert.Equal("wait", call.Args[2]); } [Fact] public async Task HandoffListHandler_NextPhaseDefaultsToWait() { var listId = await SeedListAsync(); var handlerTask = await SeedTaskAsync(listId); var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor"); var sut = BuildSut(); var result = await sut.HandoffListHandler(handlerTask.Id, new[] { survivor.Id }, cancellationToken: CancellationToken.None); Assert.Equal("wait", result.NextPhase); } [Theory] [InlineData("wait")] [InlineData("merge")] [InlineData("wait_final")] [InlineData("merge_final")] public async Task HandoffListHandler_AcceptsEveryKnownPhase(string phase) { var listId = await SeedListAsync(); var handlerTask = await SeedTaskAsync(listId); var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor"); var sut = BuildSut(); var result = await sut.HandoffListHandler(handlerTask.Id, new[] { survivor.Id }, phase, CancellationToken.None); Assert.Equal(phase, result.NextPhase); } [Fact] public async Task HandoffListHandler_UnknownPhase_Throws() { var listId = await SeedListAsync(); var handlerTask = await SeedTaskAsync(listId); var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor"); var sut = BuildSut(); var ex = await Assert.ThrowsAsync(() => sut.HandoffListHandler(handlerTask.Id, new[] { survivor.Id }, "phase-9", CancellationToken.None)); Assert.Contains("phase-9", ex.Message); } [Fact] public async Task HandoffListHandler_EmptySurvivingIds_Throws() { var listId = await SeedListAsync(); var handlerTask = await SeedTaskAsync(listId); var sut = BuildSut(); await Assert.ThrowsAsync(() => sut.HandoffListHandler(handlerTask.Id, Array.Empty(), cancellationToken: CancellationToken.None)); } [Fact] public async Task HandoffListHandler_UnknownHandlerTask_Throws() { var sut = BuildSut(); await Assert.ThrowsAsync(() => sut.HandoffListHandler("missing", new[] { "x" }, cancellationToken: CancellationToken.None)); } [Fact] public async Task HandoffListHandler_UnknownSurvivingTask_Throws() { var listId = await SeedListAsync(); var handlerTask = await SeedTaskAsync(listId); var sut = BuildSut(); await Assert.ThrowsAsync(() => sut.HandoffListHandler(handlerTask.Id, new[] { "missing" }, cancellationToken: CancellationToken.None)); } [Fact] public async Task HandoffListHandler_NormalWaitMergeWaitFinalMergeFinalSequence_PassesUntouched() { var listId = await SeedListAsync(); var handlerTask = await SeedTaskAsync(listId); var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor"); var tracker = new HandoffRoundTracker(); var survivors = new[] { survivor.Id }; foreach (var phase in new[] { "wait", "merge", "wait_final", "merge_final" }) { var sut = BuildSut(tracker); var result = await sut.HandoffListHandler(handlerTask.Id, survivors, phase, CancellationToken.None); Assert.False(result.Coerced); Assert.Equal(phase, result.NextPhase); } } [Fact] public async Task HandoffListHandler_PastCap_CoercesNonFinalPhaseToItsFinalCounterpart() { var listId = await SeedListAsync(); var handlerTask = await SeedTaskAsync(listId); var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor"); var tracker = new HandoffRoundTracker(); var survivors = new[] { survivor.Id }; foreach (var phase in new[] { "wait", "merge", "wait", "merge" }) { var sut = BuildSut(tracker); var result = await sut.HandoffListHandler(handlerTask.Id, survivors, phase, CancellationToken.None); Assert.False(result.Coerced); } var fifthSut = BuildSut(tracker); var fifth = await fifthSut.HandoffListHandler(handlerTask.Id, survivors, "wait", CancellationToken.None); Assert.True(fifth.Coerced); Assert.Equal("wait_final", fifth.NextPhase); } [Fact] public async Task HandoffListHandler_AfterMergeFinal_AnyFurtherHandoffThrows() { var listId = await SeedListAsync(); var handlerTask = await SeedTaskAsync(listId); var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor"); var tracker = new HandoffRoundTracker(); var survivors = new[] { survivor.Id }; var firstSut = BuildSut(tracker); await firstSut.HandoffListHandler(handlerTask.Id, survivors, "merge_final", CancellationToken.None); var secondSut = BuildSut(tracker); await Assert.ThrowsAsync(() => secondSut.HandoffListHandler(handlerTask.Id, survivors, "wait", CancellationToken.None)); } [Fact] public async Task HandoffListHandler_TwoHandlerTasks_DoNotShareACounter() { var listId = await SeedListAsync(); var handlerA = await SeedTaskAsync(listId, title: "Handler A"); var handlerB = await SeedTaskAsync(listId, title: "Handler B"); var survivorA = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor A"); var survivorB = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor B"); var tracker = new HandoffRoundTracker(); foreach (var phase in new[] { "wait", "merge", "wait", "merge" }) { var sut = BuildSut(tracker); await sut.HandoffListHandler(handlerA.Id, new[] { survivorA.Id }, phase, CancellationToken.None); } var sutB = BuildSut(tracker); var resultB = await sutB.HandoffListHandler(handlerB.Id, new[] { survivorB.Id }, "wait", CancellationToken.None); Assert.False(resultB.Coerced); Assert.Equal("wait", resultB.NextPhase); } }