From 0a361bf25f9d543bf5854621fd01ee3f916219dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Tue, 28 Jul 2026 11:26:43 +0200 Subject: [PATCH 1/7] Update SDK --- src/Turnierplan.ImageStorage/Turnierplan.ImageStorage.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turnierplan.ImageStorage/Turnierplan.ImageStorage.csproj b/src/Turnierplan.ImageStorage/Turnierplan.ImageStorage.csproj index 6dedb7c4..86ed04fe 100644 --- a/src/Turnierplan.ImageStorage/Turnierplan.ImageStorage.csproj +++ b/src/Turnierplan.ImageStorage/Turnierplan.ImageStorage.csproj @@ -12,7 +12,7 @@ - + From 64be98e100f3c7bdfa98a4880cb6507a768c8ca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Tue, 28 Jul 2026 13:26:23 +0200 Subject: [PATCH 2/7] Add first S3 integration test --- .../S3/S3ImageStorageTest.cs | 120 ++++++++++++++++++ ...rplan.ImageStorage.Test.Integration.csproj | 19 +++ src/Turnierplan.ImageStorage/AssemblyInfo.cs | 3 + src/turnierplan.NET.slnx | 1 + 4 files changed, 143 insertions(+) create mode 100644 src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs create mode 100644 src/Turnierplan.ImageStorage.Test.Integration/Turnierplan.ImageStorage.Test.Integration.csproj diff --git a/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs b/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs new file mode 100644 index 00000000..151fcda8 --- /dev/null +++ b/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs @@ -0,0 +1,120 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using S3ServerLibrary; +using S3ServerLibrary.S3Objects; +using Turnierplan.Core.Image; +using Turnierplan.Core.Organization; +using Turnierplan.Core.PublicId; +using Turnierplan.ImageStorage.S3; +using WatsonWebserver.Core; +using Xunit; +using BindingFlags = System.Reflection.BindingFlags; + +namespace Turnierplan.ImageStorage.Test.Integration.S3; + +public sealed class S3ImageStorageTest : IDisposable +{ + private readonly S3Server _server; + private readonly TestLogger _logger; + + public S3ImageStorageTest(ITestOutputHelper testOutputHelper) + { + var port = Random.Shared.Next(50000, 51000); + var settings = new S3ServerSettings + { + Webserver = new WebserverSettings("localhost", port), + Logger = testOutputHelper.WriteLine + }; + + _server = new S3Server(settings); + _server.Start(); + + _logger = new TestLogger(); + } + + public void Dispose() + { + _server.Dispose(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task S3ImageStorage_With_Non_Aws_Options_Can_Upload_File(bool isAuthenticated) + { + var options = new S3ImageStorageOptions + { + ServiceUrl = $"http://localhost:{_server.Webserver.Settings.Port}", + AccessKey = "key", + AccessKeySecret = "s3cr3t", + BucketName = "test_bucket" + }; + + var storage = new S3ImageStorage(new OptionsWrapper(options), _logger); + + var image = CreateImage(Guid.Parse("969bd4c6-c7bb-4631-8c25-1e196bc77512"), new DateTime(2026, 7, 27), "png"); + var imageData = new MemoryStream([0x00, 0x01, 0x02, 0x03]); + + var writeCalled = false; + _server.Object.Write = async ctx => + { + writeCalled = true; + + if (!isAuthenticated) + { + ctx.Response.StatusCode = 401; + await ctx.Response.Send(ErrorCode.AccessDenied); + } + + ctx.Request.Bucket.Should().Be("test_bucket"); + ctx.Request.Key.Should().Be("images/2026/07/969bd4c6-c7bb-4631-8c25-1e196bc77512.png"); + }; + + writeCalled.Should().BeFalse(); + await storage.SaveImageAsync(image, imageData); + writeCalled.Should().BeTrue(); + + if (isAuthenticated) + { + _logger.Messages.Should().BeEmpty(); + } + else + { + _logger.Messages.Single().Should().Be("Failed to upload image 'images/2026/07/969bd4c6-c7bb-4631-8c25-1e196bc77512.png' to S3 because of an exception."); + } + } + + private static Image CreateImage(Guid resourceIdentifier, DateTime createdAt, string extension) + { + // Create instances of Image class using reflection so we can specify resourceIdentifier & createdAt via internal ctor. + + var ctor = typeof(Image).GetConstructor( + BindingFlags.NonPublic | BindingFlags.Instance, + [typeof(long), typeof(Guid), typeof(PublicId), typeof(DateTime), typeof(string), typeof(string), typeof(long), typeof(ushort), typeof(ushort)]); + + if (ctor is null) + { + throw new InvalidOperationException($"Could not find internal '{nameof(Image)}' constructor."); + } + + var image = ctor.Invoke([0L, resourceIdentifier, PublicId.Empty, createdAt, string.Empty, extension, 0L, (ushort)0, (ushort)0]); + + return image as Image ?? throw new InvalidOperationException($"Could not instantiate '{nameof(Image)}' using reflection."); + } + + private sealed class TestLogger : ILogger + { + public readonly List Messages = []; + + public IDisposable BeginScope(TState state) where TState : notnull => throw new NotSupportedException(); + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Messages.Add(formatter(state, exception)); + } + } +} diff --git a/src/Turnierplan.ImageStorage.Test.Integration/Turnierplan.ImageStorage.Test.Integration.csproj b/src/Turnierplan.ImageStorage.Test.Integration/Turnierplan.ImageStorage.Test.Integration.csproj new file mode 100644 index 00000000..b364e5b4 --- /dev/null +++ b/src/Turnierplan.ImageStorage.Test.Integration/Turnierplan.ImageStorage.Test.Integration.csproj @@ -0,0 +1,19 @@ + + + + + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/src/Turnierplan.ImageStorage/AssemblyInfo.cs b/src/Turnierplan.ImageStorage/AssemblyInfo.cs index 3533c6d3..8d761858 100644 --- a/src/Turnierplan.ImageStorage/AssemblyInfo.cs +++ b/src/Turnierplan.ImageStorage/AssemblyInfo.cs @@ -1,3 +1,6 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Turnierplan.ImageStorage.Test.Migration")] + +// TODO: When image storage projects are refactored, each implementation should have a testable public API and the following line should not be necessary +[assembly: InternalsVisibleTo("Turnierplan.ImageStorage.Test.Integration")] diff --git a/src/turnierplan.NET.slnx b/src/turnierplan.NET.slnx index 31da8eed..a409a491 100644 --- a/src/turnierplan.NET.slnx +++ b/src/turnierplan.NET.slnx @@ -10,6 +10,7 @@ + From c55fc7dc33c16f32c2c37c082855ca2666b4051d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Tue, 28 Jul 2026 13:36:31 +0200 Subject: [PATCH 3/7] Add initial tests --- .../S3/S3ImageStorageTest.cs | 60 +++++++++++++++---- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs b/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs index 151fcda8..a5404311 100644 --- a/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs +++ b/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs @@ -1,11 +1,9 @@ using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using S3ServerLibrary; using S3ServerLibrary.S3Objects; using Turnierplan.Core.Image; -using Turnierplan.Core.Organization; using Turnierplan.Core.PublicId; using Turnierplan.ImageStorage.S3; using WatsonWebserver.Core; @@ -16,18 +14,29 @@ namespace Turnierplan.ImageStorage.Test.Integration.S3; public sealed class S3ImageStorageTest : IDisposable { + private readonly int _port; private readonly S3Server _server; + private readonly S3ImageStorageOptions _options; private readonly TestLogger _logger; public S3ImageStorageTest(ITestOutputHelper testOutputHelper) { - var port = Random.Shared.Next(50000, 51000); + _port = Random.Shared.Next(50000, 51000); + var settings = new S3ServerSettings { - Webserver = new WebserverSettings("localhost", port), + Webserver = new WebserverSettings("localhost", _port), Logger = testOutputHelper.WriteLine }; + _options = new S3ImageStorageOptions + { + ServiceUrl = $"http://localhost:{_port}", + AccessKey = "key", + AccessKeySecret = "s3cr3t", + BucketName = "test_bucket" + }; + _server = new S3Server(settings); _server.Start(); @@ -39,20 +48,45 @@ public void Dispose() _server.Dispose(); } + [Fact] + public void S3ImageStorage_Throws_Exception_When_Both_ServiceUrl_And_RegionEndpoint_Are_Specified() + { + var invalidOptions = _options with { RegionEndpoint = "us-east-1" }; + var action = () => new S3ImageStorage(new OptionsWrapper(invalidOptions), _logger); + action.Should().ThrowExactly().WithMessage("Please specify either 'RegionEndpoint' or 'ServiceUrl'."); + } + + [Fact] + public void S3ImageStorage_Throws_Exception_When_Invalid_RegionEndpoint_Is_Specified() + { + var invalidOptions = _options with { RegionEndpoint = "test" }; + var action = () => new S3ImageStorage(new OptionsWrapper(invalidOptions), _logger); + action.Should().ThrowExactly().WithMessage("The specified region endpoint 'test' seems to be unknown."); + } + + [Fact] + public void S3ImageStorage_GetFullImageUrl_Returns_Correct_Value() + { + using var storage = new S3ImageStorage(new OptionsWrapper(_options), _logger); + + var image = CreateImage(Guid.Parse("969bd4c6-c7bb-4631-8c25-1e196bc77512"), new DateTime(2026, 7, 27), "png"); + storage.GetFullImageUrl(image).Should().Be($"http://localhost:{_port}/images/2026/07/969bd4c6-c7bb-4631-8c25-1e196bc77512.png"); + + var image2 = CreateImage(Guid.Parse("88a530a9-0272-4b2d-a495-b876ba30b005"), new DateTime(2025, 12, 27), "jpeg"); + storage.GetFullImageUrl(image2).Should().Be($"http://localhost:{_port}/images/2025/12/88a530a9-0272-4b2d-a495-b876ba30b005.jpeg"); + + var awsOptions = _options with { ServiceUrl = null, RegionEndpoint = "us-east-1" }; + using var awsStorage = new S3ImageStorage(new OptionsWrapper(awsOptions), _logger); + + awsStorage.GetFullImageUrl(image).Should().Be("https://test_bucket.s3.us-east-1.amazonaws.com/images/2026/07/969bd4c6-c7bb-4631-8c25-1e196bc77512.png"); + } + [Theory] [InlineData(true)] [InlineData(false)] public async Task S3ImageStorage_With_Non_Aws_Options_Can_Upload_File(bool isAuthenticated) { - var options = new S3ImageStorageOptions - { - ServiceUrl = $"http://localhost:{_server.Webserver.Settings.Port}", - AccessKey = "key", - AccessKeySecret = "s3cr3t", - BucketName = "test_bucket" - }; - - var storage = new S3ImageStorage(new OptionsWrapper(options), _logger); + using var storage = new S3ImageStorage(new OptionsWrapper(_options), _logger); var image = CreateImage(Guid.Parse("969bd4c6-c7bb-4631-8c25-1e196bc77512"), new DateTime(2026, 7, 27), "png"); var imageData = new MemoryStream([0x00, 0x01, 0x02, 0x03]); From b6156d2d47d849e4c85b2c5d727a06a274604bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Tue, 28 Jul 2026 13:37:13 +0200 Subject: [PATCH 4/7] Add trimmed _serviceUrl --- src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs b/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs index 0c54c038..3afda7ad 100644 --- a/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs +++ b/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs @@ -14,6 +14,7 @@ internal sealed class S3ImageStorage : IImageStorage private readonly ILogger _logger; private readonly AmazonS3Client _client; private readonly string _bucketName; + private readonly string? _serviceUrl; public S3ImageStorage(IOptions options, ILogger logger) { @@ -49,6 +50,7 @@ public S3ImageStorage(IOptions options, ILogger SaveImageAsync(Image image, MemoryStream imageData) From 9104c8d2e9cc8abefb14918ad380a37ed8699d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Tue, 28 Jul 2026 16:21:22 +0200 Subject: [PATCH 5/7] Add test for 'GetImageAsync' --- .../S3/S3ImageStorageTest.cs | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs b/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs index a5404311..25c371d7 100644 --- a/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs +++ b/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs @@ -1,3 +1,4 @@ +using Amazon.S3; using FluentAssertions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -84,7 +85,7 @@ public void S3ImageStorage_GetFullImageUrl_Returns_Correct_Value() [Theory] [InlineData(true)] [InlineData(false)] - public async Task S3ImageStorage_With_Non_Aws_Options_Can_Upload_File(bool isAuthenticated) + public async Task S3ImageStorage_With_Local_Server_Can_Upload_Image(bool isAuthenticated) { using var storage = new S3ImageStorage(new OptionsWrapper(_options), _logger); @@ -120,6 +121,75 @@ public async Task S3ImageStorage_With_Non_Aws_Options_Can_Upload_File(bool isAut } } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task S3ImageStorage_With_Local_Server_Can_Read_Image(bool isAuthenticated) + { + using var storage = new S3ImageStorage(new OptionsWrapper(_options), _logger); + + var image = CreateImage(Guid.Parse("969bd4c6-c7bb-4631-8c25-1e196bc77512"), new DateTime(2026, 7, 27), "png"); + + var readCalled = false; + _server.Object.Read = async ctx => + { + readCalled = true; + + if (!isAuthenticated) + { + ctx.Response.StatusCode = 401; + await ctx.Response.Send(ErrorCode.AccessDenied); + } + + ctx.Request.Bucket.Should().Be("test_bucket"); + ctx.Request.Key.Should().Be("images/2026/07/969bd4c6-c7bb-4631-8c25-1e196bc77512.png"); + + var data = "Hello, S3!"u8; + + return new S3Object( + ctx.Request.Key, + "version-1", + true, + DateTime.UtcNow, + "etag-123", + data.Length, + new Owner("admin", "Administrator"), + [.. data], + "text/plain" + ); + }; + + readCalled.Should().BeFalse(); + + if (!isAuthenticated) + { + var func = () => storage.GetImageAsync(image); + await func.Should().ThrowAsync().WithMessage("Access denied."); + readCalled.Should().BeTrue(); + + return; + } + + await using var result = await storage.GetImageAsync(image); + readCalled.Should().BeTrue(); + + using var temp = new MemoryStream(); + await result.CopyToAsync(temp, TestContext.Current.CancellationToken); + + temp.ToArray().Should().BeEquivalentTo([0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0x53, 0x33, 0x21], opt => opt.WithStrictOrdering()); + + if (isAuthenticated) + { + _logger.Messages.Should().BeEmpty(); + } + else + { + _logger.Messages.Single().Should().Be("Failed to upload image 'images/2026/07/969bd4c6-c7bb-4631-8c25-1e196bc77512.png' to S3 because of an exception."); + } + + readCalled.Should().BeTrue(); + } + private static Image CreateImage(Guid resourceIdentifier, DateTime createdAt, string extension) { // Create instances of Image class using reflection so we can specify resourceIdentifier & createdAt via internal ctor. From 72a493127d81a55ea23eb3b59c0c05a302f3c94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Tue, 28 Jul 2026 16:23:13 +0200 Subject: [PATCH 6/7] Test for 'DeleteImageAsync' --- .../S3/S3ImageStorageTest.cs | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs b/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs index 25c371d7..e7d3b175 100644 --- a/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs +++ b/src/Turnierplan.ImageStorage.Test.Integration/S3/S3ImageStorageTest.cs @@ -108,15 +108,17 @@ public async Task S3ImageStorage_With_Local_Server_Can_Upload_Image(bool isAuthe }; writeCalled.Should().BeFalse(); - await storage.SaveImageAsync(image, imageData); + var result = await storage.SaveImageAsync(image, imageData); writeCalled.Should().BeTrue(); if (isAuthenticated) { + result.Should().BeTrue(); _logger.Messages.Should().BeEmpty(); } else { + result.Should().BeFalse(); _logger.Messages.Single().Should().Be("Failed to upload image 'images/2026/07/969bd4c6-c7bb-4631-8c25-1e196bc77512.png' to S3 because of an exception."); } } @@ -190,6 +192,46 @@ public async Task S3ImageStorage_With_Local_Server_Can_Read_Image(bool isAuthent readCalled.Should().BeTrue(); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task S3ImageStorage_With_Local_Server_Can_Delete_Image(bool isAuthenticated) + { + using var storage = new S3ImageStorage(new OptionsWrapper(_options), _logger); + + var image = CreateImage(Guid.Parse("969bd4c6-c7bb-4631-8c25-1e196bc77512"), new DateTime(2026, 7, 27), "png"); + + var writeCalled = false; + _server.Object.Delete = async ctx => + { + writeCalled = true; + + if (!isAuthenticated) + { + ctx.Response.StatusCode = 401; + await ctx.Response.Send(ErrorCode.AccessDenied); + } + + ctx.Request.Bucket.Should().Be("test_bucket"); + ctx.Request.Key.Should().Be("images/2026/07/969bd4c6-c7bb-4631-8c25-1e196bc77512.png"); + }; + + writeCalled.Should().BeFalse(); + var result = await storage.DeleteImageAsync(image); + writeCalled.Should().BeTrue(); + + if (isAuthenticated) + { + result.Should().BeTrue(); + _logger.Messages.Should().BeEmpty(); + } + else + { + result.Should().BeFalse(); + _logger.Messages.Single().Should().Be("Failed to delete image 'images/2026/07/969bd4c6-c7bb-4631-8c25-1e196bc77512.png' from S3 because of an exception."); + } + } + private static Image CreateImage(Guid resourceIdentifier, DateTime createdAt, string extension) { // Create instances of Image class using reflection so we can specify resourceIdentifier & createdAt via internal ctor. From 9662efb663a918b225274ecfd1e8fb1ab75d2422 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Tue, 28 Jul 2026 16:25:23 +0200 Subject: [PATCH 7/7] Adjust wording --- src/Turnierplan.ImageStorage/AssemblyInfo.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Turnierplan.ImageStorage/AssemblyInfo.cs b/src/Turnierplan.ImageStorage/AssemblyInfo.cs index 8d761858..6a11713a 100644 --- a/src/Turnierplan.ImageStorage/AssemblyInfo.cs +++ b/src/Turnierplan.ImageStorage/AssemblyInfo.cs @@ -2,5 +2,6 @@ [assembly: InternalsVisibleTo("Turnierplan.ImageStorage.Test.Migration")] -// TODO: When image storage projects are refactored, each implementation should have a testable public API and the following line should not be necessary +// TODO: Once the image storage projects are refactored, each implementation should have +// a testable public API and the following line should no longer be necessary. [assembly: InternalsVisibleTo("Turnierplan.ImageStorage.Test.Integration")]