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 excludedConnectionIds) => Proxy; public IClientProxy Client(string connectionId) => Proxy; public IClientProxy Clients(IReadOnlyList connectionIds) => Proxy; public IClientProxy Group(string groupName) => Proxy; public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => Proxy; public IClientProxy Groups(IReadOnlyList groupNames) => Proxy; public IClientProxy User(string userId) => Proxy; public IClientProxy Users(IReadOnlyList 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 { 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 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); return new PlanningMcpService(_tasks, accessor, broadcaster); } private IReadOnlyList TaskUpdatedIds() => _hubCalls .Where(c => c.Method == "TaskUpdated") .Select(c => (string)c.Args[0]!) .ToList(); 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); } [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, 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); } }