4.1 ImageStore
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<StorageOptions>`; ensures `ImageDirectory` exists on startup.
|
||||
- `Task WriteAsync(MacAddress, ReadOnlyMemory<byte>, 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.
|
||||
|
||||
52
src/FrameProcessor/Storage/ImageStore.cs
Normal file
52
src/FrameProcessor/Storage/ImageStore.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using FrameProcessor.Configuration;
|
||||
using FrameProcessor.Domain;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace FrameProcessor.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the latest processed PNG for each frame to <see cref="StorageOptions.ImageDirectory"/>.
|
||||
/// Writes are atomic via a temp file + <see cref="File.Move(string, string, bool)"/> rename so the
|
||||
/// fetch endpoint never observes a partial file (see SPEC.md §7.2, CLAUDE.md "Atomic writes only").
|
||||
/// </summary>
|
||||
public sealed class ImageStore
|
||||
{
|
||||
private readonly string _directory;
|
||||
|
||||
public ImageStore(IOptions<StorageOptions> options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_directory = options.Value.ImageDirectory;
|
||||
Directory.CreateDirectory(_directory);
|
||||
}
|
||||
|
||||
public async Task WriteAsync(MacAddress mac, ReadOnlyMemory<byte> 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");
|
||||
}
|
||||
113
tests/FrameProcessor.Tests/ImageStoreTests.cs
Normal file
113
tests/FrameProcessor.Tests/ImageStoreTests.cs
Normal file
@@ -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 }));
|
||||
}
|
||||
Reference in New Issue
Block a user