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.
117 lines
3.7 KiB
C#
117 lines
3.7 KiB
C#
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);
|
|
}
|
|
}
|