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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user