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_Succeeds_On_NonPlanning_Parent_For_Improvement_Children()
{
// The planning-phase guard was removed: improvement children attach to a
// non-planning parent, with CreatedBy stamped to the parent id by the caller.
var listId = await CreateListAsync();
var parent = MakeTask(listId, phase: PlanningPhase.None);
await _tasks.AddAsync(parent);
var child = await _tasks.CreateChildAsync(parent.Id, "child", null, null, createdBy: parent.Id);
Assert.Equal(parent.Id, child.ParentTaskId);
Assert.Equal(parent.Id, child.CreatedBy);
Assert.Equal(TaskStatus.Idle, child.Status);
}
[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);
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);
await _tasks.CreateChildAsync(parent.Id, "b", 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);
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);
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);
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);
var failed = await _tasks.CreateChildAsync(parent.Id, "failed", 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_Queued_Improvement_Children_Alone()
{
// Improvement children (CreatedBy == ParentTaskId) queue under a non-planning
// parent on purpose — the orphan sweep must NOT reset them.
var listId = await CreateListAsync();
var parent = MakeTask(listId, phase: PlanningPhase.None);
await _tasks.AddAsync(parent);
var child = MakeTask(listId, status: TaskStatus.Queued);
child.ParentTaskId = parent.Id;
child.CreatedBy = parent.Id;
await _tasks.AddAsync(child);
var dequeued = await _tasks.DequeueOrphanedChildrenAsync();
Assert.Equal(0, dequeued);
Assert.Equal(TaskStatus.Queued, _ctx.Tasks.AsNoTracking().Single(t => t.Id == child.Id).Status);
}
[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);
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;
}
}