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 NullHubClients : IHubClients { public IClientProxy All => NullClientProxy.Instance; public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => NullClientProxy.Instance; public IClientProxy Client(string connectionId) => NullClientProxy.Instance; public IClientProxy Clients(IReadOnlyList connectionIds) => NullClientProxy.Instance; public IClientProxy Group(string groupName) => NullClientProxy.Instance; public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => NullClientProxy.Instance; public IClientProxy Groups(IReadOnlyList groupNames) => NullClientProxy.Instance; public IClientProxy User(string userId) => NullClientProxy.Instance; public IClientProxy Users(IReadOnlyList userIds) => NullClientProxy.Instance; } file sealed class NullClientProxy : IClientProxy { public static readonly NullClientProxy Instance = new(); public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) => Task.CompletedTask; } file sealed class FakeHubContext : IHubContext { public IHubClients Clients { get; } = new NullHubClients(); 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 PlanningMcpService BuildSut(string parentTaskId) { var httpContext = new DefaultHttpContext(); httpContext.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parentTaskId }; var accessor = new PlanningMcpContextAccessor(new FakeHttpContextAccessor { HttpContext = httpContext }); var broadcaster = new HubBroadcaster(new FakeHubContext()); return new PlanningMcpService(_tasks, accessor, broadcaster); } private async Task 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.Manual, 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("Draft", result.Status); var child = await _tasks.GetByIdAsync(result.TaskId); Assert.Equal("My child", child!.Title); Assert.Equal(TaskStatus.Draft, 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(() => sut.UpdateChildTask(otherChild.Id, "new", null, null, null, CancellationToken.None)); } [Fact] public async Task UpdateChildTask_NotDraft_Throws() { var parent = await SeedPlanningParentAsync(); var c = await _tasks.CreateChildAsync(parent.Id, "c", null, null, null); await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false); var sut = BuildSut(parent.Id); await Assert.ThrowsAsync(() => sut.UpdateChildTask(c.Id, "new", null, null, null, 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(TaskStatus.Planned, loaded!.Status); Assert.Null(loaded.PlanningSessionToken); } }