using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Tests.Infrastructure; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Queue; public sealed class QueuePickerTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly TagRepository _tags; private readonly QueuePicker _picker; public QueuePickerTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); _tags = new TagRepository(_ctx); _picker = new QueuePicker(_db.CreateFactory()); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private async Task CreateListAsync(bool listAgentTag = false) { var listId = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "Test", CreatedAt = DateTime.UtcNow, }); if (listAgentTag) { var tagId = await _tags.GetOrCreateAsync("agent"); await _lists.AddTagAsync(listId, tagId); } return listId; } private async Task SeedAsync( string listId, TaskStatus status = TaskStatus.Queued, DateTime? createdAt = null, DateTime? scheduledFor = null, string? blockedBy = null, bool taskAgentTag = false, int? sortOrder = null) { var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "T", Status = status, CreatedAt = createdAt ?? DateTime.UtcNow, ScheduledFor = scheduledFor, BlockedByTaskId = blockedBy, CommitType = "feat", }; await _tasks.AddAsync(task); if (taskAgentTag) { var tagId = await _tags.GetOrCreateAsync("agent"); await _tasks.AddTagAsync(task.Id, tagId); } if (sortOrder is not null) { task.SortOrder = sortOrder.Value; await _tasks.UpdateAsync(task); } return task; } [Fact] public async Task ClaimNextAsync_Skips_TasksWithBlockedByTaskId() { var listId = await CreateListAsync(listAgentTag: true); var blocker = await SeedAsync(listId); await SeedAsync(listId, blockedBy: blocker.Id); // Only `blocker` is unblocked → it should be claimed; the second pick is null. var first = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.NotNull(first); Assert.Equal(blocker.Id, first!.Id); var second = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.Null(second); } [Fact] public async Task ClaimNextAsync_Skips_TasksWithoutAgentTag() { var listId = await CreateListAsync(listAgentTag: false); await SeedAsync(listId); var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.Null(picked); } [Fact] public async Task ClaimNextAsync_Skips_FutureScheduledFor() { var listId = await CreateListAsync(listAgentTag: true); await SeedAsync(listId, scheduledFor: DateTime.UtcNow.AddHours(1)); var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.Null(picked); } [Fact] public async Task ClaimNextAsync_Skips_NonQueuedStatuses() { var listId = await CreateListAsync(listAgentTag: true); await SeedAsync(listId, status: TaskStatus.Idle); await SeedAsync(listId, status: TaskStatus.Running); await SeedAsync(listId, status: TaskStatus.Done); await SeedAsync(listId, status: TaskStatus.Failed); await SeedAsync(listId, status: TaskStatus.Cancelled); var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.Null(picked); } [Fact] public async Task ClaimNextAsync_Picks_ByUserSortOrder_ThenCreatedAt() { var listId = await CreateListAsync(listAgentTag: true); // Created in order first, second; reorder so second is sort-order 0. var first = await SeedAsync(listId, createdAt: DateTime.UtcNow.AddMinutes(-10)); var second = await SeedAsync(listId, createdAt: DateTime.UtcNow); await _tasks.ReorderAsync(listId, new[] { second.Id, first.Id }); var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); Assert.NotNull(picked); Assert.Equal(second.Id, picked!.Id); } [Fact] public async Task ClaimNextAsync_FlipsToRunning_WithStartedAt() { var listId = await CreateListAsync(listAgentTag: true); var task = await SeedAsync(listId); var before = DateTime.UtcNow; var picked = await _picker.ClaimNextAsync(before, CancellationToken.None); Assert.NotNull(picked); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Running, loaded!.Status); Assert.NotNull(loaded.StartedAt); } [Fact] public async Task ClaimNextAsync_TwoParallelPickers_OnlyOneClaimsRow() { var listId = await CreateListAsync(listAgentTag: true); await SeedAsync(listId); // Two pickers, same DB factory, racing each other. var picker1 = new QueuePicker(_db.CreateFactory()); var picker2 = new QueuePicker(_db.CreateFactory()); var t1 = Task.Run(() => picker1.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None)); var t2 = Task.Run(() => picker2.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None)); var results = await Task.WhenAll(t1, t2); var nonNull = results.Where(r => r is not null).ToList(); Assert.Single(nonNull); } }