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