using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.AspNetCore.SignalR; using Xunit; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Hub; /// Covers the guarded "manual done toggle" (SetTaskDone/UnsetTaskDone) and "remove from queue" /// (DequeueTask) hub methods added to replace the UI's raw EF writes -- each checks the expected /// starting status server-side so a concurrent picker claim can't be silently overwritten. public sealed class TaskDoneDequeueHubTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly RecordingClientProxy _proxy = new(); public TaskDoneDequeueHubTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private WorkerHub CreateHub() { var factory = _db.CreateFactory(); 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.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")), new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); hub.Clients = new FakeHubCallerClients(_proxy); hub.Context = new FakeHubCallerContext(); return hub; } private async Task SeedListAsync() { var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); return listId; } private async Task SeedTaskAsync( string listId, TaskStatus status, string? blockedByTaskId = null, string title = "T") { var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = title, Status = status, BlockedByTaskId = blockedByTaskId, CreatedAt = DateTime.UtcNow, }; await _tasks.AddAsync(task); return task; } // ── SetTaskDone ── [Fact] public async Task SetTaskDone_FromIdle_TransitionsToDone() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, TaskStatus.Idle); var hub = CreateHub(); await hub.SetTaskDone(task.Id); var reloaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Done, reloaded!.Status); } [Fact] public async Task SetTaskDone_FromRunning_Throws_AndLeavesStatusUnchanged() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, TaskStatus.Running); var hub = CreateHub(); await Assert.ThrowsAsync(() => hub.SetTaskDone(task.Id)); var reloaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Running, reloaded!.Status); } // Regression: the guard was Idle-only, so a finished task (typically a list-handler run, // which has no worktree to merge) could no longer be ticked off from the task card. [Fact] public async Task SetTaskDone_FromWaitingForReview_TransitionsToDone() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, TaskStatus.WaitingForReview); var hub = CreateHub(); await hub.SetTaskDone(task.Id); var reloaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Done, reloaded!.Status); } // ── UnsetTaskDone ── [Fact] public async Task UnsetTaskDone_FromDone_TransitionsToIdle() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, TaskStatus.Done); var hub = CreateHub(); await hub.UnsetTaskDone(task.Id); var reloaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Idle, reloaded!.Status); } [Fact] public async Task UnsetTaskDone_FromRunning_Throws_AndLeavesStatusUnchanged() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, TaskStatus.Running); var hub = CreateHub(); await Assert.ThrowsAsync(() => hub.UnsetTaskDone(task.Id)); var reloaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Running, reloaded!.Status); } // ── DequeueTask ── [Fact] public async Task DequeueTask_FromQueued_TransitionsToIdle_AndClearsBlockedByTaskId() { var listId = await SeedListAsync(); var pred = await SeedTaskAsync(listId, TaskStatus.Queued); var task = await SeedTaskAsync(listId, TaskStatus.Queued, blockedByTaskId: pred.Id); var hub = CreateHub(); await hub.DequeueTask(task.Id); var reloaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Idle, reloaded!.Status); Assert.Null(reloaded.BlockedByTaskId); } [Fact] public async Task DequeueTask_FromRunning_Throws_AndLeavesStatusUnchanged() { // Simulates the picker having already claimed the task between the UI reading its // row and the dequeue call landing -- the DB status must not be clobbered. var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, TaskStatus.Running); var hub = CreateHub(); await Assert.ThrowsAsync(() => hub.DequeueTask(task.Id)); var reloaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Running, reloaded!.Status); } }