diff --git a/web/backend/Biergarten.API.slnx b/web/backend/Biergarten.API.slnx
index 2abf4157..e62246bc 100644
--- a/web/backend/Biergarten.API.slnx
+++ b/web/backend/Biergarten.API.slnx
@@ -32,6 +32,7 @@
+
diff --git a/web/backend/Features/Features.PhotoUpload.Tests/Commands/UploadPhotoHandlerTests.cs b/web/backend/Features/Features.PhotoUpload.Tests/Commands/UploadPhotoHandlerTests.cs
new file mode 100644
index 00000000..09f5179b
--- /dev/null
+++ b/web/backend/Features/Features.PhotoUpload.Tests/Commands/UploadPhotoHandlerTests.cs
@@ -0,0 +1,95 @@
+using Domain.Entities;
+using Features.ImageUploads.Commands.UploadPhoto;
+using Features.ImageUploads.Repository;
+using FluentAssertions;
+using Infrastructure.FileUpload;
+using Microsoft.AspNetCore.Http;
+using Moq;
+
+namespace Features.PhotoUpload.Tests.Commands;
+
+public class UploadPhotoHandlerTests
+{
+ private readonly Mock _fileStorageProviderMock = new();
+ private readonly UploadPhotoHandler _handler;
+ private readonly Mock _repoMock = new();
+
+ public UploadPhotoHandlerTests()
+ {
+ _handler = new UploadPhotoHandler(_fileStorageProviderMock.Object, _repoMock.Object);
+ }
+
+ private static Mock ValidFile(string content = "fake-image-bytes", string contentType = "image/png")
+ {
+ Mock file = new();
+ byte[] bytes = System.Text.Encoding.UTF8.GetBytes(content);
+ file.Setup(f => f.OpenReadStream()).Returns(() => new MemoryStream(bytes));
+ file.Setup(f => f.ContentType).Returns(contentType);
+ file.Setup(f => f.Length).Returns(bytes.Length);
+ return file;
+ }
+
+ [Fact]
+ public async Task Handle_UploadsFileUnderRequestedKey_AndPersistsPhoto_ReturnsNewPhotoId()
+ {
+ Mock file = ValidFile();
+ UploadPhotoCommand command = new(Guid.NewGuid(), "breweries/some-brewery-id", file.Object);
+
+ string? uploadedKey = null;
+ string? uploadedContentType = null;
+ _fileStorageProviderMock
+ .Setup(p => p.UploadAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback((key, _, contentType, _) =>
+ {
+ uploadedKey = key;
+ uploadedContentType = contentType;
+ })
+ .Returns(Task.CompletedTask);
+
+ Photo? persisted = null;
+ _repoMock
+ .Setup(r => r.CreateAsync(It.IsAny(), It.IsAny()))
+ .Callback((photo, _) => persisted = photo)
+ .Returns(Task.CompletedTask);
+
+ Guid photoId = await _handler.Handle(command, CancellationToken.None);
+
+ photoId.Should().NotBe(Guid.Empty);
+
+ uploadedKey.Should().Be($"breweries/some-brewery-id/{photoId}");
+ uploadedContentType.Should().Be("image/png");
+
+ persisted.Should().NotBeNull();
+ persisted!.Hyperlink.Should().Be(uploadedKey);
+ persisted.UploadedById.Should().Be(command.UploadedById);
+
+ _fileStorageProviderMock.Verify(
+ p => p.UploadAsync(uploadedKey!, It.IsAny(), "image/png", It.IsAny()),
+ Times.Once);
+ _repoMock.Verify(r => r.CreateAsync(It.IsAny(), It.IsAny()), Times.Once);
+ }
+
+ [Fact]
+ public async Task Handle_UploadOrderedBeforePersist_DoesNotPersistWhenUploadFails()
+ {
+ Mock file = ValidFile();
+ UploadPhotoCommand command = new(Guid.NewGuid(), "avatars", file.Object);
+
+ _fileStorageProviderMock
+ .Setup(p => p.UploadAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("storage unavailable"));
+
+ Func act = () => _handler.Handle(command, CancellationToken.None);
+
+ await act.Should().ThrowAsync();
+ _repoMock.Verify(r => r.CreateAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+}
diff --git a/web/backend/Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj b/web/backend/Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj
new file mode 100644
index 00000000..a0af8703
--- /dev/null
+++ b/web/backend/Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ Features.PhotoUpload.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+