using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.External; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Services; using ClaudeDo.Worker.Tests.Infrastructure; using ClaudeDo.Worker.Tests.Services; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.External; internal sealed class ExternalRecordingHubClients : IHubClients { public ExternalRecordingClientProxy 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; } internal sealed class ExternalRecordingClientProxy : 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; } } internal sealed class ExternalFakeHubContext : IHubContext { public ExternalRecordingHubClients RecordingClients { get; } = new(); public IHubClients Clients => RecordingClients; public IGroupManager Groups => throw new NotImplementedException(); } public sealed class ExternalMcpServiceTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly TagRepository _tags; private readonly ExternalFakeHubContext _hub = new(); private readonly HubBroadcaster _broadcaster; public ExternalMcpServiceTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); _tags = new TagRepository(_ctx); _broadcaster = new HubBroadcaster(_hub); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private async Task SeedListAsync(string name = "L") { var id = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = id, Name = name, CreatedAt = DateTime.UtcNow }); return id; } private async Task SeedTaskAsync(string listId, string title = "t", TaskStatus status = TaskStatus.Manual) { var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = title, Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(task); return task; } // QueueService is needed by ExternalMcpService's constructor. For tests that // only exercise UpdateTask / DeleteTask / SetTaskTags / ListTags / ListTags, // we never call its WakeQueue/RunNow/CancelTask paths, so a real QueueService // built with the same approach used in QueueServiceTests is sufficient. private ExternalMcpService BuildSut(QueueService queue) => new(_tasks, _lists, queue, _broadcaster, _tags); private QueueService CreateQueue() { var tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_ext_{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var cfg = new WorkerConfig { SandboxRoot = Path.Combine(tempDir, "sandbox"), LogRoot = Path.Combine(tempDir, "logs"), QueueBackstopIntervalMs = 50, }; var fake = new FakeClaudeProcess(); var hubCtx = new FakeHubContext(); var broadcaster = new HubBroadcaster(hubCtx); var dbFactory = _db.CreateFactory(); var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger.Instance); var argsBuilder = new ClaudeArgsBuilder(); var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, NullLogger.Instance); return new QueueService(dbFactory, runner, cfg, NullLogger.Instance); } [Fact] public async Task SeededListAndTask_AreRetrievable() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); Assert.NotNull(await _tasks.GetByIdAsync(task.Id)); } [Fact] public async Task ListTags_ReturnsSeededAndCustomTags() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); await _tasks.SetTagsAsync(task.Id, new[] { "agent", "custom-tag" }); var queue = CreateQueue(); var sut = BuildSut(queue); var tags = await sut.ListTags(CancellationToken.None); Assert.Contains(tags, t => t.Name == "agent"); Assert.Contains(tags, t => t.Name == "custom-tag"); } [Fact] public async Task AddTask_WithTags_AttachesTags() { var listId = await SeedListAsync(); var queue = CreateQueue(); var sut = BuildSut(queue); var dto = await sut.AddTask( listId, "scope-creep handoff", "desc", "claude-cli", queueImmediately: false, tags: new[] { "agent", "custom" }, CancellationToken.None); var tags = await _tasks.GetTagsAsync(dto.Id); Assert.Contains(tags, t => t.Name == "agent"); Assert.Contains(tags, t => t.Name == "custom"); } [Fact] public async Task AddTask_NullTags_BehavesAsBefore() { var listId = await SeedListAsync(); var queue = CreateQueue(); var sut = BuildSut(queue); var dto = await sut.AddTask( listId, "no tags", null, "claude-cli", queueImmediately: false, tags: null, CancellationToken.None); Assert.Empty(await _tasks.GetTagsAsync(dto.Id)); } [Fact] public async Task UpdateTask_PatchesNonNullFieldsOnly() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, "old title"); var queue = CreateQueue(); var sut = BuildSut(queue); var dto = await sut.UpdateTask(task.Id, "new title", null, null, null, CancellationToken.None); Assert.Equal("new title", dto.Title); var loaded = await _tasks.GetByIdAsync(task.Id); Assert.Equal("new title", loaded!.Title); } [Fact] public async Task UpdateTask_TagsReplaceFullSet() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); await _tasks.SetTagsAsync(task.Id, new[] { "agent" }); var queue = CreateQueue(); var sut = BuildSut(queue); await sut.UpdateTask(task.Id, null, null, null, new[] { "manual" }, CancellationToken.None); var tags = await _tasks.GetTagsAsync(task.Id); Assert.Single(tags); Assert.Equal("manual", tags[0].Name); } [Fact] public async Task UpdateTask_OnRunning_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Running); var queue = CreateQueue(); var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.UpdateTask(task.Id, "x", null, null, null, CancellationToken.None)); } [Fact] public async Task UpdateTask_NotFound_Throws() { var queue = CreateQueue(); var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.UpdateTask("does-not-exist", "x", null, null, null, CancellationToken.None)); } [Fact] public async Task DeleteTask_RemovesTaskAndTagJoins() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); await _tasks.SetTagsAsync(task.Id, new[] { "agent" }); var queue = CreateQueue(); var sut = BuildSut(queue); await sut.DeleteTask(task.Id, CancellationToken.None); Assert.Null(await _tasks.GetByIdAsync(task.Id)); } [Fact] public async Task DeleteTask_OnRunning_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Running); var queue = CreateQueue(); var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.DeleteTask(task.Id, CancellationToken.None)); } [Fact] public async Task DeleteTask_NotFound_Throws() { var queue = CreateQueue(); var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.DeleteTask("does-not-exist", CancellationToken.None)); } [Fact] public async Task SetTaskTags_ReplacesTagSetAndBroadcasts() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); await _tasks.SetTagsAsync(task.Id, new[] { "agent" }); var queue = CreateQueue(); var sut = BuildSut(queue); var dto = await sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None); var tags = await _tasks.GetTagsAsync(task.Id); Assert.Single(tags); Assert.Equal("manual", tags[0].Name); Assert.Contains(_hub.RecordingClients.Proxy.Calls, c => c.Method == "TaskUpdated" && (string)c.Args[0]! == task.Id); } [Fact] public async Task SetTaskTags_OnRunning_Throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, status: TaskStatus.Running); var queue = CreateQueue(); var sut = BuildSut(queue); await Assert.ThrowsAsync(() => sut.SetTaskTags(task.Id, new[] { "manual" }, CancellationToken.None)); } }