diff --git a/tests/ClaudeDo.Worker.Tests/CLAUDE.md b/tests/ClaudeDo.Worker.Tests/CLAUDE.md index fff6f16..38f8abe 100644 --- a/tests/ClaudeDo.Worker.Tests/CLAUDE.md +++ b/tests/ClaudeDo.Worker.Tests/CLAUDE.md @@ -5,7 +5,7 @@ xUnit integration tests for the Worker and Data layers. One of six test projects ## Framework - xUnit 2.5.3 with `xunit.runner.visualstudio` -- No mocking library — custom sealed fakes (FakeClaudeProcess, FakeHubContext, FakeHubClients, FakeClientProxy) +- No mocking library — custom sealed fakes (`Infrastructure/FakeClaudeProcess`, `CapturingHubContext/CapturingHubClients/CapturingClientProxy` for SignalR; file-local fakes for scoped tests) - Real SQLite databases per test via `DbFixture` - Real git repos for worktree tests via `GitRepoFixture` - coverlet for coverage collection @@ -32,7 +32,7 @@ Tests are organized by Worker area (mirroring the source folders); 30+ test file ## Conventions - Test classes implement `IDisposable` and create fixtures in constructor -- Helper factory methods for entities: `MakeTask()`, `CreateListAsync()`, `SeedListWithAgentTag()` +- Helper factory methods for entities: `MakeTask()`, `CreateListAsync()`, `SeedListAsync()` - Concurrency tests use `TaskCompletionSource` as gates for deterministic ordering - Git-dependent tests are conditionally skipped via `Skip = ...` when git is not available diff --git a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs index 5827ca7..8fc3ccd 100644 --- a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs @@ -7,7 +7,6 @@ using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; -using ClaudeDo.Worker.Tests.Services; using ClaudeDo.Worker.Worktrees; using ClaudeDo.Worker.Config; using Microsoft.Extensions.Logging.Abstractions; @@ -62,7 +61,7 @@ public sealed class AddSubtaskToolTests : IDisposable QueueBackstopIntervalMs = 50, }; var dbFactory = _db.CreateFactory(); - var hubCtx = new FakeHubContext(); + var hubCtx = new CapturingHubContext(); var broadcaster = new HubBroadcaster(hubCtx); var git = new ClaudeDo.Data.Git.GitService(); var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index e4c65b3..ee79330 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -9,7 +9,6 @@ using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; -using ClaudeDo.Worker.Tests.Services; using ClaudeDo.Worker.Worktrees; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging.Abstractions; @@ -143,7 +142,7 @@ public sealed class ExternalMcpServiceTests : IDisposable QueueBackstopIntervalMs = 50, }; var fake = new FakeClaudeProcess(); - var hubCtx = new FakeHubContext(); + var hubCtx = new CapturingHubContext(); var broadcaster = new HubBroadcaster(hubCtx); var dbFactory = _db.CreateFactory(); var wtManager = new WorktreeManager(new GitService(), dbFactory, cfg, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/Infrastructure/FakeClaudeProcess.cs b/tests/ClaudeDo.Worker.Tests/Infrastructure/FakeClaudeProcess.cs new file mode 100644 index 0000000..0af9aa4 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Infrastructure/FakeClaudeProcess.cs @@ -0,0 +1,25 @@ +using ClaudeDo.Worker.Runner; + +namespace ClaudeDo.Worker.Tests.Infrastructure; + +internal sealed class FakeClaudeProcess : IClaudeProcess +{ + private readonly Func, Func, CancellationToken, Task> _handler; + private int _callCount; + + public int CallCount => _callCount; + + public FakeClaudeProcess( + Func, Func, CancellationToken, Task>? handler = null) + { + _handler = handler ?? ((_, _, _, _, _) => + Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" })); + } + + public async Task RunAsync(IReadOnlyList arguments, string prompt, string workingDirectory, + Func onStdoutLine, CancellationToken ct) + { + Interlocked.Increment(ref _callCount); + return await _handler(prompt, workingDirectory, arguments, onStdoutLine, ct); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs index 3e1769e..8f96317 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs @@ -5,7 +5,6 @@ using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; -using ClaudeDo.Worker.Tests.Services; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -33,7 +32,7 @@ public sealed class ContinueAsyncExceptionTests : IDisposable private TaskRunner BuildRunner(IClaudeProcess claude, ClaudeDoDbContext ctx) { var dbFactory = _db.CreateFactory(); - var broadcaster = new HubBroadcaster(new FakeHubContext()); + var broadcaster = new HubBroadcaster(new CapturingHubContext()); var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger.Instance); return new TaskRunner(claude, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg, diff --git a/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs index fffa820..83df840 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs @@ -5,7 +5,6 @@ using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; -using ClaudeDo.Worker.Tests.Services; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using Xunit; @@ -41,7 +40,7 @@ public sealed class StandaloneChildrenRoutingTests : IDisposable } var fake = new FakeClaudeProcess((_, _, _, _, _) => Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "done" })); - var broadcaster = new HubBroadcaster(new FakeHubContext()); + var broadcaster = new HubBroadcaster(new CapturingHubContext()); var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); var runner = new TaskRunner(fake, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg, @@ -71,7 +70,7 @@ public sealed class StandaloneChildrenRoutingTests : IDisposable Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "done" })); var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); - var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new FakeHubContext()), wt, + var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry()); using (var ctx = _db.CreateContext()) diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs index 61dc6b2..be47d60 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs @@ -6,7 +6,6 @@ using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -49,7 +48,7 @@ public sealed class QueueServiceSlotGuardTests : IDisposable Func, Func, CancellationToken, Task>? handler = null) { var fake = new FakeClaudeProcess(handler); - var broadcaster = new HubBroadcaster(new FakeHubContext()); + var broadcaster = new HubBroadcaster(new CapturingHubContext()); var dbFactory = _db.CreateFactory(); var wtManager = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger.Instance); var argsBuilder = new ClaudeArgsBuilder(); @@ -63,7 +62,7 @@ public sealed class QueueServiceSlotGuardTests : IDisposable return (service, fake); } - private async Task SeedListWithAgentTagAsync() + private async Task SeedListAsync() { var listId = Guid.NewGuid().ToString(); await _listRepo.AddAsync(new ListEntity { Id = listId, Name = "Test", CreatedAt = DateTime.UtcNow }); @@ -88,7 +87,7 @@ public sealed class QueueServiceSlotGuardTests : IDisposable [Fact] public async Task RunNow_Throws_When_Task_Already_Running_In_Queue_Slot() { - var listId = await SeedListWithAgentTagAsync(); + var listId = await SeedListAsync(); var task = await SeedQueuedTaskAsync(listId); // Gate keeps the queue slot occupied indefinitely. @@ -119,7 +118,7 @@ public sealed class QueueServiceSlotGuardTests : IDisposable [Fact] public async Task ContinueTask_Throws_When_Task_Already_Running_In_Queue_Slot() { - var listId = await SeedListWithAgentTagAsync(); + var listId = await SeedListAsync(); var task = await SeedQueuedTaskAsync(listId); var tcs = new TaskCompletionSource(); diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs index b67be6d..d0444e9 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs @@ -7,7 +7,6 @@ using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; -using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging.Abstractions; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; @@ -50,7 +49,7 @@ public sealed class QueueServiceTests : IDisposable Func, Func, CancellationToken, Task>? handler = null) { var fake = new FakeClaudeProcess(handler); - var broadcaster = new HubBroadcaster(new FakeHubContext()); + var broadcaster = new HubBroadcaster(new CapturingHubContext()); var dbFactory = _db.CreateFactory(); var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); var argsBuilder = new ClaudeArgsBuilder(); @@ -64,11 +63,11 @@ public sealed class QueueServiceTests : IDisposable return (service, fake); } - private async Task<(string listId, long agentTagId)> SeedListWithAgentTag() + private async Task SeedListAsync() { var listId = Guid.NewGuid().ToString(); await _listRepo.AddAsync(new ListEntity { Id = listId, Name = "Test", CreatedAt = DateTime.UtcNow }); - return (listId, 0L); + return listId; } private async Task SeedQueuedTask(string listId, DateTime? scheduledFor = null, DateTime? createdAt = null) @@ -90,7 +89,7 @@ public sealed class QueueServiceTests : IDisposable [Fact] public async Task RunNow_Throws_When_Override_Slot_Busy() { - var (listId, _) = await SeedListWithAgentTag(); + var listId = await SeedListAsync(); var tcs = new TaskCompletionSource(); var (service, _) = CreateService((_, _, _, _, ct) => tcs.Task); @@ -116,7 +115,7 @@ public sealed class QueueServiceTests : IDisposable [Fact] public async Task ReQueuedReviewTask_ResumesSession_WithFeedbackPrompt_AndClearsFeedback() { - var (listId, _) = await SeedListWithAgentTag(); + var listId = await SeedListAsync(); IReadOnlyList? capturedArgs = null; string? capturedPrompt = null; @@ -181,7 +180,7 @@ public sealed class QueueServiceTests : IDisposable [Fact] public async Task Schedule_Filter_Skips_Future_Tasks() { - var (listId, _) = await SeedListWithAgentTag(); + var listId = await SeedListAsync(); await SeedQueuedTask(listId, scheduledFor: DateTime.UtcNow.AddHours(1)); var (service, fake) = CreateService((_, _, _, _, _) => @@ -202,7 +201,7 @@ public sealed class QueueServiceTests : IDisposable [Fact] public async Task Queue_FIFO_Sequentiality() { - var (listId, _) = await SeedListWithAgentTag(); + var listId = await SeedListAsync(); var order = new List(); var gate1 = new TaskCompletionSource(); @@ -249,7 +248,7 @@ public sealed class QueueServiceTests : IDisposable [Fact] public async Task CancelTask_Triggers_Cancellation() { - var (listId, _) = await SeedListWithAgentTag(); + var listId = await SeedListAsync(); var running = new TaskCompletionSource(); var cancelled = false; @@ -284,7 +283,7 @@ public sealed class QueueServiceTests : IDisposable [Fact] public async Task RunNow_AutoRetries_On_Failure_With_SessionId() { - var (listId, agentTagId) = await SeedListWithAgentTag(); + var listId = await SeedListAsync(); var task = await SeedQueuedTask(listId); var callCount = 0; @@ -327,7 +326,7 @@ public sealed class QueueServiceTests : IDisposable [Fact] public async Task GetActive_Returns_Running_Slots() { - var (listId, _) = await SeedListWithAgentTag(); + var listId = await SeedListAsync(); var tcs = new TaskCompletionSource(); var (service, _) = CreateService((_, _, _, _, _) => tcs.Task); @@ -343,55 +342,3 @@ public sealed class QueueServiceTests : IDisposable tcs.SetResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }); } } - -#region Test doubles - -internal sealed class FakeClaudeProcess : IClaudeProcess -{ - private readonly Func, Func, CancellationToken, Task> _handler; - private int _callCount; - - public int CallCount => _callCount; - - public FakeClaudeProcess( - Func, Func, CancellationToken, Task>? handler = null) - { - _handler = handler ?? ((_, _, _, _, _) => - Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" })); - } - - public async Task RunAsync(IReadOnlyList arguments, string prompt, string workingDirectory, - Func onStdoutLine, CancellationToken ct) - { - Interlocked.Increment(ref _callCount); - return await _handler(prompt, workingDirectory, arguments, onStdoutLine, ct); - } -} - -internal sealed class FakeHubContext : IHubContext -{ - public IHubClients Clients { get; } = new FakeHubClients(); - public IGroupManager Groups => throw new NotImplementedException(); -} - -internal sealed class FakeHubClients : IHubClients -{ - private readonly FakeClientProxy _proxy = 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 FakeClientProxy : IClientProxy -{ - public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) => - Task.CompletedTask; -} - -#endregion diff --git a/tests/ClaudeDo.Worker.Tests/SuggestImprovementTests.cs b/tests/ClaudeDo.Worker.Tests/SuggestImprovementTests.cs index e19ab9d..3a31439 100644 --- a/tests/ClaudeDo.Worker.Tests/SuggestImprovementTests.cs +++ b/tests/ClaudeDo.Worker.Tests/SuggestImprovementTests.cs @@ -3,7 +3,6 @@ using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Tests.Infrastructure; -using ClaudeDo.Worker.Tests.Services; using Microsoft.AspNetCore.Http; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using Xunit; @@ -38,7 +37,7 @@ public sealed class SuggestImprovementTests : IDisposable await SeedCallerAsync("caller", parentId: null); using var ctx = _db.CreateContext(); var svc = new TaskRunMcpService(new TaskRepository(ctx), AccessorFor("caller"), - new HubBroadcaster(new FakeHubContext())); + new HubBroadcaster(new CapturingHubContext())); var dto = await svc.SuggestImprovement("Refactor X", "details", model: null, default); var child = await new TaskRepository(ctx).GetByIdAsync(dto.ChildTaskId); Assert.Equal("caller", child!.ParentTaskId); @@ -54,7 +53,7 @@ public sealed class SuggestImprovementTests : IDisposable await SeedCallerAsync("caller", parentId: null); using var ctx = _db.CreateContext(); var svc = new TaskRunMcpService(new TaskRepository(ctx), AccessorFor("caller"), - new HubBroadcaster(new FakeHubContext())); + new HubBroadcaster(new CapturingHubContext())); var dto = await svc.SuggestImprovement("Refactor X", "details", model: "HAIKU", default); var child = await new TaskRepository(ctx).GetByIdAsync(dto.ChildTaskId); Assert.Equal("haiku", child!.Model); @@ -66,7 +65,7 @@ public sealed class SuggestImprovementTests : IDisposable await SeedCallerAsync("caller", parentId: null); using var ctx = _db.CreateContext(); var svc = new TaskRunMcpService(new TaskRepository(ctx), AccessorFor("caller"), - new HubBroadcaster(new FakeHubContext())); + new HubBroadcaster(new CapturingHubContext())); await Assert.ThrowsAsync( () => svc.SuggestImprovement("x", "y", model: "gpt4", default)); } @@ -78,7 +77,7 @@ public sealed class SuggestImprovementTests : IDisposable await SeedCallerAsync("child", parentId: "parent"); using var ctx = _db.CreateContext(); var svc = new TaskRunMcpService(new TaskRepository(ctx), AccessorFor("child"), - new HubBroadcaster(new FakeHubContext())); + new HubBroadcaster(new CapturingHubContext())); await Assert.ThrowsAsync( () => svc.SuggestImprovement("nested", "x", model: null, default)); }