using System.Diagnostics; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.External; using ClaudeDo.Worker.Tests.Infrastructure; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.External; public sealed class TaskWaitMcpToolsTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; public TaskWaitMcpToolsTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private TaskWaitMcpTools BuildSut() => new(_db.CreateFactory()); private async Task SeedTaskAsync(TaskStatus status) { var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t", Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(task); return task; } [Fact] public async Task WaitForTaskChange_AlreadyOutOfQueuedRunning_ReturnsImmediately() { var task = await SeedTaskAsync(TaskStatus.WaitingForReview); var sut = BuildSut(); var sw = Stopwatch.StartNew(); var result = await sut.WaitForTaskChange([task.Id], timeoutSeconds: 30, CancellationToken.None); sw.Stop(); Assert.False(result.TimedOut); Assert.Equal(task.Id, Assert.Single(result.Changed).TaskId); Assert.Equal("WaitingForReview", result.Changed[0].Status); Assert.True(sw.Elapsed < TimeSpan.FromSeconds(2), $"took {sw.Elapsed}"); } [Fact] public async Task WaitForTaskChange_UnknownId_ReturnsImmediatelyAsNotFound() { var sut = BuildSut(); var sw = Stopwatch.StartNew(); var result = await sut.WaitForTaskChange(["missing-id"], timeoutSeconds: 30, CancellationToken.None); sw.Stop(); Assert.False(result.TimedOut); Assert.Equal("NotFound", Assert.Single(result.Changed).Status); Assert.True(sw.Elapsed < TimeSpan.FromSeconds(2), $"took {sw.Elapsed}"); } [Fact] public async Task WaitForTaskChange_StatusChangesWhileWaiting_ReturnsBeforeTimeout() { var task = await SeedTaskAsync(TaskStatus.Running); var sut = BuildSut(); var sw = Stopwatch.StartNew(); var waitTask = sut.WaitForTaskChange([task.Id], timeoutSeconds: 10, CancellationToken.None); await Task.Delay(150); // Simulate the status change a broadcast would announce, via a separate context // (mirrors what TaskStateService does from a different scope/process). await using (var writeCtx = _db.CreateContext()) { var writeRepo = new TaskRepository(writeCtx); var loaded = await writeRepo.GetByIdAsync(task.Id); loaded!.Status = TaskStatus.Done; await writeRepo.UpdateAsync(loaded); } var result = await waitTask; sw.Stop(); Assert.False(result.TimedOut); Assert.Equal("Done", Assert.Single(result.Changed).Status); Assert.True(sw.Elapsed < TimeSpan.FromSeconds(5), $"took {sw.Elapsed}"); } [Fact] public async Task WaitForTaskChange_NoChange_TimesOut() { var task = await SeedTaskAsync(TaskStatus.Running); var sut = BuildSut(); var sw = Stopwatch.StartNew(); var result = await sut.WaitForTaskChange([task.Id], timeoutSeconds: 1, CancellationToken.None); sw.Stop(); Assert.True(result.TimedOut); Assert.Empty(result.Changed); Assert.True(sw.Elapsed >= TimeSpan.FromMilliseconds(900), $"took {sw.Elapsed}"); } [Fact] public async Task WaitForTaskChange_EmptyTaskIds_Throws() { var sut = BuildSut(); await Assert.ThrowsAsync(() => sut.WaitForTaskChange([], timeoutSeconds: 5, CancellationToken.None)); } [Fact] public void MaxTimeoutSeconds_StaysComfortablyUnderMcpToolTimeout() { // InteractiveLaunchSpecService sets MCP_TOOL_TIMEOUT=200000ms for the list handler. Assert.True(TaskWaitMcpTools.MaxTimeoutSeconds < 200); } }