using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.EntityFrameworkCore; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.Repositories; /// /// Covers the invariant that no task may have ParentTaskId pointing to a /// parent without PlanningPhase.Active|Finalized. Tests the three guard /// rails: CreateChildAsync validation, DiscardPlanningAsync /// gating with the optional dequeue path, and the startup repair sweep. /// public sealed class TaskRepositoryOrphanGuardTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; public TaskRepositoryOrphanGuardTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private async Task CreateListAsync() { var id = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow }); return id; } private TaskEntity MakeTask(string listId, TaskStatus status = TaskStatus.Idle, PlanningPhase phase = PlanningPhase.None) => new() { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "T", Status = status, PlanningPhase = phase, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; private async Task SeedPlanningParentAsync(string listId) { var parent = MakeTask(listId, status: TaskStatus.Idle, phase: PlanningPhase.Active); await _tasks.AddAsync(parent); return parent; } // --- CreateChildAsync validation --- [Fact] public async Task CreateChildAsync_Throws_When_Parent_Has_No_Planning_Phase() { var listId = await CreateListAsync(); var parent = MakeTask(listId, phase: PlanningPhase.None); await _tasks.AddAsync(parent); var ex = await Assert.ThrowsAsync( () => _tasks.CreateChildAsync(parent.Id, "child", null, null, null)); Assert.Contains("not in a planning phase", ex.Message); } [Fact] public async Task CreateChildAsync_Succeeds_When_Parent_Is_Active() { var listId = await CreateListAsync(); var parent = await SeedPlanningParentAsync(listId); var child = await _tasks.CreateChildAsync(parent.Id, "child", null, null, null); Assert.Equal(parent.Id, child.ParentTaskId); Assert.Equal(TaskStatus.Idle, child.Status); } // --- DiscardPlanningAsync gating --- [Fact] public async Task DiscardPlanning_NotInPlanning_When_Parent_Phase_Is_None() { var listId = await CreateListAsync(); var stray = MakeTask(listId, phase: PlanningPhase.None); await _tasks.AddAsync(stray); var outcome = await _tasks.DiscardPlanningAsync(stray.Id, dequeueQueuedChildren: false); Assert.Equal(DiscardPlanningResult.NotInPlanning, outcome.Result); } [Fact] public async Task DiscardPlanning_Succeeds_When_All_Children_Are_Idle() { var listId = await CreateListAsync(); var parent = await SeedPlanningParentAsync(listId); await _tasks.CreateChildAsync(parent.Id, "a", null, null, null); await _tasks.CreateChildAsync(parent.Id, "b", null, null, null); var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false); Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result); Assert.Equal(0, _ctx.Tasks.AsNoTracking().Count(t => t.ParentTaskId == parent.Id)); var reloaded = _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id); Assert.Equal(PlanningPhase.None, reloaded.PlanningPhase); } [Fact] public async Task DiscardPlanning_Blocks_On_Queued_Children_Without_Optin() { var listId = await CreateListAsync(); var parent = await SeedPlanningParentAsync(listId); var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); await SetChildStatusAsync(child.Id, TaskStatus.Queued); var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false); Assert.Equal(DiscardPlanningResult.BlockedByQueuedChildren, outcome.Result); Assert.Equal(1, outcome.QueuedChildrenCount); // Parent and child are untouched. Assert.Equal(PlanningPhase.Active, _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id).PlanningPhase); Assert.Equal(TaskStatus.Queued, _ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id).Status); } [Fact] public async Task DiscardPlanning_With_Dequeue_Succeeds_And_Drops_Idle_Children() { var listId = await CreateListAsync(); var parent = await SeedPlanningParentAsync(listId); var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); await SetChildStatusAsync(child.Id, TaskStatus.Queued); var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: true); Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result); // Child was dequeued to Idle and then deleted as part of the discard. Assert.False(_ctx.Tasks.AsNoTracking().Any(t => t.Id == child.Id)); } [Fact] public async Task DiscardPlanning_Blocks_On_Running_Children_Even_With_Dequeue_Optin() { var listId = await CreateListAsync(); var parent = await SeedPlanningParentAsync(listId); var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); await SetChildStatusAsync(child.Id, TaskStatus.Running); var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: true); Assert.Equal(DiscardPlanningResult.BlockedByRunningChildren, outcome.Result); Assert.Equal(1, outcome.RunningChildrenCount); Assert.Equal(PlanningPhase.Active, _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id).PlanningPhase); } [Fact] public async Task DiscardPlanning_Leaves_Terminal_Children_Attached() { var listId = await CreateListAsync(); var parent = await SeedPlanningParentAsync(listId); var done = await _tasks.CreateChildAsync(parent.Id, "done", null, null, null); var failed = await _tasks.CreateChildAsync(parent.Id, "failed", null, null, null); await SetChildStatusAsync(done.Id, TaskStatus.Done); await SetChildStatusAsync(failed.Id, TaskStatus.Failed); var outcome = await _tasks.DiscardPlanningAsync(parent.Id, dequeueQueuedChildren: false); Assert.Equal(DiscardPlanningResult.Discarded, outcome.Result); Assert.Equal(parent.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == done.Id).ParentTaskId); Assert.Equal(parent.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == failed.Id).ParentTaskId); } // --- Dequeue sweep --- [Fact] public async Task Dequeue_Dequeues_Queued_Child_When_Parent_Is_Not_Planning() { var listId = await CreateListAsync(); // Parent is plain (not planning), child attached -> stuck queued. var parent = MakeTask(listId, phase: PlanningPhase.None); await _tasks.AddAsync(parent); var predecessor = MakeTask(listId, status: TaskStatus.Idle); await _tasks.AddAsync(predecessor); var child = MakeTask(listId, status: TaskStatus.Queued); child.ParentTaskId = parent.Id; child.BlockedByTaskId = predecessor.Id; await _tasks.AddAsync(child); var dequeued = await _tasks.DequeueOrphanedChildrenAsync(); Assert.Equal(1, dequeued); var reloaded = _ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id); Assert.Equal(TaskStatus.Idle, reloaded.Status); Assert.Null(reloaded.BlockedByTaskId); Assert.Equal(parent.Id, reloaded.ParentTaskId); // lineage stays } [Fact] public async Task Dequeue_Leaves_Idle_Children_Of_NonPlanning_Parent_Alone() { var listId = await CreateListAsync(); var parent = MakeTask(listId, phase: PlanningPhase.None); await _tasks.AddAsync(parent); var child = MakeTask(listId, status: TaskStatus.Idle); child.ParentTaskId = parent.Id; await _tasks.AddAsync(child); var dequeued = await _tasks.DequeueOrphanedChildrenAsync(); Assert.Equal(0, dequeued); } [Fact] public async Task Dequeue_Leaves_Valid_Children_Untouched() { var listId = await CreateListAsync(); var parent = await SeedPlanningParentAsync(listId); var child = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); var dequeued = await _tasks.DequeueOrphanedChildrenAsync(); Assert.Equal(0, dequeued); Assert.Equal(parent.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id).ParentTaskId); } // --- Planning lineage restoration --- [Fact] public async Task RestoreLineage_ReAttaches_Unambiguous_Chain_And_Dequeues_Queued_Members() { var listId = await CreateListAsync(); // Parent that once had a planning session but lost the link. var parent = MakeTask(listId, phase: PlanningPhase.None); await _tasks.AddAsync(parent); // Chain: head (idle, no blocked_by, someone is blocked by it) + 2 queued successors. var head = MakeTask(listId, status: TaskStatus.Idle); head.BlockedByTaskId = null; await _tasks.AddAsync(head); var mid = MakeTask(listId, status: TaskStatus.Queued); mid.BlockedByTaskId = head.Id; await _tasks.AddAsync(mid); var tail = MakeTask(listId, status: TaskStatus.Queued); tail.BlockedByTaskId = mid.Id; await _tasks.AddAsync(tail); var restored = await _tasks.RestorePlanningLineageAsync(parent.Id); Assert.Equal(3, restored); Assert.Equal(PlanningPhase.Finalized, _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id).PlanningPhase); Assert.All(new[] { head.Id, mid.Id, tail.Id }, id => Assert.Equal(parent.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == id).ParentTaskId)); Assert.Equal(TaskStatus.Idle, _ctx.Tasks.AsNoTracking().Single(t => t.Id == mid.Id).Status); Assert.Equal(TaskStatus.Idle, _ctx.Tasks.AsNoTracking().Single(t => t.Id == tail.Id).Status); // blocked_by intact for chain order. Assert.Equal(head.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == mid.Id).BlockedByTaskId); Assert.Equal(mid.Id, _ctx.Tasks.AsNoTracking().Single(t => t.Id == tail.Id).BlockedByTaskId); } [Fact] public async Task RestoreLineage_Skips_When_Multiple_Chains_Exist() { var listId = await CreateListAsync(); var parent = MakeTask(listId, phase: PlanningPhase.None); await _tasks.AddAsync(parent); // Two independent chains in the same list -> ambiguous. var headA = MakeTask(listId, status: TaskStatus.Idle); await _tasks.AddAsync(headA); var midA = MakeTask(listId, status: TaskStatus.Queued); midA.BlockedByTaskId = headA.Id; await _tasks.AddAsync(midA); var headB = MakeTask(listId, status: TaskStatus.Idle); await _tasks.AddAsync(headB); var midB = MakeTask(listId, status: TaskStatus.Queued); midB.BlockedByTaskId = headB.Id; await _tasks.AddAsync(midB); var restored = await _tasks.RestorePlanningLineageAsync(parent.Id); Assert.Equal(0, restored); Assert.Equal(PlanningPhase.None, _ctx.Tasks.AsNoTracking().Single(t => t.Id == parent.Id).PlanningPhase); } [Fact] public async Task RestoreLineage_Skips_When_Parent_Already_Has_Planning_Phase() { var listId = await CreateListAsync(); var parent = await SeedPlanningParentAsync(listId); var restored = await _tasks.RestorePlanningLineageAsync(parent.Id); Assert.Equal(0, restored); } private async Task SetChildStatusAsync(string id, TaskStatus status) { var t = await _ctx.Tasks.FindAsync(id) ?? throw new InvalidOperationException(); t.Status = status; await _ctx.SaveChangesAsync(); _ctx.Entry(t).State = Microsoft.EntityFrameworkCore.EntityState.Detached; } }