From 662794051b56a804747e7ec5a5d73b366cf89f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Mar 2026 12:22:25 +0100 Subject: [PATCH 01/20] Add framework for image storage migration --- src/Turnierplan.App/Program.cs | 3 ++ .../EndpointRouteBuilderExtensions.cs | 20 ----------- .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Extensions/WebApplicationExtensions.cs | 34 +++++++++++++++++++ .../IImageStorageMigration.cs | 6 ++++ .../Local/LocalImageStorageMigration.cs | 9 +++++ 6 files changed, 53 insertions(+), 20 deletions(-) delete mode 100644 src/Turnierplan.ImageStorage/Extensions/EndpointRouteBuilderExtensions.cs create mode 100644 src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs create mode 100644 src/Turnierplan.ImageStorage/IImageStorageMigration.cs create mode 100644 src/Turnierplan.ImageStorage/Local/LocalImageStorageMigration.cs diff --git a/src/Turnierplan.App/Program.cs b/src/Turnierplan.App/Program.cs index 34a2396d..6f7e88e9 100644 --- a/src/Turnierplan.App/Program.cs +++ b/src/Turnierplan.App/Program.cs @@ -113,4 +113,7 @@ // Migrate database and create admin user if DB is empty await app.InitializeDatabaseAsync(); +// Run image storage migrations if required by the configured image storage +await app.MigrateImageStorageAsync(); + await app.RunAsync(); diff --git a/src/Turnierplan.ImageStorage/Extensions/EndpointRouteBuilderExtensions.cs b/src/Turnierplan.ImageStorage/Extensions/EndpointRouteBuilderExtensions.cs deleted file mode 100644 index 171c10c8..00000000 --- a/src/Turnierplan.ImageStorage/Extensions/EndpointRouteBuilderExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using Turnierplan.ImageStorage.Local; - -namespace Turnierplan.ImageStorage.Extensions; - -public static class EndpointRouteBuilderExtensions -{ - public static void MapImageStorageEndpoint(this IApplicationBuilder builder) - { - var imageStorage = builder.ApplicationServices.GetRequiredService(); - - if (imageStorage is not ILocalImageStorage localImageStorage) - { - return; - } - - localImageStorage.MapEndpoint(builder); - } -} diff --git a/src/Turnierplan.ImageStorage/Extensions/ServiceCollectionExtensions.cs b/src/Turnierplan.ImageStorage/Extensions/ServiceCollectionExtensions.cs index b7cf35b2..237882d3 100644 --- a/src/Turnierplan.ImageStorage/Extensions/ServiceCollectionExtensions.cs +++ b/src/Turnierplan.ImageStorage/Extensions/ServiceCollectionExtensions.cs @@ -21,6 +21,7 @@ public static void AddTurnierplanImageStorage(this IServiceCollection services, case "Local": services.Configure(configuration); services.AddSingleton(); + services.AddScoped(); break; case "S3": services.Configure(configuration); diff --git a/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs new file mode 100644 index 00000000..5b64c65e --- /dev/null +++ b/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Turnierplan.ImageStorage.Local; + +namespace Turnierplan.ImageStorage.Extensions; + +public static class WebApplicationExtensions +{ + public static void MapImageStorageEndpoint(this WebApplication application) + { + var imageStorage = application.Services.GetRequiredService(); + + if (imageStorage is not ILocalImageStorage localImageStorage) + { + return; + } + + localImageStorage.MapEndpoint(application); + } + + public static async Task MigrateImageStorageAsync(this WebApplication application) + { + await using var scope = application.Services.CreateAsyncScope(); + + var migration = scope.ServiceProvider.GetService(); + + if (migration is null) + { + return; + } + + await migration.MigrateAsync(application.Lifetime.ApplicationStopping); + } +} diff --git a/src/Turnierplan.ImageStorage/IImageStorageMigration.cs b/src/Turnierplan.ImageStorage/IImageStorageMigration.cs new file mode 100644 index 00000000..78f639c0 --- /dev/null +++ b/src/Turnierplan.ImageStorage/IImageStorageMigration.cs @@ -0,0 +1,6 @@ +namespace Turnierplan.ImageStorage; + +internal interface IImageStorageMigration +{ + Task MigrateAsync(CancellationToken cancellationToken); +} diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorageMigration.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorageMigration.cs new file mode 100644 index 00000000..00dd04f4 --- /dev/null +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorageMigration.cs @@ -0,0 +1,9 @@ +namespace Turnierplan.ImageStorage.Local; + +internal sealed class LocalImageStorageMigration : IImageStorageMigration +{ + public Task MigrateAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } +} From 2a58b249d54f83b8ab8f17c3e1835dc7ec60cde3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 15 Mar 2026 19:30:05 +0100 Subject: [PATCH 02/20] Some WIP changes with todos --- .../Local/LocalImageStorage.cs | 43 +++++++++++++++++++ .../Local/LocalImageStorageMigration.cs | 23 +++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs index 3534a99a..8b197ecb 100644 --- a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs @@ -11,6 +11,7 @@ internal sealed class LocalImageStorage : ILocalImageStorage { private readonly ILogger _logger; private readonly string _storagePath; + private readonly bool _skipMigration; public LocalImageStorage(ILogger logger, IOptions options) { @@ -26,6 +27,7 @@ public LocalImageStorage(ILogger logger, IOptions>> getImagesFunction, CancellationToken cancellationToken) + { + if (_skipMigration) + { + _logger.LogCritical("A previous initialization error of local image storage will cause the migrations to be skipped."); + return; + } + + try + { + var version = "1"; + var versionFile = Path.Join(_storagePath, ".version"); + + if (File.Exists(versionFile)) + { + version = await File.ReadAllTextAsync(versionFile, cancellationToken); + } + + if (version.Equals("2")) + { + // Latest version, no migrations required + _logger.LogInformation("Storage of local image storage is up to date."); + return; + } + + if (!version.Equals("1")) + { + _logger.LogCritical("Version file in local image storage path contains an unknown version '{Version}'.", version); + return; + } + + var images = await getImagesFunction(); + // TODO: Run migration by copying the files & writing new version number + // TODO: Display info message if any old files still exist + } + catch (Exception ex) + { + _logger.LogCritical(ex, "An unexpected exception occurred while trying to run local image storage migrations."); + } + } + public void Dispose() { } diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorageMigration.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorageMigration.cs index 00dd04f4..34337022 100644 --- a/src/Turnierplan.ImageStorage/Local/LocalImageStorageMigration.cs +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorageMigration.cs @@ -1,9 +1,28 @@ +using Turnierplan.Core.Image; + namespace Turnierplan.ImageStorage.Local; internal sealed class LocalImageStorageMigration : IImageStorageMigration { - public Task MigrateAsync(CancellationToken cancellationToken) + private readonly LocalImageStorage _imageStorage; + + public LocalImageStorageMigration(IImageStorage imageStorage) + { + if (imageStorage is not LocalImageStorage localImageStorage) + { + throw new ArgumentException($"{nameof(LocalImageStorageMigration)} requires the registered {nameof(IImageStorage)} to be of type {nameof(LocalImageStorage)}.", nameof(imageStorage)); + } + + _imageStorage = localImageStorage; + } + + public async Task MigrateAsync(CancellationToken cancellationToken) + { + await _imageStorage.MigrateAsync(LoadImagesAsync, cancellationToken); + } + + private async Task> LoadImagesAsync() { - throw new NotImplementedException(); + // TODO: We cannot load images here because this project does not reference Turnierplan.Dal (!) } } From b5c94dfe1850d345cc651bb5b072d9fbe7d5876b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 23 Apr 2026 22:14:10 +0200 Subject: [PATCH 03/20] Refactor image storage migrations & integration into Turnierplan.App --- .../Helpers/DatabaseImageProvider.cs | 13 +++++++ src/Turnierplan.App/Program.cs | 2 +- .../Repositories/ImageRepository.cs | 7 ++++ .../Azure/AzureImageStorage.cs | 14 +++----- .../Extensions/ServiceCollectionExtensions.cs | 6 ++-- .../Extensions/WebApplicationExtensions.cs | 9 ++--- .../IImageProvider.cs | 8 +++++ .../IImageStorageMigration.cs | 6 ---- .../IImageStorageMigrator.cs | 14 ++++++++ .../IMigratableImageStorage.cs | 6 ++++ .../ImageStorageBase.cs | 34 +++++++++++++++++++ .../Local/ILocalImageStorage.cs | 8 ----- .../Local/LocalImageStorage.cs | 20 +++++------ .../Local/LocalImageStorageMigration.cs | 28 --------------- .../S3/S3ImageStorage.cs | 19 +++++++---- 15 files changed, 118 insertions(+), 76 deletions(-) create mode 100644 src/Turnierplan.App/Helpers/DatabaseImageProvider.cs create mode 100644 src/Turnierplan.ImageStorage/IImageProvider.cs delete mode 100644 src/Turnierplan.ImageStorage/IImageStorageMigration.cs create mode 100644 src/Turnierplan.ImageStorage/IImageStorageMigrator.cs create mode 100644 src/Turnierplan.ImageStorage/IMigratableImageStorage.cs create mode 100644 src/Turnierplan.ImageStorage/ImageStorageBase.cs delete mode 100644 src/Turnierplan.ImageStorage/Local/ILocalImageStorage.cs delete mode 100644 src/Turnierplan.ImageStorage/Local/LocalImageStorageMigration.cs diff --git a/src/Turnierplan.App/Helpers/DatabaseImageProvider.cs b/src/Turnierplan.App/Helpers/DatabaseImageProvider.cs new file mode 100644 index 00000000..be0ccb95 --- /dev/null +++ b/src/Turnierplan.App/Helpers/DatabaseImageProvider.cs @@ -0,0 +1,13 @@ +using Turnierplan.Core.Image; +using Turnierplan.Dal.Repositories; +using Turnierplan.ImageStorage; + +namespace Turnierplan.App.Helpers; + +internal sealed class DatabaseImageProvider(IImageRepository imageRepository) : IImageProvider +{ + public async Task> GetImagesAsync() + { + return (await imageRepository.GetAllImagesAsync()).AsReadOnly(); + } +} diff --git a/src/Turnierplan.App/Program.cs b/src/Turnierplan.App/Program.cs index 82aa06a1..c92aeec8 100644 --- a/src/Turnierplan.App/Program.cs +++ b/src/Turnierplan.App/Program.cs @@ -37,7 +37,7 @@ builder.Services.AddTurnierplanMonitoring(builder.Configuration); builder.Services.AddTurnierplanDataAccessLayer(builder.Configuration); builder.Services.AddTurnierplanDocumentRendering(); -builder.Services.AddTurnierplanImageStorage(builder.Configuration.GetSection("ImageStorage")); +builder.Services.AddTurnierplanImageStorage(builder.Configuration.GetSection("ImageStorage")); builder.Services.AddTurnierplanLocalization(); builder.Services.AddTurnierplanSecurity(builder.Configuration.GetSection("Identity")); diff --git a/src/Turnierplan.Dal/Repositories/ImageRepository.cs b/src/Turnierplan.Dal/Repositories/ImageRepository.cs index 1357e440..52430df4 100644 --- a/src/Turnierplan.Dal/Repositories/ImageRepository.cs +++ b/src/Turnierplan.Dal/Repositories/ImageRepository.cs @@ -6,6 +6,8 @@ namespace Turnierplan.Dal.Repositories; public interface IImageRepository : IRepositoryWithPublicId { + Task> GetAllImagesAsync(); + Task CountNumberOfReferencingTournamentsAsync(long imageId); } @@ -22,6 +24,11 @@ internal sealed class ImageRepository(TurnierplanContext context) : RepositoryBa .FirstOrDefaultAsync(); } + public Task> GetAllImagesAsync() + { + return DbSet.ToListAsync(); + } + public async Task CountNumberOfReferencingTournamentsAsync(long imageId) { var count = await _context.Tournaments diff --git a/src/Turnierplan.ImageStorage/Azure/AzureImageStorage.cs b/src/Turnierplan.ImageStorage/Azure/AzureImageStorage.cs index 4f7e92fe..60b28b34 100644 --- a/src/Turnierplan.ImageStorage/Azure/AzureImageStorage.cs +++ b/src/Turnierplan.ImageStorage/Azure/AzureImageStorage.cs @@ -8,7 +8,7 @@ namespace Turnierplan.ImageStorage.Azure; -internal sealed class AzureImageStorage : IImageStorage +internal sealed class AzureImageStorage : ImageStorageBase { private readonly ILogger _logger; private readonly BlobContainerClient _client; @@ -71,14 +71,14 @@ public AzureImageStorage(IOptions options, ILogger SaveImageAsync(Image image, MemoryStream imageData) + public override async Task SaveImageAsync(Image image, MemoryStream imageData) { var blobName = GetBlobName(image); var blobClient = _client.GetBlobClient(blobName); @@ -97,7 +97,7 @@ public async Task SaveImageAsync(Image image, MemoryStream imageData) return false; } - public async Task GetImageAsync(Image image) + public override async Task GetImageAsync(Image image) { var blobName = GetBlobName(image); var blobClient = _client.GetBlobClient(blobName); @@ -116,7 +116,7 @@ public async Task GetImageAsync(Image image) } } - public async Task DeleteImageAsync(Image image) + public override async Task DeleteImageAsync(Image image) { var blobName = GetBlobName(image); var blobClient = _client.GetBlobClient(blobName); @@ -135,10 +135,6 @@ public async Task DeleteImageAsync(Image image) return false; } - public void Dispose() - { - } - private static string GetBlobName(Image image) { return $"{image.CreatedAt.Year}/{image.CreatedAt.Month:D2}/{image.ResourceIdentifier}.{image.FileExtension}"; diff --git a/src/Turnierplan.ImageStorage/Extensions/ServiceCollectionExtensions.cs b/src/Turnierplan.ImageStorage/Extensions/ServiceCollectionExtensions.cs index 237882d3..b78e8acf 100644 --- a/src/Turnierplan.ImageStorage/Extensions/ServiceCollectionExtensions.cs +++ b/src/Turnierplan.ImageStorage/Extensions/ServiceCollectionExtensions.cs @@ -8,7 +8,8 @@ namespace Turnierplan.ImageStorage.Extensions; public static class ServiceCollectionExtensions { - public static void AddTurnierplanImageStorage(this IServiceCollection services, IConfigurationSection configuration) + public static void AddTurnierplanImageStorage(this IServiceCollection services, IConfigurationSection configuration) + where T : IImageProvider { var type = configuration.GetValue("Type"); @@ -21,7 +22,6 @@ public static void AddTurnierplanImageStorage(this IServiceCollection services, case "Local": services.Configure(configuration); services.AddSingleton(); - services.AddScoped(); break; case "S3": services.Configure(configuration); @@ -30,5 +30,7 @@ public static void AddTurnierplanImageStorage(this IServiceCollection services, default: throw new InvalidOperationException($"Invalid image storage type specified: '{type}'"); } + + services.AddTransient(sp => new ImageStorageMigrator(sp.GetRequiredService(), ActivatorUtilities.CreateInstance(sp))); } } diff --git a/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs index 5b64c65e..7c929338 100644 --- a/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs @@ -10,7 +10,7 @@ public static void MapImageStorageEndpoint(this WebApplication application) { var imageStorage = application.Services.GetRequiredService(); - if (imageStorage is not ILocalImageStorage localImageStorage) + if (imageStorage is not LocalImageStorage localImageStorage) { return; } @@ -22,13 +22,14 @@ public static async Task MigrateImageStorageAsync(this WebApplication applicatio { await using var scope = application.Services.CreateAsyncScope(); - var migration = scope.ServiceProvider.GetService(); + var migrator = scope.ServiceProvider.GetService(); - if (migration is null) + if (migrator is null) { return; } - await migration.MigrateAsync(application.Lifetime.ApplicationStopping); + await migrator.MigrateAsync(application.Lifetime.ApplicationStopping); + } } diff --git a/src/Turnierplan.ImageStorage/IImageProvider.cs b/src/Turnierplan.ImageStorage/IImageProvider.cs new file mode 100644 index 00000000..c11dc0d9 --- /dev/null +++ b/src/Turnierplan.ImageStorage/IImageProvider.cs @@ -0,0 +1,8 @@ +using Turnierplan.Core.Image; + +namespace Turnierplan.ImageStorage; + +public interface IImageProvider +{ + Task> GetImagesAsync(); +} diff --git a/src/Turnierplan.ImageStorage/IImageStorageMigration.cs b/src/Turnierplan.ImageStorage/IImageStorageMigration.cs deleted file mode 100644 index 78f639c0..00000000 --- a/src/Turnierplan.ImageStorage/IImageStorageMigration.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Turnierplan.ImageStorage; - -internal interface IImageStorageMigration -{ - Task MigrateAsync(CancellationToken cancellationToken); -} diff --git a/src/Turnierplan.ImageStorage/IImageStorageMigrator.cs b/src/Turnierplan.ImageStorage/IImageStorageMigrator.cs new file mode 100644 index 00000000..3bf1e7e5 --- /dev/null +++ b/src/Turnierplan.ImageStorage/IImageStorageMigrator.cs @@ -0,0 +1,14 @@ +namespace Turnierplan.ImageStorage; + +internal sealed class ImageStorageMigrator(IImageStorage imageStorage, IImageProvider imageProvider) +{ + public async Task MigrateAsync(CancellationToken cancellationToken) + { + if (imageStorage is not IMigratableImageStorage migratableImageStorage) + { + return; + } + + await migratableImageStorage.MigrateAsync(imageProvider, cancellationToken); + } +} diff --git a/src/Turnierplan.ImageStorage/IMigratableImageStorage.cs b/src/Turnierplan.ImageStorage/IMigratableImageStorage.cs new file mode 100644 index 00000000..31038092 --- /dev/null +++ b/src/Turnierplan.ImageStorage/IMigratableImageStorage.cs @@ -0,0 +1,6 @@ +namespace Turnierplan.ImageStorage; + +public interface IMigratableImageStorage +{ + Task MigrateAsync(IImageProvider imageProvider, CancellationToken cancellationToken); +} diff --git a/src/Turnierplan.ImageStorage/ImageStorageBase.cs b/src/Turnierplan.ImageStorage/ImageStorageBase.cs new file mode 100644 index 00000000..0ec8622a --- /dev/null +++ b/src/Turnierplan.ImageStorage/ImageStorageBase.cs @@ -0,0 +1,34 @@ +using Turnierplan.Core.Image; + +namespace Turnierplan.ImageStorage; + +internal abstract class ImageStorageBase : IImageStorage, IMigratableImageStorage +{ + ~ImageStorageBase() + { + Dispose(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + public abstract string GetFullImageUrl(Image image); + + public abstract Task SaveImageAsync(Image image, MemoryStream imageData); + + public abstract Task GetImageAsync(Image image); + + public abstract Task DeleteImageAsync(Image image); + + public virtual Task MigrateAsync(IImageProvider imageProvider, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + protected virtual void Dispose(bool disposing) + { + } +} diff --git a/src/Turnierplan.ImageStorage/Local/ILocalImageStorage.cs b/src/Turnierplan.ImageStorage/Local/ILocalImageStorage.cs deleted file mode 100644 index fd04324b..00000000 --- a/src/Turnierplan.ImageStorage/Local/ILocalImageStorage.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Microsoft.AspNetCore.Builder; - -namespace Turnierplan.ImageStorage.Local; - -public interface ILocalImageStorage : IImageStorage -{ - void MapEndpoint(IApplicationBuilder builder); -} diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs index fad3b897..bf9897c5 100644 --- a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs @@ -7,7 +7,7 @@ namespace Turnierplan.ImageStorage.Local; -internal sealed class LocalImageStorage : ILocalImageStorage +internal sealed class LocalImageStorage : ImageStorageBase { private readonly ILogger _logger; private readonly string _storagePath; @@ -31,12 +31,12 @@ public LocalImageStorage(ILogger logger, IOptions SaveImageAsync(Image image, MemoryStream imageData) + public override Task SaveImageAsync(Image image, MemoryStream imageData) { var filePath = GetImageFullPath(image); @@ -73,12 +73,12 @@ public Task SaveImageAsync(Image image, MemoryStream imageData) } } - public Task GetImageAsync(Image image) + public override Task GetImageAsync(Image image) { return Task.FromResult(new FileStream(GetImageFullPath(image), FileMode.Open)); } - public Task DeleteImageAsync(Image image) + public override Task DeleteImageAsync(Image image) { var filePath = GetImageFullPath(image); @@ -105,7 +105,7 @@ public void MapEndpoint(IApplicationBuilder builder) }); } - internal async Task MigrateAsync(Func>> getImagesFunction, CancellationToken cancellationToken) + public override async Task MigrateAsync(IImageProvider imageProvider, CancellationToken cancellationToken) { if (_skipMigration) { @@ -115,6 +115,8 @@ internal async Task MigrateAsync(Func>> getImagesFunction, Can try { + // TODO: Don't use version file and detect whether migration is necessary by looking at the files. + var version = "1"; var versionFile = Path.Join(_storagePath, ".version"); @@ -136,7 +138,7 @@ internal async Task MigrateAsync(Func>> getImagesFunction, Can return; } - var images = await getImagesFunction(); + var images = await imageProvider.GetImagesAsync(); // TODO: Run migration by copying the files & writing new version number // TODO: Display info message if any old files still exist } @@ -146,10 +148,6 @@ internal async Task MigrateAsync(Func>> getImagesFunction, Can } } - public void Dispose() - { - } - private string GetImageFullPath(Image image) { return Path.Join(_storagePath, image.CreatedAt.Year.ToString(CultureInfo.InvariantCulture), GetImageFileName(image)); diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorageMigration.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorageMigration.cs deleted file mode 100644 index 34337022..00000000 --- a/src/Turnierplan.ImageStorage/Local/LocalImageStorageMigration.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Turnierplan.Core.Image; - -namespace Turnierplan.ImageStorage.Local; - -internal sealed class LocalImageStorageMigration : IImageStorageMigration -{ - private readonly LocalImageStorage _imageStorage; - - public LocalImageStorageMigration(IImageStorage imageStorage) - { - if (imageStorage is not LocalImageStorage localImageStorage) - { - throw new ArgumentException($"{nameof(LocalImageStorageMigration)} requires the registered {nameof(IImageStorage)} to be of type {nameof(LocalImageStorage)}.", nameof(imageStorage)); - } - - _imageStorage = localImageStorage; - } - - public async Task MigrateAsync(CancellationToken cancellationToken) - { - await _imageStorage.MigrateAsync(LoadImagesAsync, cancellationToken); - } - - private async Task> LoadImagesAsync() - { - // TODO: We cannot load images here because this project does not reference Turnierplan.Dal (!) - } -} diff --git a/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs b/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs index 0c54c038..723ae182 100644 --- a/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs +++ b/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs @@ -9,7 +9,7 @@ namespace Turnierplan.ImageStorage.S3; -internal sealed class S3ImageStorage : IImageStorage +internal sealed class S3ImageStorage : ImageStorageBase { private readonly ILogger _logger; private readonly AmazonS3Client _client; @@ -55,7 +55,7 @@ public S3ImageStorage(IOptions options, ILogger SaveImageAsync(Image image, MemoryStream imageData) + public override async Task SaveImageAsync(Image image, MemoryStream imageData) { var objectKey = GetObjectKey(image); @@ -95,7 +95,7 @@ public async Task SaveImageAsync(Image image, MemoryStream imageData) return false; } - public async Task GetImageAsync(Image image) + public override async Task GetImageAsync(Image image) { var objectKey = GetObjectKey(image); @@ -117,7 +117,7 @@ public async Task GetImageAsync(Image image) throw new InvalidOperationException($"Failed to read image from S3. Status code: {response.HttpStatusCode}"); } - public async Task DeleteImageAsync(Image image) + public override async Task DeleteImageAsync(Image image) { var objectKey = GetObjectKey(image); @@ -144,9 +144,14 @@ public async Task DeleteImageAsync(Image image) return false; } - public void Dispose() + protected override void Dispose(bool disposing) { - _client.Dispose(); + base.Dispose(disposing); + + if (disposing) + { + _client.Dispose(); + } } private static string GetObjectKey(Image image) From 83d1d368a31a54a98254b8dc440777e8bfeedcbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 3 May 2026 07:58:34 +0200 Subject: [PATCH 04/20] Remove implementation of IMigratableImageStorage in base image storage --- src/Turnierplan.ImageStorage/ImageStorageBase.cs | 7 +------ src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs | 4 ++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Turnierplan.ImageStorage/ImageStorageBase.cs b/src/Turnierplan.ImageStorage/ImageStorageBase.cs index 0ec8622a..78132132 100644 --- a/src/Turnierplan.ImageStorage/ImageStorageBase.cs +++ b/src/Turnierplan.ImageStorage/ImageStorageBase.cs @@ -2,7 +2,7 @@ namespace Turnierplan.ImageStorage; -internal abstract class ImageStorageBase : IImageStorage, IMigratableImageStorage +internal abstract class ImageStorageBase : IImageStorage { ~ImageStorageBase() { @@ -23,11 +23,6 @@ public void Dispose() public abstract Task DeleteImageAsync(Image image); - public virtual Task MigrateAsync(IImageProvider imageProvider, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - protected virtual void Dispose(bool disposing) { } diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs index bf9897c5..c018a28f 100644 --- a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs @@ -7,7 +7,7 @@ namespace Turnierplan.ImageStorage.Local; -internal sealed class LocalImageStorage : ImageStorageBase +internal sealed class LocalImageStorage : ImageStorageBase, IMigratableImageStorage { private readonly ILogger _logger; private readonly string _storagePath; @@ -105,7 +105,7 @@ public void MapEndpoint(IApplicationBuilder builder) }); } - public override async Task MigrateAsync(IImageProvider imageProvider, CancellationToken cancellationToken) + public async Task MigrateAsync(IImageProvider imageProvider, CancellationToken cancellationToken) { if (_skipMigration) { From c7a6cf9feffb71e9d1f9cf714fa01824987befe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 3 May 2026 08:54:34 +0200 Subject: [PATCH 05/20] Implement migration --- .../Local/LocalImageStorage.cs | 105 ++++++++++++++---- 1 file changed, 86 insertions(+), 19 deletions(-) diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs index c018a28f..9863f0cb 100644 --- a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; @@ -7,10 +8,12 @@ namespace Turnierplan.ImageStorage.Local; -internal sealed class LocalImageStorage : ImageStorageBase, IMigratableImageStorage +internal sealed partial class LocalImageStorage : ImageStorageBase, IMigratableImageStorage { private readonly ILogger _logger; private readonly string _storagePath; + + private readonly SemaphoreSlim _migrationSemaphore = new(1, 1); private readonly bool _skipMigration; public LocalImageStorage(ILogger logger, IOptions options) @@ -33,7 +36,7 @@ public LocalImageStorage(ILogger logger, IOptions SaveImageAsync(Image image, MemoryStream imageData) @@ -113,48 +116,112 @@ public async Task MigrateAsync(IImageProvider imageProvider, CancellationToken c return; } + /* + * This migration copies files from the previous folder structure to the new one: + * + * Previous folder structure: + * /2025/5b22d459-58b8-4aac-8ad0-1b0ba5e453b3.webp + * /2026/7e669676-443b-4d01-a12e-676c9c097739.webp + * + * New folder structure: + * /2025/07/5b22d459-58b8-4aac-8ad0-1b0ba5e453b3.webp + * /2026/04/7e669676-443b-4d01-a12e-676c9c097739.webp + * + * The new folder structure introduces a month-based subdirectory. This is done to be aligned with the + * other image storage implementations, which should make switching between implementations easier. + */ + try { - // TODO: Don't use version file and detect whether migration is necessary by looking at the files. - - var version = "1"; - var versionFile = Path.Join(_storagePath, ".version"); + await _migrationSemaphore.WaitAsync(cancellationToken); - if (File.Exists(versionFile)) + // First, we figure out if a migration needs to be run by checking if any files with the old naming scheme exist. + var hasOldFiles = Directory.GetDirectories(_storagePath).SelectMany(Directory.GetFiles).Any(); + if (!hasOldFiles) { - version = await File.ReadAllTextAsync(versionFile, cancellationToken); + _logger.LogInformation("The directory structure is up to date, no migration is needed."); + return; } - if (version.Equals("2")) + _logger.LogInformation("A migration will be attempted because at least one file in the storage directory matches the old file pattern."); + + // Load images from DB because we need the months of creation + var images = await imageProvider.GetImagesAsync(); + + // Consistency check - there might be an error in the image provider implementation causing invalid data to be returned. + if (images.Count == 0) { - // Latest version, no migrations required - _logger.LogInformation("Storage of local image storage is up to date."); + _logger.LogWarning("The image provider returned no images even though the image storage folder contains old image files - migration is skipped."); return; } - if (!version.Equals("1")) + // Iterate over all old files and handle them accordingly + foreach (var directory in Directory.GetDirectories(_storagePath)) { - _logger.LogCritical("Version file in local image storage path contains an unknown version '{Version}'.", version); - return; - } + foreach (var file in Directory.GetFiles(directory)) + { + var fileName = Path.GetFileName(file); + var match = FileNameRegex().Match(fileName); - var images = await imageProvider.GetImagesAsync(); - // TODO: Run migration by copying the files & writing new version number - // TODO: Display info message if any old files still exist + if (!match.Success) + { + _logger.LogWarning("Encountered a file in the image storage directory which does not match the file name pattern: '{ImagePath}'", Path.GetFullPath(file)); + continue; + } + + if (!Guid.TryParse(match.Groups["identifier"].Value, out var resourceIdentifier)) + { + _logger.LogWarning("Encountered a file in the image storage directory which does not have a valid GUID in the file name: '{ImagePath}'", Path.GetFullPath(file)); + continue; + } + + var image = images.FirstOrDefault(x => x.ResourceIdentifier == resourceIdentifier); + + if (image is null) + { + _logger.LogWarning("Encountered a file in the image storage directory for which there exists no corresponding entry from the image provider: '{ImagePath}'", Path.GetFullPath(file)); + continue; + } + + var targetDirectory = Path.Join(_storagePath, image.CreatedAt.Year.ToString(CultureInfo.InvariantCulture), image.CreatedAt.Month.ToString("D2", CultureInfo.InvariantCulture)); + var targetPath = Path.Join(targetDirectory, fileName); + + Directory.CreateDirectory(targetDirectory); + + if (File.Exists(targetPath)) + { + _logger.LogWarning("Encountered an already existing target file '{TargetPath}' while migrating '{SourcePath}' - the Move operation will be attempted again.", targetPath, file); + } + + File.Move(file, targetPath, overwrite: true); + _logger.LogInformation("Successfully moved image file '{TargetPath}' to '{SourcePath}'.", targetPath, file); + } + } } catch (Exception ex) { _logger.LogCritical(ex, "An unexpected exception occurred while trying to run local image storage migrations."); } + finally + { + _migrationSemaphore.Release(); + } } private string GetImageFullPath(Image image) { - return Path.Join(_storagePath, image.CreatedAt.Year.ToString(CultureInfo.InvariantCulture), GetImageFileName(image)); + return Path.Join( + _storagePath, + image.CreatedAt.Year.ToString(CultureInfo.InvariantCulture), + image.CreatedAt.Month.ToString("D2", CultureInfo.InvariantCulture), + GetImageFileName(image)); } private static string GetImageFileName(Image image) { return $"{image.ResourceIdentifier}.{image.FileExtension}"; } + + [GeneratedRegex(@"(?[0-9a-f-]{36})\.(?[a-z]+)$")] + private static partial Regex FileNameRegex(); } From 827a0d0b9b930825fa7f6647449d20f4abe6c651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 3 May 2026 08:58:01 +0200 Subject: [PATCH 06/20] Remove warning from documentation --- docs/pages/configuration/index.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/docs/pages/configuration/index.md b/docs/pages/configuration/index.md index 4454f682..21618886 100644 --- a/docs/pages/configuration/index.md +++ b/docs/pages/configuration/index.md @@ -43,9 +43,6 @@ Alternativ können externe Services zum Speichern der Bilder konfiguriert werden - **AWS S3** (oder kompatibler Dienst) - **Azure Blob Storage** -!!! warning - Die nachfolgend vorgestellten Alternativen verwenden nicht zwangsläufig eine identische Verzeichnisstruktur zur Organisation der Dateien. Dadurch wird eine nachträgliche Umstellung ggf. erschwert! - ### AWS S3 Um Bilder in einem AWS S3 oder S3-kompatiblen Bucket zu speichern, müssen die folgenden Umgebungsvariablen gesetzt werden: From f596b5bac123c365ddf05c9f4109f16352b1dd79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 3 May 2026 08:58:11 +0200 Subject: [PATCH 07/20] Fix typo --- docs/pages/configuration/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pages/configuration/index.md b/docs/pages/configuration/index.md index 21618886..6a697b47 100644 --- a/docs/pages/configuration/index.md +++ b/docs/pages/configuration/index.md @@ -38,7 +38,7 @@ In der Weboberfläche können Bilddateien hochgeladen werden. Diese werden mit e | `Turnierplan__ImageQuality` | Die Qualitätseinstellung für Bild-Uploads. Ein Wert von `100` entspricht einer verlustfreien Komprimierung. | `80` | | `ImageStorage__StoragePath` | Das Verzeichnis innerhalb vom Container, hochgeladene Bilder gespeichert werden. | `/var/turnierplan/images` | -Alternativ können externe Services zum Speichern der Bilder konfiguriert werden. Dies hat den Vorteil, dass das Bereitstellen von Bilddateien keine CPU- und Netzwerkresourcen vom turnierplan.NET-Server beansprucht. Aktuell werden die folgenden externen Services unterstützt: +Alternativ können externe Services zum Speichern der Bilder konfiguriert werden. Dies hat den Vorteil, dass das Bereitstellen von Bilddateien keine CPU- und Netzwerkressourcen vom turnierplan.NET-Server beansprucht. Aktuell werden die folgenden externen Services unterstützt: - **AWS S3** (oder kompatibler Dienst) - **Azure Blob Storage** From db883ef489c82381bc259b2eaed52c8fce0bb148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 3 May 2026 09:07:57 +0200 Subject: [PATCH 08/20] Move log messages to separate file --- .../Local/LocalImageStorage.cs | 37 +++++++------ .../Local/LocalImageStorageLogger.cs | 54 +++++++++++++++++++ 2 files changed, 72 insertions(+), 19 deletions(-) create mode 100644 src/Turnierplan.ImageStorage/Local/LocalImageStorageLogger.cs diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs index 9863f0cb..533cf8fb 100644 --- a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs @@ -10,26 +10,25 @@ namespace Turnierplan.ImageStorage.Local; internal sealed partial class LocalImageStorage : ImageStorageBase, IMigratableImageStorage { - private readonly ILogger _logger; + private readonly LocalImageStorageLogger _logger; private readonly string _storagePath; - private readonly SemaphoreSlim _migrationSemaphore = new(1, 1); private readonly bool _skipMigration; public LocalImageStorage(ILogger logger, IOptions options) { - _logger = logger; + _logger = new LocalImageStorageLogger(logger); ArgumentException.ThrowIfNullOrWhiteSpace(options.Value.StoragePath); _storagePath = Path.GetFullPath(options.Value.StoragePath); - _logger.LogInformation("Using the following directory for local image storage: '{LocalImageStoragePath}'", _storagePath); + _logger.UsingDirectoryForStorage(_storagePath); Directory.CreateDirectory(_storagePath); if (!Directory.Exists(_storagePath)) { - _logger.LogCritical("The directory for local image storage does not exist and could not be created."); + _logger.RootDirectoryDoesNotExistAndCouldNotBeCreated(); _skipMigration = true; } } @@ -55,13 +54,13 @@ public override Task SaveImageAsync(Image image, MemoryStream imageData) if (!Directory.Exists(imageDirectoryPath)) { - _logger.LogCritical("The directory for the image could not be created: '{ImageDirectory}'.", imageDirectoryPath); + _logger.ImageDirectoryCouldNotBeCreated(imageDirectoryPath); return Task.FromResult(false); } } - _logger.LogDebug("Writing image to: {FilePath}", filePath); + _logger.WritingImageToPath(filePath); using var destination = new FileStream(filePath, FileMode.Create); imageData.CopyTo(destination); @@ -70,7 +69,7 @@ public override Task SaveImageAsync(Image image, MemoryStream imageData) } catch (Exception ex) { - _logger.LogError(ex, "Failed to save image to file to '{FilePath}'.", filePath); + _logger.FailedToWriteImageToPath(filePath, ex); return Task.FromResult(false); } @@ -91,7 +90,7 @@ public override Task DeleteImageAsync(Image image) } catch (Exception ex) { - _logger.LogError(ex, "Failed to delete image file '{FilePath}'.", filePath); + _logger.FailedToDeleteImageFile(filePath, ex); return Task.FromResult(false); } @@ -112,7 +111,7 @@ public async Task MigrateAsync(IImageProvider imageProvider, CancellationToken c { if (_skipMigration) { - _logger.LogCritical("A previous initialization error of local image storage will cause the migrations to be skipped."); + _logger.SkippingMigration(); return; } @@ -139,11 +138,11 @@ public async Task MigrateAsync(IImageProvider imageProvider, CancellationToken c var hasOldFiles = Directory.GetDirectories(_storagePath).SelectMany(Directory.GetFiles).Any(); if (!hasOldFiles) { - _logger.LogInformation("The directory structure is up to date, no migration is needed."); + _logger.DirectoryStructureIsUpToDate(); return; } - _logger.LogInformation("A migration will be attempted because at least one file in the storage directory matches the old file pattern."); + _logger.MigrationWillBeAttempted(); // Load images from DB because we need the months of creation var images = await imageProvider.GetImagesAsync(); @@ -151,7 +150,7 @@ public async Task MigrateAsync(IImageProvider imageProvider, CancellationToken c // Consistency check - there might be an error in the image provider implementation causing invalid data to be returned. if (images.Count == 0) { - _logger.LogWarning("The image provider returned no images even though the image storage folder contains old image files - migration is skipped."); + _logger.ImageProviderReturnedNoImages(); return; } @@ -165,13 +164,13 @@ public async Task MigrateAsync(IImageProvider imageProvider, CancellationToken c if (!match.Success) { - _logger.LogWarning("Encountered a file in the image storage directory which does not match the file name pattern: '{ImagePath}'", Path.GetFullPath(file)); + _logger.EncounteredFileWhichDoesNotMatchPattern(Path.GetFullPath(file)); continue; } if (!Guid.TryParse(match.Groups["identifier"].Value, out var resourceIdentifier)) { - _logger.LogWarning("Encountered a file in the image storage directory which does not have a valid GUID in the file name: '{ImagePath}'", Path.GetFullPath(file)); + _logger.EncounteredFileThatHasNoValidGuidInName(Path.GetFullPath(file)); continue; } @@ -179,7 +178,7 @@ public async Task MigrateAsync(IImageProvider imageProvider, CancellationToken c if (image is null) { - _logger.LogWarning("Encountered a file in the image storage directory for which there exists no corresponding entry from the image provider: '{ImagePath}'", Path.GetFullPath(file)); + _logger.EncounteredFileWithoutCorrespondingImageFromProvider(Path.GetFullPath(file)); continue; } @@ -190,17 +189,17 @@ public async Task MigrateAsync(IImageProvider imageProvider, CancellationToken c if (File.Exists(targetPath)) { - _logger.LogWarning("Encountered an already existing target file '{TargetPath}' while migrating '{SourcePath}' - the Move operation will be attempted again.", targetPath, file); + _logger.EncounteredTargetFileThatAlreadyExists(targetPath, file); } File.Move(file, targetPath, overwrite: true); - _logger.LogInformation("Successfully moved image file '{TargetPath}' to '{SourcePath}'.", targetPath, file); + _logger.SuccessfullyMovedFileTo(targetPath, file); } } } catch (Exception ex) { - _logger.LogCritical(ex, "An unexpected exception occurred while trying to run local image storage migrations."); + _logger.ExceptionOccurredDuringMigration(ex); } finally { diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorageLogger.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorageLogger.cs new file mode 100644 index 00000000..fb692fed --- /dev/null +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorageLogger.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Logging; + +namespace Turnierplan.ImageStorage.Local; + +internal sealed partial class LocalImageStorageLogger(ILogger logger) +{ + [LoggerMessage(LogLevel.Information, "Using the following directory for local image storage: '{LocalImageStoragePath}'", EventId = 1)] + public partial void UsingDirectoryForStorage(string localImageStoragePath); + + [LoggerMessage(LogLevel.Critical, "The directory for local image storage does not exist and could not be created.", EventId = 2)] + public partial void RootDirectoryDoesNotExistAndCouldNotBeCreated(); + + [LoggerMessage(LogLevel.Critical, "The directory for the image could not be created: '{ImageDirectory}'.", EventId = 3)] + public partial void ImageDirectoryCouldNotBeCreated(string imageDirectory); + + [LoggerMessage(LogLevel.Debug, "Writing image to: {FilePath}", EventId = 4)] + public partial void WritingImageToPath(string filePath); + + [LoggerMessage(LogLevel.Error, "Failed to save image to file to '{FilePath}'.", EventId = 5)] + public partial void FailedToWriteImageToPath(string filePath, Exception exception); + + [LoggerMessage(LogLevel.Error, "Failed to delete image file '{FilePath}'.", EventId = 6)] + public partial void FailedToDeleteImageFile(string filePath, Exception exception); + + [LoggerMessage(LogLevel.Critical, "A previous initialization error of local image storage will cause the migrations to be skipped.", EventId = 7)] + public partial void SkippingMigration(); + + [LoggerMessage(LogLevel.Information, "The directory structure is up to date, no migration is needed.", EventId = 8)] + public partial void DirectoryStructureIsUpToDate(); + + [LoggerMessage(LogLevel.Information, "A migration will be attempted because at least one file in the storage directory matches the old file pattern.", EventId = 9)] + public partial void MigrationWillBeAttempted(); + + [LoggerMessage(LogLevel.Warning, "The image provider returned no images even though the image storage folder contains old image files - migration is skipped.", EventId = 10)] + public partial void ImageProviderReturnedNoImages(); + + [LoggerMessage(LogLevel.Warning, "Encountered a file in the image storage directory which does not match the file name pattern: '{ImagePath}'", EventId = 11)] + public partial void EncounteredFileWhichDoesNotMatchPattern(string imagePath); + + [LoggerMessage(LogLevel.Warning, "Encountered a file in the image storage directory which does not have a valid GUID in the file name: '{ImagePath}'", EventId = 12)] + public partial void EncounteredFileThatHasNoValidGuidInName(string imagePath); + + [LoggerMessage(LogLevel.Warning, "Encountered a file in the image storage directory for which there exists no corresponding entry from the image provider: '{ImagePath}'", EventId = 13)] + public partial void EncounteredFileWithoutCorrespondingImageFromProvider(string imagePath); + + [LoggerMessage(LogLevel.Warning, "Encountered an already existing target file '{TargetPath}' while migrating '{SourcePath}' - the Move operation will be attempted again.", EventId = 14)] + public partial void EncounteredTargetFileThatAlreadyExists(string targetPath, string sourcePath); + + [LoggerMessage(LogLevel.Information, "Successfully moved image file '{TargetPath}' to '{SourcePath}'.", EventId = 15)] + public partial void SuccessfullyMovedFileTo(string targetPath, string sourcePath); + + [LoggerMessage(LogLevel.Critical, "An unexpected exception occurred while trying to run local image storage migrations.", EventId = 16)] + public partial void ExceptionOccurredDuringMigration(Exception exception); +} From a56c4de203a8f2ee79035e628d8a7b9feeac2abf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 3 May 2026 10:32:41 +0200 Subject: [PATCH 09/20] Add some first migration tests --- .../Local/LocalImageStorageMigrationTest.cs | 240 ++++++++++++++++++ ...ierplan.ImageStorage.Test.Migration.csproj | 15 ++ src/Turnierplan.ImageStorage/AssemblyInfo.cs | 3 + .../Extensions/WebApplicationExtensions.cs | 8 +- ...ageMigrator.cs => ImageStorageMigrator.cs} | 2 +- src/turnierplan.NET.slnx | 1 + 6 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs create mode 100644 src/Turnierplan.ImageStorage.Test.Migration/Turnierplan.ImageStorage.Test.Migration.csproj create mode 100644 src/Turnierplan.ImageStorage/AssemblyInfo.cs rename src/Turnierplan.ImageStorage/{IImageStorageMigrator.cs => ImageStorageMigrator.cs} (95%) diff --git a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs new file mode 100644 index 00000000..716f02c5 --- /dev/null +++ b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs @@ -0,0 +1,240 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Turnierplan.Core.Image; +using Turnierplan.Core.PublicId; +using Turnierplan.ImageStorage.Extensions; +using Xunit; +using BindingFlags = System.Reflection.BindingFlags; + +namespace Turnierplan.ImageStorage.Test.Migration.Local; + +public sealed class LocalImageStorageMigrationTest : IDisposable +{ + private const string ImageExtension = "jpg"; + + private const int LogEventIdUsingDirectoryForStorage = 1; + private const int LogEventIdDirectoryStructureIsUpToDate = 8; + private const int LogEventIdMigrationWillBeAttempted = 9; + private const int LogEventIdImageProviderReturnedNoImages = 10; + private const int LogEventIdEncounteredFileWithoutCorrespondingImageFromProvider = 13; + private const int LogEventIdSuccessfullyMovedFileTo = 15; + + private readonly string _basePath; + private readonly TestLoggerProvider _loggerProvider; + private readonly StateForTestImageProvider _imageProviderState; + private readonly ServiceProvider _serviceProvider; + + public LocalImageStorageMigrationTest() + { + _basePath = Path.Join(Path.GetTempPath(), $"{nameof(LocalImageStorageMigrationTest)}-{Guid.NewGuid()}"); + + var config = new ConfigurationBuilder().AddInMemoryCollection([ + new KeyValuePair("ImageStorage:Type", "Local"), + new KeyValuePair("ImageStorage:StoragePath", _basePath) + ]).Build().GetSection("ImageStorage"); + + var services = new ServiceCollection(); + + _loggerProvider = new TestLoggerProvider(); + services.AddLogging(builder => builder.AddProvider(_loggerProvider)); + + _imageProviderState = new StateForTestImageProvider(); + services.AddSingleton(_imageProviderState); + services.AddTurnierplanImageStorage(config); + + _serviceProvider = services.BuildServiceProvider(); + } + + public void Dispose() + { + _serviceProvider.Dispose(); + + if (Directory.Exists(_basePath)) + { + Directory.Delete(_basePath, recursive: true); + } + } + + [Fact] + public async Task Migration___With_Empty_Directory_And_No_Images___Works_As_Expected() + { + await RunMigrationAsync(); + + ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdDirectoryStructureIsUpToDate); + } + + [Fact] + public async Task Migration___With_Some_Images_In_Old_Structure___Works_As_Expected() + { + var image1 = AddNewImage(new DateTime(2025, 05, 03)); + var image2 = AddNewImage(new DateTime(2025, 05, 15)); + var image3 = AddNewImage(new DateTime(2026, 02, 17)); + + await RunMigrationAsync(); + + ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdSuccessfullyMovedFileTo); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image1.ResourceIdentifier}\.{ImageExtension}' to '.*{image1.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image2.ResourceIdentifier}\.{ImageExtension}' to '.*{image2.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image3.ResourceIdentifier}\.{ImageExtension}' to '.*{image3.ResourceIdentifier}\.{ImageExtension}'\.$"); + + // Images should all be moved to the new structure - the old files should have been deleted + CheckImageFileInOldStructure(image1, expectExists: false); + CheckImageFileInOldStructure(image2, expectExists: false); + CheckImageFileInOldStructure(image3, expectExists: false); + CheckImageFileInNewStructure(image1, expectExists: true); + CheckImageFileInNewStructure(image2, expectExists: true); + CheckImageFileInNewStructure(image3, expectExists: true); + } + + [Fact] + public async Task Migration___With_Some_Images_In_Old_Structure_But_Image_Is_Missing_One_Specific_Image___Works_As_Expected() + { + var image1 = AddNewImage(new DateTime(2025, 05, 03)); + var image2 = AddNewImage(new DateTime(2025, 05, 15)); + var image3 = AddNewImage(new DateTime(2026, 02, 17)); + + _imageProviderState.Images.Remove(image2); + + await RunMigrationAsync(); + + ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdSuccessfullyMovedFileTo, LogEventIdEncounteredFileWithoutCorrespondingImageFromProvider); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image1.ResourceIdentifier}\.{ImageExtension}' to '.*{image1.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image3.ResourceIdentifier}\.{ImageExtension}' to '.*{image3.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdEncounteredFileWithoutCorrespondingImageFromProvider, $@"^Encountered a file in the image storage directory for which there exists no corresponding entry from the image provider: '.*{image2.ResourceIdentifier}\.{ImageExtension}'$"); + + // Images 1 & 3 are moved to the new structure, while image 2 remains in the old structure + CheckImageFileInOldStructure(image1, expectExists: false); + CheckImageFileInOldStructure(image2, expectExists: true); + CheckImageFileInOldStructure(image3, expectExists: false); + CheckImageFileInNewStructure(image1, expectExists: true); + CheckImageFileInNewStructure(image2, expectExists: false); + CheckImageFileInNewStructure(image3, expectExists: true); + } + + [Fact] + public async Task Migration___With_Some_Images_In_Old_Structure_But_Image_Provider_Returns_No_Images___Works_As_Expected() + { + var image1 = AddNewImage(new DateTime(2025, 05, 03)); + var image2 = AddNewImage(new DateTime(2025, 05, 15)); + var image3 = AddNewImage(new DateTime(2026, 02, 17)); + + _imageProviderState.Images.Clear(); + + await RunMigrationAsync(); + + ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdImageProviderReturnedNoImages); + + // Images should still exist in the old structure + CheckImageFileInOldStructure(image1, expectExists: true); + CheckImageFileInOldStructure(image2, expectExists: true); + CheckImageFileInOldStructure(image3, expectExists: true); + CheckImageFileInNewStructure(image1, expectExists: false); + CheckImageFileInNewStructure(image2, expectExists: false); + CheckImageFileInNewStructure(image3, expectExists: false); + } + + private async Task RunMigrationAsync() + { + await using var scope = _serviceProvider.CreateAsyncScope(); + + var migrator = scope.ServiceProvider.GetRequiredService(); + + await migrator.MigrateAsync(TestContext.Current.CancellationToken); + } + + private Image AddNewImage(DateTime createdAt) + { + 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)]); + var id = (long)_imageProviderState.Images.Count + 1; + var image = (Image)ctor!.Invoke([id, Guid.NewGuid(), PublicId.Empty, createdAt, $"Image{_imageProviderState.Images.Count + 1}", ImageExtension, (long)1, (ushort)1, (ushort)1]); + + _imageProviderState.Images.Add(image); + + CreateImageFile(image.CreatedAt.Year, GetImageFileName(image), image.Name); + + return image; + } + + private void CreateImageFile(int year, string fileName, string contents) + { + var directory = Path.Join(_basePath, $"{year}"); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Join(directory, fileName), contents); + } + + private void CheckImageFileInOldStructure(int year, string fileName, bool expectExists = true) + { + var path = Path.Join(_basePath, $"{year}", fileName); + File.Exists(path).Should().Be(expectExists); + } + + private void CheckImageFileInOldStructure(Image image, bool expectExists = true) + { + CheckImageFileInOldStructure(image.CreatedAt.Year, GetImageFileName(image), expectExists); + } + + private void CheckImageFileInNewStructure(int year, int month, string fileName, bool expectExists = true) + { + var path = Path.Join(_basePath, $"{year}", $"{month:D2}", fileName); + File.Exists(path).Should().Be(expectExists); + } + + private void CheckImageFileInNewStructure(Image image, bool expectExists = true) + { + CheckImageFileInNewStructure(image.CreatedAt.Year, image.CreatedAt.Month, GetImageFileName(image), expectExists); + } + + private void ExpectLogMessages(params int[] expectedEventIds) + { + var actualEventIds = _loggerProvider.Logger.Messages.Keys; + actualEventIds.Except(expectedEventIds).Should().BeEmpty(because: "all expected log event IDs should be present at least once in the actual log messages"); + expectedEventIds.Except(actualEventIds).Should().BeEmpty(because: "the actual log messages should only contain expected log event IDs"); + } + + private void ExpectLogMessageExists(int eventId, [StringSyntax(StringSyntaxAttribute.Regex)] string pattern) + { + _loggerProvider.Logger.Messages.TryGetValue(eventId, out var list).Should().BeTrue(); + var regex = new Regex(pattern); + list.Should().ContainSingle(x => regex.IsMatch(x)); + } + + private static string GetImageFileName(Image image) => $"{image.ResourceIdentifier}.{ImageExtension}"; + + private sealed class TestLogger : ILogger + { + public readonly Dictionary> 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.TryAdd(eventId.Id, []); + Messages[eventId.Id].Add(formatter(state, exception)); + } + } + + private sealed class TestLoggerProvider : ILoggerProvider + { + public readonly TestLogger Logger = new(); + + public ILogger CreateLogger(string name) => Logger; + + public void Dispose() { } + } + + private sealed class StateForTestImageProvider + { + public readonly List Images = []; + } + + private sealed class TestImageProvider(StateForTestImageProvider state) : IImageProvider + { + public Task> GetImagesAsync() => Task.FromResult>(state.Images.AsReadOnly()); + } +} diff --git a/src/Turnierplan.ImageStorage.Test.Migration/Turnierplan.ImageStorage.Test.Migration.csproj b/src/Turnierplan.ImageStorage.Test.Migration/Turnierplan.ImageStorage.Test.Migration.csproj new file mode 100644 index 00000000..2d56fe69 --- /dev/null +++ b/src/Turnierplan.ImageStorage.Test.Migration/Turnierplan.ImageStorage.Test.Migration.csproj @@ -0,0 +1,15 @@ + + + + + net10.0 + enable + enable + + + + + + + + diff --git a/src/Turnierplan.ImageStorage/AssemblyInfo.cs b/src/Turnierplan.ImageStorage/AssemblyInfo.cs new file mode 100644 index 00000000..3533c6d3 --- /dev/null +++ b/src/Turnierplan.ImageStorage/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Turnierplan.ImageStorage.Test.Migration")] diff --git a/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs index 7c929338..b66ae33d 100644 --- a/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs @@ -22,14 +22,8 @@ public static async Task MigrateImageStorageAsync(this WebApplication applicatio { await using var scope = application.Services.CreateAsyncScope(); - var migrator = scope.ServiceProvider.GetService(); - - if (migrator is null) - { - return; - } + var migrator = scope.ServiceProvider.GetRequiredService(); await migrator.MigrateAsync(application.Lifetime.ApplicationStopping); - } } diff --git a/src/Turnierplan.ImageStorage/IImageStorageMigrator.cs b/src/Turnierplan.ImageStorage/ImageStorageMigrator.cs similarity index 95% rename from src/Turnierplan.ImageStorage/IImageStorageMigrator.cs rename to src/Turnierplan.ImageStorage/ImageStorageMigrator.cs index 3bf1e7e5..b739ad5a 100644 --- a/src/Turnierplan.ImageStorage/IImageStorageMigrator.cs +++ b/src/Turnierplan.ImageStorage/ImageStorageMigrator.cs @@ -2,7 +2,7 @@ namespace Turnierplan.ImageStorage; internal sealed class ImageStorageMigrator(IImageStorage imageStorage, IImageProvider imageProvider) { - public async Task MigrateAsync(CancellationToken cancellationToken) + public async Task MigrateAsync(CancellationToken cancellationToken = default) { if (imageStorage is not IMigratableImageStorage migratableImageStorage) { diff --git a/src/turnierplan.NET.slnx b/src/turnierplan.NET.slnx index b163cf28..31da8eed 100644 --- a/src/turnierplan.NET.slnx +++ b/src/turnierplan.NET.slnx @@ -10,6 +10,7 @@ + From 79fea7ccb501b84db8b45a538452a9c4e8ea0ca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 3 May 2026 10:35:11 +0200 Subject: [PATCH 10/20] Remove spacing --- .../Local/LocalImageStorageMigrationTest.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs index 716f02c5..3d95e278 100644 --- a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs +++ b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs @@ -140,9 +140,7 @@ public async Task Migration___With_Some_Images_In_Old_Structure_But_Image_Provid private async Task RunMigrationAsync() { await using var scope = _serviceProvider.CreateAsyncScope(); - var migrator = scope.ServiceProvider.GetRequiredService(); - await migrator.MigrateAsync(TestContext.Current.CancellationToken); } From db80579cdbcfc58b80b64c17d30c754364797a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 3 May 2026 10:44:13 +0200 Subject: [PATCH 11/20] Migration___With_Some_Images_In_Old_Structure_But_One_Image_File_Already_Exists_In_New_Structure___Works_As_Expected --- .../Local/LocalImageStorageMigrationTest.cs | 73 ++++++++++++++----- .../Local/LocalImageStorageLogger.cs | 2 +- 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs index 3d95e278..94ace5fa 100644 --- a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs +++ b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs @@ -21,6 +21,7 @@ public sealed class LocalImageStorageMigrationTest : IDisposable private const int LogEventIdMigrationWillBeAttempted = 9; private const int LogEventIdImageProviderReturnedNoImages = 10; private const int LogEventIdEncounteredFileWithoutCorrespondingImageFromProvider = 13; + private const int LogEventIdEncounteredTargetFileThatAlreadyExists = 14; private const int LogEventIdSuccessfullyMovedFileTo = 15; private readonly string _basePath; @@ -74,6 +75,7 @@ public async Task Migration___With_Some_Images_In_Old_Structure___Works_As_Expec var image2 = AddNewImage(new DateTime(2025, 05, 15)); var image3 = AddNewImage(new DateTime(2026, 02, 17)); + CheckImageFiles([image1, image2, image3], []); await RunMigrationAsync(); ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdSuccessfullyMovedFileTo); @@ -82,12 +84,7 @@ public async Task Migration___With_Some_Images_In_Old_Structure___Works_As_Expec ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image3.ResourceIdentifier}\.{ImageExtension}' to '.*{image3.ResourceIdentifier}\.{ImageExtension}'\.$"); // Images should all be moved to the new structure - the old files should have been deleted - CheckImageFileInOldStructure(image1, expectExists: false); - CheckImageFileInOldStructure(image2, expectExists: false); - CheckImageFileInOldStructure(image3, expectExists: false); - CheckImageFileInNewStructure(image1, expectExists: true); - CheckImageFileInNewStructure(image2, expectExists: true); - CheckImageFileInNewStructure(image3, expectExists: true); + CheckImageFiles([], [image1, image2, image3]); } [Fact] @@ -99,6 +96,8 @@ public async Task Migration___With_Some_Images_In_Old_Structure_But_Image_Is_Mis _imageProviderState.Images.Remove(image2); + CheckImageFiles([image1, image2, image3], []); + await RunMigrationAsync(); ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdSuccessfullyMovedFileTo, LogEventIdEncounteredFileWithoutCorrespondingImageFromProvider); @@ -107,12 +106,7 @@ public async Task Migration___With_Some_Images_In_Old_Structure_But_Image_Is_Mis ExpectLogMessageExists(LogEventIdEncounteredFileWithoutCorrespondingImageFromProvider, $@"^Encountered a file in the image storage directory for which there exists no corresponding entry from the image provider: '.*{image2.ResourceIdentifier}\.{ImageExtension}'$"); // Images 1 & 3 are moved to the new structure, while image 2 remains in the old structure - CheckImageFileInOldStructure(image1, expectExists: false); - CheckImageFileInOldStructure(image2, expectExists: true); - CheckImageFileInOldStructure(image3, expectExists: false); - CheckImageFileInNewStructure(image1, expectExists: true); - CheckImageFileInNewStructure(image2, expectExists: false); - CheckImageFileInNewStructure(image3, expectExists: true); + CheckImageFiles([image2], [image1, image3]); } [Fact] @@ -122,6 +116,8 @@ public async Task Migration___With_Some_Images_In_Old_Structure_But_Image_Provid var image2 = AddNewImage(new DateTime(2025, 05, 15)); var image3 = AddNewImage(new DateTime(2026, 02, 17)); + CheckImageFiles([image1, image2, image3], []); + _imageProviderState.Images.Clear(); await RunMigrationAsync(); @@ -129,12 +125,33 @@ public async Task Migration___With_Some_Images_In_Old_Structure_But_Image_Provid ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdImageProviderReturnedNoImages); // Images should still exist in the old structure - CheckImageFileInOldStructure(image1, expectExists: true); + CheckImageFiles([image1, image2, image3], []); + } + + [Fact] + public async Task Migration___With_Some_Images_In_Old_Structure_But_One_Image_File_Already_Exists_In_New_Structure___Works_As_Expected() + { + var image1 = AddNewImage(new DateTime(2025, 05, 03)); + var image2 = AddNewImage(new DateTime(2025, 05, 15)); + var image3 = AddNewImage(new DateTime(2026, 02, 17)); + + CreateImageFileInNewStructure(image2.CreatedAt.Year, image2.CreatedAt.Month, GetImageFileName(image2), "data"); + + // Images 1 & 3 only exist in the old structure. Image 2 exists in both structures + CheckImageFiles([image1, image3], []); CheckImageFileInOldStructure(image2, expectExists: true); - CheckImageFileInOldStructure(image3, expectExists: true); - CheckImageFileInNewStructure(image1, expectExists: false); - CheckImageFileInNewStructure(image2, expectExists: false); - CheckImageFileInNewStructure(image3, expectExists: false); + CheckImageFileInNewStructure(image2, expectExists: true); + + await RunMigrationAsync(); + + ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdSuccessfullyMovedFileTo, LogEventIdEncounteredTargetFileThatAlreadyExists); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image1.ResourceIdentifier}\.{ImageExtension}' to '.*{image1.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image2.ResourceIdentifier}\.{ImageExtension}' to '.*{image2.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image3.ResourceIdentifier}\.{ImageExtension}' to '.*{image3.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdEncounteredTargetFileThatAlreadyExists, $@"^Encountered an already existing target file '.*{image2.ResourceIdentifier}\.{ImageExtension}' while migrating '.*{image2.ResourceIdentifier}\.{ImageExtension}' - the move operation will be attempted again\.$"); + + // All images only exist in the new structure + CheckImageFiles([], [image1, image2, image3]); } private async Task RunMigrationAsync() @@ -164,6 +181,13 @@ private void CreateImageFile(int year, string fileName, string contents) File.WriteAllText(Path.Join(directory, fileName), contents); } + private void CreateImageFileInNewStructure(int year, int month, string fileName, string contents) + { + var directory = Path.Join(_basePath, $"{year}", $"{month:D2}"); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Join(directory, fileName), contents); + } + private void CheckImageFileInOldStructure(int year, string fileName, bool expectExists = true) { var path = Path.Join(_basePath, $"{year}", fileName); @@ -186,6 +210,21 @@ private void CheckImageFileInNewStructure(Image image, bool expectExists = true) CheckImageFileInNewStructure(image.CreatedAt.Year, image.CreatedAt.Month, GetImageFileName(image), expectExists); } + private void CheckImageFiles(Image[] imagesInOldStructure, Image[] imagesInNewStructure) + { + foreach (var image in imagesInOldStructure) + { + CheckImageFileInOldStructure(image, expectExists: true); + CheckImageFileInNewStructure(image, expectExists: false); + } + + foreach (var image in imagesInNewStructure) + { + CheckImageFileInOldStructure(image, expectExists: false); + CheckImageFileInNewStructure(image, expectExists: true); + } + } + private void ExpectLogMessages(params int[] expectedEventIds) { var actualEventIds = _loggerProvider.Logger.Messages.Keys; diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorageLogger.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorageLogger.cs index fb692fed..21ab409f 100644 --- a/src/Turnierplan.ImageStorage/Local/LocalImageStorageLogger.cs +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorageLogger.cs @@ -43,7 +43,7 @@ internal sealed partial class LocalImageStorageLogger(ILogger [LoggerMessage(LogLevel.Warning, "Encountered a file in the image storage directory for which there exists no corresponding entry from the image provider: '{ImagePath}'", EventId = 13)] public partial void EncounteredFileWithoutCorrespondingImageFromProvider(string imagePath); - [LoggerMessage(LogLevel.Warning, "Encountered an already existing target file '{TargetPath}' while migrating '{SourcePath}' - the Move operation will be attempted again.", EventId = 14)] + [LoggerMessage(LogLevel.Warning, "Encountered an already existing target file '{TargetPath}' while migrating '{SourcePath}' - the move operation will be attempted again.", EventId = 14)] public partial void EncounteredTargetFileThatAlreadyExists(string targetPath, string sourcePath); [LoggerMessage(LogLevel.Information, "Successfully moved image file '{TargetPath}' to '{SourcePath}'.", EventId = 15)] From 1ad2af3ca6e22edf613bfc014791e79a3d253b7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 3 May 2026 10:50:59 +0200 Subject: [PATCH 12/20] Harden regex and fix swapped paths in logging --- .../Local/LocalImageStorageMigrationTest.cs | 20 +++++++++---------- .../Local/LocalImageStorage.cs | 2 +- .../Local/LocalImageStorageLogger.cs | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs index 94ace5fa..02411015 100644 --- a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs +++ b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs @@ -79,9 +79,9 @@ public async Task Migration___With_Some_Images_In_Old_Structure___Works_As_Expec await RunMigrationAsync(); ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdSuccessfullyMovedFileTo); - ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image1.ResourceIdentifier}\.{ImageExtension}' to '.*{image1.ResourceIdentifier}\.{ImageExtension}'\.$"); - ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image2.ResourceIdentifier}\.{ImageExtension}' to '.*{image2.ResourceIdentifier}\.{ImageExtension}'\.$"); - ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image3.ResourceIdentifier}\.{ImageExtension}' to '.*{image3.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2025[^\d]+{image1.ResourceIdentifier}\.{ImageExtension}' to '.+2025.+05.+{image1.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2025[^\d]+{image2.ResourceIdentifier}\.{ImageExtension}' to '.+2025.+05.+{image2.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2026[^\d]+{image3.ResourceIdentifier}\.{ImageExtension}' to '.+2026.+02.+{image3.ResourceIdentifier}\.{ImageExtension}'\.$"); // Images should all be moved to the new structure - the old files should have been deleted CheckImageFiles([], [image1, image2, image3]); @@ -101,9 +101,9 @@ public async Task Migration___With_Some_Images_In_Old_Structure_But_Image_Is_Mis await RunMigrationAsync(); ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdSuccessfullyMovedFileTo, LogEventIdEncounteredFileWithoutCorrespondingImageFromProvider); - ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image1.ResourceIdentifier}\.{ImageExtension}' to '.*{image1.ResourceIdentifier}\.{ImageExtension}'\.$"); - ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image3.ResourceIdentifier}\.{ImageExtension}' to '.*{image3.ResourceIdentifier}\.{ImageExtension}'\.$"); - ExpectLogMessageExists(LogEventIdEncounteredFileWithoutCorrespondingImageFromProvider, $@"^Encountered a file in the image storage directory for which there exists no corresponding entry from the image provider: '.*{image2.ResourceIdentifier}\.{ImageExtension}'$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2025[^\d]+{image1.ResourceIdentifier}\.{ImageExtension}' to '.+2025.+05.+{image1.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2026[^\d]+{image3.ResourceIdentifier}\.{ImageExtension}' to '.+2026.+02.+{image3.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdEncounteredFileWithoutCorrespondingImageFromProvider, $@"^Encountered a file in the image storage directory for which there exists no corresponding entry from the image provider: '.+2025[^\d]+{image2.ResourceIdentifier}\.{ImageExtension}'$"); // Images 1 & 3 are moved to the new structure, while image 2 remains in the old structure CheckImageFiles([image2], [image1, image3]); @@ -145,10 +145,10 @@ public async Task Migration___With_Some_Images_In_Old_Structure_But_One_Image_Fi await RunMigrationAsync(); ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdSuccessfullyMovedFileTo, LogEventIdEncounteredTargetFileThatAlreadyExists); - ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image1.ResourceIdentifier}\.{ImageExtension}' to '.*{image1.ResourceIdentifier}\.{ImageExtension}'\.$"); - ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image2.ResourceIdentifier}\.{ImageExtension}' to '.*{image2.ResourceIdentifier}\.{ImageExtension}'\.$"); - ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.*{image3.ResourceIdentifier}\.{ImageExtension}' to '.*{image3.ResourceIdentifier}\.{ImageExtension}'\.$"); - ExpectLogMessageExists(LogEventIdEncounteredTargetFileThatAlreadyExists, $@"^Encountered an already existing target file '.*{image2.ResourceIdentifier}\.{ImageExtension}' while migrating '.*{image2.ResourceIdentifier}\.{ImageExtension}' - the move operation will be attempted again\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2025[^\d]+{image1.ResourceIdentifier}\.{ImageExtension}' to '.+2025.+05.+{image1.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2025[^\d]+{image2.ResourceIdentifier}\.{ImageExtension}' to '.+2025.+05.+{image2.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2026[^\d]+{image3.ResourceIdentifier}\.{ImageExtension}' to '.+2026.+02.+{image3.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdEncounteredTargetFileThatAlreadyExists, $@"^Encountered an already existing target file '.+2025.+05.+{image2.ResourceIdentifier}\.{ImageExtension}' while migrating '.+2025[^\d]+{image2.ResourceIdentifier}\.{ImageExtension}' - the move operation will be attempted again\.$"); // All images only exist in the new structure CheckImageFiles([], [image1, image2, image3]); diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs index 533cf8fb..912bd248 100644 --- a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs @@ -193,7 +193,7 @@ public async Task MigrateAsync(IImageProvider imageProvider, CancellationToken c } File.Move(file, targetPath, overwrite: true); - _logger.SuccessfullyMovedFileTo(targetPath, file); + _logger.SuccessfullyMovedFileTo(file, targetPath); } } } diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorageLogger.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorageLogger.cs index 21ab409f..9cd0e8b9 100644 --- a/src/Turnierplan.ImageStorage/Local/LocalImageStorageLogger.cs +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorageLogger.cs @@ -46,8 +46,8 @@ internal sealed partial class LocalImageStorageLogger(ILogger [LoggerMessage(LogLevel.Warning, "Encountered an already existing target file '{TargetPath}' while migrating '{SourcePath}' - the move operation will be attempted again.", EventId = 14)] public partial void EncounteredTargetFileThatAlreadyExists(string targetPath, string sourcePath); - [LoggerMessage(LogLevel.Information, "Successfully moved image file '{TargetPath}' to '{SourcePath}'.", EventId = 15)] - public partial void SuccessfullyMovedFileTo(string targetPath, string sourcePath); + [LoggerMessage(LogLevel.Information, "Successfully moved image file '{SourcePath}' to '{TargetPath}'.", EventId = 15)] + public partial void SuccessfullyMovedFileTo(string sourcePath, string targetPath); [LoggerMessage(LogLevel.Critical, "An unexpected exception occurred while trying to run local image storage migrations.", EventId = 16)] public partial void ExceptionOccurredDuringMigration(Exception exception); From d663342344d5f368b70d5ee0969cab33533bd2ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 3 May 2026 10:54:56 +0200 Subject: [PATCH 13/20] Increase number of images in test --- .../Local/LocalImageStorageMigrationTest.cs | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs index 02411015..f542c9de 100644 --- a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs +++ b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs @@ -69,22 +69,32 @@ public async Task Migration___With_Empty_Directory_And_No_Images___Works_As_Expe } [Fact] - public async Task Migration___With_Some_Images_In_Old_Structure___Works_As_Expected() + public async Task Migration___With_Many_Images_In_Old_Structure___Works_As_Expected() { - var image1 = AddNewImage(new DateTime(2025, 05, 03)); - var image2 = AddNewImage(new DateTime(2025, 05, 15)); - var image3 = AddNewImage(new DateTime(2026, 02, 17)); - - CheckImageFiles([image1, image2, image3], []); + var image1 = AddNewImage(new DateTime(2023, 07, 03)); + var image2 = AddNewImage(new DateTime(2024, 08, 15)); + var image3 = AddNewImage(new DateTime(2024, 08, 17)); + var image4 = AddNewImage(new DateTime(2025, 03, 12)); + var image5 = AddNewImage(new DateTime(2025, 03, 05)); + var image6 = AddNewImage(new DateTime(2025, 06, 29)); + var image7 = AddNewImage(new DateTime(2026, 04, 21)); + var image8 = AddNewImage(new DateTime(2026, 06, 04)); + + CheckImageFiles([image1, image2, image3, image4, image5, image6, image7, image8], []); await RunMigrationAsync(); ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdSuccessfullyMovedFileTo); - ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2025[^\d]+{image1.ResourceIdentifier}\.{ImageExtension}' to '.+2025.+05.+{image1.ResourceIdentifier}\.{ImageExtension}'\.$"); - ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2025[^\d]+{image2.ResourceIdentifier}\.{ImageExtension}' to '.+2025.+05.+{image2.ResourceIdentifier}\.{ImageExtension}'\.$"); - ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2026[^\d]+{image3.ResourceIdentifier}\.{ImageExtension}' to '.+2026.+02.+{image3.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2023[^\d]+{image1.ResourceIdentifier}\.{ImageExtension}' to '.+2023.+07.+{image1.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2024[^\d]+{image2.ResourceIdentifier}\.{ImageExtension}' to '.+2024.+08.+{image2.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2024[^\d]+{image3.ResourceIdentifier}\.{ImageExtension}' to '.+2024.+08.+{image3.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2025[^\d]+{image4.ResourceIdentifier}\.{ImageExtension}' to '.+2025.+03.+{image4.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2025[^\d]+{image5.ResourceIdentifier}\.{ImageExtension}' to '.+2025.+03.+{image5.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2025[^\d]+{image6.ResourceIdentifier}\.{ImageExtension}' to '.+2025.+06.+{image6.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2026[^\d]+{image7.ResourceIdentifier}\.{ImageExtension}' to '.+2026.+04.+{image7.ResourceIdentifier}\.{ImageExtension}'\.$"); + ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2026[^\d]+{image8.ResourceIdentifier}\.{ImageExtension}' to '.+2026.+06.+{image8.ResourceIdentifier}\.{ImageExtension}'\.$"); // Images should all be moved to the new structure - the old files should have been deleted - CheckImageFiles([], [image1, image2, image3]); + CheckImageFiles([], [image1, image2, image3, image4, image5, image6, image7, image8]); } [Fact] From d85e7537773276bd38d53b0cf9037dc382a3efb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 3 May 2026 10:57:51 +0200 Subject: [PATCH 14/20] Update using --- .../Local/LocalImageStorageMigrationTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs index f542c9de..af98fc89 100644 --- a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs +++ b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Reflection; using System.Text.RegularExpressions; using FluentAssertions; using Microsoft.Extensions.Configuration; @@ -8,7 +9,6 @@ using Turnierplan.Core.PublicId; using Turnierplan.ImageStorage.Extensions; using Xunit; -using BindingFlags = System.Reflection.BindingFlags; namespace Turnierplan.ImageStorage.Test.Migration.Local; From 5daf45d993260d011847d8085c52a4c83799f0b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 3 May 2026 11:03:26 +0200 Subject: [PATCH 15/20] Migration___With_Images_In_Provider_But_Storage_Directory_Is_Empty___Works_As_Expected --- .../Local/LocalImageStorageMigrationTest.cs | 51 ++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs index af98fc89..65191041 100644 --- a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs +++ b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs @@ -69,7 +69,33 @@ public async Task Migration___With_Empty_Directory_And_No_Images___Works_As_Expe } [Fact] - public async Task Migration___With_Many_Images_In_Old_Structure___Works_As_Expected() + public async Task Migration___With_Images_In_Provider_But_Storage_Directory_Is_Empty___Works_As_Expected() + { + var image1 = AddNewImage(new DateTime(2025, 07, 03), createImageFile: false); + var image2 = AddNewImage(new DateTime(2025, 08, 15), createImageFile: false); + + // No image files exist in either old/new structure + CheckImageFileInOldStructure(image1, expectExists: false); + CheckImageFileInOldStructure(image2, expectExists: false); + CheckImageFileInNewStructure(image1, expectExists: false); + CheckImageFileInNewStructure(image2, expectExists: false); + + await RunMigrationAsync(); + + // If no physical files exist, the image storage migration will never query the image provider. + _imageProviderState.WasQueried.Should().BeFalse(); + + ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdDirectoryStructureIsUpToDate); + + // No image files exist in either old/new structure + CheckImageFileInOldStructure(image1, expectExists: false); + CheckImageFileInOldStructure(image2, expectExists: false); + CheckImageFileInNewStructure(image1, expectExists: false); + CheckImageFileInNewStructure(image2, expectExists: false); + } + + [Fact] + public async Task Migration___With_Images_In_Old_Structure___Works_As_Expected() { var image1 = AddNewImage(new DateTime(2023, 07, 03)); var image2 = AddNewImage(new DateTime(2024, 08, 15)); @@ -83,6 +109,8 @@ public async Task Migration___With_Many_Images_In_Old_Structure___Works_As_Expec CheckImageFiles([image1, image2, image3, image4, image5, image6, image7, image8], []); await RunMigrationAsync(); + _imageProviderState.WasQueried.Should().BeTrue(); + ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdSuccessfullyMovedFileTo); ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2023[^\d]+{image1.ResourceIdentifier}\.{ImageExtension}' to '.+2023.+07.+{image1.ResourceIdentifier}\.{ImageExtension}'\.$"); ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2024[^\d]+{image2.ResourceIdentifier}\.{ImageExtension}' to '.+2024.+08.+{image2.ResourceIdentifier}\.{ImageExtension}'\.$"); @@ -98,7 +126,7 @@ public async Task Migration___With_Many_Images_In_Old_Structure___Works_As_Expec } [Fact] - public async Task Migration___With_Some_Images_In_Old_Structure_But_Image_Is_Missing_One_Specific_Image___Works_As_Expected() + public async Task Migration___With_Images_In_Old_Structure_But_Image_Is_Missing_One_Specific_Image___Works_As_Expected() { var image1 = AddNewImage(new DateTime(2025, 05, 03)); var image2 = AddNewImage(new DateTime(2025, 05, 15)); @@ -120,7 +148,7 @@ public async Task Migration___With_Some_Images_In_Old_Structure_But_Image_Is_Mis } [Fact] - public async Task Migration___With_Some_Images_In_Old_Structure_But_Image_Provider_Returns_No_Images___Works_As_Expected() + public async Task Migration___With_Images_In_Old_Structure_But_Image_Provider_Returns_No_Images___Works_As_Expected() { var image1 = AddNewImage(new DateTime(2025, 05, 03)); var image2 = AddNewImage(new DateTime(2025, 05, 15)); @@ -139,7 +167,7 @@ public async Task Migration___With_Some_Images_In_Old_Structure_But_Image_Provid } [Fact] - public async Task Migration___With_Some_Images_In_Old_Structure_But_One_Image_File_Already_Exists_In_New_Structure___Works_As_Expected() + public async Task Migration___With_Images_In_Old_Structure_But_One_Image_File_Already_Exists_In_New_Structure___Works_As_Expected() { var image1 = AddNewImage(new DateTime(2025, 05, 03)); var image2 = AddNewImage(new DateTime(2025, 05, 15)); @@ -171,7 +199,7 @@ private async Task RunMigrationAsync() await migrator.MigrateAsync(TestContext.Current.CancellationToken); } - private Image AddNewImage(DateTime createdAt) + private Image AddNewImage(DateTime createdAt, bool createImageFile = true) { 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)]); var id = (long)_imageProviderState.Images.Count + 1; @@ -179,7 +207,10 @@ private Image AddNewImage(DateTime createdAt) _imageProviderState.Images.Add(image); - CreateImageFile(image.CreatedAt.Year, GetImageFileName(image), image.Name); + if (createImageFile) + { + CreateImageFile(image.CreatedAt.Year, GetImageFileName(image), image.Name); + } return image; } @@ -278,10 +309,16 @@ public void Dispose() { } private sealed class StateForTestImageProvider { public readonly List Images = []; + public bool WasQueried; } private sealed class TestImageProvider(StateForTestImageProvider state) : IImageProvider { - public Task> GetImagesAsync() => Task.FromResult>(state.Images.AsReadOnly()); + public Task> GetImagesAsync() + { + state.WasQueried = true; + + return Task.FromResult>(state.Images.AsReadOnly()); + } } } From 13a314891c2d3211ae9ffe420c14d3b1ce8d85a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Sun, 3 May 2026 11:11:48 +0200 Subject: [PATCH 16/20] add WasQueried check --- .../Local/LocalImageStorageMigrationTest.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs index 65191041..5a5585fc 100644 --- a/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs +++ b/src/Turnierplan.ImageStorage.Test.Migration/Local/LocalImageStorageMigrationTest.cs @@ -138,6 +138,8 @@ public async Task Migration___With_Images_In_Old_Structure_But_Image_Is_Missing_ await RunMigrationAsync(); + _imageProviderState.WasQueried.Should().BeTrue(); + ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdSuccessfullyMovedFileTo, LogEventIdEncounteredFileWithoutCorrespondingImageFromProvider); ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2025[^\d]+{image1.ResourceIdentifier}\.{ImageExtension}' to '.+2025.+05.+{image1.ResourceIdentifier}\.{ImageExtension}'\.$"); ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2026[^\d]+{image3.ResourceIdentifier}\.{ImageExtension}' to '.+2026.+02.+{image3.ResourceIdentifier}\.{ImageExtension}'\.$"); @@ -160,6 +162,8 @@ public async Task Migration___With_Images_In_Old_Structure_But_Image_Provider_Re await RunMigrationAsync(); + _imageProviderState.WasQueried.Should().BeTrue(); + ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdImageProviderReturnedNoImages); // Images should still exist in the old structure @@ -182,6 +186,8 @@ public async Task Migration___With_Images_In_Old_Structure_But_One_Image_File_Al await RunMigrationAsync(); + _imageProviderState.WasQueried.Should().BeTrue(); + ExpectLogMessages(LogEventIdUsingDirectoryForStorage, LogEventIdMigrationWillBeAttempted, LogEventIdSuccessfullyMovedFileTo, LogEventIdEncounteredTargetFileThatAlreadyExists); ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2025[^\d]+{image1.ResourceIdentifier}\.{ImageExtension}' to '.+2025.+05.+{image1.ResourceIdentifier}\.{ImageExtension}'\.$"); ExpectLogMessageExists(LogEventIdSuccessfullyMovedFileTo, $@"^Successfully moved image file '.+2025[^\d]+{image2.ResourceIdentifier}\.{ImageExtension}' to '.+2025.+05.+{image2.ResourceIdentifier}\.{ImageExtension}'\.$"); From dc919b584f862f29a8dbeda0f38a99a8016a0498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 14 May 2026 21:18:03 +0200 Subject: [PATCH 17/20] internal --- src/Turnierplan.ImageStorage/IMigratableImageStorage.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turnierplan.ImageStorage/IMigratableImageStorage.cs b/src/Turnierplan.ImageStorage/IMigratableImageStorage.cs index 31038092..e75fe58e 100644 --- a/src/Turnierplan.ImageStorage/IMigratableImageStorage.cs +++ b/src/Turnierplan.ImageStorage/IMigratableImageStorage.cs @@ -1,6 +1,6 @@ namespace Turnierplan.ImageStorage; -public interface IMigratableImageStorage +internal interface IMigratableImageStorage { Task MigrateAsync(IImageProvider imageProvider, CancellationToken cancellationToken); } From 7bcda367cb512a7f47c53a20ca883dcae36698a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 14 May 2026 21:21:54 +0200 Subject: [PATCH 18/20] remove unnecessary base class --- .../Azure/AzureImageStorage.cs | 14 +++++---- .../ImageStorageBase.cs | 29 ------------------- .../Local/LocalImageStorage.cs | 14 +++++---- .../S3/S3ImageStorage.cs | 19 +++++------- 4 files changed, 25 insertions(+), 51 deletions(-) delete mode 100644 src/Turnierplan.ImageStorage/ImageStorageBase.cs diff --git a/src/Turnierplan.ImageStorage/Azure/AzureImageStorage.cs b/src/Turnierplan.ImageStorage/Azure/AzureImageStorage.cs index 60b28b34..4f7e92fe 100644 --- a/src/Turnierplan.ImageStorage/Azure/AzureImageStorage.cs +++ b/src/Turnierplan.ImageStorage/Azure/AzureImageStorage.cs @@ -8,7 +8,7 @@ namespace Turnierplan.ImageStorage.Azure; -internal sealed class AzureImageStorage : ImageStorageBase +internal sealed class AzureImageStorage : IImageStorage { private readonly ILogger _logger; private readonly BlobContainerClient _client; @@ -71,14 +71,14 @@ public AzureImageStorage(IOptions options, ILogger SaveImageAsync(Image image, MemoryStream imageData) + public async Task SaveImageAsync(Image image, MemoryStream imageData) { var blobName = GetBlobName(image); var blobClient = _client.GetBlobClient(blobName); @@ -97,7 +97,7 @@ public override async Task SaveImageAsync(Image image, MemoryStream imageD return false; } - public override async Task GetImageAsync(Image image) + public async Task GetImageAsync(Image image) { var blobName = GetBlobName(image); var blobClient = _client.GetBlobClient(blobName); @@ -116,7 +116,7 @@ public override async Task GetImageAsync(Image image) } } - public override async Task DeleteImageAsync(Image image) + public async Task DeleteImageAsync(Image image) { var blobName = GetBlobName(image); var blobClient = _client.GetBlobClient(blobName); @@ -135,6 +135,10 @@ public override async Task DeleteImageAsync(Image image) return false; } + public void Dispose() + { + } + private static string GetBlobName(Image image) { return $"{image.CreatedAt.Year}/{image.CreatedAt.Month:D2}/{image.ResourceIdentifier}.{image.FileExtension}"; diff --git a/src/Turnierplan.ImageStorage/ImageStorageBase.cs b/src/Turnierplan.ImageStorage/ImageStorageBase.cs deleted file mode 100644 index 78132132..00000000 --- a/src/Turnierplan.ImageStorage/ImageStorageBase.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Turnierplan.Core.Image; - -namespace Turnierplan.ImageStorage; - -internal abstract class ImageStorageBase : IImageStorage -{ - ~ImageStorageBase() - { - Dispose(false); - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - public abstract string GetFullImageUrl(Image image); - - public abstract Task SaveImageAsync(Image image, MemoryStream imageData); - - public abstract Task GetImageAsync(Image image); - - public abstract Task DeleteImageAsync(Image image); - - protected virtual void Dispose(bool disposing) - { - } -} diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs index 912bd248..037ba1b0 100644 --- a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs @@ -8,7 +8,7 @@ namespace Turnierplan.ImageStorage.Local; -internal sealed partial class LocalImageStorage : ImageStorageBase, IMigratableImageStorage +internal sealed partial class LocalImageStorage : IImageStorage, IMigratableImageStorage { private readonly LocalImageStorageLogger _logger; private readonly string _storagePath; @@ -33,12 +33,12 @@ public LocalImageStorage(ILogger logger, IOptions SaveImageAsync(Image image, MemoryStream imageData) + public Task SaveImageAsync(Image image, MemoryStream imageData) { var filePath = GetImageFullPath(image); @@ -75,12 +75,12 @@ public override Task SaveImageAsync(Image image, MemoryStream imageData) } } - public override Task GetImageAsync(Image image) + public Task GetImageAsync(Image image) { return Task.FromResult(new FileStream(GetImageFullPath(image), FileMode.Open)); } - public override Task DeleteImageAsync(Image image) + public Task DeleteImageAsync(Image image) { var filePath = GetImageFullPath(image); @@ -98,6 +98,10 @@ public override Task DeleteImageAsync(Image image) return Task.FromResult(true); } + public void Dispose() + { + } + public void MapEndpoint(IApplicationBuilder builder) { builder.UseStaticFiles(new StaticFileOptions diff --git a/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs b/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs index 723ae182..0c54c038 100644 --- a/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs +++ b/src/Turnierplan.ImageStorage/S3/S3ImageStorage.cs @@ -9,7 +9,7 @@ namespace Turnierplan.ImageStorage.S3; -internal sealed class S3ImageStorage : ImageStorageBase +internal sealed class S3ImageStorage : IImageStorage { private readonly ILogger _logger; private readonly AmazonS3Client _client; @@ -55,7 +55,7 @@ public S3ImageStorage(IOptions options, ILogger SaveImageAsync(Image image, MemoryStream imageData) + public async Task SaveImageAsync(Image image, MemoryStream imageData) { var objectKey = GetObjectKey(image); @@ -95,7 +95,7 @@ public override async Task SaveImageAsync(Image image, MemoryStream imageD return false; } - public override async Task GetImageAsync(Image image) + public async Task GetImageAsync(Image image) { var objectKey = GetObjectKey(image); @@ -117,7 +117,7 @@ public override async Task GetImageAsync(Image image) throw new InvalidOperationException($"Failed to read image from S3. Status code: {response.HttpStatusCode}"); } - public override async Task DeleteImageAsync(Image image) + public async Task DeleteImageAsync(Image image) { var objectKey = GetObjectKey(image); @@ -144,14 +144,9 @@ public override async Task DeleteImageAsync(Image image) return false; } - protected override void Dispose(bool disposing) + public void Dispose() { - base.Dispose(disposing); - - if (disposing) - { - _client.Dispose(); - } + _client.Dispose(); } private static string GetObjectKey(Image image) From 07dd6a271fb248a7ecf64e063ccc44df32bdd582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Thu, 14 May 2026 21:38:12 +0200 Subject: [PATCH 19/20] re-introduce interfaace IHostedImageStorage --- .../Extensions/WebApplicationExtensions.cs | 3 +-- src/Turnierplan.ImageStorage/IHostedImageStorage.cs | 8 ++++++++ src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 src/Turnierplan.ImageStorage/IHostedImageStorage.cs diff --git a/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs b/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs index b66ae33d..c7833596 100644 --- a/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs +++ b/src/Turnierplan.ImageStorage/Extensions/WebApplicationExtensions.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; -using Turnierplan.ImageStorage.Local; namespace Turnierplan.ImageStorage.Extensions; @@ -10,7 +9,7 @@ public static void MapImageStorageEndpoint(this WebApplication application) { var imageStorage = application.Services.GetRequiredService(); - if (imageStorage is not LocalImageStorage localImageStorage) + if (imageStorage is not IHostedImageStorage localImageStorage) { return; } diff --git a/src/Turnierplan.ImageStorage/IHostedImageStorage.cs b/src/Turnierplan.ImageStorage/IHostedImageStorage.cs new file mode 100644 index 00000000..387393a4 --- /dev/null +++ b/src/Turnierplan.ImageStorage/IHostedImageStorage.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Builder; + +namespace Turnierplan.ImageStorage; + +internal interface IHostedImageStorage +{ + void MapEndpoint(IApplicationBuilder applicationBuilder); +} diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs index 037ba1b0..95273cc0 100644 --- a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs @@ -8,7 +8,7 @@ namespace Turnierplan.ImageStorage.Local; -internal sealed partial class LocalImageStorage : IImageStorage, IMigratableImageStorage +internal sealed partial class LocalImageStorage : IImageStorage, IHostedImageStorage, IMigratableImageStorage { private readonly LocalImageStorageLogger _logger; private readonly string _storagePath; From f15609038694825abe4bf4332943b30e0e27abd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20H=C3=B6rner?= Date: Fri, 15 May 2026 12:18:32 +0200 Subject: [PATCH 20/20] fix method order --- src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs index 95273cc0..c973c9c8 100644 --- a/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs +++ b/src/Turnierplan.ImageStorage/Local/LocalImageStorage.cs @@ -98,10 +98,6 @@ public Task DeleteImageAsync(Image image) return Task.FromResult(true); } - public void Dispose() - { - } - public void MapEndpoint(IApplicationBuilder builder) { builder.UseStaticFiles(new StaticFileOptions @@ -211,6 +207,10 @@ public async Task MigrateAsync(IImageProvider imageProvider, CancellationToken c } } + public void Dispose() + { + } + private string GetImageFullPath(Image image) { return Path.Join(