feat(storage): add IFileStorage + local-disk implementation

Adds IFileStorage abstraction and LocalDiskFileStorage for receipt file storage with path-traversal protection, and registers it in DI. Includes 3 TDD-verified xUnit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-05-29 18:18:28 -07:00
parent ac65c68e18
commit e7bf07c2ad
6 changed files with 119 additions and 0 deletions
@@ -0,0 +1,52 @@
using System.Text;
using Microsoft.Extensions.Configuration;
using ROLAC.API.Services.Storage;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class LocalDiskFileStorageTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "rolac-test-" + Guid.NewGuid());
private LocalDiskFileStorage Build()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Storage:LocalRoot"] = _root })
.Build();
return new LocalDiskFileStorage(config);
}
[Fact]
public async Task SaveThenOpen_RoundTrips()
{
var fs = Build();
using var input = new MemoryStream(Encoding.UTF8.GetBytes("hello"));
var path = await fs.SaveAsync(input, "finance/receipts/2026/5/1-r.txt");
await using var read = await fs.OpenReadAsync(path);
Assert.NotNull(read);
using var sr = new StreamReader(read!);
Assert.Equal("hello", await sr.ReadToEndAsync());
}
[Fact]
public async Task OpenRead_ReturnsNull_WhenMissing()
{
var fs = Build();
Assert.Null(await fs.OpenReadAsync("finance/receipts/none.txt"));
}
[Fact]
public async Task Save_RejectsPathTraversal()
{
var fs = Build();
using var input = new MemoryStream(Encoding.UTF8.GetBytes("x"));
await Assert.ThrowsAsync<ArgumentException>(() => fs.SaveAsync(input, "../escape.txt"));
}
public void Dispose()
{
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
}
}