MarkDoneAsync was Idle-only, so a finished task (typically a list-handler run with no worktree to merge) could not be ticked off. Guard on Running instead and cover the other statuses with tests.
176 lines
5.9 KiB
C#
176 lines
5.9 KiB
C#
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.Online.OnlineTokenStore(),
|
|
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
|
|
hub.Clients = new FakeHubCallerClients(_proxy);
|
|
hub.Context = new FakeHubCallerContext();
|
|
return hub;
|
|
}
|
|
|
|
private async Task<string> SeedListAsync()
|
|
{
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
|
return listId;
|
|
}
|
|
|
|
private async Task<TaskEntity> 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<HubException>(() => 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<HubException>(() => 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<HubException>(() => hub.DequeueTask(task.Id));
|
|
|
|
var reloaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Running, reloaded!.Status);
|
|
}
|
|
}
|