diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index dae5d44..a4dc15a 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -119,7 +119,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor. ## Phase 4 — Storage -### [ ] 4.1 `ImageStore` +### [x] 4.1 `ImageStore` - Constructor takes `IOptions`; ensures `ImageDirectory` exists on startup. - `Task WriteAsync(MacAddress, ReadOnlyMemory, CancellationToken)` — writes to `{mac}.png.tmp`, then `File.Move(tmp, final, overwrite: true)`. - `bool TryGetPath(MacAddress, out string path)` returning the on-disk path if present. diff --git a/src/FrameProcessor/Storage/ImageStore.cs b/src/FrameProcessor/Storage/ImageStore.cs new file mode 100644 index 0000000..bf2c356 --- /dev/null +++ b/src/FrameProcessor/Storage/ImageStore.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; +using FrameProcessor.Configuration; +using FrameProcessor.Domain; +using Microsoft.Extensions.Options; + +namespace FrameProcessor.Storage; + +/// +/// Persists the latest processed PNG for each frame to . +/// Writes are atomic via a temp file + rename so the +/// fetch endpoint never observes a partial file (see SPEC.md §7.2, CLAUDE.md "Atomic writes only"). +/// +public sealed class ImageStore +{ + private readonly string _directory; + + public ImageStore(IOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _directory = options.Value.ImageDirectory; + Directory.CreateDirectory(_directory); + } + + public async Task WriteAsync(MacAddress mac, ReadOnlyMemory bytes, CancellationToken cancellationToken) + { + var finalPath = GetPath(mac); + var tmpPath = finalPath + ".tmp"; + + await using (var stream = new FileStream(tmpPath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + File.Move(tmpPath, finalPath, overwrite: true); + } + + public bool TryGetPath(MacAddress mac, [NotNullWhen(true)] out string? path) + { + var candidate = GetPath(mac); + if (File.Exists(candidate)) + { + path = candidate; + return true; + } + + path = null; + return false; + } + + private string GetPath(MacAddress mac) => Path.Combine(_directory, $"{mac}.png"); +} diff --git a/tests/FrameProcessor.Tests/ImageStoreTests.cs b/tests/FrameProcessor.Tests/ImageStoreTests.cs new file mode 100644 index 0000000..84c83d7 --- /dev/null +++ b/tests/FrameProcessor.Tests/ImageStoreTests.cs @@ -0,0 +1,113 @@ +using FrameProcessor.Configuration; +using FrameProcessor.Domain; +using FrameProcessor.Storage; +using Microsoft.Extensions.Options; + +namespace FrameProcessor.Tests; + +public class ImageStoreTests : IDisposable +{ + private readonly string _directory; + + public ImageStoreTests() + { + _directory = Path.Combine(Path.GetTempPath(), "frame-processor-tests", Guid.NewGuid().ToString("N")); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } + + [Fact] + public void Constructor_CreatesImageDirectoryIfMissing() + { + Assert.False(Directory.Exists(_directory)); + + _ = CreateStore(); + + Assert.True(Directory.Exists(_directory)); + } + + [Fact] + public async Task WriteAsync_PersistsBytesAtMacKeyedPath() + { + var store = CreateStore(); + var mac = MacAddress.Parse("AA:BB:CC:DD:EE:FF"); + var payload = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }; + + await store.WriteAsync(mac, payload, CancellationToken.None); + + var expectedPath = Path.Combine(_directory, "aabbccddeeff.png"); + Assert.True(File.Exists(expectedPath)); + Assert.Equal(payload, await File.ReadAllBytesAsync(expectedPath)); + } + + [Fact] + public async Task WriteAsync_RemovesTempFileAfterRename() + { + var store = CreateStore(); + var mac = MacAddress.Parse("aabbccddeeff"); + + await store.WriteAsync(mac, new byte[] { 0x01 }, CancellationToken.None); + + Assert.False(File.Exists(Path.Combine(_directory, "aabbccddeeff.png.tmp"))); + } + + [Fact] + public async Task WriteAsync_OverwritesPreviousImage() + { + var store = CreateStore(); + var mac = MacAddress.Parse("aabbccddeeff"); + await store.WriteAsync(mac, new byte[] { 0x01, 0x02 }, CancellationToken.None); + + await store.WriteAsync(mac, new byte[] { 0x09, 0x08, 0x07 }, CancellationToken.None); + + var path = Path.Combine(_directory, "aabbccddeeff.png"); + Assert.Equal(new byte[] { 0x09, 0x08, 0x07 }, await File.ReadAllBytesAsync(path)); + } + + [Fact] + public async Task WriteAsync_OverwritesStaleTempFileFromPriorCrash() + { + Directory.CreateDirectory(_directory); + var stalePath = Path.Combine(_directory, "aabbccddeeff.png.tmp"); + await File.WriteAllBytesAsync(stalePath, new byte[] { 0xFF, 0xFF }); + + var store = CreateStore(); + await store.WriteAsync(MacAddress.Parse("aabbccddeeff"), new byte[] { 0x01 }, CancellationToken.None); + + Assert.False(File.Exists(stalePath)); + Assert.Equal(new byte[] { 0x01 }, await File.ReadAllBytesAsync(Path.Combine(_directory, "aabbccddeeff.png"))); + } + + [Fact] + public void TryGetPath_ReturnsFalseWhenAbsent() + { + var store = CreateStore(); + + var found = store.TryGetPath(MacAddress.Parse("aabbccddeeff"), out var path); + + Assert.False(found); + Assert.Null(path); + } + + [Fact] + public async Task TryGetPath_ReturnsPathAfterWrite() + { + var store = CreateStore(); + var mac = MacAddress.Parse("aabbccddeeff"); + await store.WriteAsync(mac, new byte[] { 0x01 }, CancellationToken.None); + + var found = store.TryGetPath(mac, out var path); + + Assert.True(found); + Assert.Equal(Path.Combine(_directory, "aabbccddeeff.png"), path); + } + + private ImageStore CreateStore() => + new(Options.Create(new StorageOptions { ImageDirectory = _directory })); +}