diff --git a/docs/open.md b/docs/open.md index 0392c5c..e33840d 100644 --- a/docs/open.md +++ b/docs/open.md @@ -154,10 +154,9 @@ Voraussetzung: Gitea-Release unter `git.kuns.dev/releases/ClaudeDo` mit `ClaudeD - `tests/.../Runner/ClaudeProcessSmokeTest.cs` existiert nicht. Real-CLI-Test als `[Fact(Skip=...)]`, nur lokal bei `CLAUDE_AUTHENTICATED=1`. - **Aufwand:** klein. -### 5.4 ExternalMcpService-Tests 🟑 -- Service exponiert jetzt **18 Tools** (war 11): `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus`, `ReviewTask`, `RunTaskNow`, `CancelTask`, `DeleteTask`, `GetTaskStatusValues`, `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`, `GetDailyPrepCandidates`, `SetMyDay`. -- `ExternalMcpServiceTests.cs` hat 14 Tests, plus 4 Sibling-Dateien (`ConfigMcpToolsTests`, `LifecycleMcpToolsTests`, `ListMcpToolsTests`, `RunHistoryMcpToolsTests`). -- **Ungetestet im External-Ordner:** `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree` β€” je Happy-Path + Error-Pfad ergΓ€nzen. +### 5.4 ExternalMcpService-Tests βœ… +- Service exponiert **18 Tools** (war 11): `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus`, `ReviewTask`, `RunTaskNow`, `CancelTask`, `DeleteTask`, `GetTaskStatusValues`, `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`, `GetDailyPrepCandidates`, `SetMyDay`. +- `ExternalMcpServiceTests.cs` hat jetzt 26 Tests (war 14): die zuvor ungetesteten Worktree/Git-Tools (`GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`) haben je Error-Pfad + git-gestΓΌtzten Happy-Path (skippen wenn git fehlt). Plus 4 Sibling-Dateien (`ConfigMcpToolsTests`, `LifecycleMcpToolsTests`, `ListMcpToolsTests`, `RunHistoryMcpToolsTests`). --- diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index dfc7e51..2306b0e 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -56,6 +56,10 @@ public sealed class ExternalMcpServiceTests : IDisposable private readonly ListRepository _lists; private readonly ExternalFakeHubContext _hub = new(); private readonly HubBroadcaster _broadcaster; + private readonly List _repos = new(); + private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = new(); + + private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); public ExternalMcpServiceTests() { @@ -65,7 +69,34 @@ public sealed class ExternalMcpServiceTests : IDisposable _broadcaster = new HubBroadcaster(_hub); } - public void Dispose() { _ctx.Dispose(); _db.Dispose(); } + public void Dispose() + { + foreach (var (repoDir, wtPath) in _worktreeCleanups) + { + try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { } + } + foreach (var r in _repos) r.Dispose(); + _ctx.Dispose(); + _db.Dispose(); + } + + private async Task<(TaskEntity task, ListEntity list, WorktreeContext wt)> SeedWorktreeAsync( + TaskStatus status = TaskStatus.Done) + { + var repo = new GitRepoFixture(); + _repos.Add(repo); + + var listId = Guid.NewGuid().ToString(); + var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }; + await _lists.AddAsync(list); + var task = await SeedTaskAsync(listId, status: status); + + var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; + var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); + var wt = await mgr.CreateAsync(task, list, CancellationToken.None); + _worktreeCleanups.Add((repo.RepoDir, wt.WorktreePath)); + return (task, list, wt); + } private async Task SeedListAsync(string name = "L") { @@ -412,4 +443,156 @@ public sealed class ExternalMcpServiceTests : IDisposable var dto = await svc.SetMyDay(id, false, null, CancellationToken.None); Assert.False(dto.IsMyDay); } + + // ── GetTaskWorktree ──────────────────────────────────────────────────────── + + [Fact] + public async Task GetTaskWorktree_NoWorktree_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(CreateQueue()); + + await Assert.ThrowsAsync( + () => sut.GetTaskWorktree(task.Id, CancellationToken.None)); + } + + [Fact] + public async Task GetTaskWorktree_ReturnsBranchAndBaseCommit() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, wt) = await SeedWorktreeAsync(); + var sut = BuildSut(CreateQueue()); + + var info = await sut.GetTaskWorktree(task.Id, CancellationToken.None); + + Assert.Equal(wt.BranchName, info.Branch); + Assert.Equal(wt.BaseCommit, info.BaseCommit); + Assert.Equal(0, info.Ahead); + Assert.False(info.IsDirty); + } + + // ── GetTaskDiff ──────────────────────────────────────────────────────────── + + [Fact] + public async Task GetTaskDiff_NoWorktree_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId); + var sut = BuildSut(CreateQueue()); + + await Assert.ThrowsAsync( + () => sut.GetTaskDiff(task.Id, false, CancellationToken.None)); + } + + [Fact] + public async Task GetTaskDiff_AfterCommit_ListsChangedFile() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, list, wt) = await SeedWorktreeAsync(); + File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content"); + + var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" }; + var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger.Instance); + await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None); + + var sut = BuildSut(CreateQueue()); + var diff = await sut.GetTaskDiff(task.Id, false, CancellationToken.None); + + Assert.Contains("added.txt", diff.Files); + Assert.False(diff.Truncated); + } + + // ── MergeTask ────────────────────────────────────────────────────────────── + + [Fact] + public async Task MergeTask_NotDone_Throws() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync( + () => sut.MergeTask(task.Id, "main", true, false, CancellationToken.None)); + Assert.Contains("Done", ex.Message); + } + + [Fact] + public async Task MergeTask_DryRun_DoesNotMerge() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done); + var sut = BuildSut(CreateQueue()); + + var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, CancellationToken.None); + + Assert.False(result.Merged); + Assert.Null(result.MergeCommit); + } + + // ── ListWorktrees ────────────────────────────────────────────────────────── + + [Fact] + public async Task ListWorktrees_Empty_ReturnsEmpty() + { + var sut = BuildSut(CreateQueue()); + + var rows = await sut.ListWorktrees(CancellationToken.None); + + Assert.Empty(rows); + } + + [Fact] + public async Task ListWorktrees_ReturnsCreatedWorktree() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(); + var sut = BuildSut(CreateQueue()); + + var rows = await sut.ListWorktrees(CancellationToken.None); + + Assert.Contains(rows, r => r.TaskId == task.Id); + } + + // ── CleanupTaskWorktree ──────────────────────────────────────────────────── + + [Fact] + public async Task CleanupTaskWorktree_NotFound_Throws() + { + var sut = BuildSut(CreateQueue()); + + await Assert.ThrowsAsync( + () => sut.CleanupTaskWorktree("does-not-exist", false, CancellationToken.None)); + } + + [Fact] + public async Task CleanupTaskWorktree_Running_Throws() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Running); + var sut = BuildSut(CreateQueue()); + + var ex = await Assert.ThrowsAsync( + () => sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None)); + Assert.Contains("running", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CleanupTaskWorktree_CleanWorktree_Removes() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var (task, _, wt) = await SeedWorktreeAsync(TaskStatus.Done); + var sut = BuildSut(CreateQueue()); + + var result = await sut.CleanupTaskWorktree(task.Id, false, CancellationToken.None); + + Assert.True(result.Removed); + Assert.False(Directory.Exists(wt.WorktreePath)); + } }