From 071b5fcc3ca5297cf4ff3b1ab0dee6398ebcb583 Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sun, 11 Oct 2026 22:20:31 -0400 Subject: [PATCH] Swap in an in-memory file storage provider for the API specs --- docs/web/testing.md | 3 +- .../Mocks/MockFileStorageProvider.cs | 55 +++++++++++++++++++ web/backend/API/API.Specs/TestApiFactory.cs | 10 ++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs diff --git a/docs/web/testing.md b/docs/web/testing.md index 7ba616e8..b84a6a3c 100644 --- a/docs/web/testing.md +++ b/docs/web/testing.md @@ -257,7 +257,8 @@ API.Specs/ │ └── ApiGeneralSteps.cs # Shared/general step definitions ├── Mocks/ │ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher -│ └── MockEmailProvider.cs # Substitutes Features.Emails' IEmailProvider +│ ├── MockEmailProvider.cs # Substitutes Features.Emails' IEmailProvider +│ └── MockFileStorageProvider.cs # Substitutes Features.ImageUploads' IFileStorageProvider └── TestApiFactory.cs # Test server setup (swaps in the mocks above) ``` diff --git a/web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs b/web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs new file mode 100644 index 00000000..71a38dcb --- /dev/null +++ b/web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs @@ -0,0 +1,55 @@ +using Features.ImageUploads.Services; + +namespace API.Specs.Mocks; + +/// +/// Test double for that records uploads in memory, so specs can +/// exercise photo-backed features without object storage. +/// +public class MockFileStorageProvider : IFileStorageProvider +{ + private readonly List _keys = []; + private readonly Dictionary _objects = new(); + + public IReadOnlyCollection UploadedKeys => _keys.ToList(); + + /// The key of the most recent upload. + /// Thrown when nothing has been uploaded yet. + public string LastUploadedKey => + _keys.Count > 0 + ? _keys[^1] + : throw new InvalidOperationException("no file has been uploaded"); + + public Task UploadAsync( + string key, + Stream content, + string contentType, + CancellationToken cancellationToken = default + ) + { + using MemoryStream buffer = new(); + content.CopyTo(buffer); + + if (!_objects.ContainsKey(key)) + _keys.Add(key); + + _objects[key] = buffer.ToArray(); + return Task.CompletedTask; + } + + public Task DeleteAsync(string key, CancellationToken cancellationToken = default) + { + _objects.Remove(key); + _keys.Remove(key); + return Task.CompletedTask; + } + + /// + /// Stands in for a signed download URL: an absolute URL on a host the object store would serve, + /// carrying the key in its path, so specs can assert the shape callers actually receive. + /// + public string GetPresignedUrl(string key, TimeSpan expiresIn) + { + return $"https://storage.test/{key}?X-Amz-Expires={(int)expiresIn.TotalSeconds}"; + } +} diff --git a/web/backend/API/API.Specs/TestApiFactory.cs b/web/backend/API/API.Specs/TestApiFactory.cs index e6b5c1e0..c9e57a00 100644 --- a/web/backend/API/API.Specs/TestApiFactory.cs +++ b/web/backend/API/API.Specs/TestApiFactory.cs @@ -1,6 +1,7 @@ using API.Core; using API.Specs.Mocks; using Features.Emails.Services; +using Features.ImageUploads.Services; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; @@ -32,6 +33,15 @@ internal class TestApiFactory : WebApplicationFactory services.Remove(emailDispatcherDescriptor); services.AddScoped(); + + ServiceDescriptor? fileStorageDescriptor = services.SingleOrDefault(d => + d.ServiceType == typeof(IFileStorageProvider) + ); + + if (fileStorageDescriptor != null) + services.Remove(fileStorageDescriptor); + + services.AddSingleton(); }); } }