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:
94
tests/ClaudeDo.Data.Tests/AttachmentStoreTests.cs
Normal file
94
tests/ClaudeDo.Data.Tests/AttachmentStoreTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
116
tests/ClaudeDo.Data.Tests/TaskAttachmentRepositoryTests.cs
Normal file
116
tests/ClaudeDo.Data.Tests/TaskAttachmentRepositoryTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user