From a6ff7cec30081cf1496783f218b0817158fd42b8 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 4 Aug 2025 14:17:26 +0300 Subject: [PATCH 01/19] Allow empty validation when email is null --- .../shared-kernel/SharedKernel/Validation/SharedValidations.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs b/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs index 07756a338d..94273040c3 100644 --- a/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs +++ b/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs @@ -4,7 +4,7 @@ namespace PlatformPlatform.SharedKernel.Validation; public static class SharedValidations { - public sealed class Email : AbstractValidator + public sealed class Email : AbstractValidator { // While emails can be longer, we will limit them to 100 characters which should be enough for most cases private const int EmailMaxLength = 100; From ca8b69b08aec8f49c5de1690214afdca75b0075f Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 13 Aug 2025 01:33:41 +0200 Subject: [PATCH 02/19] Move AddCrossServiceDataProtection from ApiDependencyConfiguration to SharedDependencyConfiguration --- .../Configuration/ApiDependencyConfiguration.cs | 16 ---------------- .../SharedDependencyConfiguration.cs | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs index 2350638345..92a41a2944 100644 --- a/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/ApiDependencyConfiguration.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; @@ -68,7 +67,6 @@ public IServiceCollection AddApiServices(Assembly[] assemblies) .AddApiEndpoints(assemblies) .AddOpenApiConfiguration(assemblies) .AddAuthConfiguration() - .AddCrossServiceDataProtection() .AddAntiforgery(options => { options.Cookie.Name = AuthenticationTokenHttpKeys.AntiforgeryTokenCookieName; @@ -186,20 +184,6 @@ private IServiceCollection AddAuthConfiguration() return services.AddAuthorization(); } - private IServiceCollection AddCrossServiceDataProtection() - { - // Configure shared data protection to ensure encrypted data can be shared across all self-contained systems - var dataProtection = services.AddDataProtection(); - - if (!SharedInfrastructureConfiguration.IsRunningInAzure) - { - // Set a common application name for all self-contained systems for local development (handled automatically by Azure Container Apps Environment) - dataProtection.SetApplicationName("PlatformPlatform"); - } - - return services; - } - public IServiceCollection AddHttpForwardHeaders() { // Ensure correct client IP addresses are set for requests diff --git a/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs index d2a3c3616e..f41782b3e1 100644 --- a/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs @@ -3,6 +3,7 @@ using Azure.Security.KeyVault.Keys.Cryptography; using Azure.Security.KeyVault.Secrets; using FluentValidation; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http.Json; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -63,6 +64,7 @@ public IServiceCollection AddSharedServices(Assembly[] assemblies) return services .AddServiceDiscovery() .AddSingleton(GetTokenSigningService()) + .AddCrossServiceDataProtection() .AddSingleton(Settings.Current) .AddAuthentication() .AddDefaultJsonSerializerOptions() @@ -74,6 +76,20 @@ public IServiceCollection AddSharedServices(Assembly[] assemblies) .RegisterRepositories(assemblies); } + private IServiceCollection AddCrossServiceDataProtection() + { + // Configure shared data protection to ensure encrypted data can be shared across all self-contained systems + var dataProtection = services.AddDataProtection(); + + if (!SharedInfrastructureConfiguration.IsRunningInAzure) + { + // Set a common application name for all self-contained systems for local development (handled automatically by Azure Container Apps Environment) + dataProtection.SetApplicationName("PlatformPlatform"); + } + + return services; + } + private IServiceCollection AddAuthentication() { return services From 2993cfcd77be55708399f01eabc7168c41c5bf00 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 15 Aug 2025 14:28:28 +0200 Subject: [PATCH 03/19] Fix UseStringForEnums to handle nullable enum properties --- .../EntityFramework/ModelBuilderExtensions.cs | 9 +- .../EntityFramework/UseStringForEnumsTests.cs | 106 ++++++++++++++++++ .../Tests/TestEntities/TestAggregate.cs | 11 ++ 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 application/shared-kernel/Tests/EntityFramework/UseStringForEnumsTests.cs diff --git a/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs b/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs index 18730801f7..16178e8974 100644 --- a/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs +++ b/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs @@ -66,9 +66,14 @@ public ModelBuilder UseStringForEnums() { foreach (var property in entityType.GetProperties()) { - if (!property.ClrType.IsEnum) continue; + // Get the enum type (handling both nullable and non-nullable enums) + var enumType = property.ClrType.IsEnum + ? property.ClrType + : Nullable.GetUnderlyingType(property.ClrType); - var converterType = typeof(EnumToStringConverter<>).MakeGenericType(property.ClrType); + if (enumType?.IsEnum != true) continue; + + var converterType = typeof(EnumToStringConverter<>).MakeGenericType(enumType); var converterInstance = (ValueConverter)Activator.CreateInstance(converterType)!; property.SetValueConverter(converterInstance); } diff --git a/application/shared-kernel/Tests/EntityFramework/UseStringForEnumsTests.cs b/application/shared-kernel/Tests/EntityFramework/UseStringForEnumsTests.cs new file mode 100644 index 0000000000..9fc57430e0 --- /dev/null +++ b/application/shared-kernel/Tests/EntityFramework/UseStringForEnumsTests.cs @@ -0,0 +1,106 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using PlatformPlatform.SharedKernel.ExecutionContext; +using PlatformPlatform.SharedKernel.Tests.Persistence; +using PlatformPlatform.SharedKernel.Tests.TestEntities; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.EntityFramework; + +public sealed class UseStringForEnumsTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly SqliteInMemoryDbContextFactory _sqliteInMemoryDbContextFactory; + private readonly TestDbContext _testDbContext; + + public UseStringForEnumsTests() + { + var executionContext = new BackgroundWorkerExecutionContext(); + _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(executionContext); + _testDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); + _connection = (SqliteConnection)_testDbContext.Database.GetDbConnection(); + } + + public void Dispose() + { + _sqliteInMemoryDbContextFactory.Dispose(); + } + + [Fact] + public async Task UseStringForEnums_WhenSavingEntity_ShouldStoreEnumAsString() + { + // Arrange + var testAggregate = TestAggregate.Create("Test"); + testAggregate.Status = TestStatus.Active; + + // Act + _testDbContext.TestAggregates.Add(testAggregate); + await _testDbContext.SaveChangesAsync(); + + // Assert - Query the raw database to verify enum is stored as string + var result = _connection.ExecuteScalar( + "SELECT Status FROM TestAggregates WHERE Id = @id", + [new { id = testAggregate.Id }] + ); + result.Should().Be("Active"); + } + + [Fact] + public async Task UseStringForEnums_WhenSavingEntityWithNullableEnum_ShouldStoreEnumAsString() + { + // Arrange + var testAggregate = TestAggregate.Create("Test"); + testAggregate.NullableStatus = TestStatus.Completed; + + // Act + _testDbContext.TestAggregates.Add(testAggregate); + await _testDbContext.SaveChangesAsync(); + + // Assert - Query the raw database to verify nullable enum is stored as string + var result = _connection.ExecuteScalar( + "SELECT NullableStatus FROM TestAggregates WHERE Id = @id", + [new { id = testAggregate.Id }] + ); + result.Should().Be("Completed"); + } + + [Fact] + public async Task UseStringForEnums_WhenSavingEntityWithNullEnum_ShouldStoreNull() + { + // Arrange + var testAggregate = TestAggregate.Create("Test"); + testAggregate.NullableStatus = null; + + // Act + _testDbContext.TestAggregates.Add(testAggregate); + await _testDbContext.SaveChangesAsync(); + + // Assert - Query the raw database to verify null is stored + var result = _connection.ExecuteScalar( + "SELECT NullableStatus FROM TestAggregates WHERE Id = @id", + [new { id = testAggregate.Id }] + ); + result.Should().BeNull(); + } + + [Fact] + public async Task UseStringForEnums_WhenReadingEntity_ShouldCorrectlyDeserializeEnums() + { + // Arrange + var testAggregate = TestAggregate.Create("Test"); + testAggregate.Status = TestStatus.Completed; + testAggregate.NullableStatus = TestStatus.Active; + _testDbContext.TestAggregates.Add(testAggregate); + await _testDbContext.SaveChangesAsync(); + _testDbContext.ChangeTracker.Clear(); + + // Act + var retrievedAggregate = await _testDbContext.TestAggregates.FindAsync(testAggregate.Id); + + // Assert + retrievedAggregate.Should().NotBeNull(); + retrievedAggregate.Status.Should().Be(TestStatus.Completed); + retrievedAggregate.NullableStatus.Should().Be(TestStatus.Active); + } +} diff --git a/application/shared-kernel/Tests/TestEntities/TestAggregate.cs b/application/shared-kernel/Tests/TestEntities/TestAggregate.cs index 44a1f4107b..abebcb6201 100644 --- a/application/shared-kernel/Tests/TestEntities/TestAggregate.cs +++ b/application/shared-kernel/Tests/TestEntities/TestAggregate.cs @@ -3,11 +3,22 @@ namespace PlatformPlatform.SharedKernel.Tests.TestEntities; +public enum TestStatus +{ + Pending, + Active, + Completed +} + public sealed class TestAggregate(string name) : AggregateRoot(IdGenerator.NewId()) { // ReSharper disable once EntityFramework.ModelValidation.UnlimitedStringLength public string Name { get; set; } = name; + public TestStatus Status { get; set; } = TestStatus.Pending; + + public TestStatus? NullableStatus { get; set; } + public static TestAggregate Create(string name) { var testAggregate = new TestAggregate(name); From b156bde35c80f23b00bbb97c68939d33ef25e6ae Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 19 Aug 2025 16:40:28 +0200 Subject: [PATCH 04/19] Remove error message from SharedValidation of phone numbers --- .../shared-kernel/SharedKernel/Validation/SharedValidations.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs b/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs index 94273040c3..bcf04ff934 100644 --- a/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs +++ b/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs @@ -49,12 +49,11 @@ public sealed class Phone : AbstractValidator // Additional 5 characters are added to allow for spaces, dashes, parentheses, etc. private const int PhoneMaxLength = 20; - public Phone(string phoneName = nameof(Phone)) + public Phone() { const string errorMessage = "Phone must be in a valid format and no longer than 20 characters."; RuleFor(phone => phone) .MaximumLength(PhoneMaxLength) - .WithName(phoneName) .WithMessage(errorMessage) .Matches(@"^\+?(\d[\d-. ]+)?(\([\d-. ]+\))?[\d-. ]+\d$") .WithMessage(errorMessage) From d0d6344fae6aebbbddc0506db3f2b8badd2a4a27 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 23 Aug 2025 13:08:14 +0200 Subject: [PATCH 05/19] Add StronglyTypedString for creating strongly typed IDs with strings as base --- .../EntityFramework/ModelBuilderExtensions.cs | 7 + .../StronglyTypedIds/StronglyTypedString.cs | 75 ++++++++++ .../MapStronglyTypedStringTests.cs | 95 ++++++++++++ .../StronglyTypedStringTests.cs | 139 ++++++++++++++++++ .../Tests/TestEntities/TestAggregate.cs | 5 + .../Tests/TestEntities/TestDbContext.cs | 2 + 6 files changed, 323 insertions(+) create mode 100644 application/shared-kernel/SharedKernel/StronglyTypedIds/StronglyTypedString.cs create mode 100644 application/shared-kernel/Tests/EntityFramework/MapStronglyTypedStringTests.cs create mode 100644 application/shared-kernel/Tests/StronglyTypedIds/StronglyTypedStringTests.cs diff --git a/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs b/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs index 16178e8974..6e1938d1c4 100644 --- a/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs +++ b/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs @@ -28,6 +28,13 @@ public void MapStronglyTypedUuid(Expression> expression) where .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); } + public void MapStronglyTypedString(Expression> expression) where TId : StronglyTypedString + { + builder + .Property(expression) + .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); + } + public void MapStronglyTypedId(Expression> expression) where TValue : IComparable where TId : StronglyTypedId diff --git a/application/shared-kernel/SharedKernel/StronglyTypedIds/StronglyTypedString.cs b/application/shared-kernel/SharedKernel/StronglyTypedIds/StronglyTypedString.cs new file mode 100644 index 0000000000..536d76c550 --- /dev/null +++ b/application/shared-kernel/SharedKernel/StronglyTypedIds/StronglyTypedString.cs @@ -0,0 +1,75 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; + +namespace PlatformPlatform.SharedKernel.StronglyTypedIds; + +/// +/// This is a strongly typed ID for string values. It uses a custom string value with an optional prefix. +/// IDs can be prefixed with the value of the inspired by Stripe's API. +/// +public abstract record StronglyTypedString(string Value) : StronglyTypedId(Value) + where T : StronglyTypedString +{ + public static T NewId(string value) + { + var prefixWithUnderscore = PrefixCache.GetPrefixWithUnderscore(typeof(T)); + if (prefixWithUnderscore is not null && !IsValidPrefixedValue(value, prefixWithUnderscore)) + { + var prefix = PrefixCache.GetPrefix(typeof(T)); + throw new ArgumentException($"Value must start with prefix '{prefix}_' followed by at least one character", nameof(value)); + } + + return CreateInstance(value); + } + + public static bool TryParse(string? value, [NotNullWhen(true)] out T? result) + { + var prefixWithUnderscore = PrefixCache.GetPrefixWithUnderscore(typeof(T)); + if (value is null || (prefixWithUnderscore is not null && !IsValidPrefixedValue(value, prefixWithUnderscore))) + { + result = null; + return false; + } + + result = CreateInstance(value); + return true; + } + + private static bool IsValidPrefixedValue(string value, string prefixWithUnderscore) + { + return value.AsSpan().StartsWith(prefixWithUnderscore, StringComparison.Ordinal) + && value.Length > prefixWithUnderscore.Length; + } + + private static T CreateInstance(string value) + { + return (T)Activator.CreateInstance( + typeof(T), + BindingFlags.Instance | BindingFlags.Public, + null, + [value], + null + )!; + } +} + +internal static class PrefixCache +{ + private static readonly ConcurrentDictionary Prefixes = new(); + private static readonly ConcurrentDictionary PrefixesWithUnderscore = new(); + + public static string? GetPrefix(Type type) + { + return Prefixes.GetOrAdd(type, t => t.GetCustomAttribute()?.Prefix); + } + + public static string? GetPrefixWithUnderscore(Type type) + { + return PrefixesWithUnderscore.GetOrAdd(type, t => + { + var prefix = GetPrefix(t); + return prefix is not null ? $"{prefix}_" : null; + } + ); + } +} diff --git a/application/shared-kernel/Tests/EntityFramework/MapStronglyTypedStringTests.cs b/application/shared-kernel/Tests/EntityFramework/MapStronglyTypedStringTests.cs new file mode 100644 index 0000000000..8c31f82d45 --- /dev/null +++ b/application/shared-kernel/Tests/EntityFramework/MapStronglyTypedStringTests.cs @@ -0,0 +1,95 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using PlatformPlatform.SharedKernel.ExecutionContext; +using PlatformPlatform.SharedKernel.Tests.Persistence; +using PlatformPlatform.SharedKernel.Tests.TestEntities; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.EntityFramework; + +public sealed class MapStronglyTypedStringTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly SqliteInMemoryDbContextFactory _sqliteInMemoryDbContextFactory; + private readonly TestDbContext _testDbContext; + + public MapStronglyTypedStringTests() + { + var executionContext = new BackgroundWorkerExecutionContext(); + _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(executionContext); + _testDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); + _connection = (SqliteConnection)_testDbContext.Database.GetDbConnection(); + } + + public void Dispose() + { + _sqliteInMemoryDbContextFactory.Dispose(); + } + + [Fact] + public async Task MapStronglyTypedString_WhenSavingEntity_ShouldStoreStringValue() + { + // Arrange + var testAggregate = TestAggregate.Create("Test"); + testAggregate.ExternalId = ExternalId.NewId("ext_abc123"); + + // Act + _testDbContext.TestAggregates.Add(testAggregate); + await _testDbContext.SaveChangesAsync(); + + // Assert + var result = _connection.ExecuteScalar( + "SELECT ExternalId FROM TestAggregates WHERE Id = @id", + [new { id = testAggregate.Id }] + ); + result.Should().Be("ext_abc123"); + } + + [Fact] + public async Task MapStronglyTypedString_WhenReadingEntity_ShouldDeserializeCorrectly() + { + // Arrange + const long id = 123; + _connection.Insert("TestAggregates", + [ + ("Id", id), + ("Name", "Test"), + ("Status", "Pending"), + ("ExternalId", "ext_xyz789"), + ("CreatedAt", DateTime.UtcNow.ToString("O")) + ] + ); + + // Act + var retrievedAggregate = await _testDbContext.TestAggregates.FindAsync(id); + + // Assert + retrievedAggregate.Should().NotBeNull(); + retrievedAggregate.ExternalId.Value.Should().Be("ext_xyz789"); + } + + [Fact] + public async Task MapStronglyTypedString_WhenQueryingByStringId_ShouldFindEntity() + { + // Arrange + var externalId = ExternalId.NewId("ext_findme"); + _connection.Insert("TestAggregates", + [ + ("Id", 456L), + ("Name", "Test"), + ("Status", "Pending"), + ("ExternalId", externalId.Value), + ("CreatedAt", DateTime.UtcNow.ToString("O")) + ] + ); + + // Act + var retrievedAggregate = await _testDbContext.TestAggregates + .FirstOrDefaultAsync(e => e.ExternalId == externalId); + + // Assert + retrievedAggregate.Should().NotBeNull(); + retrievedAggregate.Name.Should().Be("Test"); + } +} diff --git a/application/shared-kernel/Tests/StronglyTypedIds/StronglyTypedStringTests.cs b/application/shared-kernel/Tests/StronglyTypedIds/StronglyTypedStringTests.cs new file mode 100644 index 0000000000..05c8b2f438 --- /dev/null +++ b/application/shared-kernel/Tests/StronglyTypedIds/StronglyTypedStringTests.cs @@ -0,0 +1,139 @@ +using FluentAssertions; +using PlatformPlatform.SharedKernel.StronglyTypedIds; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.StronglyTypedIds; + +public class StronglyTypedStringTests +{ + [Fact] + public void NewId_WhenValidPrefixedValue_ShouldAcceptAsIs() + { + // Arrange & Act + var id = PrefixedStringId.NewId("cus_test123"); + + // Assert + id.Value.Should().Be("cus_test123"); + } + + [Fact] + public void NewId_WhenPrefixMissing_ShouldThrow() + { + // Arrange & Act + var act = () => PrefixedStringId.NewId("test123"); + + // Assert + act.Should().Throw() + .WithMessage("Value must start with prefix 'cus_' followed by at least one character*"); + } + + [Fact] + public void NewId_WhenOnlyPrefix_ShouldThrow() + { + // Arrange & Act + var act = () => PrefixedStringId.NewId("cus_"); + + // Assert + act.Should().Throw() + .WithMessage("Value must start with prefix 'cus_' followed by at least one character*"); + } + + [Fact] + public void TryParse_WhenOnlyPrefix_ShouldFail() + { + // Arrange & Act + var isParsedSuccessfully = PrefixedStringId.TryParse("cus_", out var result); + + // Assert + isParsedSuccessfully.Should().BeFalse(); + result.Should().BeNull(); + } + + [Fact] + public void NewId_WhenCreatingWithoutPrefix_ShouldKeepValueAsIs() + { + // Arrange & Act + var id = UnprefixedStringId.NewId("test123"); + + // Assert + id.Value.Should().Be("test123"); + } + + [Fact] + public void TryParse_WhenValidIdWithPrefix_ShouldSucceed() + { + // Arrange + var id = PrefixedStringId.NewId("cus_test123"); + + // Act + var isParsedSuccessfully = PrefixedStringId.TryParse(id.Value, out var result); + + // Assert + isParsedSuccessfully.Should().BeTrue(); + result.Should().NotBeNull(); + result.Value.Should().Be("cus_test123"); + } + + [Fact] + public void TryParse_WhenInvalidPrefix_ShouldFail() + { + // Arrange & Act + var isParsedSuccessfully = PrefixedStringId.TryParse("wrong_test123", out var result); + + // Assert + isParsedSuccessfully.Should().BeFalse(); + result.Should().BeNull(); + } + + [Fact] + public void TryParse_WhenNullValue_ShouldFail() + { + // Arrange & Act + var isParsedSuccessfully = PrefixedStringId.TryParse(null, out var result); + + // Assert + isParsedSuccessfully.Should().BeFalse(); + result.Should().BeNull(); + } + + [Fact] + public void TryParse_WhenUnprefixedId_ShouldSucceed() + { + // Arrange & Act + var isParsedSuccessfully = UnprefixedStringId.TryParse("anyvalue", out var result); + + // Assert + isParsedSuccessfully.Should().BeTrue(); + result.Should().NotBeNull(); + result.Value.Should().Be("anyvalue"); + } + + [Fact] + public void Equality_WhenSameValue_ShouldBeEqual() + { + // Arrange + var id1 = PrefixedStringId.NewId("cus_test123"); + var id2 = PrefixedStringId.NewId("cus_test123"); + + // Assert + id1.Should().Be(id2); + (id1 == id2).Should().BeTrue(); + } + + [Fact] + public void Equality_WhenDifferentValue_ShouldNotBeEqual() + { + // Arrange + var id1 = PrefixedStringId.NewId("cus_test123"); + var id2 = PrefixedStringId.NewId("cus_test456"); + + // Assert + id1.Should().NotBe(id2); + (id1 != id2).Should().BeTrue(); + } + + [IdPrefix("cus")] + public record PrefixedStringId(string Value) : StronglyTypedString(Value); + + public record UnprefixedStringId(string Value) : StronglyTypedString(Value); +} diff --git a/application/shared-kernel/Tests/TestEntities/TestAggregate.cs b/application/shared-kernel/Tests/TestEntities/TestAggregate.cs index abebcb6201..b174af5351 100644 --- a/application/shared-kernel/Tests/TestEntities/TestAggregate.cs +++ b/application/shared-kernel/Tests/TestEntities/TestAggregate.cs @@ -10,6 +10,9 @@ public enum TestStatus Completed } +[IdPrefix("ext")] +public sealed record ExternalId(string Value) : StronglyTypedString(Value); + public sealed class TestAggregate(string name) : AggregateRoot(IdGenerator.NewId()) { // ReSharper disable once EntityFramework.ModelValidation.UnlimitedStringLength @@ -19,6 +22,8 @@ public sealed class TestAggregate(string name) : AggregateRoot(IdGenerator public TestStatus? NullableStatus { get; set; } + public ExternalId ExternalId { get; set; } = ExternalId.NewId("ext_default"); + public static TestAggregate Create(string name) { var testAggregate = new TestAggregate(name); diff --git a/application/shared-kernel/Tests/TestEntities/TestDbContext.cs b/application/shared-kernel/Tests/TestEntities/TestDbContext.cs index 9dead116e6..a634c36d0d 100644 --- a/application/shared-kernel/Tests/TestEntities/TestDbContext.cs +++ b/application/shared-kernel/Tests/TestEntities/TestDbContext.cs @@ -14,5 +14,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) base.OnModelCreating(modelBuilder); modelBuilder.UseStringForEnums(); + + modelBuilder.Entity(entity => { entity.MapStronglyTypedString(e => e.ExternalId); }); } } From 3c50e5e4fecab21689f05514807bd409e715fbeb Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Thu, 23 Oct 2025 22:11:12 +0200 Subject: [PATCH 06/19] Add API tests for Logout and ChangeLocale commands --- .../Tests/Authentication/LogoutTests.cs | 62 +++++++++ .../Tests/Users/ChangeLocaleTests.cs | 125 ++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 application/account-management/Tests/Authentication/LogoutTests.cs create mode 100644 application/account-management/Tests/Users/ChangeLocaleTests.cs diff --git a/application/account-management/Tests/Authentication/LogoutTests.cs b/application/account-management/Tests/Authentication/LogoutTests.cs new file mode 100644 index 0000000000..ba6ff965e4 --- /dev/null +++ b/application/account-management/Tests/Authentication/LogoutTests.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using PlatformPlatform.AccountManagement.Database; +using PlatformPlatform.AccountManagement.Features.Authentication.Commands; +using PlatformPlatform.SharedKernel.Tests; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.Authentication; + +public sealed class LogoutTests : EndpointBaseTest +{ + [Fact] + public async Task Logout_WhenAuthenticatedAsOwner_ShouldSucceedAndCollectLogoutEvent() + { + // Arrange + var command = new LogoutCommand(); + + // Act + var response = await AuthenticatedOwnerHttpClient.PostAsJsonAsync("/api/account-management/authentication/logout", command); + + // Assert + await response.ShouldBeSuccessfulPostRequest(hasLocation: false); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("Logout"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task Logout_WhenAuthenticatedAsMember_ShouldSucceedAndCollectLogoutEvent() + { + // Arrange + var command = new LogoutCommand(); + + // Act + var response = await AuthenticatedMemberHttpClient.PostAsJsonAsync("/api/account-management/authentication/logout", command); + + // Assert + await response.ShouldBeSuccessfulPostRequest(hasLocation: false); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("Logout"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task Logout_WhenNotAuthenticated_ShouldReturnUnauthorized() + { + // Arrange + var command = new LogoutCommand(); + + // Act + var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/logout", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } +} diff --git a/application/account-management/Tests/Users/ChangeLocaleTests.cs b/application/account-management/Tests/Users/ChangeLocaleTests.cs new file mode 100644 index 0000000000..22a44edef3 --- /dev/null +++ b/application/account-management/Tests/Users/ChangeLocaleTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using PlatformPlatform.AccountManagement.Database; +using PlatformPlatform.AccountManagement.Features.Users.Commands; +using PlatformPlatform.SharedKernel.Tests; +using PlatformPlatform.SharedKernel.Tests.Persistence; +using PlatformPlatform.SharedKernel.Validation; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.Users; + +public sealed class ChangeLocaleTests : EndpointBaseTest +{ + [Fact] + public async Task ChangeLocale_WhenValidLocale_ShouldUpdateUserLocaleAndCollectEvent() + { + // Arrange + var newLocale = "da-DK"; + var command = new ChangeLocaleCommand(newLocale); + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync("/api/account-management/users/me/change-locale", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var updatedLocale = Connection.ExecuteScalar( + "SELECT Locale FROM Users WHERE Id = @id", [new { id = DatabaseSeeder.Tenant1Owner.Id.ToString() }] + ); + updatedLocale.Should().Be(newLocale); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("UserLocaleChanged"); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.from_locale"].Should().Be(string.Empty); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.to_locale"].Should().Be(newLocale); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task ChangeLocale_WhenMemberChangesLocale_ShouldSucceed() + { + // Arrange + var newLocale = "da-DK"; + var command = new ChangeLocaleCommand(newLocale); + + // Act + var response = await AuthenticatedMemberHttpClient.PutAsJsonAsync("/api/account-management/users/me/change-locale", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var updatedLocale = Connection.ExecuteScalar( + "SELECT Locale FROM Users WHERE Id = @id", [new { id = DatabaseSeeder.Tenant1Member.Id.ToString() }] + ); + updatedLocale.Should().Be(newLocale); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("UserLocaleChanged"); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task ChangeLocale_WhenInvalidLocale_ShouldReturnValidationError() + { + // Arrange + var invalidLocale = "fr-FR"; + var command = new ChangeLocaleCommand(invalidLocale); + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync("/api/account-management/users/me/change-locale", command); + + // Assert + var expectedErrors = new[] + { + new ErrorDetail("locale", "Language must be one of the following: en-US, da-DK") + }; + await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, expectedErrors); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task ChangeLocale_WhenNotAuthenticated_ShouldReturnUnauthorized() + { + // Arrange + var command = new ChangeLocaleCommand("da-DK"); + + // Act + var response = await AnonymousHttpClient.PutAsJsonAsync("/api/account-management/users/me/change-locale", command); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task ChangeLocale_WhenChangingToSameLocale_ShouldSucceed() + { + // Arrange + var locale = "en-US"; + Connection.Update("Users", "Id", DatabaseSeeder.Tenant1Owner.Id.ToString(), [("Locale", locale)]); + var command = new ChangeLocaleCommand(locale); + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync("/api/account-management/users/me/change-locale", command); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var updatedLocale = Connection.ExecuteScalar( + "SELECT Locale FROM Users WHERE Id = @id", [new { id = DatabaseSeeder.Tenant1Owner.Id.ToString() }] + ); + updatedLocale.Should().Be(locale); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("UserLocaleChanged"); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.from_locale"].Should().Be(locale); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.to_locale"].Should().Be(locale); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } +} From 900498fd4bc165d63c4d013f876914f993001f5d Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Thu, 23 Oct 2025 22:25:00 +0200 Subject: [PATCH 07/19] Add API tests for ChangeUserRole command --- .../Tests/Users/ChangeUserRoleTests.cs | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 application/account-management/Tests/Users/ChangeUserRoleTests.cs diff --git a/application/account-management/Tests/Users/ChangeUserRoleTests.cs b/application/account-management/Tests/Users/ChangeUserRoleTests.cs new file mode 100644 index 0000000000..c4c76e4c74 --- /dev/null +++ b/application/account-management/Tests/Users/ChangeUserRoleTests.cs @@ -0,0 +1,153 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using PlatformPlatform.AccountManagement.Database; +using PlatformPlatform.AccountManagement.Features.Users.Commands; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.Tests; +using PlatformPlatform.SharedKernel.Tests.Persistence; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.Users; + +public sealed class ChangeUserRoleTests : EndpointBaseTest +{ + [Fact] + public async Task ChangeUserRole_WhenOwnerChangesAnotherUserRole_ShouldSucceed() + { + // Arrange + var command = new ChangeUserRoleCommand { UserRole = UserRole.Owner }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync( + $"/api/account-management/users/{DatabaseSeeder.Tenant1Member.Id}/change-user-role", command + ); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var updatedRole = Connection.ExecuteScalar( + "SELECT Role FROM Users WHERE Id = @id", [new { id = DatabaseSeeder.Tenant1Member.Id.ToString() }] + ); + updatedRole.Should().Be(nameof(UserRole.Owner)); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("UserRoleChanged"); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.user_id"].Should().Be(DatabaseSeeder.Tenant1Member.Id.ToString()); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.from_role"].Should().Be(nameof(UserRole.Member)); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.to_role"].Should().Be(nameof(UserRole.Owner)); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task ChangeUserRole_WhenOwnerChangesRoleFromOwnerToMember_ShouldSucceed() + { + // Arrange + Connection.Update("Users", "Id", DatabaseSeeder.Tenant1Member.Id.ToString(), [("Role", nameof(UserRole.Owner))]); + var command = new ChangeUserRoleCommand { UserRole = UserRole.Member }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync( + $"/api/account-management/users/{DatabaseSeeder.Tenant1Member.Id}/change-user-role", command + ); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + var updatedRole = Connection.ExecuteScalar( + "SELECT Role FROM Users WHERE Id = @id", [new { id = DatabaseSeeder.Tenant1Member.Id.ToString() }] + ); + updatedRole.Should().Be(nameof(UserRole.Member)); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("UserRoleChanged"); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.from_role"].Should().Be(nameof(UserRole.Owner)); + TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.to_role"].Should().Be(nameof(UserRole.Member)); + } + + [Fact] + public async Task ChangeUserRole_WhenOwnerTriesToChangeTheirOwnRole_ShouldReturnForbidden() + { + // Arrange + var command = new ChangeUserRoleCommand { UserRole = UserRole.Member }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync( + $"/api/account-management/users/{DatabaseSeeder.Tenant1Owner.Id}/change-user-role", command + ); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.Forbidden, "You cannot change your own user role."); + + var roleUnchanged = Connection.ExecuteScalar( + "SELECT Role FROM Users WHERE Id = @id", [new { id = DatabaseSeeder.Tenant1Owner.Id.ToString() }] + ); + roleUnchanged.Should().Be(nameof(UserRole.Owner)); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task ChangeUserRole_WhenMemberTriesToChangeRole_ShouldReturnForbidden() + { + // Arrange + var command = new ChangeUserRoleCommand { UserRole = UserRole.Owner }; + + // Act + var response = await AuthenticatedMemberHttpClient.PutAsJsonAsync( + $"/api/account-management/users/{DatabaseSeeder.Tenant1Owner.Id}/change-user-role", command + ); + + // Assert + await response.ShouldHaveErrorStatusCode( + HttpStatusCode.Forbidden, "Only owners are allowed to change the user roles of users." + ); + + var roleUnchanged = Connection.ExecuteScalar( + "SELECT Role FROM Users WHERE Id = @id", [new { id = DatabaseSeeder.Tenant1Owner.Id.ToString() }] + ); + roleUnchanged.Should().Be(nameof(UserRole.Owner)); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task ChangeUserRole_WhenUserNotFound_ShouldReturnNotFound() + { + // Arrange + var nonExistentUserId = UserId.NewId(); + var command = new ChangeUserRoleCommand { UserRole = UserRole.Owner }; + + // Act + var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync( + $"/api/account-management/users/{nonExistentUserId}/change-user-role", command + ); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.NotFound, $"User with id '{nonExistentUserId}' not found."); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task ChangeUserRole_WhenNotAuthenticated_ShouldReturnUnauthorized() + { + // Arrange + var command = new ChangeUserRoleCommand { UserRole = UserRole.Owner }; + + // Act + var response = await AnonymousHttpClient.PutAsJsonAsync( + $"/api/account-management/users/{DatabaseSeeder.Tenant1Member.Id}/change-user-role", command + ); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + + TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty(); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } +} From 0646403a475a198b4271233e180d84cd4add075c Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 25 Nov 2025 12:50:04 +0100 Subject: [PATCH 08/19] Fix NotSupportedException when copying avatar during tenant switch --- .../Core/Features/Authentication/Commands/SwitchTenant.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs b/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs index 72918201cb..005bd8add6 100644 --- a/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs +++ b/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs @@ -73,8 +73,14 @@ private async Task CopyProfileDataFromCurrentUser(User targetUser, CancellationT var avatarData = await blobStorageClient.DownloadAsync("avatars", sourceBlobPath, cancellationToken); if (avatarData is not null) { + // Copy to MemoryStream since Azure's RetriableStream doesn't support seeking (Position reset) + await using var avatarStream = avatarData.Value.Stream; + using var memoryStream = new MemoryStream(); + await avatarStream.CopyToAsync(memoryStream, cancellationToken); + memoryStream.Position = 0; + // Upload the avatar to the target tenant's storage location - await avatarUpdater.UpdateAvatar(targetUser, false, avatarData.Value.ContentType, avatarData.Value.Stream, cancellationToken); + await avatarUpdater.UpdateAvatar(targetUser, false, avatarData.Value.ContentType, memoryStream, cancellationToken); } } From f4786e6b705d919599a7366d6cfd4c84aa51d6db Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 25 Nov 2025 13:07:07 +0100 Subject: [PATCH 09/19] Use MailAddress.TryCreate for email validation to match .NET email infrastructure --- .../Tests/Authentication/StartLoginTests.cs | 3 +++ .../Validation/SharedValidations.cs | 20 ++++--------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/application/account-management/Tests/Authentication/StartLoginTests.cs b/application/account-management/Tests/Authentication/StartLoginTests.cs index cafb2aa583..2d5e36fde0 100644 --- a/application/account-management/Tests/Authentication/StartLoginTests.cs +++ b/application/account-management/Tests/Authentication/StartLoginTests.cs @@ -68,6 +68,9 @@ public async Task StartLoginCommand_WhenEmailIsEmpty_ShouldFail() [Theory] [InlineData("Invalid Email Format", "invalid-email")] [InlineData("Email Too Long", "abcdefghijklmnopqrstuvwyz0123456789-abcdefghijklmnopqrstuvwyz0123456789-abcdefghijklmnopqrstuvwyz0123456789@example.com")] + [InlineData("Double Dots In Domain", "neo@gmail..com")] + [InlineData("Comma Instead Of Dot", "q@q,com")] + [InlineData("Space In Domain", "tje@mentum .dk")] public async Task StartLoginCommand_WhenEmailInvalid_ShouldFail(string scenario, string invalidEmail) { // Arrange diff --git a/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs b/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs index bcf04ff934..3ba8a2c66b 100644 --- a/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs +++ b/application/shared-kernel/SharedKernel/Validation/SharedValidations.cs @@ -1,3 +1,4 @@ +using System.Net.Mail; using FluentValidation; namespace PlatformPlatform.SharedKernel.Validation; @@ -14,26 +15,13 @@ public Email(bool allowEmpty = false) const string errorMessage = "Email must be in a valid format and no longer than 100 characters."; var rule = RuleFor(email => email) - .EmailAddress() + .Must(email => MailAddress.TryCreate(email, out _)) .WithMessage(errorMessage) .MaximumLength(EmailMaxLength) .WithMessage(errorMessage) - .Must(email => email == email.ToLowerInvariant()) + .Must(email => string.Equals(email, email!.ToLowerInvariant(), StringComparison.Ordinal)) .WithMessage(errorMessage) - .Must(email => email == email.Trim()) - .WithMessage(errorMessage) - .Must(email => !email.Contains("..")) - .WithMessage(errorMessage) - .Must(email => - { - var parts = email.Split('@'); - return parts.Length == 2 && - !parts[0].StartsWith('.') && - !parts[0].EndsWith('.') && - !parts[1].StartsWith('.') && - !parts[1].EndsWith('.'); - } - ) + .Must(email => email == email!.Trim()) .WithMessage(errorMessage); if (!allowEmpty) From d1dd4c50330406db8784ba5f47410c14cd45732f Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 25 Nov 2025 13:22:39 +0100 Subject: [PATCH 10/19] Add Redirect to ApiResult and ApiResult --- .../shared-kernel/SharedKernel/ApiResults/ApiResult.cs | 5 +++++ application/shared-kernel/SharedKernel/Cqrs/Result.cs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/application/shared-kernel/SharedKernel/ApiResults/ApiResult.cs b/application/shared-kernel/SharedKernel/ApiResults/ApiResult.cs index 51313f4b01..3fd3f80db1 100644 --- a/application/shared-kernel/SharedKernel/ApiResults/ApiResult.cs +++ b/application/shared-kernel/SharedKernel/ApiResults/ApiResult.cs @@ -76,6 +76,11 @@ protected override IResult ConvertResult() { if (!result.IsSuccess) return GetProblemDetailsAsJson(); + if (result.StatusCode is HttpStatusCode.Redirect) + { + return Results.Redirect(result.Value.Adapt()); + } + return RoutePrefix is null ? Results.Ok(result.Value!.Adapt()) : Results.Created($"{RoutePrefix}/{result.Value}", null); diff --git a/application/shared-kernel/SharedKernel/Cqrs/Result.cs b/application/shared-kernel/SharedKernel/Cqrs/Result.cs index 1930e1ae0e..6688484cc5 100644 --- a/application/shared-kernel/SharedKernel/Cqrs/Result.cs +++ b/application/shared-kernel/SharedKernel/Cqrs/Result.cs @@ -151,6 +151,11 @@ public static Result TooManyRequests(string message, bool commitChanges = fal return new Result(HttpStatusCode.TooManyRequests, new ErrorMessage(message), commitChanges, []); } + public static Result Redirect(string redirectUrl) + { + return new Result(redirectUrl, HttpStatusCode.Redirect); + } + /// /// This is an implicit conversion from T to . This is used to easily return a /// successful from a command handler. From 848e3d3c1ccf4ed7ed169a6515869be4574f88d2 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 25 Nov 2025 13:28:25 +0100 Subject: [PATCH 11/19] Add IBlobStorageClient and CreateContainerIfNotExistsAsync --- .../SharedAccessSignatureRequestTransform.cs | 2 +- .../Authentication/Commands/SwitchTenant.cs | 2 +- .../Tenants/Commands/UpdateTenantLogo.cs | 2 +- .../Core/Features/Users/Shared/AvatarUpdater.cs | 2 +- .../SharedInfrastructureConfiguration.cs | 9 ++++----- .../BlobStorage/BlobStorageClient.cs | 8 +++++++- .../BlobStorage/IBlobStorageClient.cs | 16 ++++++++++++++++ 7 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 application/shared-kernel/SharedKernel/Integrations/BlobStorage/IBlobStorageClient.cs diff --git a/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs b/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs index 5d6ef1e9c4..1f2d4e6c20 100644 --- a/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs +++ b/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs @@ -3,7 +3,7 @@ namespace PlatformPlatform.AppGateway.Transformations; -public class SharedAccessSignatureRequestTransform([FromKeyedServices("account-management-storage")] BlobStorageClient accountManagementBlobStorageClient) +public class SharedAccessSignatureRequestTransform([FromKeyedServices("account-management-storage")] IBlobStorageClient accountManagementBlobStorageClient) : RequestTransform { public override ValueTask ApplyAsync(RequestTransformContext context) diff --git a/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs b/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs index 005bd8add6..aee7deb6d2 100644 --- a/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs +++ b/application/account-management/Core/Features/Authentication/Commands/SwitchTenant.cs @@ -20,7 +20,7 @@ public sealed class SwitchTenantHandler( AuthenticationTokenService authenticationTokenService, AvatarUpdater avatarUpdater, [FromKeyedServices("account-management-storage")] - BlobStorageClient blobStorageClient, + IBlobStorageClient blobStorageClient, IExecutionContext executionContext, ITelemetryEventsCollector events, ILogger logger diff --git a/application/account-management/Core/Features/Tenants/Commands/UpdateTenantLogo.cs b/application/account-management/Core/Features/Tenants/Commands/UpdateTenantLogo.cs index 810dbe46dd..03f1dcbbe4 100644 --- a/application/account-management/Core/Features/Tenants/Commands/UpdateTenantLogo.cs +++ b/application/account-management/Core/Features/Tenants/Commands/UpdateTenantLogo.cs @@ -32,7 +32,7 @@ public sealed class UpdateTenantLogoHandler( ITenantRepository tenantRepository, IExecutionContext executionContext, [FromKeyedServices("account-management-storage")] - BlobStorageClient blobStorageClient, + IBlobStorageClient blobStorageClient, ITelemetryEventsCollector events ) : IRequestHandler diff --git a/application/account-management/Core/Features/Users/Shared/AvatarUpdater.cs b/application/account-management/Core/Features/Users/Shared/AvatarUpdater.cs index 7466971c03..91730f660b 100644 --- a/application/account-management/Core/Features/Users/Shared/AvatarUpdater.cs +++ b/application/account-management/Core/Features/Users/Shared/AvatarUpdater.cs @@ -5,7 +5,7 @@ namespace PlatformPlatform.AccountManagement.Features.Users.Shared; -public sealed class AvatarUpdater(IUserRepository userRepository, [FromKeyedServices("account-management-storage")] BlobStorageClient blobStorageClient) +public sealed class AvatarUpdater(IUserRepository userRepository, [FromKeyedServices("account-management-storage")] IBlobStorageClient blobStorageClient) { private const string ContainerName = "avatars"; diff --git a/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs index 461e89cf15..0957da30e0 100644 --- a/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/SharedInfrastructureConfiguration.cs @@ -79,13 +79,12 @@ private IHostApplicationBuilder AddDefaultBlobStorage() if (IsRunningInAzure) { var defaultBlobStorageUri = new Uri(Environment.GetEnvironmentVariable("BLOB_STORAGE_URL")!); - builder.Services.AddSingleton(_ => new BlobStorageClient(new BlobServiceClient(defaultBlobStorageUri, DefaultAzureCredential)) - ); + builder.Services.AddSingleton(_ => new BlobStorageClient(new BlobServiceClient(defaultBlobStorageUri, DefaultAzureCredential))); } else { var connectionString = builder.Configuration.GetConnectionString("blob-storage"); - builder.Services.AddSingleton(_ => new BlobStorageClient(new BlobServiceClient(connectionString))); + builder.Services.AddSingleton(_ => new BlobStorageClient(new BlobServiceClient(connectionString))); } return builder; @@ -102,7 +101,7 @@ public IHostApplicationBuilder AddNamedBlobStorages((string ConnectionName, stri foreach (var connection in connections) { var storageEndpointUri = new Uri(Environment.GetEnvironmentVariable(connection!.Value.EnvironmentVariable)!); - builder.Services.AddKeyedSingleton(connection.Value.ConnectionName, + builder.Services.AddKeyedSingleton(connection.Value.ConnectionName, (_, _) => new BlobStorageClient(new BlobServiceClient(storageEndpointUri, DefaultAzureCredential)) ); } @@ -112,7 +111,7 @@ public IHostApplicationBuilder AddNamedBlobStorages((string ConnectionName, stri var connectionString = builder.Configuration.GetConnectionString("blob-storage"); foreach (var connection in connections) { - builder.Services.AddKeyedSingleton(connection!.Value.ConnectionName, + builder.Services.AddKeyedSingleton(connection!.Value.ConnectionName, (_, _) => new BlobStorageClient(new BlobServiceClient(connectionString)) ); } diff --git a/application/shared-kernel/SharedKernel/Integrations/BlobStorage/BlobStorageClient.cs b/application/shared-kernel/SharedKernel/Integrations/BlobStorage/BlobStorageClient.cs index 67f6eb9ab1..ecd77c299a 100644 --- a/application/shared-kernel/SharedKernel/Integrations/BlobStorage/BlobStorageClient.cs +++ b/application/shared-kernel/SharedKernel/Integrations/BlobStorage/BlobStorageClient.cs @@ -4,7 +4,7 @@ namespace PlatformPlatform.SharedKernel.Integrations.BlobStorage; -public class BlobStorageClient(BlobServiceClient blobServiceClient) +public class BlobStorageClient(BlobServiceClient blobServiceClient) : IBlobStorageClient { public async Task UploadAsync(string containerName, string blobName, string contentType, Stream stream, CancellationToken cancellationToken) { @@ -40,4 +40,10 @@ public string GetSharedAccessSignature(string container, TimeSpan expiresIn) var response = await blobClient.DownloadStreamingAsync(cancellationToken: cancellationToken); return (response.Value.Content, response.Value.Details.ContentType); } + + public async Task CreateContainerIfNotExistsAsync(string containerName, PublicAccessType publicAccessType, CancellationToken cancellationToken) + { + var blobContainerClient = blobServiceClient.GetBlobContainerClient(containerName); + await blobContainerClient.CreateIfNotExistsAsync(publicAccessType, cancellationToken: cancellationToken); + } } diff --git a/application/shared-kernel/SharedKernel/Integrations/BlobStorage/IBlobStorageClient.cs b/application/shared-kernel/SharedKernel/Integrations/BlobStorage/IBlobStorageClient.cs new file mode 100644 index 0000000000..9b20cd67e5 --- /dev/null +++ b/application/shared-kernel/SharedKernel/Integrations/BlobStorage/IBlobStorageClient.cs @@ -0,0 +1,16 @@ +using Azure.Storage.Blobs.Models; + +namespace PlatformPlatform.SharedKernel.Integrations.BlobStorage; + +public interface IBlobStorageClient +{ + Task UploadAsync(string containerName, string blobName, string contentType, Stream stream, CancellationToken cancellationToken); + + Task<(Stream Stream, string ContentType)?> DownloadAsync(string containerName, string blobName, CancellationToken cancellationToken); + + string GetBlobUrl(string container, string blobName); + + string GetSharedAccessSignature(string container, TimeSpan expiresIn); + + Task CreateContainerIfNotExistsAsync(string containerName, PublicAccessType publicAccessType, CancellationToken cancellationToken); +} From 8b2dc15022c6fe05734badf8246e3eadb263a848 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 25 Nov 2025 14:30:17 +0100 Subject: [PATCH 12/19] Verify API response before asserting database state in CompleteLoginTests --- .../Tests/Authentication/CompleteLoginTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/application/account-management/Tests/Authentication/CompleteLoginTests.cs b/application/account-management/Tests/Authentication/CompleteLoginTests.cs index 01a9cecf9f..89f05edfd9 100644 --- a/application/account-management/Tests/Authentication/CompleteLoginTests.cs +++ b/application/account-management/Tests/Authentication/CompleteLoginTests.cs @@ -210,9 +210,11 @@ public async Task CompleteLogin_WhenUserInviteCompleted_ShouldTrackUserInviteAcc var command = new CompleteLoginCommand(CorrectOneTimePassword); // Act - await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); + var response = await AnonymousHttpClient + .PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command); // Assert + await response.ShouldBeSuccessfulPostRequest(hasLocation: false); Connection.ExecuteScalar( "SELECT COUNT(*) FROM Users WHERE TenantId = @tenantId AND Email = @email AND EmailConfirmed = 1", [new { tenantId = DatabaseSeeder.Tenant1.Id.ToString(), email = email.ToLower() }] From b69f4b7caad2d4f6e4342058ebbaf869cb4af354 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 25 Nov 2025 14:38:51 +0100 Subject: [PATCH 13/19] Replace Faker-generated user with deterministic test data to fix flaky test --- .../account-management/Tests/Users/GetUsersTests.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/application/account-management/Tests/Users/GetUsersTests.cs b/application/account-management/Tests/Users/GetUsersTests.cs index a5ae6a0cd1..882893710f 100644 --- a/application/account-management/Tests/Users/GetUsersTests.cs +++ b/application/account-management/Tests/Users/GetUsersTests.cs @@ -39,10 +39,10 @@ public GetUsersTests() ("Id", UserId.NewId().ToString()), ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), ("ModifiedAt", null), - ("Email", Faker.Internet.Email()), - ("FirstName", Faker.Name.FirstName()), - ("LastName", Faker.Name.LastName()), - ("Title", Faker.Name.JobTitle()), + ("Email", "ada@lovelace.com"), + ("FirstName", "Ada"), + ("LastName", "Lovelace"), + ("Title", "Mathematician & Writer"), ("Role", UserRole.ToString()), ("EmailConfirmed", true), ("Avatar", JsonSerializer.Serialize(new Avatar())), @@ -91,7 +91,6 @@ public async Task GetUsers_WhenSearchingBasedOnFullName_ShouldReturnUser() // Arrange const string searchString = "William Henry Gates"; - // Act var response = await AuthenticatedOwnerHttpClient.GetAsync($"/api/account-management/users?search={searchString}"); From e182de4673832a7ba63b3871816926ca95d638c8 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 25 Nov 2025 15:23:58 +0100 Subject: [PATCH 14/19] Add Faker.Internet.UniqueEmail() to fix flaky tests from email collisions and move FakerExtensions to SharedKernel --- .../Authentication/CompleteLoginTests.cs | 2 +- .../Tests/Authentication/StartLoginTests.cs | 2 +- .../Tests/Authentication/SwitchTenantTests.cs | 2 +- .../Tests/Signups/CompleteSignupTests.cs | 10 +++++----- .../Tests/Signups/StartSignupTests.cs | 4 ++-- .../Tests/Tenants/DeleteTenantTests.cs | 2 +- .../Tests/Tenants/GetTenantsForUserTests.cs | 2 +- .../Tests/Users/BulkDeleteUsersTests.cs | 2 +- .../Tests/Users/DeleteUserTests.cs | 4 ++-- .../Tests/Users/GetUserByIdTests.cs | 2 +- .../Tests/Users/InviteUserTests.cs | 6 +++--- .../Tests/FakerExtensions.cs | 19 ++++++++++++++----- 12 files changed, 33 insertions(+), 24 deletions(-) rename application/{account-management => shared-kernel}/Tests/FakerExtensions.cs (64%) diff --git a/application/account-management/Tests/Authentication/CompleteLoginTests.cs b/application/account-management/Tests/Authentication/CompleteLoginTests.cs index 89f05edfd9..b789a06fda 100644 --- a/application/account-management/Tests/Authentication/CompleteLoginTests.cs +++ b/application/account-management/Tests/Authentication/CompleteLoginTests.cs @@ -201,7 +201,7 @@ public async Task CompleteLogin_WhenUserInviteCompleted_ShouldTrackUserInviteAcc [("Name", "Test Company")] ); - var email = Faker.Internet.Email(); + var email = Faker.Internet.UniqueEmail(); var inviteUserCommand = new InviteUserCommand(email); await AuthenticatedOwnerHttpClient.PostAsJsonAsync("/api/account-management/users/invite", inviteUserCommand); TelemetryEventsCollectorSpy.Reset(); diff --git a/application/account-management/Tests/Authentication/StartLoginTests.cs b/application/account-management/Tests/Authentication/StartLoginTests.cs index 2d5e36fde0..60a5addb61 100644 --- a/application/account-management/Tests/Authentication/StartLoginTests.cs +++ b/application/account-management/Tests/Authentication/StartLoginTests.cs @@ -94,7 +94,7 @@ public async Task StartLoginCommand_WhenEmailInvalid_ShouldFail(string scenario, public async Task StartLoginCommand_WhenUserDoesNotExist_ShouldReturnFakeLoginId() { // Arrange - var email = Faker.Internet.Email(); + var email = Faker.Internet.UniqueEmail(); var command = new StartLoginCommand(email); // Act diff --git a/application/account-management/Tests/Authentication/SwitchTenantTests.cs b/application/account-management/Tests/Authentication/SwitchTenantTests.cs index 522a9ff844..10f98bfe93 100644 --- a/application/account-management/Tests/Authentication/SwitchTenantTests.cs +++ b/application/account-management/Tests/Authentication/SwitchTenantTests.cs @@ -106,7 +106,7 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo ("Id", UserId.NewId().ToString()), ("CreatedAt", TimeProvider.System.GetUtcNow()), ("ModifiedAt", null), - ("Email", Faker.Internet.Email()), + ("Email", Faker.Internet.UniqueEmail()), ("EmailConfirmed", true), ("FirstName", Faker.Name.FirstName()), ("LastName", Faker.Name.LastName()), diff --git a/application/account-management/Tests/Signups/CompleteSignupTests.cs b/application/account-management/Tests/Signups/CompleteSignupTests.cs index 0758e29245..9da97cf1e6 100644 --- a/application/account-management/Tests/Signups/CompleteSignupTests.cs +++ b/application/account-management/Tests/Signups/CompleteSignupTests.cs @@ -29,7 +29,7 @@ protected override void RegisterMockLoggers(IServiceCollection services) public async Task CompleteSignup_WhenValid_ShouldCreateTenantAndOwnerUser() { // Arrange - var email = Faker.Internet.Email(); + var email = Faker.Internet.UniqueEmail(); var emailConfirmationId = await StartSignup(email); var command = new CompleteSignupCommand(CorrectOneTimePassword, "en-US"); @@ -72,7 +72,7 @@ public async Task CompleteSignup_WhenSignupNotFound_ShouldReturnNotFound() public async Task CompleteSignup_WhenInvalidOneTimePassword_ShouldReturnBadRequest() { // Arrange - var emailConfirmationId = await StartSignup(Faker.Internet.Email()); + var emailConfirmationId = await StartSignup(Faker.Internet.UniqueEmail()); var command = new CompleteSignupCommand(WrongOneTimePassword, "en-US"); @@ -93,7 +93,7 @@ public async Task CompleteSignup_WhenInvalidOneTimePassword_ShouldReturnBadReque public async Task CompleteSignup_WhenSignupAlreadyCompleted_ShouldReturnBadRequest() { // Arrange - var emailConfirmationId = await StartSignup(Faker.Internet.Email()); + var emailConfirmationId = await StartSignup(Faker.Internet.UniqueEmail()); var command = new CompleteSignupCommand(CorrectOneTimePassword, "en-US") { EmailConfirmationId = emailConfirmationId }; await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/signups/{emailConfirmationId}/complete", command); @@ -110,7 +110,7 @@ public async Task CompleteSignup_WhenSignupAlreadyCompleted_ShouldReturnBadReque public async Task CompleteSignup_WhenRetryCountExceeded_ShouldReturnForbidden() { // Arrange - var emailConfirmationId = await StartSignup(Faker.Internet.Email()); + var emailConfirmationId = await StartSignup(Faker.Internet.UniqueEmail()); var command = new CompleteSignupCommand(WrongOneTimePassword, "en-US"); await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/signups/{emailConfirmationId}/complete", command); @@ -137,7 +137,7 @@ public async Task CompleteSignup_WhenRetryCountExceeded_ShouldReturnForbidden() public async Task CompleteSignup_WhenSignupExpired_ShouldReturnBadRequest() { // Arrange - var email = Faker.Internet.Email(); + var email = Faker.Internet.UniqueEmail(); var emailConfirmationId = EmailConfirmationId.NewId(); Connection.Insert("EmailConfirmations", [ diff --git a/application/account-management/Tests/Signups/StartSignupTests.cs b/application/account-management/Tests/Signups/StartSignupTests.cs index 795c11bfc2..e09a0e31be 100644 --- a/application/account-management/Tests/Signups/StartSignupTests.cs +++ b/application/account-management/Tests/Signups/StartSignupTests.cs @@ -20,7 +20,7 @@ public sealed class StartSignupTests : EndpointBaseTest Date: Tue, 25 Nov 2025 15:28:33 +0100 Subject: [PATCH 15/19] Fix flaky tests by using unique emails and non-whitespace strings --- .../Tests/Users/UpdateCurrentUserTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/application/account-management/Tests/Users/UpdateCurrentUserTests.cs b/application/account-management/Tests/Users/UpdateCurrentUserTests.cs index ef2d9f79a3..2ad44eff44 100644 --- a/application/account-management/Tests/Users/UpdateCurrentUserTests.cs +++ b/application/account-management/Tests/Users/UpdateCurrentUserTests.cs @@ -33,9 +33,9 @@ public async Task UpdateCurrentUser_WhenInvalid_ShouldReturnBadRequest() // Arrange var command = new UpdateCurrentUserCommand ( - Faker.Random.String(31), - Faker.Random.String(31), - Faker.Random.String(51) + Faker.Random.String2(31), + Faker.Random.String2(31), + Faker.Random.String2(51) ); // Act From 07bf9fa1fd96d8e6048ef1e3e5d4f0aa40f08e24 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 25 Nov 2025 16:53:17 +0100 Subject: [PATCH 16/19] Exclude owned entities from global tenant query filters --- .../SharedKernel/EntityFramework/SharedKernelDbContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/shared-kernel/SharedKernel/EntityFramework/SharedKernelDbContext.cs b/application/shared-kernel/SharedKernel/EntityFramework/SharedKernelDbContext.cs index 63bf9ef82c..a3ed37ff42 100644 --- a/application/shared-kernel/SharedKernel/EntityFramework/SharedKernelDbContext.cs +++ b/application/shared-kernel/SharedKernel/EntityFramework/SharedKernelDbContext.cs @@ -59,7 +59,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) private void ApplyGlobalTenantFilters(ModelBuilder modelBuilder) { var tenantScopedEntityTypes = modelBuilder.Model.GetEntityTypes() - .Where(t => typeof(ITenantScopedEntity).IsAssignableFrom(t.ClrType)) + .Where(t => typeof(ITenantScopedEntity).IsAssignableFrom(t.ClrType) && !t.IsOwned()) .Select(t => t.ClrType); foreach (var entityType in tenantScopedEntityTypes) From 6689294f1d9452ec793ef2f79f88709078f18b54 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 25 Nov 2025 18:08:28 +0100 Subject: [PATCH 17/19] Add bulk repository interfaces and rename BulkRemove to RemoveRange --- .../Core/Features/Tenants/Commands/DeleteTenant.cs | 2 +- .../Core/Features/Users/Commands/BulkDeleteUsers.cs | 2 +- .../SharedKernel/Domain/IBulkAddRepository.cs | 6 ++++++ .../SharedKernel/Domain/IBulkRemoveRepository.cs | 2 +- .../SharedKernel/Domain/IBulkUpdateRepository.cs | 6 ++++++ .../SharedKernel/Persistence/RepositoryBase.cs | 12 +++++++++++- 6 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 application/shared-kernel/SharedKernel/Domain/IBulkAddRepository.cs create mode 100644 application/shared-kernel/SharedKernel/Domain/IBulkUpdateRepository.cs diff --git a/application/account-management/Core/Features/Tenants/Commands/DeleteTenant.cs b/application/account-management/Core/Features/Tenants/Commands/DeleteTenant.cs index 0527f09368..8062028aa5 100644 --- a/application/account-management/Core/Features/Tenants/Commands/DeleteTenant.cs +++ b/application/account-management/Core/Features/Tenants/Commands/DeleteTenant.cs @@ -32,7 +32,7 @@ public async Task Handle(DeleteTenantCommand command, CancellationToken if (tenant is null) return Result.NotFound($"Tenant with id '{command.Id}' not found."); var tenantUsers = await userRepository.GetTenantUsers(cancellationToken); - userRepository.BulkRemove(tenantUsers); + userRepository.RemoveRange(tenantUsers); tenantRepository.Remove(tenant); diff --git a/application/account-management/Core/Features/Users/Commands/BulkDeleteUsers.cs b/application/account-management/Core/Features/Users/Commands/BulkDeleteUsers.cs index d9455fc282..886766df2c 100644 --- a/application/account-management/Core/Features/Users/Commands/BulkDeleteUsers.cs +++ b/application/account-management/Core/Features/Users/Commands/BulkDeleteUsers.cs @@ -49,7 +49,7 @@ public async Task Handle(BulkDeleteUsersCommand command, CancellationTok return Result.NotFound($"Users with ids '{string.Join(", ", missingUserIds.Select(id => id.ToString()))}' not found."); } - userRepository.BulkRemove(usersToDelete); + userRepository.RemoveRange(usersToDelete); foreach (var userId in command.UserIds) { diff --git a/application/shared-kernel/SharedKernel/Domain/IBulkAddRepository.cs b/application/shared-kernel/SharedKernel/Domain/IBulkAddRepository.cs new file mode 100644 index 0000000000..a22dcae2ba --- /dev/null +++ b/application/shared-kernel/SharedKernel/Domain/IBulkAddRepository.cs @@ -0,0 +1,6 @@ +namespace PlatformPlatform.SharedKernel.Domain; + +public interface IBulkAddRepository where T : IAggregateRoot +{ + Task AddRangeAsync(IEnumerable aggregates, CancellationToken cancellationToken); +} diff --git a/application/shared-kernel/SharedKernel/Domain/IBulkRemoveRepository.cs b/application/shared-kernel/SharedKernel/Domain/IBulkRemoveRepository.cs index 939d8fcaf2..310606a79c 100644 --- a/application/shared-kernel/SharedKernel/Domain/IBulkRemoveRepository.cs +++ b/application/shared-kernel/SharedKernel/Domain/IBulkRemoveRepository.cs @@ -2,5 +2,5 @@ namespace PlatformPlatform.SharedKernel.Domain; public interface IBulkRemoveRepository where T : IAggregateRoot { - void BulkRemove(T[] aggregates); + void RemoveRange(T[] aggregates); } diff --git a/application/shared-kernel/SharedKernel/Domain/IBulkUpdateRepository.cs b/application/shared-kernel/SharedKernel/Domain/IBulkUpdateRepository.cs new file mode 100644 index 0000000000..850a7309ea --- /dev/null +++ b/application/shared-kernel/SharedKernel/Domain/IBulkUpdateRepository.cs @@ -0,0 +1,6 @@ +namespace PlatformPlatform.SharedKernel.Domain; + +public interface IBulkUpdateRepository where T : IAggregateRoot +{ + void UpdateRange(T[] aggregates); +} diff --git a/application/shared-kernel/SharedKernel/Persistence/RepositoryBase.cs b/application/shared-kernel/SharedKernel/Persistence/RepositoryBase.cs index 99be99b88b..a33a12080a 100644 --- a/application/shared-kernel/SharedKernel/Persistence/RepositoryBase.cs +++ b/application/shared-kernel/SharedKernel/Persistence/RepositoryBase.cs @@ -57,7 +57,17 @@ public void Remove(T aggregate) DbSet.Remove(aggregate); } - public void BulkRemove(T[] aggregates) + public async Task AddRangeAsync(IEnumerable aggregates, CancellationToken cancellationToken) + { + await DbSet.AddRangeAsync(aggregates, cancellationToken); + } + + public void UpdateRange(T[] aggregates) + { + DbSet.UpdateRange(aggregates); + } + + public void RemoveRange(T[] aggregates) { DbSet.RemoveRange(aggregates); } From 0b6373cd50e4a35ec00f94e24546d07128010abe Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 25 Nov 2025 18:37:34 +0100 Subject: [PATCH 18/19] Add OwnedNavigationBuilder overloads and value converter check to ModelBuilderExtensions --- .../EntityFramework/ModelBuilderExtensions.cs | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs b/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs index 6e1938d1c4..74d4fbfba1 100644 --- a/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs +++ b/application/shared-kernel/SharedKernel/EntityFramework/ModelBuilderExtensions.cs @@ -21,6 +21,14 @@ public void MapStronglyTypedLongId(Expression> expression) whe .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); } + public void MapStronglyTypedNullableLongId(Expression> idExpression) + where TId : StronglyTypedLongId + { + builder + .Property(idExpression) + .HasConversion(v => v != null ? v.Value : (long?)null, v => v != null ? Activator.CreateInstance(typeof(TId), v) as TId : null); + } + public void MapStronglyTypedUuid(Expression> expression) where TId : StronglyTypedUlid { builder @@ -62,6 +70,51 @@ public void MapStronglyTypedNullableId( } } + extension(OwnedNavigationBuilder builder) + where TOwner : class + where TDependent : class + { + public void MapStronglyTypedLongId(Expression> expression) + where TId : StronglyTypedLongId + { + builder + .Property(expression) + .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); + } + + public void MapStronglyTypedUuid(Expression> expression) + where TId : StronglyTypedUlid + { + builder + .Property(expression) + .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); + } + + public void MapStronglyTypedId(Expression> expression) + where TValue : IComparable + where TId : StronglyTypedId + { + builder + .Property(expression) + .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); + } + + public void MapStronglyTypedNullableId(Expression> idExpression) + where TValue : class, IComparable + where TId : StronglyTypedId + { + var nullConstant = Expression.Constant(null, typeof(TValue)); + var idParameter = Expression.Parameter(typeof(TId), "id"); + var idValueProperty = Expression.Property(idParameter, "Value"); + var idCoalesceExpression = + Expression.Lambda>(Expression.Coalesce(idValueProperty, nullConstant), idParameter); + + builder + .Property(idExpression) + .HasConversion(idCoalesceExpression!, v => Activator.CreateInstance(typeof(TId), v) as TId); + } + } + extension(ModelBuilder modelBuilder) { /// @@ -80,6 +133,8 @@ public ModelBuilder UseStringForEnums() if (enumType?.IsEnum != true) continue; + if (property.GetValueConverter() is not null) continue; + var converterType = typeof(EnumToStringConverter<>).MakeGenericType(enumType); var converterInstance = (ValueConverter)Activator.CreateInstance(converterType)!; property.SetValueConverter(converterInstance); From 46a2c86599da391ff88b0c7c13d9f627e84acc8e Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 25 Nov 2025 19:14:03 +0100 Subject: [PATCH 19/19] Add User Delegation Key SAS support and DeleteIfExistsAsync to BlobStorageClient --- .../BlobStorage/BlobStorageClient.cs | 51 ++++++++++++++++--- .../BlobStorage/IBlobStorageClient.cs | 4 ++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/application/shared-kernel/SharedKernel/Integrations/BlobStorage/BlobStorageClient.cs b/application/shared-kernel/SharedKernel/Integrations/BlobStorage/BlobStorageClient.cs index ecd77c299a..79d0aaedf1 100644 --- a/application/shared-kernel/SharedKernel/Integrations/BlobStorage/BlobStorageClient.cs +++ b/application/shared-kernel/SharedKernel/Integrations/BlobStorage/BlobStorageClient.cs @@ -14,31 +14,66 @@ public async Task UploadAsync(string containerName, string blobName, string cont await blobClient.UploadAsync(stream, blobHttpHeaders, cancellationToken: cancellationToken); } + public async Task<(Stream Stream, string ContentType)?> DownloadAsync(string containerName, string blobName, CancellationToken cancellationToken) + { + var blobContainerClient = blobServiceClient.GetBlobContainerClient(containerName); + var blobClient = blobContainerClient.GetBlobClient(blobName); + + if (!await blobClient.ExistsAsync(cancellationToken)) + { + return null; + } + + var response = await blobClient.DownloadStreamingAsync(cancellationToken: cancellationToken); + return (response.Value.Content, response.Value.Details.ContentType); + } + public string GetBlobUrl(string container, string blobName) { return $"{blobServiceClient.Uri}/{container}/{blobName}"; } + public async Task DeleteIfExistsAsync(string containerName, string blobName, CancellationToken cancellationToken) + { + var blobContainerClient = blobServiceClient.GetBlobContainerClient(containerName); + var blobClient = blobContainerClient.GetBlobClient(blobName); + var response = await blobClient.DeleteIfExistsAsync(cancellationToken: cancellationToken); + return response.Value; + } + public string GetSharedAccessSignature(string container, TimeSpan expiresIn) { var blobContainerClient = blobServiceClient.GetBlobContainerClient(container); var dateTimeOffset = DateTimeOffset.UtcNow.Add(expiresIn); - var generateSasUri = blobContainerClient.GenerateSasUri(BlobContainerSasPermissions.Read, dateTimeOffset); - return generateSasUri.Query; + return blobContainerClient.GenerateSasUri(BlobContainerSasPermissions.Read, dateTimeOffset).Query; } - public async Task<(Stream Stream, string ContentType)?> DownloadAsync(string containerName, string blobName, CancellationToken cancellationToken) + public Uri GetBlobUriWithSharedAccessSignature(string container, string blobName, TimeSpan expiresIn) { - var blobContainerClient = blobServiceClient.GetBlobContainerClient(containerName); + var blobContainerClient = blobServiceClient.GetBlobContainerClient(container); var blobClient = blobContainerClient.GetBlobClient(blobName); + var dateTimeOffset = DateTimeOffset.UtcNow.Add(expiresIn); - if (!await blobClient.ExistsAsync(cancellationToken)) + if (blobClient.CanGenerateSasUri) { - return null; + return blobClient.GenerateSasUri(BlobSasPermissions.Read, dateTimeOffset); } - var response = await blobClient.DownloadStreamingAsync(cancellationToken: cancellationToken); - return (response.Value.Content, response.Value.Details.ContentType); + var userDelegationKey = blobServiceClient.GetUserDelegationKey(DateTimeOffset.UtcNow, dateTimeOffset); + var builder = new BlobSasBuilder + { + BlobContainerName = container, + BlobName = blobName, + Resource = "b", + ExpiresOn = dateTimeOffset + }; + + builder.SetPermissions(BlobSasPermissions.Read); + + return new BlobUriBuilder(blobClient.Uri) + { + Sas = builder.ToSasQueryParameters(userDelegationKey, blobServiceClient.AccountName) + }.ToUri(); } public async Task CreateContainerIfNotExistsAsync(string containerName, PublicAccessType publicAccessType, CancellationToken cancellationToken) diff --git a/application/shared-kernel/SharedKernel/Integrations/BlobStorage/IBlobStorageClient.cs b/application/shared-kernel/SharedKernel/Integrations/BlobStorage/IBlobStorageClient.cs index 9b20cd67e5..0a3ad8e9a6 100644 --- a/application/shared-kernel/SharedKernel/Integrations/BlobStorage/IBlobStorageClient.cs +++ b/application/shared-kernel/SharedKernel/Integrations/BlobStorage/IBlobStorageClient.cs @@ -10,7 +10,11 @@ public interface IBlobStorageClient string GetBlobUrl(string container, string blobName); + Task DeleteIfExistsAsync(string containerName, string blobName, CancellationToken cancellationToken); + string GetSharedAccessSignature(string container, TimeSpan expiresIn); + Uri GetBlobUriWithSharedAccessSignature(string container, string blobName, TimeSpan expiresIn); + Task CreateContainerIfNotExistsAsync(string containerName, PublicAccessType publicAccessType, CancellationToken cancellationToken); }