feat(attachments): data layer for task file attachments

TaskAttachmentEntity (+config, cascade FK), TaskAttachmentRepository, and an
AttachmentStore that writes files under ~/.todo-app/attachments/<taskId>/ with
a path-traversal guard and a 5 MB cap. TaskPromptComposer gains an optional
read-only 'Reference files' section. Migration AddTaskAttachments.
This commit is contained in:
Mika Kuns
2026-06-22 17:10:51 +02:00
parent 5231ad6b86
commit 3f9f047955
12 changed files with 1249 additions and 1 deletions

View File

@@ -0,0 +1,94 @@
using ClaudeDo.Data;
namespace ClaudeDo.Data.Tests;
public sealed class AttachmentStoreTests : IDisposable
{
private readonly string _root;
private readonly AttachmentStore _store;
public AttachmentStoreTests()
{
_root = Path.Combine(Path.GetTempPath(), $"claudedo_att_{Guid.NewGuid():N}");
Directory.CreateDirectory(_root);
_store = new AttachmentStore(_root);
}
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); } catch { }
}
[Fact]
public async Task Save_then_readback_size_matches()
{
var bytes = new byte[1024];
new Random(42).NextBytes(bytes);
using var ms = new MemoryStream(bytes);
var written = await _store.SaveAsync("task1", "data.bin", ms);
Assert.Equal(1024, written);
var filePath = Path.Combine(_store.TaskDir("task1"), "data.bin");
Assert.Equal(1024, new FileInfo(filePath).Length);
}
[Fact]
public async Task Rejects_fileName_with_dotdot()
{
using var ms = new MemoryStream(new byte[10]);
await Assert.ThrowsAsync<ArgumentException>(() =>
_store.SaveAsync("task1", "../escape.txt", ms));
}
[Fact]
public async Task Rejects_fileName_with_directory_separator()
{
using var ms = new MemoryStream(new byte[10]);
await Assert.ThrowsAsync<ArgumentException>(() =>
_store.SaveAsync("task1", "sub/file.txt", ms));
}
[Fact]
public async Task Rejects_content_over_5MB()
{
var over = new byte[5 * 1024 * 1024 + 1];
using var ms = new MemoryStream(over);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_store.SaveAsync("task1", "big.bin", ms));
}
[Fact]
public void DeleteTaskDir_removes_directory()
{
var dir = _store.TaskDir("task2");
Directory.CreateDirectory(dir);
File.WriteAllText(Path.Combine(dir, "a.txt"), "x");
_store.DeleteTaskDir("task2");
Assert.False(Directory.Exists(dir));
}
[Fact]
public void Deleting_missing_file_is_noop()
{
// Should not throw
_store.DeleteFile("taskX", "nonexistent.txt");
}
[Fact]
public void DeleteFile_removes_file_and_ignores_missing_second_call()
{
var dir = _store.TaskDir("task3");
Directory.CreateDirectory(dir);
var filePath = Path.Combine(dir, "remove.txt");
File.WriteAllText(filePath, "data");
_store.DeleteFile("task3", "remove.txt");
Assert.False(File.Exists(filePath));
// Second call must not throw
_store.DeleteFile("task3", "remove.txt");
}
}

View File

@@ -0,0 +1,116 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Tests;
public sealed class TaskAttachmentRepositoryTests : IDisposable
{
private readonly string _dbPath;
private readonly ClaudeDoDbContext _ctx;
private readonly TaskAttachmentRepository _repo;
private const string ListId = "l1";
private const string TaskId = "t1";
public TaskAttachmentRepositoryTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_att_{Guid.NewGuid():N}.db");
var options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
_ctx = new ClaudeDoDbContext(options);
_ctx.Database.EnsureCreated();
_repo = new TaskAttachmentRepository(_ctx);
_ctx.Lists.Add(new ListEntity { Id = ListId, Name = "Test", CreatedAt = DateTime.UtcNow });
_ctx.Tasks.Add(new TaskEntity { Id = TaskId, ListId = ListId, Title = "T", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow });
_ctx.SaveChanges();
}
public void Dispose()
{
_ctx.Dispose();
foreach (var suffix in new[] { "", "-wal", "-shm" })
try { File.Delete(_dbPath + suffix); } catch { }
}
private TaskAttachmentEntity MakeAttachment(string fileName) => new()
{
Id = Guid.NewGuid().ToString(),
TaskId = TaskId,
FileName = fileName,
ByteSize = 100,
CreatedAt = DateTime.UtcNow,
};
[Fact]
public async Task Add_ListByTaskId_Delete_roundtrip()
{
var a1 = MakeAttachment("file1.txt");
var a2 = MakeAttachment("file2.txt");
await _repo.AddAsync(a1);
await _repo.AddAsync(a2);
var list = await _repo.ListByTaskIdAsync(TaskId);
Assert.Equal(2, list.Count);
Assert.Contains(list, a => a.FileName == "file1.txt");
Assert.Contains(list, a => a.FileName == "file2.txt");
await _repo.DeleteAsync(TaskId, "file1.txt");
var afterDelete = await _repo.ListByTaskIdAsync(TaskId);
Assert.Single(afterDelete);
Assert.Equal("file2.txt", afterDelete[0].FileName);
}
[Fact]
public async Task GetAsync_returns_correct_entity()
{
var a = MakeAttachment("target.txt");
await _repo.AddAsync(a);
var found = await _repo.GetAsync(TaskId, "target.txt");
Assert.NotNull(found);
Assert.Equal(a.Id, found!.Id);
}
[Fact]
public async Task GetAsync_returns_null_when_missing()
{
var result = await _repo.GetAsync(TaskId, "nope.txt");
Assert.Null(result);
}
[Fact]
public async Task DeleteAllForTask_clears_all_rows_for_task()
{
await _repo.AddAsync(MakeAttachment("a.txt"));
await _repo.AddAsync(MakeAttachment("b.txt"));
await _repo.AddAsync(MakeAttachment("c.txt"));
await _repo.DeleteAllForTaskAsync(TaskId);
var list = await _repo.ListByTaskIdAsync(TaskId);
Assert.Empty(list);
}
[Fact]
public async Task ListByTaskId_ordered_by_created_at()
{
var base_ = DateTime.UtcNow;
var a1 = new TaskAttachmentEntity { Id = Guid.NewGuid().ToString(), TaskId = TaskId, FileName = "first.txt", ByteSize = 1, CreatedAt = base_ };
var a2 = new TaskAttachmentEntity { Id = Guid.NewGuid().ToString(), TaskId = TaskId, FileName = "second.txt", ByteSize = 1, CreatedAt = base_.AddSeconds(1) };
await _repo.AddAsync(a2);
await _repo.AddAsync(a1);
var list = await _repo.ListByTaskIdAsync(TaskId);
Assert.Equal("first.txt", list[0].FileName);
Assert.Equal("second.txt", list[1].FileName);
}
}

View File

@@ -42,4 +42,44 @@ public class TaskPromptComposerTests
{
Assert.Equal("Just a title", TaskPromptComposer.Compose("Just a title", null, System.Array.Empty<(string, bool)>()));
}
[Fact]
public void Attachment_section_present_when_paths_given()
{
var result = TaskPromptComposer.Compose(
"Title", null, System.Array.Empty<(string, bool)>(),
new[] { "/a/b/file1.txt", "/a/b/file2.txt" });
Assert.Contains("## Reference files", result);
Assert.Contains("- /a/b/file1.txt", result);
Assert.Contains("- /a/b/file2.txt", result);
}
[Fact]
public void Attachment_section_absent_when_null()
{
var result = TaskPromptComposer.Compose("Title", null, System.Array.Empty<(string, bool)>(), null);
Assert.DoesNotContain("Reference files", result);
}
[Fact]
public void Attachment_section_absent_when_empty()
{
var result = TaskPromptComposer.Compose("Title", null, System.Array.Empty<(string, bool)>(),
System.Array.Empty<string>());
Assert.DoesNotContain("Reference files", result);
}
[Fact]
public void Attachment_paths_order_preserved()
{
var paths = new[] { "/z/last.txt", "/a/first.txt", "/m/middle.txt" };
var result = TaskPromptComposer.Compose("Title", null, System.Array.Empty<(string, bool)>(), paths);
var idxZ = result.IndexOf("/z/last.txt", StringComparison.Ordinal);
var idxA = result.IndexOf("/a/first.txt", StringComparison.Ordinal);
var idxM = result.IndexOf("/m/middle.txt", StringComparison.Ordinal);
Assert.True(idxZ < idxA && idxA < idxM, "Paths should appear in the original order.");
}
}