Files
ClaudeDo/tests/ClaudeDo.Data.Tests/AttachmentStoreTests.cs
Mika Kuns 3f9f047955 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.
2026-06-26 16:11:48 +02:00

95 lines
2.6 KiB
C#

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");
}
}