using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Repositories; public sealed class TaskRepositoryTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly TagRepository _tags; public TaskRepositoryTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); _tags = new TagRepository(_ctx); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private async Task CreateListAsync(string? id = null) { var listId = id ?? Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = listId, Name = "Test List", CreatedAt = DateTime.UtcNow, }); return listId; } private TaskEntity MakeTask(string listId, TaskStatus status = TaskStatus.Queued, DateTime? createdAt = null, DateTime? scheduledFor = null) => new() { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Test Task", Description = "A description", Status = status, ScheduledFor = scheduledFor, CreatedAt = createdAt ?? DateTime.UtcNow, CommitType = "feat", }; [Fact] public async Task AddAsync_Roundtrips_AllFields() { var listId = await CreateListAsync(); var entity = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "My Task", Description = "Desc", Status = TaskStatus.Queued, ScheduledFor = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), Result = "some result", LogPath = "/tmp/log.ndjson", CreatedAt = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), StartedAt = new DateTime(2026, 1, 1, 1, 0, 0, DateTimeKind.Utc), FinishedAt = new DateTime(2026, 1, 1, 2, 0, 0, DateTimeKind.Utc), CommitType = "feat", }; await _tasks.AddAsync(entity); var loaded = await _tasks.GetByIdAsync(entity.Id); Assert.NotNull(loaded); Assert.Equal(entity.Id, loaded.Id); Assert.Equal(entity.ListId, loaded.ListId); Assert.Equal(entity.Title, loaded.Title); Assert.Equal(entity.Description, loaded.Description); Assert.Equal(entity.Status, loaded.Status); Assert.Equal(entity.ScheduledFor!.Value.Date, loaded.ScheduledFor!.Value.Date); Assert.Equal(entity.Result, loaded.Result); Assert.Equal(entity.LogPath, loaded.LogPath); Assert.Equal(entity.CommitType, loaded.CommitType); } [Fact] public async Task GetNextQueuedAgentTaskAsync_Returns_OldestWithAgentTag_ViaTaskTag() { var listId = await CreateListAsync(); var agentTagId = await _tags.GetOrCreateAsync("agent"); var older = MakeTask(listId, createdAt: DateTime.UtcNow.AddMinutes(-10)); var newer = MakeTask(listId, createdAt: DateTime.UtcNow); await _tasks.AddAsync(older); await _tasks.AddAsync(newer); await _tasks.AddTagAsync(older.Id, agentTagId); await _tasks.AddTagAsync(newer.Id, agentTagId); var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow); Assert.NotNull(picked); Assert.Equal(older.Id, picked.Id); } [Fact] public async Task GetNextQueuedAgentTaskAsync_Returns_TaskWithAgentTag_ViaListTag() { var listId = await CreateListAsync(); var agentTagId = await _tags.GetOrCreateAsync("agent"); await _lists.AddTagAsync(listId, agentTagId); var task = MakeTask(listId); await _tasks.AddAsync(task); var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow); Assert.NotNull(picked); Assert.Equal(task.Id, picked.Id); } [Fact] public async Task GetNextQueuedAgentTaskAsync_ReturnsNull_WhenNoAgentTag() { var listId = await CreateListAsync(); var task = MakeTask(listId); await _tasks.AddAsync(task); var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow); Assert.Null(picked); } [Fact] public async Task GetNextQueuedAgentTaskAsync_Skips_FutureScheduledFor() { var listId = await CreateListAsync(); var agentTagId = await _tags.GetOrCreateAsync("agent"); var task = MakeTask(listId, scheduledFor: DateTime.UtcNow.AddHours(1)); await _tasks.AddAsync(task); await _tasks.AddTagAsync(task.Id, agentTagId); var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow); Assert.Null(picked); } [Fact] public async Task Transitions_MarkRunning_ThenMarkDone() { var listId = await CreateListAsync(); var task = MakeTask(listId); await _tasks.AddAsync(task); var startedAt = DateTime.UtcNow; await _tasks.MarkRunningAsync(task.Id, startedAt); var running = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Running, running!.Status); Assert.NotNull(running.StartedAt); var finishedAt = DateTime.UtcNow; await _tasks.MarkDoneAsync(task.Id, finishedAt, "All good"); var done = await _tasks.GetByIdAsync(task.Id); Assert.Equal(TaskStatus.Done, done!.Status); Assert.Equal("All good", done.Result); Assert.NotNull(done.FinishedAt); } [Fact] public async Task FlipAllRunningToFailedAsync_FlipsOnlyRunningRows() { var listId = await CreateListAsync(); var running1 = MakeTask(listId, status: TaskStatus.Running); var running2 = MakeTask(listId, status: TaskStatus.Running); var queued = MakeTask(listId, status: TaskStatus.Queued); var done = MakeTask(listId, status: TaskStatus.Done); await _tasks.AddAsync(running1); await _tasks.AddAsync(running2); await _tasks.AddAsync(queued); await _tasks.AddAsync(done); var flipped = await _tasks.FlipAllRunningToFailedAsync("worker restart"); Assert.Equal(2, flipped); var r1 = await _tasks.GetByIdAsync(running1.Id); Assert.Equal(TaskStatus.Failed, r1!.Status); Assert.StartsWith("[stale] ", r1.Result); var r2 = await _tasks.GetByIdAsync(running2.Id); Assert.Equal(TaskStatus.Failed, r2!.Status); var q = await _tasks.GetByIdAsync(queued.Id); Assert.Equal(TaskStatus.Queued, q!.Status); var d = await _tasks.GetByIdAsync(done.Id); Assert.Equal(TaskStatus.Done, d!.Status); } [Fact] public async Task ResetToManualAsync_ClearsResultFields_AndSetsStatusManual() { var listId = await CreateListAsync(); var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "T", CreatedAt = DateTime.UtcNow, Status = TaskStatus.Failed, StartedAt = DateTime.UtcNow.AddMinutes(-5), FinishedAt = DateTime.UtcNow, Result = "boom", CommitType = "feat", }; await _tasks.AddAsync(task); await _tasks.ResetToManualAsync(task.Id); using var readCtx = _db.CreateContext(); var after = await new TaskRepository(readCtx).GetByIdAsync(task.Id); Assert.NotNull(after); Assert.Equal(TaskStatus.Manual, after!.Status); Assert.Null(after.StartedAt); Assert.Null(after.FinishedAt); Assert.Null(after.Result); } [Fact] public async Task AddAsync_AssignsDense_PerList_SortOrder() { var listA = await CreateListAsync(); var listB = await CreateListAsync(); var a0 = MakeTask(listA); await _tasks.AddAsync(a0); var a1 = MakeTask(listA); await _tasks.AddAsync(a1); var b0 = MakeTask(listB); await _tasks.AddAsync(b0); var a2 = MakeTask(listA); await _tasks.AddAsync(a2); var reloadA0 = await _tasks.GetByIdAsync(a0.Id); var reloadA1 = await _tasks.GetByIdAsync(a1.Id); var reloadA2 = await _tasks.GetByIdAsync(a2.Id); var reloadB0 = await _tasks.GetByIdAsync(b0.Id); Assert.Equal(0, reloadA0!.SortOrder); Assert.Equal(1, reloadA1!.SortOrder); Assert.Equal(2, reloadA2!.SortOrder); Assert.Equal(0, reloadB0!.SortOrder); } [Fact] public async Task GetByListIdAsync_OrdersBy_SortOrder() { var listId = await CreateListAsync(); var t0 = MakeTask(listId); await _tasks.AddAsync(t0); var t1 = MakeTask(listId); await _tasks.AddAsync(t1); var t2 = MakeTask(listId); await _tasks.AddAsync(t2); await _tasks.ReorderAsync(listId, new[] { t2.Id, t0.Id, t1.Id }); var list = await _tasks.GetByListIdAsync(listId); Assert.Equal(new[] { t2.Id, t0.Id, t1.Id }, list.Select(t => t.Id).ToArray()); } [Fact] public async Task ReorderAsync_Renumbers_Dense() { var listId = await CreateListAsync(); var t0 = MakeTask(listId); await _tasks.AddAsync(t0); var t1 = MakeTask(listId); await _tasks.AddAsync(t1); var t2 = MakeTask(listId); await _tasks.AddAsync(t2); await _tasks.ReorderAsync(listId, new[] { t1.Id, t2.Id, t0.Id }); var r0 = await _tasks.GetByIdAsync(t0.Id); var r1 = await _tasks.GetByIdAsync(t1.Id); var r2 = await _tasks.GetByIdAsync(t2.Id); Assert.Equal(2, r0!.SortOrder); Assert.Equal(0, r1!.SortOrder); Assert.Equal(1, r2!.SortOrder); } [Fact] public async Task ReorderAsync_IgnoresIds_FromOtherLists() { var listA = await CreateListAsync(); var listB = await CreateListAsync(); var a0 = MakeTask(listA); await _tasks.AddAsync(a0); var b0 = MakeTask(listB); await _tasks.AddAsync(b0); // b0 does not belong to listA and should not be renumbered there. await _tasks.ReorderAsync(listA, new[] { b0.Id, a0.Id }); var reloadB = await _tasks.GetByIdAsync(b0.Id); Assert.Equal(0, reloadB!.SortOrder); } [Fact] public async Task GetNextQueuedAgentTaskAsync_Picks_ByUserSortOrder() { var listId = await CreateListAsync(); var agentTagId = await _tags.GetOrCreateAsync("agent"); await _lists.AddTagAsync(listId, agentTagId); // created in order first, second; then user reorders to put second on top. var first = MakeTask(listId, createdAt: DateTime.UtcNow.AddMinutes(-10)); var second = MakeTask(listId, createdAt: DateTime.UtcNow); await _tasks.AddAsync(first); await _tasks.AddAsync(second); await _tasks.ReorderAsync(listId, new[] { second.Id, first.Id }); var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow); Assert.NotNull(picked); Assert.Equal(second.Id, picked!.Id); } [Fact] public async Task GetEffectiveTagsAsync_Returns_Union_Of_ListTags_And_TaskTags() { var listId = await CreateListAsync(); var agentTagId = await _tags.GetOrCreateAsync("agent"); var manualTagId = await _tags.GetOrCreateAsync("manual"); var codeTagId = await _tags.GetOrCreateAsync("code"); await _lists.AddTagAsync(listId, agentTagId); var task = MakeTask(listId); await _tasks.AddAsync(task); await _tasks.AddTagAsync(task.Id, manualTagId); await _tasks.AddTagAsync(task.Id, codeTagId); var effective = await _tasks.GetEffectiveTagsAsync(task.Id); var names = effective.Select(t => t.Name).OrderBy(n => n).ToList(); Assert.Equal(3, names.Count); Assert.Contains("agent", names); Assert.Contains("code", names); Assert.Contains("manual", names); } }