using System.Text; using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.External; using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Tests.Infrastructure; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; namespace ClaudeDo.Worker.Tests.External; public sealed class AttachmentMcpToolsTests : IDisposable { private readonly DbFixture _db = new(); private readonly ClaudeDoDbContext _ctx; private readonly TaskRepository _tasks; private readonly ListRepository _lists; private readonly TaskAttachmentRepository _attachments; private readonly string _storeRoot; private readonly AttachmentStore _store; public AttachmentMcpToolsTests() { _ctx = _db.CreateContext(); _tasks = new TaskRepository(_ctx); _lists = new ListRepository(_ctx); _attachments = new TaskAttachmentRepository(_ctx); _storeRoot = Path.Combine(Path.GetTempPath(), $"att_test_{Guid.NewGuid():N}"); _store = new AttachmentStore(_storeRoot); } public void Dispose() { _ctx.Dispose(); _db.Dispose(); try { Directory.Delete(_storeRoot, recursive: true); } catch { } } private async Task SeedListAsync() { var id = Guid.NewGuid().ToString(); await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow }); return id; } private async Task SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle) { var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t", Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore", }; await _tasks.AddAsync(task); return task; } private AttachmentMcpTools BuildSut() { var hubCtx = new CapturingHubContext(); var broadcaster = new HubBroadcaster(hubCtx); return new AttachmentMcpTools(_tasks, _attachments, _store, broadcaster); } [Fact] public async Task Add_textContent_writes_file_and_db_row() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(); var dto = await sut.AddTaskAttachment(task.Id, "plan.md", textContent: "# Hello", ct: CancellationToken.None); // File on disk var filePath = Path.Combine(_storeRoot, task.Id, "plan.md"); Assert.True(File.Exists(filePath)); Assert.Equal("# Hello", await File.ReadAllTextAsync(filePath)); // DB row await using var vCtx = _db.CreateContext(); var row = await new TaskAttachmentRepository(vCtx).GetAsync(task.Id, "plan.md"); Assert.NotNull(row); Assert.Equal(Encoding.UTF8.GetByteCount("# Hello"), row!.ByteSize); Assert.Equal(dto.ByteSize, row.ByteSize); Assert.Equal("plan.md", dto.FileName); } [Fact] public async Task Add_base64Content_writes_file_and_db_row() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(); var original = new byte[] { 1, 2, 3, 4, 5 }; var b64 = Convert.ToBase64String(original); var dto = await sut.AddTaskAttachment(task.Id, "data.bin", base64Content: b64, ct: CancellationToken.None); var filePath = Path.Combine(_storeRoot, task.Id, "data.bin"); Assert.True(File.Exists(filePath)); Assert.Equal(original, await File.ReadAllBytesAsync(filePath)); Assert.Equal(5, dto.ByteSize); } [Fact] public async Task List_returns_added_items() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(); await sut.AddTaskAttachment(task.Id, "a.txt", textContent: "a", ct: CancellationToken.None); await sut.AddTaskAttachment(task.Id, "b.txt", textContent: "bb", ct: CancellationToken.None); var list = await sut.ListTaskAttachments(task.Id); Assert.Equal(2, list.Count); Assert.Contains(list, d => d.FileName == "a.txt"); Assert.Contains(list, d => d.FileName == "b.txt"); } [Fact] public async Task Remove_deletes_file_and_row() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(); await sut.AddTaskAttachment(task.Id, "remove.txt", textContent: "x", ct: CancellationToken.None); var filePath = Path.Combine(_storeRoot, task.Id, "remove.txt"); Assert.True(File.Exists(filePath)); await sut.RemoveTaskAttachment(task.Id, "remove.txt"); Assert.False(File.Exists(filePath)); await using var vCtx = _db.CreateContext(); var row = await new TaskAttachmentRepository(vCtx).GetAsync(task.Id, "remove.txt"); Assert.Null(row); } [Fact] public async Task Add_missing_task_throws() { var sut = BuildSut(); await Assert.ThrowsAsync( () => sut.AddTaskAttachment("no-such-id", "f.txt", textContent: "x", ct: CancellationToken.None)); } [Fact] public async Task Add_running_task_throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, TaskStatus.Running); var sut = BuildSut(); await Assert.ThrowsAsync( () => sut.AddTaskAttachment(task.Id, "f.txt", textContent: "x", ct: CancellationToken.None)); } [Fact] public async Task Add_neither_content_throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(); await Assert.ThrowsAsync( () => sut.AddTaskAttachment(task.Id, "f.txt", ct: CancellationToken.None)); } [Fact] public async Task Add_both_content_throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(); await Assert.ThrowsAsync( () => sut.AddTaskAttachment(task.Id, "f.txt", textContent: "x", base64Content: "eA==", ct: CancellationToken.None)); } [Fact] public async Task Add_invalid_base64_throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(); await Assert.ThrowsAsync( () => sut.AddTaskAttachment(task.Id, "f.bin", base64Content: "!!!not-base64!!!", ct: CancellationToken.None)); } [Fact] public async Task Readd_same_fileName_overwrites_one_row_updated_size() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); var sut = BuildSut(); await sut.AddTaskAttachment(task.Id, "plan.md", textContent: "short", ct: CancellationToken.None); await sut.AddTaskAttachment(task.Id, "plan.md", textContent: "much longer content here", ct: CancellationToken.None); // Only one row in DB await using var vCtx = _db.CreateContext(); var rows = await new TaskAttachmentRepository(vCtx).ListByTaskIdAsync(task.Id); Assert.Single(rows); // Updated size var expected = Encoding.UTF8.GetByteCount("much longer content here"); Assert.Equal(expected, rows[0].ByteSize); } [Fact] public async Task Remove_running_task_throws() { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId, TaskStatus.Running); var sut = BuildSut(); await Assert.ThrowsAsync( () => sut.RemoveTaskAttachment(task.Id, "f.txt")); } }