Drops TagEntity, TagRepository, and tag wiring across data layer, worker, and UI. Adds RemoveTags migration to clean up schema. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
302 lines
12 KiB
C#
302 lines
12 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Covers the invariant that no task may have <c>ParentTaskId</c> pointing to a
|
|
/// parent without <c>PlanningPhase.Active|Finalized</c>. Tests the three guard
|
|
/// rails: <c>CreateChildAsync</c> validation, <c>DiscardPlanningAsync</c>
|
|
/// gating with the optional dequeue path, and the startup repair sweep.
|
|
/// </summary>
|
|
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<string> 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<TaskEntity> 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<InvalidOperationException>(
|
|
() => _tasks.CreateChildAsync(parent.Id, "child", 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);
|
|
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_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;
|
|
}
|
|
}
|