using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.External; using ClaudeDo.Worker.Tests.Infrastructure; namespace ClaudeDo.Worker.Tests.External; public sealed class RunHistoryMcpToolsTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRunRepository _runs; private readonly RunHistoryMcpTools _sut; public RunHistoryMcpToolsTests() { _ctx = _db.CreateContext(); _runs = new TaskRunRepository(_ctx); _sut = new RunHistoryMcpTools(_runs); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); } private async Task SeedTaskAsync(string taskId) { var lists = new ListRepository(_ctx); var tasks = new TaskRepository(_ctx); var listId = Guid.NewGuid().ToString(); await lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); await tasks.AddAsync(new TaskEntity { Id = taskId, ListId = listId, Title = "t", Status = ClaudeDo.Data.Models.TaskStatus.Done, CreatedAt = DateTime.UtcNow, CommitType = "chore", }); } [Fact] public async Task ListRuns_ReturnsProjectedRuns() { var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId); await _runs.AddAsync(new TaskRunEntity { Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false, Prompt = "p", ResultMarkdown = "done", TokensIn = 10, TokensOut = 20, }); var list = await _sut.ListRuns(taskId, CancellationToken.None); Assert.Single(list); Assert.Equal("done", list[0].ResultMarkdown); Assert.Equal(10, list[0].TokensIn); } [Fact] public async Task GetTaskLog_NoLog_Throws() { var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId); await Assert.ThrowsAsync(() => _sut.GetTaskLog(taskId, CancellationToken.None)); } [Fact] public async Task GetTaskLog_ReadsLatestRunLogFile() { var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId); var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt"); await File.WriteAllTextAsync(logPath, "hello log"); await _runs.AddAsync(new TaskRunEntity { Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false, Prompt = "p", LogPath = logPath, }); string content; try { content = await _sut.GetTaskLog(taskId, CancellationToken.None); } finally { File.Delete(logPath); } Assert.Equal("hello log", content); } [Fact] public async Task GetRun_NotFound_Throws() { await Assert.ThrowsAsync(() => _sut.GetRun("missing", CancellationToken.None)); } [Fact] public async Task GetTaskLog_RunExistsButNoLogPath_Throws() { var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId); await _runs.AddAsync(new TaskRunEntity { Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false, Prompt = "p", LogPath = null, }); await Assert.ThrowsAsync(() => _sut.GetTaskLog(taskId, CancellationToken.None)); } [Fact] public async Task GetTaskLog_LargeFile_ReturnsTruncatedTail() { var taskId = Guid.NewGuid().ToString(); await SeedTaskAsync(taskId); var logPath = Path.Combine(Path.GetTempPath(), $"claudedo_log_{Guid.NewGuid():N}.txt"); // Write 300 KB so it exceeds the 256 KB cap var chunk = new string('A', 1024); await using (var w = new StreamWriter(logPath, append: false)) for (var i = 0; i < 300; i++) await w.WriteAsync(chunk); await _runs.AddAsync(new TaskRunEntity { Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false, Prompt = "p", LogPath = logPath, }); string content; try { content = await _sut.GetTaskLog(taskId, CancellationToken.None); } finally { File.Delete(logPath); } Assert.StartsWith("[truncated:", content); Assert.True(content.Length < 300 * 1024); } }