Integrate SeaweedFS for S3-compatible file storage and photo upload (#254)

This commit is contained in:
Aaron Po
2026-08-29 23:37:11 -04:00
committed by GitHub
parent a580314583
commit be672b6ca9
25 changed files with 790 additions and 19 deletions

View File

@@ -0,0 +1,120 @@
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<IFileStorageProvider> _fileStorageProviderMock = new();
private readonly UploadPhotoHandler _handler;
private readonly Mock<IPhotoUploadRepository> _repoMock = new();
public UploadPhotoHandlerTests()
{
_handler = new UploadPhotoHandler(_fileStorageProviderMock.Object, _repoMock.Object);
}
private static Mock<IFormFile> ValidFile(
string content = "fake-image-bytes",
string contentType = "image/png"
)
{
Mock<IFormFile> 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<IFormFile> 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<string>(),
It.IsAny<Stream>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()
)
)
.Callback<string, Stream, string, CancellationToken>(
(key, _, contentType, _) =>
{
uploadedKey = key;
uploadedContentType = contentType;
}
)
.Returns(Task.CompletedTask);
Photo? persisted = null;
_repoMock
.Setup(r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()))
.Callback<Photo, CancellationToken>((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<Stream>(),
"image/png",
It.IsAny<CancellationToken>()
),
Times.Once
);
_repoMock.Verify(
r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()),
Times.Once
);
}
[Fact]
public async Task Handle_UploadOrderedBeforePersist_DoesNotPersistWhenUploadFails()
{
Mock<IFormFile> file = ValidFile();
UploadPhotoCommand command = new(Guid.NewGuid(), "avatars", file.Object);
_fileStorageProviderMock
.Setup(p =>
p.UploadAsync(
It.IsAny<string>(),
It.IsAny<Stream>(),
It.IsAny<string>(),
It.IsAny<CancellationToken>()
)
)
.ThrowsAsync(new InvalidOperationException("storage unavailable"));
Func<Task> act = () => _handler.Handle(command, CancellationToken.None);
await act.Should().ThrowAsync<InvalidOperationException>();
_repoMock.Verify(
r => r.CreateAsync(It.IsAny<Photo>(), It.IsAny<CancellationToken>()),
Times.Never
);
}
}

View File

@@ -0,0 +1,154 @@
using Features.ImageUploads.Commands.UploadPhoto;
using FluentAssertions;
using FluentValidation.Results;
using Microsoft.AspNetCore.Http;
using Moq;
namespace Features.PhotoUpload.Tests.Commands;
public class UploadPhotoValidatorTests
{
private static readonly byte[] PngSignature =
[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A
];
private static readonly byte[] JpegSignature =
[
0xFF, 0xD8, 0xFF
];
private static readonly byte[] WebPSignature =
[
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50 // "RIFF____WEBP"
];
private static readonly byte[] GifSignature =
[
0x47, 0x49, 0x46, 0x38, 0x39, 0x61 // "GIF89a"
];
private static readonly byte[] BmpSignature =
[
0x42, 0x4D // "BM"
];
private static readonly byte[] PdfSignature =
[
0x25, 0x50, 0x44, 0x46 // "%PDF"
];
private static readonly byte[] PlainTextBytes = "not an image"u8.ToArray();
private const long MinSizeBytes = 1024; // 1 KiB
private const long MaxSizeBytes = 24 * 1024 * 1024; // 24 MiB
private readonly UploadPhotoValidator _validator = new();
private static Mock<IFormFile> FileOfSize(long sizeInBytes)
{
byte[] bytes = new byte[Math.Max(sizeInBytes, PngSignature.Length)];
PngSignature.CopyTo(bytes, 0);
Mock<IFormFile> file = new();
file.Setup(f => f.OpenReadStream()).Returns(() => new MemoryStream(bytes));
file.Setup(f => f.ContentType).Returns("image/png");
file.Setup(f => f.Length).Returns(sizeInBytes);
return file;
}
private static Mock<IFormFile> FileWithHeader(byte[] header, long sizeInBytes = 0)
{
byte[] bytes = new byte[Math.Max(sizeInBytes, Math.Max(header.Length, MinSizeBytes))];
header.CopyTo(bytes, 0);
Mock<IFormFile> file = new();
file.Setup(f => f.OpenReadStream()).Returns(() => new MemoryStream(bytes));
file.Setup(f => f.ContentType).Returns("image/png");
file.Setup(f => f.Length).Returns(bytes.Length);
return file;
}
private static UploadPhotoCommand CommandWithFile(IFormFile file) =>
new(Guid.NewGuid(), "breweries/some-brewery-id", file);
public static TheoryData<string, byte[]> ValidSignatures => new()
{
{ "PNG", PngSignature },
{ "JPEG", JpegSignature },
{ "WebP", WebPSignature }
};
public static TheoryData<string, byte[]> InvalidSignatures => new()
{
{ "GIF", GifSignature },
{ "BMP", BmpSignature },
{ "PDF", PdfSignature },
{ "plain text", PlainTextBytes }
};
[Fact]
public async Task Validate_FileBelowMinSize_FailsWithSizeError()
{
Mock<IFormFile> file = FileOfSize(MinSizeBytes - 1);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "File.Length");
}
[Fact]
public async Task Validate_FileAtMinSize_PassesSizeCheck()
{
Mock<IFormFile> file = FileOfSize(MinSizeBytes);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.Errors.Should().NotContain(e => e.PropertyName == "File.Length");
}
[Fact]
public async Task Validate_FileAtMaxSize_PassesSizeCheck()
{
Mock<IFormFile> file = FileOfSize(MaxSizeBytes);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.Errors.Should().NotContain(e => e.PropertyName == "File.Length");
}
[Fact]
public async Task Validate_FileAboveMaxSize_FailsWithSizeError()
{
Mock<IFormFile> file = FileOfSize(MaxSizeBytes + 1);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.IsValid.Should().BeFalse();
result.Errors.Should().Contain(e => e.PropertyName == "File.Length");
}
[Theory]
[MemberData(nameof(ValidSignatures))]
public async Task Validate_ValidImageSignature_PassesSignatureCheck(string formatName, byte[] header)
{
Mock<IFormFile> file = FileWithHeader(header);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.Errors.Should().NotContain(e => e.PropertyName == "File", formatName);
}
[Theory]
[MemberData(nameof(InvalidSignatures))]
public async Task Validate_InvalidImageSignature_FailsWithSignatureError(string formatName, byte[] header)
{
Mock<IFormFile> file = FileWithHeader(header);
ValidationResult result = await _validator.ValidateAsync(CommandWithFile(file.Object));
result.IsValid.Should().BeFalse(formatName);
result.Errors.Should().Contain(e => e.PropertyName == "File", formatName);
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Features.PhotoUpload.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Features.PhotoUpload\Features.PhotoUpload.csproj" />
</ItemGroup>
</Project>