Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Planning/PlanningMcpServiceTests.cs
Mika Kuns dc3fc443b4 refactor(data): retire legacy TaskStatus values and backfill existing rows
Slice 6 of the worker state and queue consolidation refactor.

* Drop Manual, Planning, Planned, Draft, Waiting from the TaskStatus enum
  and from the EF value converter; only the lifecycle values remain
  (Idle, Queued, Running, Done, Failed, Cancelled).
* Add migration RetireLegacyTaskStatus that rewrites existing rows:
  manual/draft -> idle, planning -> idle+planning_phase=active,
  planned -> idle+planning_phase=finalized, waiting -> queued+blocked_by
  derived from sort_order via a CTE with LAG().
* Reroute every call site that compared/set legacy values to the new
  three-field model (Status + PlanningPhase + BlockedByTaskId), including
  the planning repo helpers, MCP services, the planning chain coordinator,
  and the UI view-models. TaskRowViewModel now exposes PlanningPhase to
  drive the planning badge.
* Refresh Worker/CLAUDE.md and Data/CLAUDE.md, the docs/plan.md status
  section, and the planning verification notes in docs/open.md.
2026-04-27 15:28:55 +02:00

328 lines
12 KiB
C#

using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Planning;
// Minimal fakes — avoids Moq dependency.
file sealed class FakeHttpContextAccessor : IHttpContextAccessor
{
public HttpContext? HttpContext { get; set; }
}
file sealed class RecordingHubClients : IHubClients
{
public RecordingClientProxy Proxy { get; } = new();
public IClientProxy All => Proxy;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => Proxy;
public IClientProxy Client(string connectionId) => Proxy;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => Proxy;
public IClientProxy Group(string groupName) => Proxy;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => Proxy;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => Proxy;
public IClientProxy User(string userId) => Proxy;
public IClientProxy Users(IReadOnlyList<string> userIds) => Proxy;
}
file sealed class RecordingClientProxy : IClientProxy
{
public List<(string Method, object?[] Args)> Calls { get; } = new();
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
Calls.Add((method, args));
return Task.CompletedTask;
}
}
file sealed class FakeHubContext : IHubContext<WorkerHub>
{
public RecordingHubClients RecordingClients { get; } = new();
public IHubClients Clients => RecordingClients;
public IGroupManager Groups => throw new NotImplementedException();
}
public sealed class PlanningMcpServiceTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
public PlanningMcpServiceTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private List<(string Method, object?[] Args)> _hubCalls = new();
private TaskStateServiceBuilder.Built? _built;
private PlanningMcpService BuildSut(string parentTaskId)
{
var httpContext = new DefaultHttpContext();
httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parentTaskId };
var accessor = new PlanningMcpContextAccessor(new FakeHttpContextAccessor { HttpContext = httpContext });
var hub = new FakeHubContext();
_hubCalls = hub.RecordingClients.Proxy.Calls;
var broadcaster = new HubBroadcaster(hub);
_built = TaskStateServiceBuilder.Build(_db.CreateFactory());
return new PlanningMcpService(_tasks, accessor, broadcaster, _built.State, _built.Chain);
}
private IReadOnlyList<string> TaskUpdatedIds() =>
_hubCalls
.Where(c => c.Method == "TaskUpdated")
.Select(c => (string)c.Args[0]!)
.ToList();
private async Task<TaskEntity> SeedPlanningParentAsync()
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
var parent = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "p",
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
CommitType = "chore",
};
await _tasks.AddAsync(parent);
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
return (await _tasks.GetByIdAsync(parent.Id))!;
}
[Fact]
public async Task CreateChildTask_CreatesDraft()
{
var parent = await SeedPlanningParentAsync();
var sut = BuildSut(parent.Id);
var result = await sut.CreateChildTask("My child", "desc", null, null, CancellationToken.None);
Assert.Equal("Idle", result.Status);
var child = await _tasks.GetByIdAsync(result.TaskId);
Assert.Equal("My child", child!.Title);
Assert.Equal(TaskStatus.Idle, child.Status);
}
[Fact]
public async Task ListChildTasks_ReturnsOnlyThisParentsChildren()
{
var parent = await SeedPlanningParentAsync();
var other = await SeedPlanningParentAsync();
await _tasks.CreateChildAsync(parent.Id, "mine", null, null, null);
await _tasks.CreateChildAsync(other.Id, "theirs", null, null, null);
var sut = BuildSut(parent.Id);
var list = await sut.ListChildTasks(CancellationToken.None);
Assert.Single(list);
Assert.Equal("mine", list[0].Title);
}
[Fact]
public async Task UpdateChildTask_NotAChild_Throws()
{
var parent = await SeedPlanningParentAsync();
var other = await SeedPlanningParentAsync();
var otherChild = await _tasks.CreateChildAsync(other.Id, "x", null, null, null);
var sut = BuildSut(parent.Id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(otherChild.Id, "new", null, null, null, null, CancellationToken.None));
}
[Fact]
public async Task UpdateChildTask_AfterFinalize_Throws()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
// Simulate post-finalize state directly: parent.PlanningPhase=Finalized
// is the gate the MCP service checks.
var sut = BuildSut(parent.Id);
var result = await _built!.State.FinalizePlanningAsync(parent.Id, CancellationToken.None);
Assert.True(result.Ok, result.Reason);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(c.Id, "new", null, null, null, null, CancellationToken.None));
}
[Fact]
public async Task UpdateChildTask_SetsTags()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
var result = await sut.UpdateChildTask(c.Id, null, null, new[] { "agent", "custom-tag" }, null, null, CancellationToken.None);
Assert.Contains("agent", result.Tags);
Assert.Contains("custom-tag", result.Tags);
Assert.Equal(2, result.Tags.Count);
}
[Fact]
public async Task UpdateChildTask_ReplacesTagSet()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, new[] { "agent" }, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
var result = await sut.UpdateChildTask(c.Id, null, null, new[] { "manual" }, null, null, CancellationToken.None);
Assert.Single(result.Tags);
Assert.Equal("manual", result.Tags[0]);
}
[Fact]
public async Task UpdateChildTask_SetsStatus()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
var result = await sut.UpdateChildTask(c.Id, null, null, null, null, "Queued", CancellationToken.None);
Assert.Equal("Queued", result.Status);
var loaded = await _tasks.GetByIdAsync(c.Id);
Assert.Equal(TaskStatus.Queued, loaded!.Status);
}
[Fact]
public async Task UpdateChildTask_DisallowedStatus_Throws()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(c.Id, null, null, null, null, "Running", CancellationToken.None));
}
[Fact]
public async Task UpdateChildTask_UnknownStatus_Throws()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.UpdateChildTask(c.Id, null, null, null, null, "NotARealStatus", CancellationToken.None));
}
[Fact]
public async Task DeleteChildTask_RemovesDraft()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
var sut = BuildSut(parent.Id);
await sut.DeleteChildTask(c.Id, CancellationToken.None);
Assert.Null(await _tasks.GetByIdAsync(c.Id));
}
[Fact]
public async Task UpdatePlanningTask_SetsTitleAndDescription()
{
var parent = await SeedPlanningParentAsync();
var sut = BuildSut(parent.Id);
await sut.UpdatePlanningTask("new title", "new desc", CancellationToken.None);
var loaded = await _tasks.GetByIdAsync(parent.Id);
Assert.Equal("new title", loaded!.Title);
Assert.Equal("new desc", loaded.Description);
}
[Fact]
public async Task Finalize_PromotesDraftsAndInvalidatesToken()
{
var parent = await SeedPlanningParentAsync();
await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
var sut = BuildSut(parent.Id);
var count = await sut.Finalize(true, CancellationToken.None);
Assert.Equal(2, count);
var loaded = await _tasks.GetByIdAsync(parent.Id);
Assert.Equal(PlanningPhase.Finalized, loaded!.PlanningPhase);
Assert.Null(loaded.PlanningSessionToken);
}
[Fact]
public async Task CreateChildTask_BroadcastsBothChildAndParent()
{
var parent = await SeedPlanningParentAsync();
var sut = BuildSut(parent.Id);
var result = await sut.CreateChildTask("c", null, null, null, CancellationToken.None);
var ids = TaskUpdatedIds();
Assert.Contains(result.TaskId, ids);
Assert.Contains(parent.Id, ids);
}
[Fact]
public async Task UpdateChildTask_BroadcastsBothChildAndParent()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
_ctx.ChangeTracker.Clear();
var sut = BuildSut(parent.Id);
await sut.UpdateChildTask(c.Id, "new title", null, null, null, null, CancellationToken.None);
var ids = TaskUpdatedIds();
Assert.Contains(c.Id, ids);
Assert.Contains(parent.Id, ids);
}
[Fact]
public async Task DeleteChildTask_BroadcastsBothChildAndParent()
{
var parent = await SeedPlanningParentAsync();
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
var sut = BuildSut(parent.Id);
await sut.DeleteChildTask(c.Id, CancellationToken.None);
var ids = TaskUpdatedIds();
Assert.Contains(c.Id, ids);
Assert.Contains(parent.Id, ids);
}
[Fact]
public async Task Finalize_BroadcastsEachChildAndParent()
{
var parent = await SeedPlanningParentAsync();
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
var sut = BuildSut(parent.Id);
await sut.Finalize(true, CancellationToken.None);
var ids = TaskUpdatedIds();
Assert.Contains(c1.Id, ids);
Assert.Contains(c2.Id, ids);
Assert.Contains(parent.Id, ids);
}
}