diff --git a/application/.editorconfig b/application/.editorconfig index 24b7d5eb5..93fdb958b 100644 --- a/application/.editorconfig +++ b/application/.editorconfig @@ -7,6 +7,9 @@ indent_style = space indent_size = 4 tab_width = 4 +[*.cs] +end_of_line = crlf + [*.{ts,tsx,js,jsx,json,md,mdx,.prettierrc,.eslintrc,yml,Dockerfile}] indent_size = 2 tab_width = 2 diff --git a/application/AppGateway/Filters/ClusterDestinationConfigFilter.cs b/application/AppGateway/Filters/ClusterDestinationConfigFilter.cs index f132624ff..7bca0b6e8 100644 --- a/application/AppGateway/Filters/ClusterDestinationConfigFilter.cs +++ b/application/AppGateway/Filters/ClusterDestinationConfigFilter.cs @@ -1,39 +1,39 @@ -using Yarp.ReverseProxy.Configuration; - -namespace PlatformPlatform.AppGateway.Filters; - -public class ClusterDestinationConfigFilter : IProxyConfigFilter -{ - public ValueTask ConfigureClusterAsync(ClusterConfig cluster, CancellationToken cancel) - { - return cluster.ClusterId switch - { - "account-management-api" => ReplaceDestinationAddress(cluster, "ACCOUNT_MANAGEMENT_API_URL"), - "avatars-storage" => ReplaceDestinationAddress(cluster, "AVATARS_STORAGE_URL"), - "back-office-api" => ReplaceDestinationAddress(cluster, "BACK_OFFICE_API_URL"), - _ => throw new InvalidOperationException($"Unknown Cluster ID {cluster.ClusterId}") - }; - } - - public ValueTask ConfigureRouteAsync(RouteConfig route, ClusterConfig? cluster, CancellationToken cancel) - { - return new ValueTask(route); - } - - private static ValueTask ReplaceDestinationAddress(ClusterConfig cluster, string environmentVariable) - { - var destinationAddress = Environment.GetEnvironmentVariable(environmentVariable); - if (destinationAddress is null) return new ValueTask(cluster); - - // Each cluster has a dictionary with one and only one destination - var destination = cluster.Destinations!.Single(); - - // This is read-only, so we'll create a new one with our updates - var newDestinations = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - { destination.Key, destination.Value with { Address = destinationAddress } } - }; - - return new ValueTask(cluster with { Destinations = newDestinations }); - } -} +using Yarp.ReverseProxy.Configuration; + +namespace PlatformPlatform.AppGateway.Filters; + +public class ClusterDestinationConfigFilter : IProxyConfigFilter +{ + public ValueTask ConfigureClusterAsync(ClusterConfig cluster, CancellationToken cancel) + { + return cluster.ClusterId switch + { + "account-management-api" => ReplaceDestinationAddress(cluster, "ACCOUNT_MANAGEMENT_API_URL"), + "avatars-storage" => ReplaceDestinationAddress(cluster, "AVATARS_STORAGE_URL"), + "back-office-api" => ReplaceDestinationAddress(cluster, "BACK_OFFICE_API_URL"), + _ => throw new InvalidOperationException($"Unknown Cluster ID {cluster.ClusterId}") + }; + } + + public ValueTask ConfigureRouteAsync(RouteConfig route, ClusterConfig? cluster, CancellationToken cancel) + { + return new ValueTask(route); + } + + private static ValueTask ReplaceDestinationAddress(ClusterConfig cluster, string environmentVariable) + { + var destinationAddress = Environment.GetEnvironmentVariable(environmentVariable); + if (destinationAddress is null) return new ValueTask(cluster); + + // Each cluster has a dictionary with one and only one destination + var destination = cluster.Destinations!.Single(); + + // This is read-only, so we'll create a new one with our updates + var newDestinations = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { destination.Key, destination.Value with { Address = destinationAddress } } + }; + + return new ValueTask(cluster with { Destinations = newDestinations }); + } +} diff --git a/application/AppGateway/Program.cs b/application/AppGateway/Program.cs index 5289e1956..f755372b2 100644 --- a/application/AppGateway/Program.cs +++ b/application/AppGateway/Program.cs @@ -1,51 +1,51 @@ -using Azure.Core; -using PlatformPlatform.AppGateway.Filters; -using PlatformPlatform.AppGateway.Transformations; -using PlatformPlatform.SharedKernel.InfrastructureCore; - -var builder = WebApplication.CreateBuilder(args); - -var reverseProxyBuilder = builder.Services - .AddReverseProxy() - .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")) - .AddConfigFilter(); - -if (InfrastructureCoreConfiguration.IsRunningInAzure) -{ - builder.Services.AddSingleton(InfrastructureCoreConfiguration.GetDefaultAzureCredential()); - builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - reverseProxyBuilder.AddTransforms(context => - { - context.RequestTransforms.Add(context.Services.GetRequiredService()); - context.RequestTransforms.Add(context.Services.GetRequiredService()); - } - ); -} -else -{ - builder.Services.AddSingleton(); - reverseProxyBuilder.AddTransforms(context => - context.RequestTransforms.Add(context.Services.GetRequiredService()) - ); -} - -builder.Services.AddNamedBlobStorages(builder, ("avatars-storage", "AVATARS_STORAGE_URL")); - -builder.WebHost.UseKestrel(option => option.AddServerHeader = false); - -var app = builder.Build(); - -// Adds middleware for redirecting HTTP Requests to HTTPS -app.UseHttpsRedirection(); - -if (!app.Environment.IsDevelopment()) -{ - // Adds middleware for using HSTS, which adds the Strict-Transport-Security header - // Defaults to 30 days. See https://aka.ms/aspnetcore-hsts, so be careful during development - app.UseHsts(); -} - -app.MapReverseProxy(); - -app.Run(); +using Azure.Core; +using PlatformPlatform.AppGateway.Filters; +using PlatformPlatform.AppGateway.Transformations; +using PlatformPlatform.SharedKernel.InfrastructureCore; + +var builder = WebApplication.CreateBuilder(args); + +var reverseProxyBuilder = builder.Services + .AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")) + .AddConfigFilter(); + +if (InfrastructureCoreConfiguration.IsRunningInAzure) +{ + builder.Services.AddSingleton(InfrastructureCoreConfiguration.GetDefaultAzureCredential()); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + reverseProxyBuilder.AddTransforms(context => + { + context.RequestTransforms.Add(context.Services.GetRequiredService()); + context.RequestTransforms.Add(context.Services.GetRequiredService()); + } + ); +} +else +{ + builder.Services.AddSingleton(); + reverseProxyBuilder.AddTransforms(context => + context.RequestTransforms.Add(context.Services.GetRequiredService()) + ); +} + +builder.Services.AddNamedBlobStorages(builder, ("avatars-storage", "AVATARS_STORAGE_URL")); + +builder.WebHost.UseKestrel(option => option.AddServerHeader = false); + +var app = builder.Build(); + +// Adds middleware for redirecting HTTP Requests to HTTPS +app.UseHttpsRedirection(); + +if (!app.Environment.IsDevelopment()) +{ + // Adds middleware for using HSTS, which adds the Strict-Transport-Security header + // Defaults to 30 days. See https://aka.ms/aspnetcore-hsts, so be careful during development + app.UseHsts(); +} + +app.MapReverseProxy(); + +app.Run(); diff --git a/application/AppGateway/Transformations/ManagedIdentityTransform.cs b/application/AppGateway/Transformations/ManagedIdentityTransform.cs index 1b1a743c1..9aab22d84 100644 --- a/application/AppGateway/Transformations/ManagedIdentityTransform.cs +++ b/application/AppGateway/Transformations/ManagedIdentityTransform.cs @@ -1,27 +1,27 @@ -using Azure.Core; -using Yarp.ReverseProxy.Transforms; - -namespace PlatformPlatform.AppGateway.Transformations; - -public class ManagedIdentityTransform(TokenCredential credential) - : RequestHeaderTransform("Authorization", false) -{ - protected override string? GetValue(RequestTransformContext context) - { - if (!context.HttpContext.Request.Path.StartsWithSegments("/avatars")) return null; - - var tokenRequestContext = new TokenRequestContext(["https://storage.azure.com/.default"]); - var token = credential.GetToken(tokenRequestContext, context.HttpContext.RequestAborted); - return $"Bearer {token.Token}"; - } -} - -public class ApiVersionHeaderTransform() : RequestHeaderTransform("x-ms-version", false) -{ - protected override string? GetValue(RequestTransformContext context) - { - if (!context.HttpContext.Request.Path.StartsWithSegments("/avatars")) return null; - - return "2023-11-03"; - } -} +using Azure.Core; +using Yarp.ReverseProxy.Transforms; + +namespace PlatformPlatform.AppGateway.Transformations; + +public class ManagedIdentityTransform(TokenCredential credential) + : RequestHeaderTransform("Authorization", false) +{ + protected override string? GetValue(RequestTransformContext context) + { + if (!context.HttpContext.Request.Path.StartsWithSegments("/avatars")) return null; + + var tokenRequestContext = new TokenRequestContext(["https://storage.azure.com/.default"]); + var token = credential.GetToken(tokenRequestContext, context.HttpContext.RequestAborted); + return $"Bearer {token.Token}"; + } +} + +public class ApiVersionHeaderTransform() : RequestHeaderTransform("x-ms-version", false) +{ + protected override string? GetValue(RequestTransformContext context) + { + if (!context.HttpContext.Request.Path.StartsWithSegments("/avatars")) return null; + + return "2023-11-03"; + } +} diff --git a/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs b/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs index b262a4058..6914f4dcb 100644 --- a/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs +++ b/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs @@ -1,18 +1,18 @@ -using PlatformPlatform.SharedKernel.ApplicationCore.Services; -using Yarp.ReverseProxy.Transforms; - -namespace PlatformPlatform.AppGateway.Transformations; - -public class SharedAccessSignatureRequestTransform([FromKeyedServices("avatars-storage")] IBlobStorage blobStorage) - : RequestTransform -{ - public override ValueTask ApplyAsync(RequestTransformContext context) - { - if (!context.Path.StartsWithSegments("/avatars")) return ValueTask.CompletedTask; - - var sharedAccessSignature = blobStorage.GetSharedAccessSignature("avatars", TimeSpan.FromMinutes(10)); - context.HttpContext.Request.QueryString = new QueryString(sharedAccessSignature); - - return ValueTask.CompletedTask; - } -} +using PlatformPlatform.SharedKernel.ApplicationCore.Services; +using Yarp.ReverseProxy.Transforms; + +namespace PlatformPlatform.AppGateway.Transformations; + +public class SharedAccessSignatureRequestTransform([FromKeyedServices("avatars-storage")] IBlobStorage blobStorage) + : RequestTransform +{ + public override ValueTask ApplyAsync(RequestTransformContext context) + { + if (!context.Path.StartsWithSegments("/avatars")) return ValueTask.CompletedTask; + + var sharedAccessSignature = blobStorage.GetSharedAccessSignature("avatars", TimeSpan.FromMinutes(10)); + context.HttpContext.Request.QueryString = new QueryString(sharedAccessSignature); + + return ValueTask.CompletedTask; + } +} diff --git a/application/AppHost/Program.cs b/application/AppHost/Program.cs index 7684880a5..bd4a22fe9 100644 --- a/application/AppHost/Program.cs +++ b/application/AppHost/Program.cs @@ -1,89 +1,89 @@ -using AppHost; -using Azure.Storage.Blobs; -using Microsoft.Extensions.Configuration; -using Projects; - -var builder = DistributedApplication.CreateBuilder(args); - -var certificatePassword = builder.CreateSslCertificateIfNotExists(); - -var sqlPassword = builder.CreateStablePassword("sql-server-password"); -var sqlServer = builder.AddSqlServer("sql-server", sqlPassword, 9002) - .WithVolume("platform-platform-sql-server-data", "/var/opt/mssql"); - -var azureStorage = builder - .AddAzureStorage("azure-storage") - .RunAsEmulator(resourceBuilder => - { - resourceBuilder.WithVolume("platform-platform-azure-storage-data", "/data"); - resourceBuilder.WithBlobPort(10000); - } - ) - .AddBlobs("blobs"); - -builder - .AddContainer("mail-server", "axllent/mailpit") - .WithHttpEndpoint(9003, 8025) - .WithEndpoint(9004, 1025); - -var accountManagementDatabase = sqlServer - .AddDatabase("account-management-database", "account-management"); - -CreateBlobContainer("avatars"); - -var accountManagementApi = builder - .AddProject("account-management-api") - .WithReference(accountManagementDatabase) - .WithReference(azureStorage); - -var accountManagementSpa = builder - .AddNpmApp("account-management-spa", "../account-management/WebApp", "dev") - .WithReference(accountManagementApi) - .WithEnvironment("CERTIFICATE_PASSWORD", certificatePassword); - -builder - .AddProject("account-management-workers") - .WithReference(accountManagementDatabase) - .WithReference(azureStorage); - -var backOfficeDatabase = sqlServer - .AddDatabase("back-office-database", "back-office"); - -var backOfficeApi = builder - .AddProject("back-office-api") - .WithReference(backOfficeDatabase) - .WithReference(azureStorage); - -var backOfficeSpa = builder - .AddNpmApp("back-office-spa", "../back-office/WebApp", "dev") - .WithReference(backOfficeApi) - .WithEnvironment("CERTIFICATE_PASSWORD", certificatePassword); - -builder - .AddProject("back-office-workers") - .WithReference(backOfficeDatabase) - .WithReference(azureStorage); - -builder - .AddProject("app-gateway") - .WithReference(accountManagementApi) - .WithReference(accountManagementSpa) - .WithReference(backOfficeApi) - .WithReference(backOfficeSpa); - -builder.Build().Run(); - -return; - -void CreateBlobContainer(string containerName) -{ - var connectionString = builder.Configuration.GetConnectionString("blob-storage"); - - new Task(() => - { - var blobServiceClient = new BlobServiceClient(connectionString); - var containerClient = blobServiceClient.GetBlobContainerClient(containerName); - containerClient.CreateIfNotExists(); - } - ).Start(); -} +using AppHost; +using Azure.Storage.Blobs; +using Microsoft.Extensions.Configuration; +using Projects; + +var builder = DistributedApplication.CreateBuilder(args); + +var certificatePassword = builder.CreateSslCertificateIfNotExists(); + +var sqlPassword = builder.CreateStablePassword("sql-server-password"); +var sqlServer = builder.AddSqlServer("sql-server", sqlPassword, 9002) + .WithVolume("platform-platform-sql-server-data", "/var/opt/mssql"); + +var azureStorage = builder + .AddAzureStorage("azure-storage") + .RunAsEmulator(resourceBuilder => + { + resourceBuilder.WithVolume("platform-platform-azure-storage-data", "/data"); + resourceBuilder.WithBlobPort(10000); + } + ) + .AddBlobs("blobs"); + +builder + .AddContainer("mail-server", "axllent/mailpit") + .WithHttpEndpoint(9003, 8025) + .WithEndpoint(9004, 1025); + +var accountManagementDatabase = sqlServer + .AddDatabase("account-management-database", "account-management"); + +CreateBlobContainer("avatars"); + +var accountManagementApi = builder + .AddProject("account-management-api") + .WithReference(accountManagementDatabase) + .WithReference(azureStorage); + +var accountManagementSpa = builder + .AddNpmApp("account-management-spa", "../account-management/WebApp", "dev") + .WithReference(accountManagementApi) + .WithEnvironment("CERTIFICATE_PASSWORD", certificatePassword); + +builder + .AddProject("account-management-workers") + .WithReference(accountManagementDatabase) + .WithReference(azureStorage); + +var backOfficeDatabase = sqlServer + .AddDatabase("back-office-database", "back-office"); + +var backOfficeApi = builder + .AddProject("back-office-api") + .WithReference(backOfficeDatabase) + .WithReference(azureStorage); + +var backOfficeSpa = builder + .AddNpmApp("back-office-spa", "../back-office/WebApp", "dev") + .WithReference(backOfficeApi) + .WithEnvironment("CERTIFICATE_PASSWORD", certificatePassword); + +builder + .AddProject("back-office-workers") + .WithReference(backOfficeDatabase) + .WithReference(azureStorage); + +builder + .AddProject("app-gateway") + .WithReference(accountManagementApi) + .WithReference(accountManagementSpa) + .WithReference(backOfficeApi) + .WithReference(backOfficeSpa); + +builder.Build().Run(); + +return; + +void CreateBlobContainer(string containerName) +{ + var connectionString = builder.Configuration.GetConnectionString("blob-storage"); + + new Task(() => + { + var blobServiceClient = new BlobServiceClient(connectionString); + var containerClient = blobServiceClient.GetBlobContainerClient(containerName); + containerClient.CreateIfNotExists(); + } + ).Start(); +} diff --git a/application/AppHost/SecretManagerHelper.cs b/application/AppHost/SecretManagerHelper.cs index 8cc7bf6d4..3dd6f163f 100644 --- a/application/AppHost/SecretManagerHelper.cs +++ b/application/AppHost/SecretManagerHelper.cs @@ -1,48 +1,48 @@ -using System.Diagnostics; -using System.Reflection; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Configuration.UserSecrets; - -namespace AppHost; - -public static class SecretManagerHelper -{ - private static string UserSecretsId => - Assembly.GetEntryAssembly()!.GetCustomAttribute()!.UserSecretsId; - - public static IResourceBuilder CreateStablePassword( - this IDistributedApplicationBuilder builder, - string secretName - ) - { - var config = new ConfigurationBuilder().AddUserSecrets(UserSecretsId).Build(); - - var password = config[secretName]; - - if (string.IsNullOrEmpty(password)) - { - var passwordGenerator = new GenerateParameterDefault - { - MinLower = 5, MinUpper = 5, MinNumeric = 3, MinSpecial = 3 - }; - password = passwordGenerator.GetDefaultValue(); - SavePasswordToUserSecrets(secretName, password); - } - - return builder.CreateResourceBuilder(new ParameterResource(secretName, _ => password, true)); - } - - private static void SavePasswordToUserSecrets(string key, string value) - { - var args = $"user-secrets set {key} {value} --id {UserSecretsId}"; - var startInfo = new ProcessStartInfo("dotnet", args) - { - RedirectStandardOutput = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - using var process = Process.Start(startInfo)!; - process.WaitForExit(); - } -} +using System.Diagnostics; +using System.Reflection; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.UserSecrets; + +namespace AppHost; + +public static class SecretManagerHelper +{ + private static string UserSecretsId => + Assembly.GetEntryAssembly()!.GetCustomAttribute()!.UserSecretsId; + + public static IResourceBuilder CreateStablePassword( + this IDistributedApplicationBuilder builder, + string secretName + ) + { + var config = new ConfigurationBuilder().AddUserSecrets(UserSecretsId).Build(); + + var password = config[secretName]; + + if (string.IsNullOrEmpty(password)) + { + var passwordGenerator = new GenerateParameterDefault + { + MinLower = 5, MinUpper = 5, MinNumeric = 3, MinSpecial = 3 + }; + password = passwordGenerator.GetDefaultValue(); + SavePasswordToUserSecrets(secretName, password); + } + + return builder.CreateResourceBuilder(new ParameterResource(secretName, _ => password, true)); + } + + private static void SavePasswordToUserSecrets(string key, string value) + { + var args = $"user-secrets set {key} {value} --id {UserSecretsId}"; + var startInfo = new ProcessStartInfo("dotnet", args) + { + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + } +} diff --git a/application/AppHost/SslCertificateManager.cs b/application/AppHost/SslCertificateManager.cs index 17b723812..524a1c3c3 100644 --- a/application/AppHost/SslCertificateManager.cs +++ b/application/AppHost/SslCertificateManager.cs @@ -1,114 +1,114 @@ -using System.Diagnostics; -using System.Reflection; -using System.Runtime.InteropServices; -using System.Security.Cryptography; -using System.Security.Cryptography.X509Certificates; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Configuration.UserSecrets; - -namespace AppHost; - -public static class SslCertificateManager -{ - public static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - - private static string UserSecretsId => - Assembly.GetEntryAssembly()!.GetCustomAttribute()!.UserSecretsId; - - public static string CreateSslCertificateIfNotExists(this IDistributedApplicationBuilder builder) - { - var config = new ConfigurationBuilder().AddUserSecrets(UserSecretsId).Build(); - - const string certificatePasswordKey = "certificate-password"; - var certificatePassword = config[certificatePasswordKey] - ?? builder.CreateStablePassword(certificatePasswordKey).Resource.Value; - - var certificateLocation = GetLocalhostCertificateLocation(); - if (!IsValidCertificate(certificatePassword, certificateLocation)) - { - CreateNewSelfSignedDeveloperCertificate(certificatePassword, certificateLocation); - } - - return certificatePassword; - } - - private static string GetLocalhostCertificateLocation() - { - var userFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return $"{userFolder}/.aspnet/dev-certs/https/platformplatform.pfx"; - } - - private static bool IsValidCertificate(string? password, string certificateLocation) - { - if (!File.Exists(certificateLocation)) - { - return false; - } - - if (IsWindows) - { - try - { - // Try to load the certificate with the provided password - _ = new X509Certificate2(certificateLocation, password); - return true; - } - catch (CryptographicException) - { - // If a CryptographicException is thrown, the password is invalid - // Ignore the exception and return false - } - } - else - { - var certificateValidation = StartProcess(new ProcessStartInfo - { - FileName = "openssl", - Arguments = $"pkcs12 -in {certificateLocation} -passin pass:{password} -nokeys", - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true - } - ); - - if (certificateValidation.Contains("--BEGIN CERTIFICATE--")) - { - return true; - } - } - - Console.WriteLine($"Certificate {certificateLocation} exists, but password {password} was invalid. Creating a new certificate."); - return false; - } - - private static void CreateNewSelfSignedDeveloperCertificate(string password, string certificateLocation) - { - if (File.Exists(certificateLocation)) - { - File.Delete(certificateLocation); - } - - StartProcess(new ProcessStartInfo - { - FileName = "dotnet", - Arguments = $"dev-certs https --trust -ep {certificateLocation} -p {password}", - RedirectStandardOutput = false, - RedirectStandardError = false, - UseShellExecute = false - } - ); - } - - private static string StartProcess(ProcessStartInfo processStartInfo) - { - var process = Process.Start(processStartInfo)!; - - var output = string.Empty; - if (processStartInfo.RedirectStandardOutput) output += process.StandardOutput.ReadToEnd(); - if (processStartInfo.RedirectStandardError) output += process.StandardError.ReadToEnd(); - - process.WaitForExit(); - - return output; - } -} +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.UserSecrets; + +namespace AppHost; + +public static class SslCertificateManager +{ + public static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + private static string UserSecretsId => + Assembly.GetEntryAssembly()!.GetCustomAttribute()!.UserSecretsId; + + public static string CreateSslCertificateIfNotExists(this IDistributedApplicationBuilder builder) + { + var config = new ConfigurationBuilder().AddUserSecrets(UserSecretsId).Build(); + + const string certificatePasswordKey = "certificate-password"; + var certificatePassword = config[certificatePasswordKey] + ?? builder.CreateStablePassword(certificatePasswordKey).Resource.Value; + + var certificateLocation = GetLocalhostCertificateLocation(); + if (!IsValidCertificate(certificatePassword, certificateLocation)) + { + CreateNewSelfSignedDeveloperCertificate(certificatePassword, certificateLocation); + } + + return certificatePassword; + } + + private static string GetLocalhostCertificateLocation() + { + var userFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return $"{userFolder}/.aspnet/dev-certs/https/platformplatform.pfx"; + } + + private static bool IsValidCertificate(string? password, string certificateLocation) + { + if (!File.Exists(certificateLocation)) + { + return false; + } + + if (IsWindows) + { + try + { + // Try to load the certificate with the provided password + _ = new X509Certificate2(certificateLocation, password); + return true; + } + catch (CryptographicException) + { + // If a CryptographicException is thrown, the password is invalid + // Ignore the exception and return false + } + } + else + { + var certificateValidation = StartProcess(new ProcessStartInfo + { + FileName = "openssl", + Arguments = $"pkcs12 -in {certificateLocation} -passin pass:{password} -nokeys", + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + ); + + if (certificateValidation.Contains("--BEGIN CERTIFICATE--")) + { + return true; + } + } + + Console.WriteLine($"Certificate {certificateLocation} exists, but password {password} was invalid. Creating a new certificate."); + return false; + } + + private static void CreateNewSelfSignedDeveloperCertificate(string password, string certificateLocation) + { + if (File.Exists(certificateLocation)) + { + File.Delete(certificateLocation); + } + + StartProcess(new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"dev-certs https --trust -ep {certificateLocation} -p {password}", + RedirectStandardOutput = false, + RedirectStandardError = false, + UseShellExecute = false + } + ); + } + + private static string StartProcess(ProcessStartInfo processStartInfo) + { + var process = Process.Start(processStartInfo)!; + + var output = string.Empty; + if (processStartInfo.RedirectStandardOutput) output += process.StandardOutput.ReadToEnd(); + if (processStartInfo.RedirectStandardError) output += process.StandardError.ReadToEnd(); + + process.WaitForExit(); + + return output; + } +} diff --git a/application/Directory.Packages.props b/application/Directory.Packages.props index 1ebd24ff3..8569c6552 100644 --- a/application/Directory.Packages.props +++ b/application/Directory.Packages.props @@ -1,5 +1,4 @@ - true true @@ -8,7 +7,6 @@ 8.0.5 8.0.1 - @@ -63,7 +61,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - @@ -101,5 +98,4 @@ - - + \ No newline at end of file diff --git a/application/PlatformPlatform.sln.DotSettings b/application/PlatformPlatform.sln.DotSettings index 649c80faf..5720f4d4c 100644 --- a/application/PlatformPlatform.sln.DotSettings +++ b/application/PlatformPlatform.sln.DotSettings @@ -11,6 +11,7 @@ HINT HINT HINT + True <?xml version="1.0" encoding="utf-16"?><Profile name=".NET only"><CppCodeStyleCleanupDescriptor /><CSReorderTypeMembers>True</CSReorderTypeMembers><CSCodeStyleAttributes ArrangeVarStyle="True" ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" ArrangeArgumentsStyle="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeCodeBodyStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" ArrangeNullCheckingPattern="True" /><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><IDEA_SETTINGS>&lt;profile version="1.0"&gt; &lt;option name="myName" value=".NET only" /&gt; &lt;inspection_tool class="ES6ShorthandObjectProperty" enabled="false" level="WARNING" enabled_by_default="false" /&gt; diff --git a/application/account-management/Api/AccountRegistrations/AccountRegistrationsEndpoints.cs b/application/account-management/Api/AccountRegistrations/AccountRegistrationsEndpoints.cs index 17c0340df..3e89d9ab3 100644 --- a/application/account-management/Api/AccountRegistrations/AccountRegistrationsEndpoints.cs +++ b/application/account-management/Api/AccountRegistrations/AccountRegistrationsEndpoints.cs @@ -1,28 +1,28 @@ -using PlatformPlatform.AccountManagement.Application.AccountRegistrations; -using PlatformPlatform.AccountManagement.Domain.AccountRegistrations; -using PlatformPlatform.SharedKernel.ApiCore.ApiResults; -using PlatformPlatform.SharedKernel.ApiCore.Endpoints; - -namespace PlatformPlatform.AccountManagement.Api.AccountRegistrations; - -public class AccountRegistrationsEndpoints : IEndpoints -{ - private const string RoutesPrefix = "/api/account-management/account-registrations"; - - public void MapEndpoints(IEndpointRouteBuilder routes) - { - var group = routes.MapGroup(RoutesPrefix).WithTags("AccountRegistrations"); - - group.MapGet("/is-subdomain-free", async Task> ([AsParameters] IsSubdomainFreeQuery query, ISender mediator) - => await mediator.Send(query) - ); - - group.MapPost("/start", async Task (StartAccountRegistrationCommand command, ISender mediator) - => (await mediator.Send(command)).AddResourceUri(RoutesPrefix) - ); - - group.MapPost("{id}/complete", async Task (AccountRegistrationId id, CompleteAccountRegistrationCommand command, ISender mediator) - => await mediator.Send(command with { Id = id }) - ); - } -} +using PlatformPlatform.AccountManagement.Application.AccountRegistrations; +using PlatformPlatform.AccountManagement.Domain.AccountRegistrations; +using PlatformPlatform.SharedKernel.ApiCore.ApiResults; +using PlatformPlatform.SharedKernel.ApiCore.Endpoints; + +namespace PlatformPlatform.AccountManagement.Api.AccountRegistrations; + +public class AccountRegistrationsEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/account-management/account-registrations"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup(RoutesPrefix).WithTags("AccountRegistrations"); + + group.MapGet("/is-subdomain-free", async Task> ([AsParameters] IsSubdomainFreeQuery query, ISender mediator) + => await mediator.Send(query) + ); + + group.MapPost("/start", async Task (StartAccountRegistrationCommand command, ISender mediator) + => (await mediator.Send(command)).AddResourceUri(RoutesPrefix) + ); + + group.MapPost("{id}/complete", async Task (AccountRegistrationId id, CompleteAccountRegistrationCommand command, ISender mediator) + => await mediator.Send(command with { Id = id }) + ); + } +} diff --git a/application/account-management/Api/Program.cs b/application/account-management/Api/Program.cs index b74bfa8df..a76edb0c5 100644 --- a/application/account-management/Api/Program.cs +++ b/application/account-management/Api/Program.cs @@ -1,26 +1,26 @@ -using PlatformPlatform.AccountManagement.Application; -using PlatformPlatform.AccountManagement.Domain; -using PlatformPlatform.AccountManagement.Infrastructure; -using PlatformPlatform.SharedKernel.ApiCore; -using PlatformPlatform.SharedKernel.ApiCore.Middleware; - -var builder = WebApplication.CreateBuilder(args); - -// Configure services for the Application, Infrastructure, and Api layers like Entity Framework, Repositories, MediatR, -// FluentValidation validators, Pipelines. -builder.Services - .AddApplicationServices() - .AddInfrastructureServices() - .AddApiCoreServices(builder, Assembly.GetExecutingAssembly(), DomainConfiguration.Assembly) - .AddConfigureStorage(builder) - .AddWebAppMiddleware(); - -var app = builder.Build(); - -// Add common configuration for all APIs like Swagger, HSTS, and DeveloperExceptionPage. -app.UseApiCoreConfiguration(); - -// Server the SPA Index.html if no other endpoints are found -app.UseWebAppMiddleware(); - -app.Run(); +using PlatformPlatform.AccountManagement.Application; +using PlatformPlatform.AccountManagement.Domain; +using PlatformPlatform.AccountManagement.Infrastructure; +using PlatformPlatform.SharedKernel.ApiCore; +using PlatformPlatform.SharedKernel.ApiCore.Middleware; + +var builder = WebApplication.CreateBuilder(args); + +// Configure services for the Application, Infrastructure, and Api layers like Entity Framework, Repositories, MediatR, +// FluentValidation validators, Pipelines. +builder.Services + .AddApplicationServices() + .AddInfrastructureServices() + .AddApiCoreServices(builder, Assembly.GetExecutingAssembly(), DomainConfiguration.Assembly) + .AddConfigureStorage(builder) + .AddWebAppMiddleware(); + +var app = builder.Build(); + +// Add common configuration for all APIs like Swagger, HSTS, and DeveloperExceptionPage. +app.UseApiCoreConfiguration(); + +// Server the SPA Index.html if no other endpoints are found +app.UseWebAppMiddleware(); + +app.Run(); diff --git a/application/account-management/Api/Tenants/TenantEndpoints.cs b/application/account-management/Api/Tenants/TenantEndpoints.cs index 40b027c31..df99ffb48 100644 --- a/application/account-management/Api/Tenants/TenantEndpoints.cs +++ b/application/account-management/Api/Tenants/TenantEndpoints.cs @@ -1,27 +1,27 @@ -using PlatformPlatform.AccountManagement.Application.Tenants; -using PlatformPlatform.SharedKernel.ApiCore.ApiResults; -using PlatformPlatform.SharedKernel.ApiCore.Endpoints; - -namespace PlatformPlatform.AccountManagement.Api.Tenants; - -public class TenantEndpoints : IEndpoints -{ - private const string RoutesPrefix = "/api/account-management/tenants"; - - public void MapEndpoints(IEndpointRouteBuilder routes) - { - var group = routes.MapGroup(RoutesPrefix).WithTags("Tenants"); - - group.MapGet("/{id}", async Task> ([AsParameters] GetTenantQuery query, ISender mediator) - => await mediator.Send(query) - ); - - group.MapPut("/{id}", async Task (TenantId id, UpdateTenantCommand command, ISender mediator) - => await mediator.Send(command with { Id = id }) - ); - - group.MapDelete("/{id}", async Task ([AsParameters] DeleteTenantCommand command, ISender mediator) - => await mediator.Send(command) - ); - } -} +using PlatformPlatform.AccountManagement.Application.Tenants; +using PlatformPlatform.SharedKernel.ApiCore.ApiResults; +using PlatformPlatform.SharedKernel.ApiCore.Endpoints; + +namespace PlatformPlatform.AccountManagement.Api.Tenants; + +public class TenantEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/account-management/tenants"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup(RoutesPrefix).WithTags("Tenants"); + + group.MapGet("/{id}", async Task> ([AsParameters] GetTenantQuery query, ISender mediator) + => await mediator.Send(query) + ); + + group.MapPut("/{id}", async Task (TenantId id, UpdateTenantCommand command, ISender mediator) + => await mediator.Send(command with { Id = id }) + ); + + group.MapDelete("/{id}", async Task ([AsParameters] DeleteTenantCommand command, ISender mediator) + => await mediator.Send(command) + ); + } +} diff --git a/application/account-management/Api/Users/UserEndpoints.cs b/application/account-management/Api/Users/UserEndpoints.cs index 285f77c14..293cd141f 100644 --- a/application/account-management/Api/Users/UserEndpoints.cs +++ b/application/account-management/Api/Users/UserEndpoints.cs @@ -1,45 +1,45 @@ -using PlatformPlatform.AccountManagement.Application.Users; -using PlatformPlatform.SharedKernel.ApiCore.ApiResults; -using PlatformPlatform.SharedKernel.ApiCore.Endpoints; - -namespace PlatformPlatform.AccountManagement.Api.Users; - -public class UserEndpoints : IEndpoints -{ - private const string RoutesPrefix = "/api/account-management/users"; - - public void MapEndpoints(IEndpointRouteBuilder routes) - { - var group = routes.MapGroup(RoutesPrefix).WithTags("Users"); - - group.MapGet("/", async Task> ([AsParameters] GetUsersQuery query, ISender mediator) - => await mediator.Send(query) - ); - - group.MapGet("/{id}", async Task> ([AsParameters] GetUserQuery query, ISender mediator) - => await mediator.Send(query) - ); - - group.MapPost("/", async Task (CreateUserCommand command, ISender mediator) - => (await mediator.Send(command)).AddResourceUri(RoutesPrefix) - ); - - group.MapPut("/{id}", async Task (UserId id, UpdateUserCommand command, ISender mediator) - => await mediator.Send(command with { Id = id }) - ); - - group.MapDelete("/{id}", async Task ([AsParameters] DeleteUserCommand command, ISender mediator) - => await mediator.Send(command) - ); - - // Id should be inferred from the authenticated user - group.MapPost("/{id}/update-avatar", async Task (UserId id, IFormFile file, ISender mediator) - => await mediator.Send(new UpdateAvatarCommand(id, file.OpenReadStream(), file.ContentType)) - ).DisableAntiforgery(); // Disable antiforgery until we implement it - - // Id should be inferred from the authenticated user - group.MapPost("/{id}/remove-avatar", async Task ([AsParameters] RemoveAvatarCommand command, ISender mediator) - => await mediator.Send(command) - ); - } -} +using PlatformPlatform.AccountManagement.Application.Users; +using PlatformPlatform.SharedKernel.ApiCore.ApiResults; +using PlatformPlatform.SharedKernel.ApiCore.Endpoints; + +namespace PlatformPlatform.AccountManagement.Api.Users; + +public class UserEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/account-management/users"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var group = routes.MapGroup(RoutesPrefix).WithTags("Users"); + + group.MapGet("/", async Task> ([AsParameters] GetUsersQuery query, ISender mediator) + => await mediator.Send(query) + ); + + group.MapGet("/{id}", async Task> ([AsParameters] GetUserQuery query, ISender mediator) + => await mediator.Send(query) + ); + + group.MapPost("/", async Task (CreateUserCommand command, ISender mediator) + => (await mediator.Send(command)).AddResourceUri(RoutesPrefix) + ); + + group.MapPut("/{id}", async Task (UserId id, UpdateUserCommand command, ISender mediator) + => await mediator.Send(command with { Id = id }) + ); + + group.MapDelete("/{id}", async Task ([AsParameters] DeleteUserCommand command, ISender mediator) + => await mediator.Send(command) + ); + + // Id should be inferred from the authenticated user + group.MapPost("/{id}/update-avatar", async Task (UserId id, IFormFile file, ISender mediator) + => await mediator.Send(new UpdateAvatarCommand(id, file.OpenReadStream(), file.ContentType)) + ).DisableAntiforgery(); // Disable antiforgery until we implement it + + // Id should be inferred from the authenticated user + group.MapPost("/{id}/remove-avatar", async Task ([AsParameters] RemoveAvatarCommand command, ISender mediator) + => await mediator.Send(command) + ); + } +} diff --git a/application/account-management/Application/AccountRegistrations/CompleteAccountRegistration.cs b/application/account-management/Application/AccountRegistrations/CompleteAccountRegistration.cs index c835c1e71..bde09c084 100644 --- a/application/account-management/Application/AccountRegistrations/CompleteAccountRegistration.cs +++ b/application/account-management/Application/AccountRegistrations/CompleteAccountRegistration.cs @@ -1,75 +1,75 @@ -using Microsoft.AspNetCore.Identity; -using PlatformPlatform.AccountManagement.Application.TelemetryEvents; -using PlatformPlatform.AccountManagement.Domain.AccountRegistrations; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.AccountManagement.Application.AccountRegistrations; - -public sealed record CompleteAccountRegistrationCommand(string OneTimePassword) - : ICommand, IRequest -{ - [JsonIgnore] - public AccountRegistrationId Id { get; init; } = null!; -} - -public sealed class CompleteAccountRegistrationHandler( - ITenantRepository tenantRepository, - IAccountRegistrationRepository accountRegistrationRepository, - IPasswordHasher passwordHasher, - ITelemetryEventsCollector events, - ILogger logger -) : IRequestHandler -{ - public async Task Handle(CompleteAccountRegistrationCommand command, CancellationToken cancellationToken) - { - var accountRegistration = await accountRegistrationRepository.GetByIdAsync(command.Id, cancellationToken); - - if (accountRegistration is null) - { - return Result.NotFound($"AccountRegistration with id '{command.Id}' not found."); - } - - if (passwordHasher.VerifyHashedPassword(this, accountRegistration.OneTimePasswordHash, command.OneTimePassword) - == PasswordVerificationResult.Failed) - { - accountRegistration.RegisterInvalidPasswordAttempt(); - accountRegistrationRepository.Update(accountRegistration); - events.CollectEvent(new AccountRegistrationFailed(accountRegistration.RetryCount)); - return Result.BadRequest("The code is wrong or no longer valid.", true); - } - - if (accountRegistration.Completed) - { - logger.LogWarning( - "AccountRegistration with id '{AccountRegistrationId}' has already been completed.", accountRegistration.Id - ); - return Result.BadRequest( - $"The account registration {accountRegistration.Id} for tenant {accountRegistration.TenantId} has already been completed." - ); - } - - if (accountRegistration.RetryCount >= AccountRegistration.MaxAttempts) - { - events.CollectEvent(new AccountRegistrationBlocked(accountRegistration.RetryCount)); - return Result.Forbidden("To many attempts, please request a new code.", true); - } - - var registrationTimeInSeconds = (TimeProvider.System.GetUtcNow() - accountRegistration.CreatedAt).TotalSeconds; - if (accountRegistration.HasExpired()) - { - events.CollectEvent(new AccountRegistrationExpired((int)registrationTimeInSeconds)); - return Result.BadRequest("The code is no longer valid, please request a new code.", true); - } - - var tenant = Tenant.Create(accountRegistration.TenantId, accountRegistration.Email); - await tenantRepository.AddAsync(tenant, cancellationToken); - - accountRegistration.MarkAsCompleted(); - accountRegistrationRepository.Update(accountRegistration); - - events.CollectEvent(new AccountRegistrationCompleted(tenant.Id, tenant.State, (int)registrationTimeInSeconds)); - - return Result.Success(); - } -} +using Microsoft.AspNetCore.Identity; +using PlatformPlatform.AccountManagement.Application.TelemetryEvents; +using PlatformPlatform.AccountManagement.Domain.AccountRegistrations; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.AccountManagement.Application.AccountRegistrations; + +public sealed record CompleteAccountRegistrationCommand(string OneTimePassword) + : ICommand, IRequest +{ + [JsonIgnore] + public AccountRegistrationId Id { get; init; } = null!; +} + +public sealed class CompleteAccountRegistrationHandler( + ITenantRepository tenantRepository, + IAccountRegistrationRepository accountRegistrationRepository, + IPasswordHasher passwordHasher, + ITelemetryEventsCollector events, + ILogger logger +) : IRequestHandler +{ + public async Task Handle(CompleteAccountRegistrationCommand command, CancellationToken cancellationToken) + { + var accountRegistration = await accountRegistrationRepository.GetByIdAsync(command.Id, cancellationToken); + + if (accountRegistration is null) + { + return Result.NotFound($"AccountRegistration with id '{command.Id}' not found."); + } + + if (passwordHasher.VerifyHashedPassword(this, accountRegistration.OneTimePasswordHash, command.OneTimePassword) + == PasswordVerificationResult.Failed) + { + accountRegistration.RegisterInvalidPasswordAttempt(); + accountRegistrationRepository.Update(accountRegistration); + events.CollectEvent(new AccountRegistrationFailed(accountRegistration.RetryCount)); + return Result.BadRequest("The code is wrong or no longer valid.", true); + } + + if (accountRegistration.Completed) + { + logger.LogWarning( + "AccountRegistration with id '{AccountRegistrationId}' has already been completed.", accountRegistration.Id + ); + return Result.BadRequest( + $"The account registration {accountRegistration.Id} for tenant {accountRegistration.TenantId} has already been completed." + ); + } + + if (accountRegistration.RetryCount >= AccountRegistration.MaxAttempts) + { + events.CollectEvent(new AccountRegistrationBlocked(accountRegistration.RetryCount)); + return Result.Forbidden("To many attempts, please request a new code.", true); + } + + var registrationTimeInSeconds = (TimeProvider.System.GetUtcNow() - accountRegistration.CreatedAt).TotalSeconds; + if (accountRegistration.HasExpired()) + { + events.CollectEvent(new AccountRegistrationExpired((int)registrationTimeInSeconds)); + return Result.BadRequest("The code is no longer valid, please request a new code.", true); + } + + var tenant = Tenant.Create(accountRegistration.TenantId, accountRegistration.Email); + await tenantRepository.AddAsync(tenant, cancellationToken); + + accountRegistration.MarkAsCompleted(); + accountRegistrationRepository.Update(accountRegistration); + + events.CollectEvent(new AccountRegistrationCompleted(tenant.Id, tenant.State, (int)registrationTimeInSeconds)); + + return Result.Success(); + } +} diff --git a/application/account-management/Application/AccountRegistrations/IsSubdomainFree.cs b/application/account-management/Application/AccountRegistrations/IsSubdomainFree.cs index 310c2d131..5e418a448 100644 --- a/application/account-management/Application/AccountRegistrations/IsSubdomainFree.cs +++ b/application/account-management/Application/AccountRegistrations/IsSubdomainFree.cs @@ -1,14 +1,14 @@ -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; - -namespace PlatformPlatform.AccountManagement.Application.AccountRegistrations; - -public sealed record IsSubdomainFreeQuery(string Subdomain) : IRequest>; - -public sealed class IsSubdomainFreeHandler(ITenantRepository tenantRepository) - : IRequestHandler> -{ - public async Task> Handle(IsSubdomainFreeQuery request, CancellationToken cancellationToken) - { - return await tenantRepository.IsSubdomainFreeAsync(request.Subdomain, cancellationToken); - } -} +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; + +namespace PlatformPlatform.AccountManagement.Application.AccountRegistrations; + +public sealed record IsSubdomainFreeQuery(string Subdomain) : IRequest>; + +public sealed class IsSubdomainFreeHandler(ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(IsSubdomainFreeQuery request, CancellationToken cancellationToken) + { + return await tenantRepository.IsSubdomainFreeAsync(request.Subdomain, cancellationToken); + } +} diff --git a/application/account-management/Application/AccountRegistrations/StartAccountRegistration.cs b/application/account-management/Application/AccountRegistrations/StartAccountRegistration.cs index 6c413e6e6..03c20ef5b 100644 --- a/application/account-management/Application/AccountRegistrations/StartAccountRegistration.cs +++ b/application/account-management/Application/AccountRegistrations/StartAccountRegistration.cs @@ -1,92 +1,92 @@ -using System.Security.Cryptography; -using System.Text; -using FluentValidation; -using Microsoft.AspNetCore.Identity; -using PlatformPlatform.AccountManagement.Application.TelemetryEvents; -using PlatformPlatform.AccountManagement.Domain.AccountRegistrations; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.ApplicationCore.Services; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; -using PlatformPlatform.SharedKernel.ApplicationCore.Validation; - -namespace PlatformPlatform.AccountManagement.Application.AccountRegistrations; - -public sealed record StartAccountRegistrationCommand(string Subdomain, string Email) - : ICommand, IRequest> -{ - public TenantId GetTenantId() - { - return new TenantId(Subdomain); - } -} - -public sealed class StartAccountRegistrationValidator : AbstractValidator -{ - public StartAccountRegistrationValidator(ITenantRepository tenantRepository) - { - RuleFor(x => x.Subdomain).NotEmpty(); - RuleFor(x => x.Subdomain) - .Matches("^[a-z0-9]{3,30}$") - .WithMessage("Subdomain must be between 3-30 alphanumeric and lowercase characters.") - .MustAsync(tenantRepository.IsSubdomainFreeAsync) - .WithMessage("The subdomain is not available.") - .When(x => !string.IsNullOrEmpty(x.Subdomain)); - RuleFor(x => x.Email).NotEmpty().SetValidator(new SharedValidations.Email()); - } -} - -public sealed class StartAccountRegistrationCommandHandler( - IAccountRegistrationRepository accountRegistrationRepository, - IEmailService emailService, - IPasswordHasher passwordHasher, - ITelemetryEventsCollector events -) : IRequestHandler> -{ - public async Task> Handle(StartAccountRegistrationCommand command, CancellationToken cancellationToken) - { - var existingAccountRegistrations - = accountRegistrationRepository.GetByEmailOrTenantId(command.GetTenantId(), command.Email); - - if (existingAccountRegistrations.Any(r => !r.HasExpired())) - { - return Result.Conflict( - "Account registration for this subdomain/mail has already been started. Please check your spam folder." - ); - } - - if (existingAccountRegistrations.Count(r => r.CreatedAt > TimeProvider.System.GetUtcNow().AddDays(-1)) > 3) - { - return Result.TooManyRequests("Too many attempts to register this email address. Please try again later."); - } - - var oneTimePassword = GenerateOneTimePassword(6); - var oneTimePasswordHash = passwordHasher.HashPassword(this, oneTimePassword); - var accountRegistration = AccountRegistration.Create(command.GetTenantId(), command.Email, oneTimePasswordHash); - - await accountRegistrationRepository.AddAsync(accountRegistration, cancellationToken); - events.CollectEvent(new AccountRegistrationStarted(command.GetTenantId())); - - await emailService.SendAsync(accountRegistration.Email, "Confirm your email address", - $""" -

Your confirmation code is below

-

Enter it in your open browser window. It is only valid for a few minutes.

-

{oneTimePassword}

- """, - cancellationToken - ); - - return accountRegistration.Id; - } - - public static string GenerateOneTimePassword(int length) - { - const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - var oneTimePassword = new StringBuilder(length); - for (var i = 0; i < length; i++) - { - oneTimePassword.Append(chars[RandomNumberGenerator.GetInt32(chars.Length)]); - } - - return oneTimePassword.ToString(); - } -} +using System.Security.Cryptography; +using System.Text; +using FluentValidation; +using Microsoft.AspNetCore.Identity; +using PlatformPlatform.AccountManagement.Application.TelemetryEvents; +using PlatformPlatform.AccountManagement.Domain.AccountRegistrations; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.Services; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; +using PlatformPlatform.SharedKernel.ApplicationCore.Validation; + +namespace PlatformPlatform.AccountManagement.Application.AccountRegistrations; + +public sealed record StartAccountRegistrationCommand(string Subdomain, string Email) + : ICommand, IRequest> +{ + public TenantId GetTenantId() + { + return new TenantId(Subdomain); + } +} + +public sealed class StartAccountRegistrationValidator : AbstractValidator +{ + public StartAccountRegistrationValidator(ITenantRepository tenantRepository) + { + RuleFor(x => x.Subdomain).NotEmpty(); + RuleFor(x => x.Subdomain) + .Matches("^[a-z0-9]{3,30}$") + .WithMessage("Subdomain must be between 3-30 alphanumeric and lowercase characters.") + .MustAsync(tenantRepository.IsSubdomainFreeAsync) + .WithMessage("The subdomain is not available.") + .When(x => !string.IsNullOrEmpty(x.Subdomain)); + RuleFor(x => x.Email).NotEmpty().SetValidator(new SharedValidations.Email()); + } +} + +public sealed class StartAccountRegistrationCommandHandler( + IAccountRegistrationRepository accountRegistrationRepository, + IEmailService emailService, + IPasswordHasher passwordHasher, + ITelemetryEventsCollector events +) : IRequestHandler> +{ + public async Task> Handle(StartAccountRegistrationCommand command, CancellationToken cancellationToken) + { + var existingAccountRegistrations + = accountRegistrationRepository.GetByEmailOrTenantId(command.GetTenantId(), command.Email); + + if (existingAccountRegistrations.Any(r => !r.HasExpired())) + { + return Result.Conflict( + "Account registration for this subdomain/mail has already been started. Please check your spam folder." + ); + } + + if (existingAccountRegistrations.Count(r => r.CreatedAt > TimeProvider.System.GetUtcNow().AddDays(-1)) > 3) + { + return Result.TooManyRequests("Too many attempts to register this email address. Please try again later."); + } + + var oneTimePassword = GenerateOneTimePassword(6); + var oneTimePasswordHash = passwordHasher.HashPassword(this, oneTimePassword); + var accountRegistration = AccountRegistration.Create(command.GetTenantId(), command.Email, oneTimePasswordHash); + + await accountRegistrationRepository.AddAsync(accountRegistration, cancellationToken); + events.CollectEvent(new AccountRegistrationStarted(command.GetTenantId())); + + await emailService.SendAsync(accountRegistration.Email, "Confirm your email address", + $""" +

Your confirmation code is below

+

Enter it in your open browser window. It is only valid for a few minutes.

+

{oneTimePassword}

+ """, + cancellationToken + ); + + return accountRegistration.Id; + } + + public static string GenerateOneTimePassword(int length) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + var oneTimePassword = new StringBuilder(length); + for (var i = 0; i < length; i++) + { + oneTimePassword.Append(chars[RandomNumberGenerator.GetInt32(chars.Length)]); + } + + return oneTimePassword.ToString(); + } +} diff --git a/application/account-management/Application/ApplicationConfiguration.cs b/application/account-management/Application/ApplicationConfiguration.cs index 441266ccc..244197f92 100644 --- a/application/account-management/Application/ApplicationConfiguration.cs +++ b/application/account-management/Application/ApplicationConfiguration.cs @@ -1,19 +1,19 @@ -using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.DependencyInjection; -using PlatformPlatform.SharedKernel.ApplicationCore; - -namespace PlatformPlatform.AccountManagement.Application; - -public static class ApplicationConfiguration -{ - public static Assembly Assembly => Assembly.GetExecutingAssembly(); - - public static IServiceCollection AddApplicationServices(this IServiceCollection services) - { - services.AddScoped, PasswordHasher>(); - - services.AddApplicationCoreServices(Assembly); - - return services; - } -} +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; +using PlatformPlatform.SharedKernel.ApplicationCore; + +namespace PlatformPlatform.AccountManagement.Application; + +public static class ApplicationConfiguration +{ + public static Assembly Assembly => Assembly.GetExecutingAssembly(); + + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + services.AddScoped, PasswordHasher>(); + + services.AddApplicationCoreServices(Assembly); + + return services; + } +} diff --git a/application/account-management/Application/TelemetryEvents/TelemetryEvents.cs b/application/account-management/Application/TelemetryEvents/TelemetryEvents.cs index a5fe3e6a2..608c8ffa3 100644 --- a/application/account-management/Application/TelemetryEvents/TelemetryEvents.cs +++ b/application/account-management/Application/TelemetryEvents/TelemetryEvents.cs @@ -1,47 +1,47 @@ -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.AccountManagement.Application.TelemetryEvents; - -/// This file contains all the telemetry events that are collected by the application. Telemetry events are important -/// to understand how the application is being used and collect valuable information for the business. Quality is -/// important, and keeping all the telemetry events in one place makes it easier to maintain high quality. -/// This particular includes the naming of the telemetry events (which should be in past tense) and the properties that -/// are collected with each telemetry event. Since missing or bad data cannot be fixed, it is important to have a good -/// data quality from the start. -public sealed class AccountRegistrationBlocked(int retryCount) - : TelemetryEvent(nameof(AccountRegistrationBlocked), ("RetryCount", retryCount.ToString())); - -public sealed class AccountRegistrationCompleted(TenantId tenantId, TenantState state, int registrationTimeInSeconds) - : TelemetryEvent(nameof(AccountRegistrationCompleted), - ("TenantId", tenantId), ("TenantState", state.ToString()), ("RegistrationTimeInSeconds", registrationTimeInSeconds.ToString()) - ); - -public sealed class AccountRegistrationExpired(int secondsFromCreation) - : TelemetryEvent(nameof(AccountRegistrationExpired), ("SecondsFromCreation", secondsFromCreation.ToString())); - -public sealed class AccountRegistrationFailed(int retryCount) - : TelemetryEvent(nameof(AccountRegistrationFailed), ("RetryCount", retryCount.ToString())); - -public sealed class AccountRegistrationStarted(TenantId tenantId) - : TelemetryEvent(nameof(AccountRegistrationStarted), ("TenantId", tenantId)); - -public sealed class TenantDeleted(TenantId tenantId, TenantState tenantState) - : TelemetryEvent(nameof(TenantDeleted), ("TenantId", tenantId), ("TenantState", tenantState.ToString())); - -public sealed class TenantUpdated(TenantId tenantId) - : TelemetryEvent(nameof(TenantUpdated), ("TenantId", tenantId)); - -public sealed class UserCreated(TenantId tenantId, bool gravatarProfileFound) - : TelemetryEvent(nameof(UserCreated), ("TenantId", tenantId), ("GravatarProfileFound", gravatarProfileFound.ToString())); - -public sealed class UserDeleted() - : TelemetryEvent(nameof(UserDeleted)); - -public sealed class UserUpdated() - : TelemetryEvent(nameof(UserUpdated)); - -public sealed class UserAvatarUpdated(string contentType, long size) - : TelemetryEvent(nameof(UserAvatarUpdated), ("ContentType", contentType), ("Size", size.ToString())); - -public sealed class UserAvatarRemoved() - : TelemetryEvent(nameof(UserAvatarUpdated)); +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.AccountManagement.Application.TelemetryEvents; + +/// This file contains all the telemetry events that are collected by the application. Telemetry events are important +/// to understand how the application is being used and collect valuable information for the business. Quality is +/// important, and keeping all the telemetry events in one place makes it easier to maintain high quality. +/// This particular includes the naming of the telemetry events (which should be in past tense) and the properties that +/// are collected with each telemetry event. Since missing or bad data cannot be fixed, it is important to have a good +/// data quality from the start. +public sealed class AccountRegistrationBlocked(int retryCount) + : TelemetryEvent(nameof(AccountRegistrationBlocked), ("RetryCount", retryCount.ToString())); + +public sealed class AccountRegistrationCompleted(TenantId tenantId, TenantState state, int registrationTimeInSeconds) + : TelemetryEvent(nameof(AccountRegistrationCompleted), + ("TenantId", tenantId), ("TenantState", state.ToString()), ("RegistrationTimeInSeconds", registrationTimeInSeconds.ToString()) + ); + +public sealed class AccountRegistrationExpired(int secondsFromCreation) + : TelemetryEvent(nameof(AccountRegistrationExpired), ("SecondsFromCreation", secondsFromCreation.ToString())); + +public sealed class AccountRegistrationFailed(int retryCount) + : TelemetryEvent(nameof(AccountRegistrationFailed), ("RetryCount", retryCount.ToString())); + +public sealed class AccountRegistrationStarted(TenantId tenantId) + : TelemetryEvent(nameof(AccountRegistrationStarted), ("TenantId", tenantId)); + +public sealed class TenantDeleted(TenantId tenantId, TenantState tenantState) + : TelemetryEvent(nameof(TenantDeleted), ("TenantId", tenantId), ("TenantState", tenantState.ToString())); + +public sealed class TenantUpdated(TenantId tenantId) + : TelemetryEvent(nameof(TenantUpdated), ("TenantId", tenantId)); + +public sealed class UserCreated(TenantId tenantId, bool gravatarProfileFound) + : TelemetryEvent(nameof(UserCreated), ("TenantId", tenantId), ("GravatarProfileFound", gravatarProfileFound.ToString())); + +public sealed class UserDeleted() + : TelemetryEvent(nameof(UserDeleted)); + +public sealed class UserUpdated() + : TelemetryEvent(nameof(UserUpdated)); + +public sealed class UserAvatarUpdated(string contentType, long size) + : TelemetryEvent(nameof(UserAvatarUpdated), ("ContentType", contentType), ("Size", size.ToString())); + +public sealed class UserAvatarRemoved() + : TelemetryEvent(nameof(UserAvatarUpdated)); diff --git a/application/account-management/Application/Tenants/DeleteTenant.cs b/application/account-management/Application/Tenants/DeleteTenant.cs index 214a61b0f..19f163498 100644 --- a/application/account-management/Application/Tenants/DeleteTenant.cs +++ b/application/account-management/Application/Tenants/DeleteTenant.cs @@ -1,36 +1,36 @@ -using FluentValidation; -using PlatformPlatform.AccountManagement.Application.TelemetryEvents; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.AccountManagement.Application.Tenants; - -public sealed record DeleteTenantCommand(TenantId Id) : ICommand, IRequest; - -public sealed class DeleteTenantValidator : AbstractValidator -{ - public DeleteTenantValidator(IUserRepository userRepository) - { - RuleFor(x => x.Id) - .MustAsync(async (tenantId, cancellationToken) => - await userRepository.CountTenantUsersAsync(tenantId, cancellationToken) == 0 - ) - .WithMessage("All users must be deleted before the tenant can be deleted."); - } -} - -public sealed class DeleteTenantHandler(ITenantRepository tenantRepository, ITelemetryEventsCollector events) - : IRequestHandler -{ - public async Task Handle(DeleteTenantCommand command, CancellationToken cancellationToken) - { - var tenant = await tenantRepository.GetByIdAsync(command.Id, cancellationToken); - if (tenant is null) return Result.NotFound($"Tenant with id '{command.Id}' not found."); - - tenantRepository.Remove(tenant); - - events.CollectEvent(new TenantDeleted(tenant.Id, tenant.State)); - - return Result.Success(); - } -} +using FluentValidation; +using PlatformPlatform.AccountManagement.Application.TelemetryEvents; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.AccountManagement.Application.Tenants; + +public sealed record DeleteTenantCommand(TenantId Id) : ICommand, IRequest; + +public sealed class DeleteTenantValidator : AbstractValidator +{ + public DeleteTenantValidator(IUserRepository userRepository) + { + RuleFor(x => x.Id) + .MustAsync(async (tenantId, cancellationToken) => + await userRepository.CountTenantUsersAsync(tenantId, cancellationToken) == 0 + ) + .WithMessage("All users must be deleted before the tenant can be deleted."); + } +} + +public sealed class DeleteTenantHandler(ITenantRepository tenantRepository, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(DeleteTenantCommand command, CancellationToken cancellationToken) + { + var tenant = await tenantRepository.GetByIdAsync(command.Id, cancellationToken); + if (tenant is null) return Result.NotFound($"Tenant with id '{command.Id}' not found."); + + tenantRepository.Remove(tenant); + + events.CollectEvent(new TenantDeleted(tenant.Id, tenant.State)); + + return Result.Success(); + } +} diff --git a/application/account-management/Application/Tenants/GetTenant.cs b/application/account-management/Application/Tenants/GetTenant.cs index 6ff2e5387..723c2f633 100644 --- a/application/account-management/Application/Tenants/GetTenant.cs +++ b/application/account-management/Application/Tenants/GetTenant.cs @@ -1,16 +1,16 @@ -using Mapster; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; - -namespace PlatformPlatform.AccountManagement.Application.Tenants; - -public sealed record GetTenantQuery(TenantId Id) : IRequest>; - -public sealed class GetTenantHandler(ITenantRepository tenantRepository) - : IRequestHandler> -{ - public async Task> Handle(GetTenantQuery request, CancellationToken cancellationToken) - { - var tenant = await tenantRepository.GetByIdAsync(request.Id, cancellationToken); - return tenant?.Adapt() ?? Result.NotFound($"Tenant with id '{request.Id}' not found."); - } -} +using Mapster; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; + +namespace PlatformPlatform.AccountManagement.Application.Tenants; + +public sealed record GetTenantQuery(TenantId Id) : IRequest>; + +public sealed class GetTenantHandler(ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(GetTenantQuery request, CancellationToken cancellationToken) + { + var tenant = await tenantRepository.GetByIdAsync(request.Id, cancellationToken); + return tenant?.Adapt() ?? Result.NotFound($"Tenant with id '{request.Id}' not found."); + } +} diff --git a/application/account-management/Application/Tenants/TenantCreatedEventHandler.cs b/application/account-management/Application/Tenants/TenantCreatedEventHandler.cs index 1e4222b7d..a9485e353 100644 --- a/application/account-management/Application/Tenants/TenantCreatedEventHandler.cs +++ b/application/account-management/Application/Tenants/TenantCreatedEventHandler.cs @@ -1,18 +1,18 @@ -using PlatformPlatform.AccountManagement.Application.Users; - -namespace PlatformPlatform.AccountManagement.Application.Tenants; - -public sealed class TenantCreatedEventHandler(ILogger logger, ISender mediator) - : INotificationHandler -{ - public async Task Handle(TenantCreatedEvent notification, CancellationToken cancellationToken) - { - var createTenantOwnerCommand - = new CreateUserCommand(notification.TenantId, notification.Email, UserRole.TenantOwner, true); - var result = await mediator.Send(createTenantOwnerCommand, cancellationToken); - - if (!result.IsSuccess) throw new UnreachableException($"Create Tenant Owner: {result.GetErrorSummary()}"); - - logger.LogInformation("Raise event to send Welcome mail to tenant."); - } -} +using PlatformPlatform.AccountManagement.Application.Users; + +namespace PlatformPlatform.AccountManagement.Application.Tenants; + +public sealed class TenantCreatedEventHandler(ILogger logger, ISender mediator) + : INotificationHandler +{ + public async Task Handle(TenantCreatedEvent notification, CancellationToken cancellationToken) + { + var createTenantOwnerCommand + = new CreateUserCommand(notification.TenantId, notification.Email, UserRole.TenantOwner, true); + var result = await mediator.Send(createTenantOwnerCommand, cancellationToken); + + if (!result.IsSuccess) throw new UnreachableException($"Create Tenant Owner: {result.GetErrorSummary()}"); + + logger.LogInformation("Raise event to send Welcome mail to tenant."); + } +} diff --git a/application/account-management/Application/Tenants/TenantResponseDto.cs b/application/account-management/Application/Tenants/TenantResponseDto.cs index f4d5c2b38..0e1d3f5a2 100644 --- a/application/account-management/Application/Tenants/TenantResponseDto.cs +++ b/application/account-management/Application/Tenants/TenantResponseDto.cs @@ -1,9 +1,9 @@ -namespace PlatformPlatform.AccountManagement.Application.Tenants; - -public sealed record TenantResponseDto( - string Id, - DateTimeOffset CreatedAt, - DateTimeOffset? ModifiedAt, - string Name, - TenantState State -); +namespace PlatformPlatform.AccountManagement.Application.Tenants; + +public sealed record TenantResponseDto( + string Id, + DateTimeOffset CreatedAt, + DateTimeOffset? ModifiedAt, + string Name, + TenantState State +); diff --git a/application/account-management/Application/Tenants/UpdateTenant.cs b/application/account-management/Application/Tenants/UpdateTenant.cs index d8feaa4d6..ba11f713c 100644 --- a/application/account-management/Application/Tenants/UpdateTenant.cs +++ b/application/account-management/Application/Tenants/UpdateTenant.cs @@ -1,42 +1,42 @@ -using FluentValidation; -using PlatformPlatform.AccountManagement.Application.TelemetryEvents; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.AccountManagement.Application.Tenants; - -public sealed record UpdateTenantCommand : ICommand, IRequest -{ - [JsonIgnore] // Removes the Id from the API contract - public TenantId Id { get; init; } = null!; - - public required string Name { get; init; } -} - -public sealed class UpdateTenantValidator : AbstractValidator -{ - public UpdateTenantValidator() - { - RuleFor(x => x.Name).NotEmpty(); - RuleFor(x => x.Name).Length(1, 30) - .WithMessage("Name must be between 1 and 30 characters.") - .When(x => !string.IsNullOrEmpty(x.Name)); - } -} - -public sealed class UpdateTenantHandler(ITenantRepository tenantRepository, ITelemetryEventsCollector events) - : IRequestHandler -{ - public async Task Handle(UpdateTenantCommand command, CancellationToken cancellationToken) - { - var tenant = await tenantRepository.GetByIdAsync(command.Id, cancellationToken); - if (tenant is null) return Result.NotFound($"Tenant with id '{command.Id}' not found."); - - tenant.Update(command.Name); - tenantRepository.Update(tenant); - - events.CollectEvent(new TenantUpdated(tenant.Id)); - - return Result.Success(); - } -} +using FluentValidation; +using PlatformPlatform.AccountManagement.Application.TelemetryEvents; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.AccountManagement.Application.Tenants; + +public sealed record UpdateTenantCommand : ICommand, IRequest +{ + [JsonIgnore] // Removes the Id from the API contract + public TenantId Id { get; init; } = null!; + + public required string Name { get; init; } +} + +public sealed class UpdateTenantValidator : AbstractValidator +{ + public UpdateTenantValidator() + { + RuleFor(x => x.Name).NotEmpty(); + RuleFor(x => x.Name).Length(1, 30) + .WithMessage("Name must be between 1 and 30 characters.") + .When(x => !string.IsNullOrEmpty(x.Name)); + } +} + +public sealed class UpdateTenantHandler(ITenantRepository tenantRepository, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(UpdateTenantCommand command, CancellationToken cancellationToken) + { + var tenant = await tenantRepository.GetByIdAsync(command.Id, cancellationToken); + if (tenant is null) return Result.NotFound($"Tenant with id '{command.Id}' not found."); + + tenant.Update(command.Name); + tenantRepository.Update(tenant); + + events.CollectEvent(new TenantUpdated(tenant.Id)); + + return Result.Success(); + } +} diff --git a/application/account-management/Application/Users/CreateUser.cs b/application/account-management/Application/Users/CreateUser.cs index d400ddd03..eee515114 100644 --- a/application/account-management/Application/Users/CreateUser.cs +++ b/application/account-management/Application/Users/CreateUser.cs @@ -1,59 +1,59 @@ -using System.Net; -using System.Security.Cryptography; -using System.Text; -using FluentValidation; -using PlatformPlatform.AccountManagement.Application.TelemetryEvents; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.AccountManagement.Application.Users; - -public sealed record CreateUserCommand(TenantId TenantId, string Email, UserRole UserRole, bool EmailConfirmed) - : ICommand, IUserValidation, IRequest>; - -public sealed class CreateUserValidator : UserValidator -{ - public CreateUserValidator(IUserRepository userRepository, ITenantRepository tenantRepository) - { - RuleFor(x => x.TenantId) - .MustAsync(tenantRepository.ExistsAsync) - .WithMessage(x => $"The tenant '{x.TenantId}' does not exist.") - .When(x => !string.IsNullOrEmpty(x.Email)); - - RuleFor(x => x) - .MustAsync((x, cancellationToken) - => userRepository.IsEmailFreeAsync(x.TenantId, x.Email, cancellationToken) - ) - .WithName("Email") - .WithMessage(x => $"The email '{x.Email}' is already in use by another user on this tenant.") - .When(x => !string.IsNullOrEmpty(x.Email)); - } -} - -public sealed class CreateUserHandler(IUserRepository userRepository, ITelemetryEventsCollector events) - : IRequestHandler> -{ - private static readonly HttpClient Client = new(); - - public async Task> Handle(CreateUserCommand command, CancellationToken cancellationToken) - { - var gravatarUrl = await GetGravatarProfileUrlIfExists(command.Email); - - var user = User.Create(command.TenantId, command.Email, command.UserRole, command.EmailConfirmed, gravatarUrl); - - await userRepository.AddAsync(user, cancellationToken); - - events.CollectEvent(new UserCreated(command.TenantId, gravatarUrl is not null)); - - return user.Id; - } - - private async Task GetGravatarProfileUrlIfExists(string email) - { - var hash = Convert.ToHexString(MD5.HashData(Encoding.ASCII.GetBytes(email))); - var gravatarUrl = $"https://gravatar.com/avatar/{hash.ToLowerInvariant()}"; - // The d=404 instructs Gravatar to return 404 the email has no Gravatar account - var httpResponseMessage = await Client.GetAsync($"{gravatarUrl}?d=404"); - return httpResponseMessage.StatusCode == HttpStatusCode.OK ? gravatarUrl : null; - } -} +using System.Net; +using System.Security.Cryptography; +using System.Text; +using FluentValidation; +using PlatformPlatform.AccountManagement.Application.TelemetryEvents; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.AccountManagement.Application.Users; + +public sealed record CreateUserCommand(TenantId TenantId, string Email, UserRole UserRole, bool EmailConfirmed) + : ICommand, IUserValidation, IRequest>; + +public sealed class CreateUserValidator : UserValidator +{ + public CreateUserValidator(IUserRepository userRepository, ITenantRepository tenantRepository) + { + RuleFor(x => x.TenantId) + .MustAsync(tenantRepository.ExistsAsync) + .WithMessage(x => $"The tenant '{x.TenantId}' does not exist.") + .When(x => !string.IsNullOrEmpty(x.Email)); + + RuleFor(x => x) + .MustAsync((x, cancellationToken) + => userRepository.IsEmailFreeAsync(x.TenantId, x.Email, cancellationToken) + ) + .WithName("Email") + .WithMessage(x => $"The email '{x.Email}' is already in use by another user on this tenant.") + .When(x => !string.IsNullOrEmpty(x.Email)); + } +} + +public sealed class CreateUserHandler(IUserRepository userRepository, ITelemetryEventsCollector events) + : IRequestHandler> +{ + private static readonly HttpClient Client = new(); + + public async Task> Handle(CreateUserCommand command, CancellationToken cancellationToken) + { + var gravatarUrl = await GetGravatarProfileUrlIfExists(command.Email); + + var user = User.Create(command.TenantId, command.Email, command.UserRole, command.EmailConfirmed, gravatarUrl); + + await userRepository.AddAsync(user, cancellationToken); + + events.CollectEvent(new UserCreated(command.TenantId, gravatarUrl is not null)); + + return user.Id; + } + + private async Task GetGravatarProfileUrlIfExists(string email) + { + var hash = Convert.ToHexString(MD5.HashData(Encoding.ASCII.GetBytes(email))); + var gravatarUrl = $"https://gravatar.com/avatar/{hash.ToLowerInvariant()}"; + // The d=404 instructs Gravatar to return 404 the email has no Gravatar account + var httpResponseMessage = await Client.GetAsync($"{gravatarUrl}?d=404"); + return httpResponseMessage.StatusCode == HttpStatusCode.OK ? gravatarUrl : null; + } +} diff --git a/application/account-management/Application/Users/DeleteUser.cs b/application/account-management/Application/Users/DeleteUser.cs index 0a26df55c..510b6f89c 100644 --- a/application/account-management/Application/Users/DeleteUser.cs +++ b/application/account-management/Application/Users/DeleteUser.cs @@ -1,23 +1,23 @@ -using PlatformPlatform.AccountManagement.Application.TelemetryEvents; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.AccountManagement.Application.Users; - -public sealed record DeleteUserCommand(UserId Id) : ICommand, IRequest; - -public sealed class DeleteUserHandler(IUserRepository userRepository, ITelemetryEventsCollector events) - : IRequestHandler -{ - public async Task Handle(DeleteUserCommand command, CancellationToken cancellationToken) - { - var user = await userRepository.GetByIdAsync(command.Id, cancellationToken); - if (user is null) return Result.NotFound($"User with id '{command.Id}' not found."); - - userRepository.Remove(user); - - events.CollectEvent(new UserDeleted()); - - return Result.Success(); - } -} +using PlatformPlatform.AccountManagement.Application.TelemetryEvents; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.AccountManagement.Application.Users; + +public sealed record DeleteUserCommand(UserId Id) : ICommand, IRequest; + +public sealed class DeleteUserHandler(IUserRepository userRepository, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(DeleteUserCommand command, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdAsync(command.Id, cancellationToken); + if (user is null) return Result.NotFound($"User with id '{command.Id}' not found."); + + userRepository.Remove(user); + + events.CollectEvent(new UserDeleted()); + + return Result.Success(); + } +} diff --git a/application/account-management/Application/Users/GetUser.cs b/application/account-management/Application/Users/GetUser.cs index 7717b0d7b..6c587d8c0 100644 --- a/application/account-management/Application/Users/GetUser.cs +++ b/application/account-management/Application/Users/GetUser.cs @@ -1,16 +1,16 @@ -using Mapster; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; - -namespace PlatformPlatform.AccountManagement.Application.Users; - -public sealed record GetUserQuery(UserId Id) : IRequest>; - -public sealed class GetUserHandler(IUserRepository userRepository) - : IRequestHandler> -{ - public async Task> Handle(GetUserQuery request, CancellationToken cancellationToken) - { - var user = await userRepository.GetByIdAsync(request.Id, cancellationToken); - return user?.Adapt() ?? Result.NotFound($"User with id '{request.Id}' not found."); - } -} +using Mapster; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; + +namespace PlatformPlatform.AccountManagement.Application.Users; + +public sealed record GetUserQuery(UserId Id) : IRequest>; + +public sealed class GetUserHandler(IUserRepository userRepository) + : IRequestHandler> +{ + public async Task> Handle(GetUserQuery request, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdAsync(request.Id, cancellationToken); + return user?.Adapt() ?? Result.NotFound($"User with id '{request.Id}' not found."); + } +} diff --git a/application/account-management/Application/Users/GetUsers.cs b/application/account-management/Application/Users/GetUsers.cs index 36b48d90a..3def5ca82 100644 --- a/application/account-management/Application/Users/GetUsers.cs +++ b/application/account-management/Application/Users/GetUsers.cs @@ -1,34 +1,34 @@ -using Mapster; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.DomainCore.Persistence; - -namespace PlatformPlatform.AccountManagement.Application.Users; - -public sealed record GetUsersQuery( - string? Search = null, - UserRole? UserRole = null, - SortableUserProperties OrderBy = SortableUserProperties.Name, - SortOrder SortOrder = SortOrder.Ascending, - int? PageSize = null, - int? PageOffset = null -) : IRequest>; - -public sealed class GetUsersHandler(IUserRepository userRepository) - : IRequestHandler> -{ - public async Task> Handle(GetUsersQuery query, CancellationToken cancellationToken) - { - var (users, count, totalPages) = await userRepository.Search( - query.Search, - query.UserRole, - query.OrderBy, - query.SortOrder, - query.PageSize, - query.PageOffset, - cancellationToken - ); - - var userResponseDtos = users.Adapt(); - return new GetUsersResponseDto(count, totalPages, query.PageOffset ?? 0, userResponseDtos); - } -} +using Mapster; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.DomainCore.Persistence; + +namespace PlatformPlatform.AccountManagement.Application.Users; + +public sealed record GetUsersQuery( + string? Search = null, + UserRole? UserRole = null, + SortableUserProperties OrderBy = SortableUserProperties.Name, + SortOrder SortOrder = SortOrder.Ascending, + int? PageSize = null, + int? PageOffset = null +) : IRequest>; + +public sealed class GetUsersHandler(IUserRepository userRepository) + : IRequestHandler> +{ + public async Task> Handle(GetUsersQuery query, CancellationToken cancellationToken) + { + var (users, count, totalPages) = await userRepository.Search( + query.Search, + query.UserRole, + query.OrderBy, + query.SortOrder, + query.PageSize, + query.PageOffset, + cancellationToken + ); + + var userResponseDtos = users.Adapt(); + return new GetUsersResponseDto(count, totalPages, query.PageOffset ?? 0, userResponseDtos); + } +} diff --git a/application/account-management/Application/Users/GetUsersResponseDto.cs b/application/account-management/Application/Users/GetUsersResponseDto.cs index bb8ee451d..98cde628c 100644 --- a/application/account-management/Application/Users/GetUsersResponseDto.cs +++ b/application/account-management/Application/Users/GetUsersResponseDto.cs @@ -1,3 +1,3 @@ -namespace PlatformPlatform.AccountManagement.Application.Users; - -public sealed record GetUsersResponseDto(int TotalCount, int TotalPages, int CurrentPageOffset, UserResponseDto[] Users); +namespace PlatformPlatform.AccountManagement.Application.Users; + +public sealed record GetUsersResponseDto(int TotalCount, int TotalPages, int CurrentPageOffset, UserResponseDto[] Users); diff --git a/application/account-management/Application/Users/RemoveAvatar.cs b/application/account-management/Application/Users/RemoveAvatar.cs index e8369fba5..8080d8987 100644 --- a/application/account-management/Application/Users/RemoveAvatar.cs +++ b/application/account-management/Application/Users/RemoveAvatar.cs @@ -1,24 +1,24 @@ -using PlatformPlatform.AccountManagement.Application.TelemetryEvents; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.AccountManagement.Application.Users; - -public sealed record RemoveAvatarCommand(UserId Id) : ICommand, IRequest; - -public sealed class RemoveAvatarCommandHandler(IUserRepository userRepository, ITelemetryEventsCollector events) - : IRequestHandler -{ - public async Task Handle(RemoveAvatarCommand command, CancellationToken cancellationToken) - { - var user = await userRepository.GetByIdAsync(command.Id, cancellationToken); - if (user is null) return Result.NotFound($"User with id '{command.Id}' not found."); - - user.RemoveAvatar(); - userRepository.Update(user); - - events.CollectEvent(new UserAvatarRemoved()); - - return Result.Success(); - } -} +using PlatformPlatform.AccountManagement.Application.TelemetryEvents; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.AccountManagement.Application.Users; + +public sealed record RemoveAvatarCommand(UserId Id) : ICommand, IRequest; + +public sealed class RemoveAvatarCommandHandler(IUserRepository userRepository, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(RemoveAvatarCommand command, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdAsync(command.Id, cancellationToken); + if (user is null) return Result.NotFound($"User with id '{command.Id}' not found."); + + user.RemoveAvatar(); + userRepository.Update(user); + + events.CollectEvent(new UserAvatarRemoved()); + + return Result.Success(); + } +} diff --git a/application/account-management/Application/Users/UpdateAvatar.cs b/application/account-management/Application/Users/UpdateAvatar.cs index f58ab8945..1cd318ebb 100644 --- a/application/account-management/Application/Users/UpdateAvatar.cs +++ b/application/account-management/Application/Users/UpdateAvatar.cs @@ -1,62 +1,62 @@ -using System.Security.Cryptography; -using FluentValidation; -using Microsoft.Extensions.DependencyInjection; -using PlatformPlatform.AccountManagement.Application.TelemetryEvents; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.ApplicationCore.Services; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.AccountManagement.Application.Users; - -public sealed record UpdateAvatarCommand(UserId Id, Stream FileSteam, string ContentType) - : ICommand, IRequest; - -public sealed class UpdateAvatarValidator : AbstractValidator -{ - public UpdateAvatarValidator() - { - RuleFor(x => x.ContentType) - .Must(x => x == "image/jpeg") - .WithMessage(_ => "Image must be of type Jpeg."); - - RuleFor(x => x.FileSteam.Length) - .LessThanOrEqualTo(1024 * 1024) - .WithMessage(_ => "Image must be less than 1MB."); - } -} - -public sealed class UpdateAvatarHandler( - IUserRepository userRepository, - [FromKeyedServices("avatars-storage")] IBlobStorage blobStorage, - ITelemetryEventsCollector events -) : IRequestHandler -{ - private const string ContainerName = "avatars"; - - public async Task Handle(UpdateAvatarCommand command, CancellationToken cancellationToken) - { - var user = await userRepository.GetByIdAsync(command.Id, cancellationToken); - if (user is null) return Result.NotFound($"User with id '{command.Id}' not found."); - - var fileHash = await GetFileHash(command.FileSteam, cancellationToken); - var blobName = $"{user.TenantId}/{user.Id}/{fileHash}.jpg"; - await blobStorage.UploadAsync(ContainerName, blobName, command.ContentType, command.FileSteam, cancellationToken); - - var avatarUrl = $"/{ContainerName}/{blobName}"; - user.UpdateAvatar(avatarUrl); - - userRepository.Update(user); - - events.CollectEvent(new UserAvatarUpdated(command.ContentType, command.FileSteam.Length)); - - return Result.Success(); - } - - private async Task GetFileHash(Stream fileStream, CancellationToken cancellationToken) - { - var hashBytes = await SHA1.Create().ComputeHashAsync(fileStream, cancellationToken); - fileStream.Position = 0; - // This just need to be unique for one user, who likely will ever have one avatar, so 16 chars should be enough - return BitConverter.ToString(hashBytes).Replace("-", "")[..16].ToUpper(); - } -} +using System.Security.Cryptography; +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; +using PlatformPlatform.AccountManagement.Application.TelemetryEvents; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.Services; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.AccountManagement.Application.Users; + +public sealed record UpdateAvatarCommand(UserId Id, Stream FileSteam, string ContentType) + : ICommand, IRequest; + +public sealed class UpdateAvatarValidator : AbstractValidator +{ + public UpdateAvatarValidator() + { + RuleFor(x => x.ContentType) + .Must(x => x == "image/jpeg") + .WithMessage(_ => "Image must be of type Jpeg."); + + RuleFor(x => x.FileSteam.Length) + .LessThanOrEqualTo(1024 * 1024) + .WithMessage(_ => "Image must be less than 1MB."); + } +} + +public sealed class UpdateAvatarHandler( + IUserRepository userRepository, + [FromKeyedServices("avatars-storage")] IBlobStorage blobStorage, + ITelemetryEventsCollector events +) : IRequestHandler +{ + private const string ContainerName = "avatars"; + + public async Task Handle(UpdateAvatarCommand command, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdAsync(command.Id, cancellationToken); + if (user is null) return Result.NotFound($"User with id '{command.Id}' not found."); + + var fileHash = await GetFileHash(command.FileSteam, cancellationToken); + var blobName = $"{user.TenantId}/{user.Id}/{fileHash}.jpg"; + await blobStorage.UploadAsync(ContainerName, blobName, command.ContentType, command.FileSteam, cancellationToken); + + var avatarUrl = $"/{ContainerName}/{blobName}"; + user.UpdateAvatar(avatarUrl); + + userRepository.Update(user); + + events.CollectEvent(new UserAvatarUpdated(command.ContentType, command.FileSteam.Length)); + + return Result.Success(); + } + + private async Task GetFileHash(Stream fileStream, CancellationToken cancellationToken) + { + var hashBytes = await SHA1.Create().ComputeHashAsync(fileStream, cancellationToken); + fileStream.Position = 0; + // This just need to be unique for one user, who likely will ever have one avatar, so 16 chars should be enough + return BitConverter.ToString(hashBytes).Replace("-", "")[..16].ToUpper(); + } +} diff --git a/application/account-management/Application/Users/UpdateUser.cs b/application/account-management/Application/Users/UpdateUser.cs index a1e09378d..f9a9c6807 100644 --- a/application/account-management/Application/Users/UpdateUser.cs +++ b/application/account-management/Application/Users/UpdateUser.cs @@ -1,35 +1,35 @@ -using PlatformPlatform.AccountManagement.Application.TelemetryEvents; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.AccountManagement.Application.Users; - -public sealed record UpdateUserCommand : ICommand, IUserValidation, IRequest -{ - [JsonIgnore] // Removes the Id from the API contract - public UserId Id { get; init; } = null!; - - public required UserRole UserRole { get; init; } - - public required string Email { get; init; } -} - -public sealed class UpdateUserValidator : UserValidator; - -public sealed class UpdateUserHandler(IUserRepository userRepository, ITelemetryEventsCollector events) - : IRequestHandler -{ - public async Task Handle(UpdateUserCommand command, CancellationToken cancellationToken) - { - var user = await userRepository.GetByIdAsync(command.Id, cancellationToken); - if (user is null) return Result.NotFound($"User with id '{command.Id}' not found."); - - user.UpdateEmail(command.Email); - user.ChangeUserRole(command.UserRole); - userRepository.Update(user); - - events.CollectEvent(new UserUpdated()); - - return Result.Success(); - } -} +using PlatformPlatform.AccountManagement.Application.TelemetryEvents; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.AccountManagement.Application.Users; + +public sealed record UpdateUserCommand : ICommand, IUserValidation, IRequest +{ + [JsonIgnore] // Removes the Id from the API contract + public UserId Id { get; init; } = null!; + + public required UserRole UserRole { get; init; } + + public required string Email { get; init; } +} + +public sealed class UpdateUserValidator : UserValidator; + +public sealed class UpdateUserHandler(IUserRepository userRepository, ITelemetryEventsCollector events) + : IRequestHandler +{ + public async Task Handle(UpdateUserCommand command, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdAsync(command.Id, cancellationToken); + if (user is null) return Result.NotFound($"User with id '{command.Id}' not found."); + + user.UpdateEmail(command.Email); + user.ChangeUserRole(command.UserRole); + userRepository.Update(user); + + events.CollectEvent(new UserUpdated()); + + return Result.Success(); + } +} diff --git a/application/account-management/Application/Users/UserResponseDto.cs b/application/account-management/Application/Users/UserResponseDto.cs index a1e7c677c..c4fcb3961 100644 --- a/application/account-management/Application/Users/UserResponseDto.cs +++ b/application/account-management/Application/Users/UserResponseDto.cs @@ -1,13 +1,13 @@ -namespace PlatformPlatform.AccountManagement.Application.Users; - -public sealed record UserResponseDto( - string Id, - DateTimeOffset CreatedAt, - DateTimeOffset? ModifiedAt, - string Email, - UserRole UserRole, - string FirstName, - string LastName, - bool EmailConfirmed, - string? AvatarUrl -); +namespace PlatformPlatform.AccountManagement.Application.Users; + +public sealed record UserResponseDto( + string Id, + DateTimeOffset CreatedAt, + DateTimeOffset? ModifiedAt, + string Email, + UserRole UserRole, + string FirstName, + string LastName, + bool EmailConfirmed, + string? AvatarUrl +); diff --git a/application/account-management/Application/Users/UserValidator.cs b/application/account-management/Application/Users/UserValidator.cs index 3ff26438d..b3d78a72d 100644 --- a/application/account-management/Application/Users/UserValidator.cs +++ b/application/account-management/Application/Users/UserValidator.cs @@ -1,17 +1,17 @@ -using FluentValidation; -using PlatformPlatform.SharedKernel.ApplicationCore.Validation; - -namespace PlatformPlatform.AccountManagement.Application.Users; - -public interface IUserValidation -{ - string Email { get; } -} - -public abstract class UserValidator : AbstractValidator where T : IUserValidation -{ - protected UserValidator() - { - RuleFor(x => x.Email).NotEmpty().SetValidator(new SharedValidations.Email()); - } -} +using FluentValidation; +using PlatformPlatform.SharedKernel.ApplicationCore.Validation; + +namespace PlatformPlatform.AccountManagement.Application.Users; + +public interface IUserValidation +{ + string Email { get; } +} + +public abstract class UserValidator : AbstractValidator where T : IUserValidation +{ + protected UserValidator() + { + RuleFor(x => x.Email).NotEmpty().SetValidator(new SharedValidations.Email()); + } +} diff --git a/application/account-management/Domain/AccountRegistrations/AccountRegistration.cs b/application/account-management/Domain/AccountRegistrations/AccountRegistration.cs index cf0370ab5..af00a7299 100644 --- a/application/account-management/Domain/AccountRegistrations/AccountRegistration.cs +++ b/application/account-management/Domain/AccountRegistrations/AccountRegistration.cs @@ -1,68 +1,68 @@ -using PlatformPlatform.SharedKernel.DomainCore.Entities; -using PlatformPlatform.SharedKernel.DomainCore.Identity; - -namespace PlatformPlatform.AccountManagement.Domain.AccountRegistrations; - -public sealed class AccountRegistration : AggregateRoot -{ - public const int MaxAttempts = 3; - - private AccountRegistration(TenantId tenantId, string email, string oneTimePasswordHash) - : base(AccountRegistrationId.NewId()) - { - TenantId = tenantId; - Email = email; - OneTimePasswordHash = oneTimePasswordHash; - ValidUntil = CreatedAt.AddMinutes(5); - } - - public TenantId TenantId { get; private set; } - - public string Email { get; private set; } - - public string OneTimePasswordHash { get; private set; } - - public int RetryCount { get; private set; } - - [UsedImplicitly] - public DateTimeOffset ValidUntil { get; private set; } - - public bool Completed { get; private set; } - - public bool HasExpired() - { - return ValidUntil < TimeProvider.System.GetUtcNow(); - } - - public static AccountRegistration Create(TenantId tenantId, string email, string oneTimePasswordHash) - { - return new AccountRegistration(tenantId, email.ToLowerInvariant(), oneTimePasswordHash); - } - - public void RegisterInvalidPasswordAttempt() - { - RetryCount++; - } - - public void MarkAsCompleted() - { - if (HasExpired() || RetryCount >= MaxAttempts) - { - throw new UnreachableException("This account registration has expired."); - } - - if (Completed) throw new UnreachableException("The account has already been created."); - - Completed = true; - } -} - -[TypeConverter(typeof(StronglyTypedIdTypeConverter))] -[IdPrefix("accreg")] -public sealed record AccountRegistrationId(string Value) : StronglyTypedUlid(Value) -{ - public override string ToString() - { - return Value; - } -} +using PlatformPlatform.SharedKernel.DomainCore.Entities; +using PlatformPlatform.SharedKernel.DomainCore.Identity; + +namespace PlatformPlatform.AccountManagement.Domain.AccountRegistrations; + +public sealed class AccountRegistration : AggregateRoot +{ + public const int MaxAttempts = 3; + + private AccountRegistration(TenantId tenantId, string email, string oneTimePasswordHash) + : base(AccountRegistrationId.NewId()) + { + TenantId = tenantId; + Email = email; + OneTimePasswordHash = oneTimePasswordHash; + ValidUntil = CreatedAt.AddMinutes(5); + } + + public TenantId TenantId { get; private set; } + + public string Email { get; private set; } + + public string OneTimePasswordHash { get; private set; } + + public int RetryCount { get; private set; } + + [UsedImplicitly] + public DateTimeOffset ValidUntil { get; private set; } + + public bool Completed { get; private set; } + + public bool HasExpired() + { + return ValidUntil < TimeProvider.System.GetUtcNow(); + } + + public static AccountRegistration Create(TenantId tenantId, string email, string oneTimePasswordHash) + { + return new AccountRegistration(tenantId, email.ToLowerInvariant(), oneTimePasswordHash); + } + + public void RegisterInvalidPasswordAttempt() + { + RetryCount++; + } + + public void MarkAsCompleted() + { + if (HasExpired() || RetryCount >= MaxAttempts) + { + throw new UnreachableException("This account registration has expired."); + } + + if (Completed) throw new UnreachableException("The account has already been created."); + + Completed = true; + } +} + +[TypeConverter(typeof(StronglyTypedIdTypeConverter))] +[IdPrefix("accreg")] +public sealed record AccountRegistrationId(string Value) : StronglyTypedUlid(Value) +{ + public override string ToString() + { + return Value; + } +} diff --git a/application/account-management/Domain/AccountRegistrations/IAccountRegistrationRepository.cs b/application/account-management/Domain/AccountRegistrations/IAccountRegistrationRepository.cs index 9ca0ed12d..5d46f01ba 100644 --- a/application/account-management/Domain/AccountRegistrations/IAccountRegistrationRepository.cs +++ b/application/account-management/Domain/AccountRegistrations/IAccountRegistrationRepository.cs @@ -1,8 +1,8 @@ -using PlatformPlatform.SharedKernel.DomainCore.Entities; - -namespace PlatformPlatform.AccountManagement.Domain.AccountRegistrations; - -public interface IAccountRegistrationRepository : ICrudRepository -{ - AccountRegistration[] GetByEmailOrTenantId(TenantId tenantId, string email); -} +using PlatformPlatform.SharedKernel.DomainCore.Entities; + +namespace PlatformPlatform.AccountManagement.Domain.AccountRegistrations; + +public interface IAccountRegistrationRepository : ICrudRepository +{ + AccountRegistration[] GetByEmailOrTenantId(TenantId tenantId, string email); +} diff --git a/application/account-management/Domain/DomainConfiguration.cs b/application/account-management/Domain/DomainConfiguration.cs index d62a6e66b..2affc1bed 100644 --- a/application/account-management/Domain/DomainConfiguration.cs +++ b/application/account-management/Domain/DomainConfiguration.cs @@ -1,6 +1,6 @@ -namespace PlatformPlatform.AccountManagement.Domain; - -public static class DomainConfiguration -{ - public static Assembly Assembly => Assembly.GetExecutingAssembly(); -} +namespace PlatformPlatform.AccountManagement.Domain; + +public static class DomainConfiguration +{ + public static Assembly Assembly => Assembly.GetExecutingAssembly(); +} diff --git a/application/account-management/Domain/Tenants/ITenantRepository.cs b/application/account-management/Domain/Tenants/ITenantRepository.cs index 879cb930d..e5e9c04fb 100644 --- a/application/account-management/Domain/Tenants/ITenantRepository.cs +++ b/application/account-management/Domain/Tenants/ITenantRepository.cs @@ -1,10 +1,10 @@ -using PlatformPlatform.SharedKernel.DomainCore.Entities; - -namespace PlatformPlatform.AccountManagement.Domain.Tenants; - -public interface ITenantRepository : ICrudRepository -{ - Task ExistsAsync(TenantId id, CancellationToken cancellationToken); - - Task IsSubdomainFreeAsync(string subdomain, CancellationToken cancellationToken); -} +using PlatformPlatform.SharedKernel.DomainCore.Entities; + +namespace PlatformPlatform.AccountManagement.Domain.Tenants; + +public interface ITenantRepository : ICrudRepository +{ + Task ExistsAsync(TenantId id, CancellationToken cancellationToken); + + Task IsSubdomainFreeAsync(string subdomain, CancellationToken cancellationToken); +} diff --git a/application/account-management/Domain/Tenants/Tenant.cs b/application/account-management/Domain/Tenants/Tenant.cs index bbb6fd160..30f09f724 100644 --- a/application/account-management/Domain/Tenants/Tenant.cs +++ b/application/account-management/Domain/Tenants/Tenant.cs @@ -1,28 +1,28 @@ -using PlatformPlatform.SharedKernel.DomainCore.Entities; - -namespace PlatformPlatform.AccountManagement.Domain.Tenants; - -public sealed class Tenant : AggregateRoot -{ - private Tenant(TenantId id, string name) : base(id) - { - Name = name; - State = TenantState.Trial; - } - - public string Name { get; private set; } - - public TenantState State { get; private set; } - - public static Tenant Create(TenantId tenantId, string email) - { - var tenant = new Tenant(tenantId, tenantId.ToString()); - tenant.AddDomainEvent(new TenantCreatedEvent(tenant.Id, email)); - return tenant; - } - - public void Update(string tenantName) - { - Name = tenantName; - } -} +using PlatformPlatform.SharedKernel.DomainCore.Entities; + +namespace PlatformPlatform.AccountManagement.Domain.Tenants; + +public sealed class Tenant : AggregateRoot +{ + private Tenant(TenantId id, string name) : base(id) + { + Name = name; + State = TenantState.Trial; + } + + public string Name { get; private set; } + + public TenantState State { get; private set; } + + public static Tenant Create(TenantId tenantId, string email) + { + var tenant = new Tenant(tenantId, tenantId.ToString()); + tenant.AddDomainEvent(new TenantCreatedEvent(tenant.Id, email)); + return tenant; + } + + public void Update(string tenantName) + { + Name = tenantName; + } +} diff --git a/application/account-management/Domain/Tenants/TenantEvents.cs b/application/account-management/Domain/Tenants/TenantEvents.cs index 110c49415..cfefa202d 100644 --- a/application/account-management/Domain/Tenants/TenantEvents.cs +++ b/application/account-management/Domain/Tenants/TenantEvents.cs @@ -1,5 +1,5 @@ -using PlatformPlatform.SharedKernel.DomainCore.DomainEvents; - -namespace PlatformPlatform.AccountManagement.Domain.Tenants; - -public sealed record TenantCreatedEvent(TenantId TenantId, string Email) : IDomainEvent; +using PlatformPlatform.SharedKernel.DomainCore.DomainEvents; + +namespace PlatformPlatform.AccountManagement.Domain.Tenants; + +public sealed record TenantCreatedEvent(TenantId TenantId, string Email) : IDomainEvent; diff --git a/application/account-management/Domain/Tenants/TenantTypes.cs b/application/account-management/Domain/Tenants/TenantTypes.cs index 4735b924e..4877c1ca6 100644 --- a/application/account-management/Domain/Tenants/TenantTypes.cs +++ b/application/account-management/Domain/Tenants/TenantTypes.cs @@ -1,32 +1,32 @@ -using PlatformPlatform.SharedKernel.DomainCore.Identity; - -namespace PlatformPlatform.AccountManagement.Domain.Tenants; - -[TypeConverter(typeof(StronglyTypedIdTypeConverter))] -public sealed record TenantId(string Value) : StronglyTypedId(Value) -{ - public override string ToString() - { - return Value; - } - - public static bool TryParse(string? value, out TenantId? result) - { - if (value is { Length: >= 3 and <= 30 } && value.All(c => char.IsLower(c) || char.IsDigit(c))) - { - result = new TenantId(value); - return true; - } - - result = null; - return false; - } -} - -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum TenantState -{ - Trial, - Active, - Suspended -} +using PlatformPlatform.SharedKernel.DomainCore.Identity; + +namespace PlatformPlatform.AccountManagement.Domain.Tenants; + +[TypeConverter(typeof(StronglyTypedIdTypeConverter))] +public sealed record TenantId(string Value) : StronglyTypedId(Value) +{ + public override string ToString() + { + return Value; + } + + public static bool TryParse(string? value, out TenantId? result) + { + if (value is { Length: >= 3 and <= 30 } && value.All(c => char.IsLower(c) || char.IsDigit(c))) + { + result = new TenantId(value); + return true; + } + + result = null; + return false; + } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TenantState +{ + Trial, + Active, + Suspended +} diff --git a/application/account-management/Domain/Users/IUserRepository.cs b/application/account-management/Domain/Users/IUserRepository.cs index ca8584bd0..1daa1dfe1 100644 --- a/application/account-management/Domain/Users/IUserRepository.cs +++ b/application/account-management/Domain/Users/IUserRepository.cs @@ -1,21 +1,21 @@ -using PlatformPlatform.SharedKernel.DomainCore.Entities; -using PlatformPlatform.SharedKernel.DomainCore.Persistence; - -namespace PlatformPlatform.AccountManagement.Domain.Users; - -public interface IUserRepository : ICrudRepository -{ - Task IsEmailFreeAsync(TenantId tenantId, string email, CancellationToken cancellationToken); - - Task CountTenantUsersAsync(TenantId tenantId, CancellationToken cancellationToken); - - Task<(User[] Users, int TotalItems, int TotalPages)> Search( - string? search, - UserRole? userRole, - SortableUserProperties? orderBy, - SortOrder? sortOrder, - int? pageSize, - int? pageOffset, - CancellationToken cancellationToken - ); -} +using PlatformPlatform.SharedKernel.DomainCore.Entities; +using PlatformPlatform.SharedKernel.DomainCore.Persistence; + +namespace PlatformPlatform.AccountManagement.Domain.Users; + +public interface IUserRepository : ICrudRepository +{ + Task IsEmailFreeAsync(TenantId tenantId, string email, CancellationToken cancellationToken); + + Task CountTenantUsersAsync(TenantId tenantId, CancellationToken cancellationToken); + + Task<(User[] Users, int TotalItems, int TotalPages)> Search( + string? search, + UserRole? userRole, + SortableUserProperties? orderBy, + SortOrder? sortOrder, + int? pageSize, + int? pageOffset, + CancellationToken cancellationToken + ); +} diff --git a/application/account-management/Domain/Users/User.cs b/application/account-management/Domain/Users/User.cs index f9250fc40..77cea2326 100644 --- a/application/account-management/Domain/Users/User.cs +++ b/application/account-management/Domain/Users/User.cs @@ -1,66 +1,66 @@ -using PlatformPlatform.SharedKernel.DomainCore.Entities; - -namespace PlatformPlatform.AccountManagement.Domain.Users; - -public sealed class User : AggregateRoot -{ - private User(TenantId tenantId, string email, UserRole userRole, bool emailConfirmed) - : base(UserId.NewId()) - { - TenantId = tenantId; - Email = email; - UserRole = userRole; - EmailConfirmed = emailConfirmed; - } - - public TenantId TenantId { get; } - - public string Email { get; private set; } - - [UsedImplicitly] - public string? FirstName { get; private set; } - - [UsedImplicitly] - public string? LastName { get; private set; } - - public UserRole UserRole { get; private set; } - - [UsedImplicitly] - public bool EmailConfirmed { get; private set; } - - public Avatar Avatar { get; private set; } = default!; - - public static User Create(TenantId tenantId, string email, UserRole userRole, bool emailConfirmed, string? gravatarUrl) - { - var avatar = new Avatar(gravatarUrl, IsGravatar: gravatarUrl is not null); - return new User(tenantId, email, userRole, emailConfirmed) { Avatar = avatar }; - } - - public void Update(string firstName, string lastName) - { - FirstName = firstName; - LastName = lastName; - } - - public void UpdateEmail(string email) - { - Email = email; - } - - public void ChangeUserRole(UserRole userRole) - { - UserRole = userRole; - } - - public void UpdateAvatar(string avatarUrl) - { - Avatar = new Avatar(avatarUrl, Avatar.Version + 1); - } - - public void RemoveAvatar() - { - Avatar = new Avatar(Version: Avatar.Version); - } -} - -public sealed record Avatar(string? Url = null, int Version = 0, bool IsGravatar = false); +using PlatformPlatform.SharedKernel.DomainCore.Entities; + +namespace PlatformPlatform.AccountManagement.Domain.Users; + +public sealed class User : AggregateRoot +{ + private User(TenantId tenantId, string email, UserRole userRole, bool emailConfirmed) + : base(UserId.NewId()) + { + TenantId = tenantId; + Email = email; + UserRole = userRole; + EmailConfirmed = emailConfirmed; + } + + public TenantId TenantId { get; } + + public string Email { get; private set; } + + [UsedImplicitly] + public string? FirstName { get; private set; } + + [UsedImplicitly] + public string? LastName { get; private set; } + + public UserRole UserRole { get; private set; } + + [UsedImplicitly] + public bool EmailConfirmed { get; private set; } + + public Avatar Avatar { get; private set; } = default!; + + public static User Create(TenantId tenantId, string email, UserRole userRole, bool emailConfirmed, string? gravatarUrl) + { + var avatar = new Avatar(gravatarUrl, IsGravatar: gravatarUrl is not null); + return new User(tenantId, email, userRole, emailConfirmed) { Avatar = avatar }; + } + + public void Update(string firstName, string lastName) + { + FirstName = firstName; + LastName = lastName; + } + + public void UpdateEmail(string email) + { + Email = email; + } + + public void ChangeUserRole(UserRole userRole) + { + UserRole = userRole; + } + + public void UpdateAvatar(string avatarUrl) + { + Avatar = new Avatar(avatarUrl, Avatar.Version + 1); + } + + public void RemoveAvatar() + { + Avatar = new Avatar(Version: Avatar.Version); + } +} + +public sealed record Avatar(string? Url = null, int Version = 0, bool IsGravatar = false); diff --git a/application/account-management/Domain/Users/UserTypes.cs b/application/account-management/Domain/Users/UserTypes.cs index 7e7415bcc..46c82ee70 100644 --- a/application/account-management/Domain/Users/UserTypes.cs +++ b/application/account-management/Domain/Users/UserTypes.cs @@ -1,30 +1,30 @@ -using PlatformPlatform.SharedKernel.DomainCore.Identity; - -namespace PlatformPlatform.AccountManagement.Domain.Users; - -[TypeConverter(typeof(StronglyTypedIdTypeConverter))] -[IdPrefix("usr")] -public sealed record UserId(string Value) : StronglyTypedUlid(Value) -{ - public override string ToString() - { - return Value; - } -} - -[JsonConverter(typeof(JsonStringEnumConverter))] -public enum UserRole -{ - TenantUser, - TenantAdmin, - TenantOwner -} - -public enum SortableUserProperties -{ - CreatedAt, - ModifiedAt, - Name, - Email, - UserRole -} +using PlatformPlatform.SharedKernel.DomainCore.Identity; + +namespace PlatformPlatform.AccountManagement.Domain.Users; + +[TypeConverter(typeof(StronglyTypedIdTypeConverter))] +[IdPrefix("usr")] +public sealed record UserId(string Value) : StronglyTypedUlid(Value) +{ + public override string ToString() + { + return Value; + } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum UserRole +{ + TenantUser, + TenantAdmin, + TenantOwner +} + +public enum SortableUserProperties +{ + CreatedAt, + ModifiedAt, + Name, + Email, + UserRole +} diff --git a/application/account-management/Infrastructure/AccountManagementDbContext.cs b/application/account-management/Infrastructure/AccountManagementDbContext.cs index 24eccffee..7087ba419 100644 --- a/application/account-management/Infrastructure/AccountManagementDbContext.cs +++ b/application/account-management/Infrastructure/AccountManagementDbContext.cs @@ -1,37 +1,37 @@ -using Microsoft.EntityFrameworkCore; -using PlatformPlatform.AccountManagement.Domain.AccountRegistrations; -using PlatformPlatform.SharedKernel.InfrastructureCore.EntityFramework; - -namespace PlatformPlatform.AccountManagement.Infrastructure; - -public sealed class AccountManagementDbContext(DbContextOptions options) - : SharedKernelDbContext(options) -{ - public DbSet AccountRegistrations => Set(); - - public DbSet Tenants => Set(); - - public DbSet Users => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - // AccountRegistration - modelBuilder.MapStronglyTypedUuid(a => a.Id); - modelBuilder.MapStronglyTypedNullableId(u => u.TenantId); - - // Tenant - modelBuilder.MapStronglyTypedId(t => t.Id); - - // User - modelBuilder.MapStronglyTypedUuid(u => u.Id); - modelBuilder.MapStronglyTypedId(u => u.TenantId); - modelBuilder.Entity() - .OwnsOne(e => e.Avatar, b => b.ToJson()) - .HasOne() - .WithMany() - .HasForeignKey(u => u.TenantId) - .HasPrincipalKey(t => t.Id); - } -} +using Microsoft.EntityFrameworkCore; +using PlatformPlatform.AccountManagement.Domain.AccountRegistrations; +using PlatformPlatform.SharedKernel.InfrastructureCore.EntityFramework; + +namespace PlatformPlatform.AccountManagement.Infrastructure; + +public sealed class AccountManagementDbContext(DbContextOptions options) + : SharedKernelDbContext(options) +{ + public DbSet AccountRegistrations => Set(); + + public DbSet Tenants => Set(); + + public DbSet Users => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // AccountRegistration + modelBuilder.MapStronglyTypedUuid(a => a.Id); + modelBuilder.MapStronglyTypedNullableId(u => u.TenantId); + + // Tenant + modelBuilder.MapStronglyTypedId(t => t.Id); + + // User + modelBuilder.MapStronglyTypedUuid(u => u.Id); + modelBuilder.MapStronglyTypedId(u => u.TenantId); + modelBuilder.Entity() + .OwnsOne(e => e.Avatar, b => b.ToJson()) + .HasOne() + .WithMany() + .HasForeignKey(u => u.TenantId) + .HasPrincipalKey(t => t.Id); + } +} diff --git a/application/account-management/Infrastructure/AccountRegistrations/AccountRegistrationRepository.cs b/application/account-management/Infrastructure/AccountRegistrations/AccountRegistrationRepository.cs index 176942db0..ca6484b8f 100644 --- a/application/account-management/Infrastructure/AccountRegistrations/AccountRegistrationRepository.cs +++ b/application/account-management/Infrastructure/AccountRegistrations/AccountRegistrationRepository.cs @@ -1,16 +1,16 @@ -using PlatformPlatform.AccountManagement.Domain.AccountRegistrations; -using PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; - -namespace PlatformPlatform.AccountManagement.Infrastructure.AccountRegistrations; - -public sealed class AccountRegistrationRepository(AccountManagementDbContext accountManagementDbContext) - : RepositoryBase(accountManagementDbContext), IAccountRegistrationRepository -{ - public AccountRegistration[] GetByEmailOrTenantId(TenantId tenantId, string email) - { - return accountManagementDbContext.AccountRegistrations - .Where(r => !r.Completed) - .Where(r => r.TenantId == tenantId || r.Email == email.ToLowerInvariant()) - .ToArray(); - } -} +using PlatformPlatform.AccountManagement.Domain.AccountRegistrations; +using PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; + +namespace PlatformPlatform.AccountManagement.Infrastructure.AccountRegistrations; + +public sealed class AccountRegistrationRepository(AccountManagementDbContext accountManagementDbContext) + : RepositoryBase(accountManagementDbContext), IAccountRegistrationRepository +{ + public AccountRegistration[] GetByEmailOrTenantId(TenantId tenantId, string email) + { + return accountManagementDbContext.AccountRegistrations + .Where(r => !r.Completed) + .Where(r => r.TenantId == tenantId || r.Email == email.ToLowerInvariant()) + .ToArray(); + } +} diff --git a/application/account-management/Infrastructure/InfrastructureConfiguration.cs b/application/account-management/Infrastructure/InfrastructureConfiguration.cs index 14a22c554..7e6f28d86 100644 --- a/application/account-management/Infrastructure/InfrastructureConfiguration.cs +++ b/application/account-management/Infrastructure/InfrastructureConfiguration.cs @@ -1,27 +1,27 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using PlatformPlatform.SharedKernel.InfrastructureCore; - -namespace PlatformPlatform.AccountManagement.Infrastructure; - -public static class InfrastructureConfiguration -{ - public static Assembly Assembly => Assembly.GetExecutingAssembly(); - - public static IServiceCollection AddConfigureStorage(this IServiceCollection services, IHostApplicationBuilder builder) - { - // Storage is configured separately from other Infrastructure services to allow mocking in tests - services.ConfigureDatabaseContext(builder, "account-management-database"); - services.AddDefaultBlobStorage(builder); - services.AddNamedBlobStorages(builder, ("avatars-storage", "BLOB_STORAGE_URL")); - - return services; - } - - public static IServiceCollection AddInfrastructureServices(this IServiceCollection services) - { - services.ConfigureInfrastructureCoreServices(Assembly); - - return services; - } -} +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using PlatformPlatform.SharedKernel.InfrastructureCore; + +namespace PlatformPlatform.AccountManagement.Infrastructure; + +public static class InfrastructureConfiguration +{ + public static Assembly Assembly => Assembly.GetExecutingAssembly(); + + public static IServiceCollection AddConfigureStorage(this IServiceCollection services, IHostApplicationBuilder builder) + { + // Storage is configured separately from other Infrastructure services to allow mocking in tests + services.ConfigureDatabaseContext(builder, "account-management-database"); + services.AddDefaultBlobStorage(builder); + services.AddNamedBlobStorages(builder, ("avatars-storage", "BLOB_STORAGE_URL")); + + return services; + } + + public static IServiceCollection AddInfrastructureServices(this IServiceCollection services) + { + services.ConfigureInfrastructureCoreServices(Assembly); + + return services; + } +} diff --git a/application/account-management/Infrastructure/Migrations/DatabaseMigrations.cs b/application/account-management/Infrastructure/Migrations/DatabaseMigrations.cs index 2ca416ca4..c29c5e3c5 100644 --- a/application/account-management/Infrastructure/Migrations/DatabaseMigrations.cs +++ b/application/account-management/Infrastructure/Migrations/DatabaseMigrations.cs @@ -1,184 +1,184 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace PlatformPlatform.AccountManagement.Infrastructure.Migrations; - -[DbContext(typeof(AccountManagementDbContext))] -[Migration("1_Initial")] -public sealed class DatabaseMigrations : Migration -{ - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - "AccountRegistrations", - table => new - { - Id = table.Column("varchar(33)", nullable: false), - CreatedAt = table.Column("datetimeoffset", nullable: false), - ModifiedAt = table.Column("datetimeoffset", nullable: true), - TenantId = table.Column("varchar(30)", nullable: false), - Email = table.Column("varchar(100)", nullable: false), - RetryCount = table.Column("int", nullable: false), - OneTimePasswordHash = table.Column("varchar(84)", nullable: false), - ValidUntil = table.Column("datetimeoffset", nullable: false), - Completed = table.Column("bit", nullable: false) - }, - constraints: table => { table.PrimaryKey("PK_AccountRegistrations", x => x.Id); } - ); - - migrationBuilder.CreateTable( - "Tenants", - table => new - { - Id = table.Column("varchar(30)", nullable: false), - CreatedAt = table.Column("datetimeoffset", nullable: false), - ModifiedAt = table.Column("datetimeoffset", nullable: true), - Name = table.Column("nvarchar(30)", nullable: false), - State = table.Column("varchar(20)", nullable: false) - }, - constraints: table => { table.PrimaryKey("PK_Tenants", x => x.Id); } - ); - - migrationBuilder.CreateTable( - "Users", - table => new - { - TenantId = table.Column("varchar(30)", nullable: false), - Id = table.Column("char(30)", nullable: false), - CreatedAt = table.Column("datetimeoffset", nullable: false), - ModifiedAt = table.Column("datetimeoffset", nullable: true), - Email = table.Column("nvarchar(100)", nullable: false), - FirstName = table.Column("nvarchar(30)", nullable: true), - LastName = table.Column("nvarchar(30)", nullable: true), - UserRole = table.Column("varchar(20)", nullable: false), - EmailConfirmed = table.Column("bit", nullable: false), - Avatar = table.Column("varchar(200)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Users", x => x.Id); - table.ForeignKey("FK_Users_Tenants_TenantId", x => x.TenantId, "Tenants", "Id"); - } - ); - - migrationBuilder.CreateIndex("IX_Users_TenantId", "Users", "TenantId"); - } - - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { - modelBuilder.UseIdentityColumns(); - - modelBuilder.Entity("PlatformPlatform.AccountManagement.Domain.AccountRegistrations.AccountRegistration", b => - { - b.Property("Id") - .HasColumnType("varchar(33)"); - - b.Property("CreatedAt") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedAt") - .IsConcurrencyToken() - .HasColumnType("datetimeoffset"); - - b.Property("TenantId") - .IsRequired() - .HasColumnType("varchar(30)"); - - b.Property("Email") - .IsRequired() - .HasColumnType("varchar(100)"); - - b.Property("RetryCount") - .HasColumnType("int"); - - b.Property("OneTimePasswordHash") - .IsRequired() - .HasColumnType("varchar(84)"); - - b.Property("ValidUntil") - .HasColumnType("datetimeoffset"); - - b.Property("Completed") - .IsRequired() - .HasColumnType("bit"); - - b.HasKey("Id"); - - b.ToTable("AccountRegistrations"); - } - ); - - modelBuilder.Entity("PlatformPlatform.AccountManagement.Domain.Tenants.Tenant", b => - { - b.Property("Id") - .HasColumnType("varchar(30)"); - - b.Property("CreatedAt") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedAt") - .IsConcurrencyToken() - .HasColumnType("datetimeoffset"); - - b.Property("Name") - .IsRequired() - .HasColumnType("nvarchar(30)"); - - b.Property("State") - .IsRequired() - .HasColumnType("varchar(20)"); - - b.HasKey("Id"); - - b.ToTable("Tenants"); - } - ); - - modelBuilder.Entity("PlatformPlatform.AccountManagement.Domain.Users.User", b => - { - b.Property("TenantId") - .IsRequired() - .HasColumnType("varchar(30)"); - - b.Property("Id") - .HasColumnType("char(30)"); - - b.Property("CreatedAt") - .HasColumnType("datetimeoffset"); - - b.Property("ModifiedAt") - .IsConcurrencyToken() - .HasColumnType("datetimeoffset"); - - b.Property("Email") - .IsRequired() - .HasColumnType("nvarchar(100)"); - - b.Property("FirstName") - .HasColumnType("nvarchar(30)"); - - b.Property("LastName") - .HasColumnType("nvarchar(30)"); - - b.Property("UserRole") - .IsRequired() - .HasColumnType("varchar(20)"); - - b.Property("EmailConfirmed") - .IsRequired() - .HasColumnType("bit"); - - b.Property("Avatar") - .IsRequired() - .HasColumnType("varchar(200)"); - - b.HasKey("Id"); - - b.HasIndex("TenantId"); - - b.ToTable("Users"); - } - ); - } -} +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace PlatformPlatform.AccountManagement.Infrastructure.Migrations; + +[DbContext(typeof(AccountManagementDbContext))] +[Migration("1_Initial")] +public sealed class DatabaseMigrations : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + "AccountRegistrations", + table => new + { + Id = table.Column("varchar(33)", nullable: false), + CreatedAt = table.Column("datetimeoffset", nullable: false), + ModifiedAt = table.Column("datetimeoffset", nullable: true), + TenantId = table.Column("varchar(30)", nullable: false), + Email = table.Column("varchar(100)", nullable: false), + RetryCount = table.Column("int", nullable: false), + OneTimePasswordHash = table.Column("varchar(84)", nullable: false), + ValidUntil = table.Column("datetimeoffset", nullable: false), + Completed = table.Column("bit", nullable: false) + }, + constraints: table => { table.PrimaryKey("PK_AccountRegistrations", x => x.Id); } + ); + + migrationBuilder.CreateTable( + "Tenants", + table => new + { + Id = table.Column("varchar(30)", nullable: false), + CreatedAt = table.Column("datetimeoffset", nullable: false), + ModifiedAt = table.Column("datetimeoffset", nullable: true), + Name = table.Column("nvarchar(30)", nullable: false), + State = table.Column("varchar(20)", nullable: false) + }, + constraints: table => { table.PrimaryKey("PK_Tenants", x => x.Id); } + ); + + migrationBuilder.CreateTable( + "Users", + table => new + { + TenantId = table.Column("varchar(30)", nullable: false), + Id = table.Column("char(30)", nullable: false), + CreatedAt = table.Column("datetimeoffset", nullable: false), + ModifiedAt = table.Column("datetimeoffset", nullable: true), + Email = table.Column("nvarchar(100)", nullable: false), + FirstName = table.Column("nvarchar(30)", nullable: true), + LastName = table.Column("nvarchar(30)", nullable: true), + UserRole = table.Column("varchar(20)", nullable: false), + EmailConfirmed = table.Column("bit", nullable: false), + Avatar = table.Column("varchar(200)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + table.ForeignKey("FK_Users_Tenants_TenantId", x => x.TenantId, "Tenants", "Id"); + } + ); + + migrationBuilder.CreateIndex("IX_Users_TenantId", "Users", "TenantId"); + } + + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + modelBuilder.UseIdentityColumns(); + + modelBuilder.Entity("PlatformPlatform.AccountManagement.Domain.AccountRegistrations.AccountRegistration", b => + { + b.Property("Id") + .HasColumnType("varchar(33)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .IsConcurrencyToken() + .HasColumnType("datetimeoffset"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("varchar(30)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("varchar(100)"); + + b.Property("RetryCount") + .HasColumnType("int"); + + b.Property("OneTimePasswordHash") + .IsRequired() + .HasColumnType("varchar(84)"); + + b.Property("ValidUntil") + .HasColumnType("datetimeoffset"); + + b.Property("Completed") + .IsRequired() + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.ToTable("AccountRegistrations"); + } + ); + + modelBuilder.Entity("PlatformPlatform.AccountManagement.Domain.Tenants.Tenant", b => + { + b.Property("Id") + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .IsConcurrencyToken() + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(30)"); + + b.Property("State") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.HasKey("Id"); + + b.ToTable("Tenants"); + } + ); + + modelBuilder.Entity("PlatformPlatform.AccountManagement.Domain.Users.User", b => + { + b.Property("TenantId") + .IsRequired() + .HasColumnType("varchar(30)"); + + b.Property("Id") + .HasColumnType("char(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ModifiedAt") + .IsConcurrencyToken() + .HasColumnType("datetimeoffset"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(100)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(30)"); + + b.Property("LastName") + .HasColumnType("nvarchar(30)"); + + b.Property("UserRole") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.Property("EmailConfirmed") + .IsRequired() + .HasColumnType("bit"); + + b.Property("Avatar") + .IsRequired() + .HasColumnType("varchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Users"); + } + ); + } +} diff --git a/application/account-management/Infrastructure/Tenants/TenantRepository.cs b/application/account-management/Infrastructure/Tenants/TenantRepository.cs index 532e9df4e..959b2b226 100644 --- a/application/account-management/Infrastructure/Tenants/TenantRepository.cs +++ b/application/account-management/Infrastructure/Tenants/TenantRepository.cs @@ -1,13 +1,13 @@ -using Microsoft.EntityFrameworkCore; -using PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; - -namespace PlatformPlatform.AccountManagement.Infrastructure.Tenants; - -internal sealed class TenantRepository(AccountManagementDbContext accountManagementDbContext) - : RepositoryBase(accountManagementDbContext), ITenantRepository -{ - public Task IsSubdomainFreeAsync(string subdomain, CancellationToken cancellationToken) - { - return DbSet.AllAsync(tenant => tenant.Id != subdomain, cancellationToken); - } -} +using Microsoft.EntityFrameworkCore; +using PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; + +namespace PlatformPlatform.AccountManagement.Infrastructure.Tenants; + +internal sealed class TenantRepository(AccountManagementDbContext accountManagementDbContext) + : RepositoryBase(accountManagementDbContext), ITenantRepository +{ + public Task IsSubdomainFreeAsync(string subdomain, CancellationToken cancellationToken) + { + return DbSet.AllAsync(tenant => tenant.Id != subdomain, cancellationToken); + } +} diff --git a/application/account-management/Infrastructure/Users/UserRepository.cs b/application/account-management/Infrastructure/Users/UserRepository.cs index f327ecfad..7ff8ad851 100644 --- a/application/account-management/Infrastructure/Users/UserRepository.cs +++ b/application/account-management/Infrastructure/Users/UserRepository.cs @@ -1,74 +1,74 @@ -using Microsoft.EntityFrameworkCore; -using PlatformPlatform.SharedKernel.DomainCore.Persistence; -using PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; - -namespace PlatformPlatform.AccountManagement.Infrastructure.Users; - -internal sealed class UserRepository(AccountManagementDbContext accountManagementDbContext) - : RepositoryBase(accountManagementDbContext), IUserRepository -{ - public async Task IsEmailFreeAsync(TenantId tenantId, string email, CancellationToken cancellationToken) - { - return !await DbSet.AnyAsync(u => u.TenantId == tenantId && u.Email == email, cancellationToken); - } - - public Task CountTenantUsersAsync(TenantId tenantId, CancellationToken cancellationToken) - { - return DbSet.CountAsync(u => u.TenantId == tenantId, cancellationToken); - } - - public async Task<(User[] Users, int TotalItems, int TotalPages)> Search( - string? search, - UserRole? userRole, - SortableUserProperties? orderBy, - SortOrder? sortOrder, - int? pageSize, - int? pageOffset, - CancellationToken cancellationToken - ) - { - IQueryable users = DbSet; - - if (search is not null) - { - // Concatenate first and last name to enable searching by full name - users = users.Where(u => u.Email.Contains(search) || (u.FirstName + " " + u.LastName).Contains(search)); - } - - if (userRole is not null) - { - users = users.Where(u => u.UserRole == userRole); - } - - users = orderBy switch - { - SortableUserProperties.CreatedAt => sortOrder == SortOrder.Ascending - ? users.OrderBy(u => u.CreatedAt) - : users.OrderByDescending(u => u.CreatedAt), - SortableUserProperties.ModifiedAt => sortOrder == SortOrder.Ascending - ? users.OrderBy(u => u.ModifiedAt) - : users.OrderByDescending(u => u.ModifiedAt), - SortableUserProperties.Name => sortOrder == SortOrder.Ascending - ? users.OrderBy(u => u.FirstName).ThenBy(u => u.LastName) - : users.OrderByDescending(u => u.FirstName).ThenByDescending(u => u.LastName), - SortableUserProperties.Email => sortOrder == SortOrder.Ascending - ? users.OrderBy(u => u.Email) - : users.OrderByDescending(u => u.Email), - SortableUserProperties.UserRole => sortOrder == SortOrder.Ascending - ? users.OrderBy(u => u.UserRole) - : users.OrderByDescending(u => u.UserRole), - _ => users - }; - - pageSize ??= 50; - var itemOffset = (pageOffset ?? 0) * pageSize.Value; - var result = await users.Skip(itemOffset).Take(pageSize.Value).ToArrayAsync(cancellationToken); - - var totalItems = pageOffset == 0 && result.Length < pageSize - ? result.Length // If the first page returns fewer items than page size, skip querying the total count - : await users.CountAsync(cancellationToken); - - var totalPages = (totalItems - 1) / pageSize.Value + 1; - return (result, totalItems, totalPages); - } -} +using Microsoft.EntityFrameworkCore; +using PlatformPlatform.SharedKernel.DomainCore.Persistence; +using PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; + +namespace PlatformPlatform.AccountManagement.Infrastructure.Users; + +internal sealed class UserRepository(AccountManagementDbContext accountManagementDbContext) + : RepositoryBase(accountManagementDbContext), IUserRepository +{ + public async Task IsEmailFreeAsync(TenantId tenantId, string email, CancellationToken cancellationToken) + { + return !await DbSet.AnyAsync(u => u.TenantId == tenantId && u.Email == email, cancellationToken); + } + + public Task CountTenantUsersAsync(TenantId tenantId, CancellationToken cancellationToken) + { + return DbSet.CountAsync(u => u.TenantId == tenantId, cancellationToken); + } + + public async Task<(User[] Users, int TotalItems, int TotalPages)> Search( + string? search, + UserRole? userRole, + SortableUserProperties? orderBy, + SortOrder? sortOrder, + int? pageSize, + int? pageOffset, + CancellationToken cancellationToken + ) + { + IQueryable users = DbSet; + + if (search is not null) + { + // Concatenate first and last name to enable searching by full name + users = users.Where(u => u.Email.Contains(search) || (u.FirstName + " " + u.LastName).Contains(search)); + } + + if (userRole is not null) + { + users = users.Where(u => u.UserRole == userRole); + } + + users = orderBy switch + { + SortableUserProperties.CreatedAt => sortOrder == SortOrder.Ascending + ? users.OrderBy(u => u.CreatedAt) + : users.OrderByDescending(u => u.CreatedAt), + SortableUserProperties.ModifiedAt => sortOrder == SortOrder.Ascending + ? users.OrderBy(u => u.ModifiedAt) + : users.OrderByDescending(u => u.ModifiedAt), + SortableUserProperties.Name => sortOrder == SortOrder.Ascending + ? users.OrderBy(u => u.FirstName).ThenBy(u => u.LastName) + : users.OrderByDescending(u => u.FirstName).ThenByDescending(u => u.LastName), + SortableUserProperties.Email => sortOrder == SortOrder.Ascending + ? users.OrderBy(u => u.Email) + : users.OrderByDescending(u => u.Email), + SortableUserProperties.UserRole => sortOrder == SortOrder.Ascending + ? users.OrderBy(u => u.UserRole) + : users.OrderByDescending(u => u.UserRole), + _ => users + }; + + pageSize ??= 50; + var itemOffset = (pageOffset ?? 0) * pageSize.Value; + var result = await users.Skip(itemOffset).Take(pageSize.Value).ToArrayAsync(cancellationToken); + + var totalItems = pageOffset == 0 && result.Length < pageSize + ? result.Length // If the first page returns fewer items than page size, skip querying the total count + : await users.CountAsync(cancellationToken); + + var totalPages = (totalItems - 1) / pageSize.Value + 1; + return (result, totalItems, totalPages); + } +} diff --git a/application/account-management/Tests/Api/AccountRegistrations/AccountRegistrationsTests.cs b/application/account-management/Tests/Api/AccountRegistrations/AccountRegistrationsTests.cs index 8c1e96d7d..a02be09d8 100644 --- a/application/account-management/Tests/Api/AccountRegistrations/AccountRegistrationsTests.cs +++ b/application/account-management/Tests/Api/AccountRegistrations/AccountRegistrationsTests.cs @@ -1,114 +1,114 @@ -using System.Net; -using System.Net.Http.Json; -using FluentAssertions; -using NSubstitute; -using PlatformPlatform.AccountManagement.Application.AccountRegistrations; -using PlatformPlatform.AccountManagement.Infrastructure; -using PlatformPlatform.SharedKernel.ApplicationCore.Validation; -using Xunit; - -namespace PlatformPlatform.AccountManagement.Tests.Api.AccountRegistrations; - -public sealed class AccountRegistrationsTests : BaseApiTests -{ - [Fact] - public async Task StartAccountRegistration_WhenTenantExists_ShouldReturnBadRequest() - { - // Arrange - var email = Faker.Internet.Email(); - var subdomain = DatabaseSeeder.Tenant1.Id; - var command = new StartAccountRegistrationCommand(subdomain, email); - - // Act - var response = await TestHttpClient.PostAsJsonAsync("/api/account-management/account-registrations/start", command); - - // Assert - var expectedErrors = new[] - { - new ErrorDetail("Subdomain", "The subdomain is not available.") - }; - await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); - - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); - } - - [Fact] - public async Task IsSubdomainFree_WhenTenantExists_ShouldReturnFalse() - { - // Arrange - var subdomain = Faker.Subdomain(); - - // Act - var response = await TestHttpClient - .GetAsync($"/api/account-management/account-registrations/is-subdomain-free?subdomain={subdomain}"); - - // Assert - EnsureSuccessGetRequest(response); - - var responseBody = await response.Content.ReadAsStringAsync(); - responseBody.Should().Be("true"); - } - - [Fact] - public async Task StartAccountRegistration_WhenSubdomainInvalid_ShouldReturnBadRequest() - { - // Arrange - var email = Faker.Internet.Email(); - var invalidSubdomain = Faker.Random.String(31); - var command = new StartAccountRegistrationCommand(invalidSubdomain, email); - - // Act - var response = await TestHttpClient.PostAsJsonAsync("/api/account-management/account-registrations/start", command); - - // Assert - var expectedErrors = new[] - { - new ErrorDetail("Subdomain", "Subdomain must be between 3-30 alphanumeric and lowercase characters.") - }; - await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); - - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); - await EmailService.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), CancellationToken.None); - } - - [Fact] - public async Task IsSubdomainFree_WhenTenantExists_ShouldReturnTrue() - { - // Arrange - var subdomain = DatabaseSeeder.Tenant1.Id; - - // Act - var response = - await TestHttpClient.GetAsync($"/api/account-management/account-registrations/is-subdomain-free?subdomain={subdomain}"); - - // Assert - EnsureSuccessGetRequest(response); - - var responseBody = await response.Content.ReadAsStringAsync(); - responseBody.Should().Be("false"); - } - - [Fact] - public async Task CompleteAccountRegistration_WhenValid_ShouldCreateTenantAndOwnerUser() - { - // Arrange - var email = DatabaseSeeder.AccountRegistration1.Email; - var oneTimePassword = DatabaseSeeder.OneTimePassword; - var command = new CompleteAccountRegistrationCommand(oneTimePassword); - var accountRegistrationId = DatabaseSeeder.AccountRegistration1.Id; - - // Act - var response = await TestHttpClient - .PostAsJsonAsync($"/api/account-management/account-registrations/{accountRegistrationId}/complete", command); - - // Assert - await EnsureSuccessPostRequest(response, hasLocation: false); - Connection.RowExists("Tenants", accountRegistrationId); - Connection.ExecuteScalar("SELECT COUNT(*) FROM Users WHERE Email = @email", new { email }).Should().Be(1); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); - TelemetryEventsCollectorSpy.CollectedEvents.Count(e => e.Name == "AccountRegistrationCompleted").Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents.Count(e => e.Name == "UserCreated").Should().Be(1); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } -} +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using NSubstitute; +using PlatformPlatform.AccountManagement.Application.AccountRegistrations; +using PlatformPlatform.AccountManagement.Infrastructure; +using PlatformPlatform.SharedKernel.ApplicationCore.Validation; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.Api.AccountRegistrations; + +public sealed class AccountRegistrationsTests : BaseApiTests +{ + [Fact] + public async Task StartAccountRegistration_WhenTenantExists_ShouldReturnBadRequest() + { + // Arrange + var email = Faker.Internet.Email(); + var subdomain = DatabaseSeeder.Tenant1.Id; + var command = new StartAccountRegistrationCommand(subdomain, email); + + // Act + var response = await TestHttpClient.PostAsJsonAsync("/api/account-management/account-registrations/start", command); + + // Assert + var expectedErrors = new[] + { + new ErrorDetail("Subdomain", "The subdomain is not available.") + }; + await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); + + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task IsSubdomainFree_WhenTenantExists_ShouldReturnFalse() + { + // Arrange + var subdomain = Faker.Subdomain(); + + // Act + var response = await TestHttpClient + .GetAsync($"/api/account-management/account-registrations/is-subdomain-free?subdomain={subdomain}"); + + // Assert + EnsureSuccessGetRequest(response); + + var responseBody = await response.Content.ReadAsStringAsync(); + responseBody.Should().Be("true"); + } + + [Fact] + public async Task StartAccountRegistration_WhenSubdomainInvalid_ShouldReturnBadRequest() + { + // Arrange + var email = Faker.Internet.Email(); + var invalidSubdomain = Faker.Random.String(31); + var command = new StartAccountRegistrationCommand(invalidSubdomain, email); + + // Act + var response = await TestHttpClient.PostAsJsonAsync("/api/account-management/account-registrations/start", command); + + // Assert + var expectedErrors = new[] + { + new ErrorDetail("Subdomain", "Subdomain must be between 3-30 alphanumeric and lowercase characters.") + }; + await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); + + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + await EmailService.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), CancellationToken.None); + } + + [Fact] + public async Task IsSubdomainFree_WhenTenantExists_ShouldReturnTrue() + { + // Arrange + var subdomain = DatabaseSeeder.Tenant1.Id; + + // Act + var response = + await TestHttpClient.GetAsync($"/api/account-management/account-registrations/is-subdomain-free?subdomain={subdomain}"); + + // Assert + EnsureSuccessGetRequest(response); + + var responseBody = await response.Content.ReadAsStringAsync(); + responseBody.Should().Be("false"); + } + + [Fact] + public async Task CompleteAccountRegistration_WhenValid_ShouldCreateTenantAndOwnerUser() + { + // Arrange + var email = DatabaseSeeder.AccountRegistration1.Email; + var oneTimePassword = DatabaseSeeder.OneTimePassword; + var command = new CompleteAccountRegistrationCommand(oneTimePassword); + var accountRegistrationId = DatabaseSeeder.AccountRegistration1.Id; + + // Act + var response = await TestHttpClient + .PostAsJsonAsync($"/api/account-management/account-registrations/{accountRegistrationId}/complete", command); + + // Assert + await EnsureSuccessPostRequest(response, hasLocation: false); + Connection.RowExists("Tenants", accountRegistrationId); + Connection.ExecuteScalar("SELECT COUNT(*) FROM Users WHERE Email = @email", new { email }).Should().Be(1); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); + TelemetryEventsCollectorSpy.CollectedEvents.Count(e => e.Name == "AccountRegistrationCompleted").Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents.Count(e => e.Name == "UserCreated").Should().Be(1); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } +} diff --git a/application/account-management/Tests/Api/ApiCore/CustomExceptionHandlingTests.cs b/application/account-management/Tests/Api/ApiCore/CustomExceptionHandlingTests.cs index 4279a0889..505e4e568 100644 --- a/application/account-management/Tests/Api/ApiCore/CustomExceptionHandlingTests.cs +++ b/application/account-management/Tests/Api/ApiCore/CustomExceptionHandlingTests.cs @@ -1,99 +1,99 @@ -using System.Net; -using FluentAssertions; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Mvc.Testing; -using PlatformPlatform.AccountManagement.Infrastructure; -using Xunit; - -namespace PlatformPlatform.AccountManagement.Tests.Api.ApiCore; - -public sealed class CustomExceptionHandlingTests : BaseApiTests -{ - private readonly WebApplicationFactory _webApplicationFactory = new(); - - [Theory] - [InlineData("Development")] - [InlineData("Production")] - public async Task GlobalExceptionHandling_WhenThrowingException_ShouldHandleExceptionsCorrectly(string environment) - { - // Arrange - var client = _webApplicationFactory.WithWebHostBuilder(builder => - { - builder.UseSetting(WebHostDefaults.EnvironmentKey, environment); - builder.ConfigureAppConfiguration((_, _) => - { - // Set the environment variable to enable the test-specific /api/throwException endpoint. - Environment.SetEnvironmentVariable("TestEndpointsEnabled", "true"); - } - ); - } - ).CreateClient(); - - // Act - var response = await client.GetAsync("/api/throwException"); - - // Assert - if (environment == "Development") - { - // In Development, we use app.UseDeveloperExceptionPage(), which returns a HTML response. - response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); - response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); - var errorResponse = await response.Content.ReadAsStringAsync(); - errorResponse.Contains("Simulate an exception.").Should().BeTrue(); - } - else - { - // In Production, we use GlobalExceptionHandler, which returns a JSON response. - await EnsureErrorStatusCode( - response, - HttpStatusCode.InternalServerError, - "An error occurred while processing the request.", - hasTraceId: true - ); - } - } - - [Theory] - [InlineData("Development")] - [InlineData("Production")] - public async Task TimeoutExceptionHandling_WhenThrowingTimeoutException_ShouldHandleTimeoutExceptionsCorrectly( - string environment - ) - { - // Arrange - var client = _webApplicationFactory.WithWebHostBuilder(builder => - { - builder.UseSetting(WebHostDefaults.EnvironmentKey, environment); - builder.ConfigureAppConfiguration((_, _) => - { - // Set the environment variable to enable the test-specific /api/throwException endpoint. - Environment.SetEnvironmentVariable("TestEndpointsEnabled", "true"); - } - ); - } - ).CreateClient(); - - // Act - var response = await client.GetAsync("/api/throwTimeoutException"); - - // Assert - if (environment == "Development") - { - // In Development, we use app.UseDeveloperExceptionPage(), which returns a HTML response. - response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); - response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); - var errorResponse = await response.Content.ReadAsStringAsync(); - errorResponse.Contains("Simulating a timeout exception.").Should().BeTrue(); - } - else - { - // In Production, we use GlobalExceptionHandlerMiddleware, which returns a JSON response. - await EnsureErrorStatusCode( - response, - HttpStatusCode.RequestTimeout, - "GET /api/throwTimeoutException", - hasTraceId: true - ); - } - } -} +using System.Net; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using PlatformPlatform.AccountManagement.Infrastructure; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.Api.ApiCore; + +public sealed class CustomExceptionHandlingTests : BaseApiTests +{ + private readonly WebApplicationFactory _webApplicationFactory = new(); + + [Theory] + [InlineData("Development")] + [InlineData("Production")] + public async Task GlobalExceptionHandling_WhenThrowingException_ShouldHandleExceptionsCorrectly(string environment) + { + // Arrange + var client = _webApplicationFactory.WithWebHostBuilder(builder => + { + builder.UseSetting(WebHostDefaults.EnvironmentKey, environment); + builder.ConfigureAppConfiguration((_, _) => + { + // Set the environment variable to enable the test-specific /api/throwException endpoint. + Environment.SetEnvironmentVariable("TestEndpointsEnabled", "true"); + } + ); + } + ).CreateClient(); + + // Act + var response = await client.GetAsync("/api/throwException"); + + // Assert + if (environment == "Development") + { + // In Development, we use app.UseDeveloperExceptionPage(), which returns a HTML response. + response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); + var errorResponse = await response.Content.ReadAsStringAsync(); + errorResponse.Contains("Simulate an exception.").Should().BeTrue(); + } + else + { + // In Production, we use GlobalExceptionHandler, which returns a JSON response. + await EnsureErrorStatusCode( + response, + HttpStatusCode.InternalServerError, + "An error occurred while processing the request.", + hasTraceId: true + ); + } + } + + [Theory] + [InlineData("Development")] + [InlineData("Production")] + public async Task TimeoutExceptionHandling_WhenThrowingTimeoutException_ShouldHandleTimeoutExceptionsCorrectly( + string environment + ) + { + // Arrange + var client = _webApplicationFactory.WithWebHostBuilder(builder => + { + builder.UseSetting(WebHostDefaults.EnvironmentKey, environment); + builder.ConfigureAppConfiguration((_, _) => + { + // Set the environment variable to enable the test-specific /api/throwException endpoint. + Environment.SetEnvironmentVariable("TestEndpointsEnabled", "true"); + } + ); + } + ).CreateClient(); + + // Act + var response = await client.GetAsync("/api/throwTimeoutException"); + + // Assert + if (environment == "Development") + { + // In Development, we use app.UseDeveloperExceptionPage(), which returns a HTML response. + response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); + var errorResponse = await response.Content.ReadAsStringAsync(); + errorResponse.Contains("Simulating a timeout exception.").Should().BeTrue(); + } + else + { + // In Production, we use GlobalExceptionHandlerMiddleware, which returns a JSON response. + await EnsureErrorStatusCode( + response, + HttpStatusCode.RequestTimeout, + "GET /api/throwTimeoutException", + hasTraceId: true + ); + } + } +} diff --git a/application/account-management/Tests/Api/BaseApiTest.cs b/application/account-management/Tests/Api/BaseApiTest.cs index 0be8b18a8..a70bd56d5 100644 --- a/application/account-management/Tests/Api/BaseApiTest.cs +++ b/application/account-management/Tests/Api/BaseApiTest.cs @@ -1,154 +1,154 @@ -using System.Net; -using System.Text.Json; -using FluentAssertions; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.TestHost; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using PlatformPlatform.SharedKernel.ApiCore.ApiResults; -using PlatformPlatform.SharedKernel.ApiCore.Middleware; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; -using PlatformPlatform.SharedKernel.ApplicationCore.Validation; -using PlatformPlatform.SharedKernel.Tests.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.AccountManagement.Tests.Api; - -public abstract class BaseApiTests : BaseTest where TContext : DbContext -{ - private readonly WebApplicationFactory _webApplicationFactory; - - protected BaseApiTests() - { - Environment.SetEnvironmentVariable(WebAppMiddlewareConfiguration.PublicUrlKey, "https://localhost:9000"); - Environment.SetEnvironmentVariable(WebAppMiddlewareConfiguration.CdnUrlKey, "https://localhost:9101"); - - _webApplicationFactory = new WebApplicationFactory().WithWebHostBuilder(builder => - { - builder.ConfigureTestServices(services => - { - // Replace the default DbContext in the WebApplication to use an in-memory SQLite database - services.Remove(services.Single(d => d.ServiceType == typeof(DbContextOptions))); - services.AddDbContext(options => { options.UseSqlite(Connection); }); - - TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); - services.AddScoped(_ => TelemetryEventsCollectorSpy); - } - ); - } - ); - - TestHttpClient = _webApplicationFactory.CreateClient(); - } - - protected HttpClient TestHttpClient { get; } - - protected override void Dispose(bool disposing) - { - _webApplicationFactory.Dispose(); - base.Dispose(disposing); - } - - protected static void EnsureSuccessGetRequest(HttpResponseMessage response) - { - response.EnsureSuccessStatusCode(); - response.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); - response.Headers.Location.Should().BeNull(); - } - - protected static async Task EnsureSuccessPostRequest( - HttpResponseMessage response, - string? exact = null, - string? startsWith = null, - bool hasLocation = true - ) - { - var responseBody = await response.Content.ReadAsStringAsync(); - responseBody.Should().BeEmpty(); - - response.EnsureSuccessStatusCode(); - response.Content.Headers.ContentType.Should().BeNull(); - - if (hasLocation) - { - response.Headers.Location.Should().NotBeNull(); - } - else - { - response.Headers.Location.Should().BeNull(); - } - - if (exact is not null) - { - response.Headers.Location!.ToString().Should().Be(exact); - } - - if (startsWith is not null) - { - response.Headers.Location!.ToString().StartsWith(startsWith).Should().BeTrue(); - } - } - - protected static void EnsureSuccessWithEmptyHeaderAndLocation(HttpResponseMessage response) - { - response.EnsureSuccessStatusCode(); - response.Content.Headers.ContentType.Should().BeNull(); - response.Headers.Location.Should().BeNull(); - } - - protected Task EnsureErrorStatusCode(HttpResponseMessage response, HttpStatusCode statusCode, IEnumerable expectedErrors) - { - return EnsureErrorStatusCode(response, statusCode, null, expectedErrors); - } - - protected async Task EnsureErrorStatusCode( - HttpResponseMessage response, - HttpStatusCode statusCode, - string? expectedDetail, - IEnumerable? expectedErrors = null, - bool hasTraceId = false - ) - { - response.StatusCode.Should().Be(statusCode); - response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); - - var problemDetails = await DeserializeProblemDetails(response); - - problemDetails.Should().NotBeNull(); - problemDetails!.Status.Should().Be((int)statusCode); - problemDetails.Type.Should().StartWith("https://tools.ietf.org/html/rfc9110#section-15."); - problemDetails.Title.Should().Be(ApiResult.GetHttpStatusDisplayName(statusCode)); - - if (expectedDetail is not null) - { - problemDetails.Detail.Should().Be(expectedDetail); - } - - if (expectedErrors is not null) - { - var actualErrorsJson = (JsonElement)problemDetails.Extensions["Errors"]!; - var actualErrors = JsonSerializer.Deserialize(actualErrorsJson.GetRawText(), JsonSerializerOptions); - - actualErrors.Should().BeEquivalentTo(expectedErrors); - } - - if (hasTraceId) - { - problemDetails.Extensions["traceId"]!.ToString().Should().NotBeEmpty(); - } - } - - protected async Task DeserializeResponse(HttpResponseMessage response) - { - var responseStream = await response.Content.ReadAsStreamAsync(); - - return await JsonSerializer.DeserializeAsync(responseStream, JsonSerializerOptions); - } - - private async Task DeserializeProblemDetails(HttpResponseMessage response) - { - var content = await response.Content.ReadAsStringAsync(); - - return JsonSerializer.Deserialize(content, JsonSerializerOptions); - } -} +using System.Net; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using PlatformPlatform.SharedKernel.ApiCore.ApiResults; +using PlatformPlatform.SharedKernel.ApiCore.Middleware; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; +using PlatformPlatform.SharedKernel.ApplicationCore.Validation; +using PlatformPlatform.SharedKernel.Tests.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.AccountManagement.Tests.Api; + +public abstract class BaseApiTests : BaseTest where TContext : DbContext +{ + private readonly WebApplicationFactory _webApplicationFactory; + + protected BaseApiTests() + { + Environment.SetEnvironmentVariable(WebAppMiddlewareConfiguration.PublicUrlKey, "https://localhost:9000"); + Environment.SetEnvironmentVariable(WebAppMiddlewareConfiguration.CdnUrlKey, "https://localhost:9101"); + + _webApplicationFactory = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + // Replace the default DbContext in the WebApplication to use an in-memory SQLite database + services.Remove(services.Single(d => d.ServiceType == typeof(DbContextOptions))); + services.AddDbContext(options => { options.UseSqlite(Connection); }); + + TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); + services.AddScoped(_ => TelemetryEventsCollectorSpy); + } + ); + } + ); + + TestHttpClient = _webApplicationFactory.CreateClient(); + } + + protected HttpClient TestHttpClient { get; } + + protected override void Dispose(bool disposing) + { + _webApplicationFactory.Dispose(); + base.Dispose(disposing); + } + + protected static void EnsureSuccessGetRequest(HttpResponseMessage response) + { + response.EnsureSuccessStatusCode(); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); + response.Headers.Location.Should().BeNull(); + } + + protected static async Task EnsureSuccessPostRequest( + HttpResponseMessage response, + string? exact = null, + string? startsWith = null, + bool hasLocation = true + ) + { + var responseBody = await response.Content.ReadAsStringAsync(); + responseBody.Should().BeEmpty(); + + response.EnsureSuccessStatusCode(); + response.Content.Headers.ContentType.Should().BeNull(); + + if (hasLocation) + { + response.Headers.Location.Should().NotBeNull(); + } + else + { + response.Headers.Location.Should().BeNull(); + } + + if (exact is not null) + { + response.Headers.Location!.ToString().Should().Be(exact); + } + + if (startsWith is not null) + { + response.Headers.Location!.ToString().StartsWith(startsWith).Should().BeTrue(); + } + } + + protected static void EnsureSuccessWithEmptyHeaderAndLocation(HttpResponseMessage response) + { + response.EnsureSuccessStatusCode(); + response.Content.Headers.ContentType.Should().BeNull(); + response.Headers.Location.Should().BeNull(); + } + + protected Task EnsureErrorStatusCode(HttpResponseMessage response, HttpStatusCode statusCode, IEnumerable expectedErrors) + { + return EnsureErrorStatusCode(response, statusCode, null, expectedErrors); + } + + protected async Task EnsureErrorStatusCode( + HttpResponseMessage response, + HttpStatusCode statusCode, + string? expectedDetail, + IEnumerable? expectedErrors = null, + bool hasTraceId = false + ) + { + response.StatusCode.Should().Be(statusCode); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); + + var problemDetails = await DeserializeProblemDetails(response); + + problemDetails.Should().NotBeNull(); + problemDetails!.Status.Should().Be((int)statusCode); + problemDetails.Type.Should().StartWith("https://tools.ietf.org/html/rfc9110#section-15."); + problemDetails.Title.Should().Be(ApiResult.GetHttpStatusDisplayName(statusCode)); + + if (expectedDetail is not null) + { + problemDetails.Detail.Should().Be(expectedDetail); + } + + if (expectedErrors is not null) + { + var actualErrorsJson = (JsonElement)problemDetails.Extensions["Errors"]!; + var actualErrors = JsonSerializer.Deserialize(actualErrorsJson.GetRawText(), JsonSerializerOptions); + + actualErrors.Should().BeEquivalentTo(expectedErrors); + } + + if (hasTraceId) + { + problemDetails.Extensions["traceId"]!.ToString().Should().NotBeEmpty(); + } + } + + protected async Task DeserializeResponse(HttpResponseMessage response) + { + var responseStream = await response.Content.ReadAsStreamAsync(); + + return await JsonSerializer.DeserializeAsync(responseStream, JsonSerializerOptions); + } + + private async Task DeserializeProblemDetails(HttpResponseMessage response) + { + var content = await response.Content.ReadAsStringAsync(); + + return JsonSerializer.Deserialize(content, JsonSerializerOptions); + } +} diff --git a/application/account-management/Tests/Api/Tenants/TenantEndpointsTests.cs b/application/account-management/Tests/Api/Tenants/TenantEndpointsTests.cs index 04817456a..a7ceb05bc 100644 --- a/application/account-management/Tests/Api/Tenants/TenantEndpointsTests.cs +++ b/application/account-management/Tests/Api/Tenants/TenantEndpointsTests.cs @@ -1,184 +1,184 @@ -using System.Net; -using System.Net.Http.Json; -using FluentAssertions; -using NJsonSchema; -using PlatformPlatform.AccountManagement.Application.Tenants; -using PlatformPlatform.AccountManagement.Infrastructure; -using PlatformPlatform.SharedKernel.ApplicationCore.Validation; -using Xunit; - -namespace PlatformPlatform.AccountManagement.Tests.Api.Tenants; - -public sealed class TenantEndpointsTests : BaseApiTests -{ - [Fact] - public async Task GetTenant_WhenTenantExists_ShouldReturnTenantWithValidContract() - { - // Arrange - var existingTenantId = DatabaseSeeder.Tenant1.Id; - - // Act - var response = await TestHttpClient.GetAsync($"/api/account-management/tenants/{existingTenantId}"); - - // Assert - EnsureSuccessGetRequest(response); - - var schema = await JsonSchema.FromJsonAsync( - """ - { - 'type': 'object', - 'properties': { - 'id': {'type': 'string', 'pattern': '^[a-z0-9]{3,30}$'}, - 'createdAt': {'type': 'string', 'format': 'date-time'}, - 'modifiedAt': {'type': ['null', 'string'], 'format': 'date-time'}, - 'name': {'type': 'string', 'minLength': 1, 'maxLength': 30}, - 'state': {'type': 'string', 'minLength': 1, 'maxLength':20} - }, - 'required': ['id', 'createdAt', 'modifiedAt', 'name', 'state'], - 'additionalProperties': false - } - """ - ); - - var responseBody = await response.Content.ReadAsStringAsync(); - schema.Validate(responseBody).Should().BeEmpty(); - } - - [Fact] - public async Task GetTenant_WhenTenantDoesNotExist_ShouldReturnNotFound() - { - // Arrange - var unknownTenantId = Faker.Subdomain(); - - // Act - var response = await TestHttpClient.GetAsync($"/api/account-management/tenants/{unknownTenantId}"); - - // Assert - await EnsureErrorStatusCode(response, HttpStatusCode.NotFound, $"Tenant with id '{unknownTenantId}' not found."); - } - - [Fact] - public async Task GetTenant_WhenTenantInvalidTenantId_ShouldReturnBadRequest() - { - // Arrange - var invalidTenantId = Faker.Random.AlphaNumeric(31); - - // Act - var response = await TestHttpClient.GetAsync($"/api/account-management/tenants/{invalidTenantId}"); - - // Assert - await EnsureErrorStatusCode(response, - HttpStatusCode.BadRequest, - $"""Failed to bind parameter "TenantId Id" from "{invalidTenantId}".""" - ); - } - - [Fact] - public async Task UpdateTenant_WhenValid_ShouldUpdateTenant() - { - // Arrange - var existingTenantId = DatabaseSeeder.Tenant1.Id; - var command = new UpdateTenantCommand { Name = Faker.TenantName() }; - - // Act - var response = await TestHttpClient.PutAsJsonAsync($"/api/account-management/tenants/{existingTenantId}", command); - - // Assert - EnsureSuccessWithEmptyHeaderAndLocation(response); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents.Count(e => e.Name == "TenantUpdated").Should().Be(1); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } - - [Fact] - public async Task UpdateTenant_WhenInvalid_ShouldReturnBadRequest() - { - // Arrange - var existingTenantId = DatabaseSeeder.Tenant1.Id; - var invalidName = Faker.Random.String2(31); - var command = new UpdateTenantCommand { Name = invalidName }; - - // Act - var response = await TestHttpClient.PutAsJsonAsync($"/api/account-management/tenants/{existingTenantId}", command); - - // Assert - var expectedErrors = new[] - { - new ErrorDetail("Name", "Name must be between 1 and 30 characters.") - }; - await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); - - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); - } - - [Fact] - public async Task UpdateTenant_WhenTenantDoesNotExists_ShouldReturnNotFound() - { - // Arrange - var unknownTenantId = Faker.Subdomain(); - var command = new UpdateTenantCommand { Name = Faker.TenantName() }; - - // Act - var response = await TestHttpClient.PutAsJsonAsync($"/api/account-management/tenants/{unknownTenantId}", command); - - //Assert - await EnsureErrorStatusCode(response, HttpStatusCode.NotFound, $"Tenant with id '{unknownTenantId}' not found."); - - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); - } - - [Fact] - public async Task DeleteTenant_WhenTenantDoesNotExists_ShouldReturnNotFound() - { - // Arrange - var unknownTenantId = Faker.Subdomain(); - - // Act - var response = await TestHttpClient.DeleteAsync($"/api/account-management/tenants/{unknownTenantId}"); - - //Assert - await EnsureErrorStatusCode(response, HttpStatusCode.NotFound, $"Tenant with id '{unknownTenantId}' not found."); - - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); - } - - [Fact] - public async Task DeleteTenant_WhenTenantHasUsers_ShouldReturnBadRequest() - { - // Act - var existingTenantId = DatabaseSeeder.Tenant1.Id; - var response = await TestHttpClient.DeleteAsync($"/api/account-management/tenants/{existingTenantId}"); - TelemetryEventsCollectorSpy.Reset(); - - // Assert - var expectedErrors = new[] - { - new ErrorDetail("Id", "All users must be deleted before the tenant can be deleted.") - }; - await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); - - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); - } - - [Fact] - public async Task DeleteTenant_WhenTenantHasNoUsers_ShouldDeleteTenant() - { - // Arrange - var existingTenantId = DatabaseSeeder.Tenant1.Id; - var existingUserId = DatabaseSeeder.User1.Id; - await TestHttpClient.DeleteAsync($"/api/account-management/users/{existingUserId}"); - TelemetryEventsCollectorSpy.Reset(); - - // Act - var response = await TestHttpClient.DeleteAsync($"/api/account-management/tenants/{existingTenantId}"); - - // Assert - EnsureSuccessWithEmptyHeaderAndLocation(response); - Connection.RowExists("Tenants", existingTenantId).Should().BeFalse(); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents.Count(e => e.Name == "TenantDeleted").Should().Be(1); - TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); - } -} +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using NJsonSchema; +using PlatformPlatform.AccountManagement.Application.Tenants; +using PlatformPlatform.AccountManagement.Infrastructure; +using PlatformPlatform.SharedKernel.ApplicationCore.Validation; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.Api.Tenants; + +public sealed class TenantEndpointsTests : BaseApiTests +{ + [Fact] + public async Task GetTenant_WhenTenantExists_ShouldReturnTenantWithValidContract() + { + // Arrange + var existingTenantId = DatabaseSeeder.Tenant1.Id; + + // Act + var response = await TestHttpClient.GetAsync($"/api/account-management/tenants/{existingTenantId}"); + + // Assert + EnsureSuccessGetRequest(response); + + var schema = await JsonSchema.FromJsonAsync( + """ + { + 'type': 'object', + 'properties': { + 'id': {'type': 'string', 'pattern': '^[a-z0-9]{3,30}$'}, + 'createdAt': {'type': 'string', 'format': 'date-time'}, + 'modifiedAt': {'type': ['null', 'string'], 'format': 'date-time'}, + 'name': {'type': 'string', 'minLength': 1, 'maxLength': 30}, + 'state': {'type': 'string', 'minLength': 1, 'maxLength':20} + }, + 'required': ['id', 'createdAt', 'modifiedAt', 'name', 'state'], + 'additionalProperties': false + } + """ + ); + + var responseBody = await response.Content.ReadAsStringAsync(); + schema.Validate(responseBody).Should().BeEmpty(); + } + + [Fact] + public async Task GetTenant_WhenTenantDoesNotExist_ShouldReturnNotFound() + { + // Arrange + var unknownTenantId = Faker.Subdomain(); + + // Act + var response = await TestHttpClient.GetAsync($"/api/account-management/tenants/{unknownTenantId}"); + + // Assert + await EnsureErrorStatusCode(response, HttpStatusCode.NotFound, $"Tenant with id '{unknownTenantId}' not found."); + } + + [Fact] + public async Task GetTenant_WhenTenantInvalidTenantId_ShouldReturnBadRequest() + { + // Arrange + var invalidTenantId = Faker.Random.AlphaNumeric(31); + + // Act + var response = await TestHttpClient.GetAsync($"/api/account-management/tenants/{invalidTenantId}"); + + // Assert + await EnsureErrorStatusCode(response, + HttpStatusCode.BadRequest, + $"""Failed to bind parameter "TenantId Id" from "{invalidTenantId}".""" + ); + } + + [Fact] + public async Task UpdateTenant_WhenValid_ShouldUpdateTenant() + { + // Arrange + var existingTenantId = DatabaseSeeder.Tenant1.Id; + var command = new UpdateTenantCommand { Name = Faker.TenantName() }; + + // Act + var response = await TestHttpClient.PutAsJsonAsync($"/api/account-management/tenants/{existingTenantId}", command); + + // Assert + EnsureSuccessWithEmptyHeaderAndLocation(response); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents.Count(e => e.Name == "TenantUpdated").Should().Be(1); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } + + [Fact] + public async Task UpdateTenant_WhenInvalid_ShouldReturnBadRequest() + { + // Arrange + var existingTenantId = DatabaseSeeder.Tenant1.Id; + var invalidName = Faker.Random.String2(31); + var command = new UpdateTenantCommand { Name = invalidName }; + + // Act + var response = await TestHttpClient.PutAsJsonAsync($"/api/account-management/tenants/{existingTenantId}", command); + + // Assert + var expectedErrors = new[] + { + new ErrorDetail("Name", "Name must be between 1 and 30 characters.") + }; + await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); + + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task UpdateTenant_WhenTenantDoesNotExists_ShouldReturnNotFound() + { + // Arrange + var unknownTenantId = Faker.Subdomain(); + var command = new UpdateTenantCommand { Name = Faker.TenantName() }; + + // Act + var response = await TestHttpClient.PutAsJsonAsync($"/api/account-management/tenants/{unknownTenantId}", command); + + //Assert + await EnsureErrorStatusCode(response, HttpStatusCode.NotFound, $"Tenant with id '{unknownTenantId}' not found."); + + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task DeleteTenant_WhenTenantDoesNotExists_ShouldReturnNotFound() + { + // Arrange + var unknownTenantId = Faker.Subdomain(); + + // Act + var response = await TestHttpClient.DeleteAsync($"/api/account-management/tenants/{unknownTenantId}"); + + //Assert + await EnsureErrorStatusCode(response, HttpStatusCode.NotFound, $"Tenant with id '{unknownTenantId}' not found."); + + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task DeleteTenant_WhenTenantHasUsers_ShouldReturnBadRequest() + { + // Act + var existingTenantId = DatabaseSeeder.Tenant1.Id; + var response = await TestHttpClient.DeleteAsync($"/api/account-management/tenants/{existingTenantId}"); + TelemetryEventsCollectorSpy.Reset(); + + // Assert + var expectedErrors = new[] + { + new ErrorDetail("Id", "All users must be deleted before the tenant can be deleted.") + }; + await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); + + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task DeleteTenant_WhenTenantHasNoUsers_ShouldDeleteTenant() + { + // Arrange + var existingTenantId = DatabaseSeeder.Tenant1.Id; + var existingUserId = DatabaseSeeder.User1.Id; + await TestHttpClient.DeleteAsync($"/api/account-management/users/{existingUserId}"); + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await TestHttpClient.DeleteAsync($"/api/account-management/tenants/{existingTenantId}"); + + // Assert + EnsureSuccessWithEmptyHeaderAndLocation(response); + Connection.RowExists("Tenants", existingTenantId).Should().BeFalse(); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents.Count(e => e.Name == "TenantDeleted").Should().Be(1); + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue(); + } +} diff --git a/application/account-management/Tests/Api/Users/UserEndpointsTests.cs b/application/account-management/Tests/Api/Users/UserEndpointsTests.cs index bd7517b0d..3384814fe 100644 --- a/application/account-management/Tests/Api/Users/UserEndpointsTests.cs +++ b/application/account-management/Tests/Api/Users/UserEndpointsTests.cs @@ -1,304 +1,304 @@ -using System.Net; -using System.Net.Http.Json; -using FluentAssertions; -using NJsonSchema; -using PlatformPlatform.AccountManagement.Application.Users; -using PlatformPlatform.AccountManagement.Infrastructure; -using PlatformPlatform.SharedKernel.ApplicationCore.Validation; -using Xunit; - -namespace PlatformPlatform.AccountManagement.Tests.Api.Users; - -public sealed class UserEndpointsTests : BaseApiTests -{ - [Fact] - public async Task GetUser_WhenUserExists_ShouldReturnUserWithValidContract() - { - // Arrange - var existingUserId = DatabaseSeeder.User1.Id; - - // Act - var response = await TestHttpClient.GetAsync($"/api/account-management/users/{existingUserId}"); - - // Assert - EnsureSuccessGetRequest(response); - - var schema = await JsonSchema.FromJsonAsync( - """ - { - 'type': 'object', - 'properties': { - 'id': {'type': 'long'}, - 'createdAt': {'type': 'string', 'format': 'date-time'}, - 'modifiedAt': {'type': ['null', 'string'], 'format': 'date-time'}, - 'email': {'type': 'string', 'maxLength': 100}, - 'firstName': {'type': ['null', 'string'], 'maxLength': 30}, - 'lastName': {'type': ['null', 'string'], 'maxLength': 30}, - 'userRole': {'type': 'string', 'minLength': 1, 'maxLength': 20}, - 'emailConfirmed': {'type': 'boolean'}, - 'avatarUrl': {'type': ['null', 'string'], 'maxLength': 100}, - }, - 'required': ['id', 'createdAt', 'modifiedAt', 'email', 'userRole'], - 'additionalProperties': false - } - """ - ); - - var responseBody = await response.Content.ReadAsStringAsync(); - schema.Validate(responseBody).Should().BeEmpty(); - } - - [Fact] - public async Task GetUser_WhenUserDoesNotExist_ShouldReturnNotFound() - { - // Arrange - var unknownUserId = UserId.NewId(); - - // Act - var response = await TestHttpClient.GetAsync($"/api/account-management/users/{unknownUserId}"); - - // Assert - await EnsureErrorStatusCode(response, HttpStatusCode.NotFound, $"User with id '{unknownUserId}' not found."); - } - - [Fact] - public async Task GetUser_WhenInvalidUserId_ShouldReturnBadRequest() - { - // Arrange - var invalidUserId = Faker.Random.AlphaNumeric(31); - - // Act - var response = await TestHttpClient.GetAsync($"/api/account-management/users/{invalidUserId}"); - - // Assert - await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, $"""Failed to bind parameter "UserId Id" from "{invalidUserId}"."""); - } - - [Fact] - public async Task GetUsers_WhenSearchingBasedOnUserEmail_ShouldReturnUser() - { - // Arrange - var searchString = "willgate"; - - // Act - var response = await TestHttpClient.GetAsync($"/api/account-management/users?search={searchString}"); - - // Assert - EnsureSuccessGetRequest(response); - var userResponse = await DeserializeResponse(response); - userResponse.Should().NotBeNull(); - userResponse!.TotalCount.Should().Be(1); - userResponse.Users.First().Email.Should().Be(DatabaseSeeder.User1ForSearching.Email); - } - - [Fact] - public async Task GetUsers_WhenSearchingBasedOnUserFirstName_ShouldReturnUser() - { - // Arrange - var searchString = "Will"; - - // Act - var response = await TestHttpClient.GetAsync($"/api/account-management/users?search={searchString}"); - - // Assert - EnsureSuccessGetRequest(response); - var userResponse = await DeserializeResponse(response); - userResponse.Should().NotBeNull(); - userResponse!.TotalCount.Should().Be(1); - userResponse.Users.First().FirstName.Should().Be(DatabaseSeeder.User1ForSearching.FirstName); - } - - [Fact] - public async Task GetUsers_WhenSearchingBasedOnFullName_ShouldReturnUser() - { - // Arrange - var searchString = "William Henry Gates"; - - // Act - var response = await TestHttpClient.GetAsync($"/api/account-management/users?search={searchString}"); - - // Assert - EnsureSuccessGetRequest(response); - var userResponse = await DeserializeResponse(response); - userResponse.Should().NotBeNull(); - userResponse!.TotalCount.Should().Be(1); - userResponse.Users.First().LastName.Should().Be(DatabaseSeeder.User1ForSearching.LastName); - } - - [Fact] - public async Task GetUsers_WhenSearchingBasedOnUserRole_ShouldReturnUser() - { - // Arrange - // Act - var response = await TestHttpClient.GetAsync($"/api/account-management/users?userRole={UserRole.TenantUser}"); - - // Assert - EnsureSuccessGetRequest(response); - var userResponse = await DeserializeResponse(response); - userResponse.Should().NotBeNull(); - userResponse!.TotalCount.Should().Be(1); - userResponse.Users.First().UserRole.Should().Be(DatabaseSeeder.User1ForSearching.UserRole); - } - - [Fact] - public async Task GetUsers_WhenSearchingWithSpecificOrdering_ShouldReturnOrderedUsers() - { - // Act - var response = await TestHttpClient.GetAsync($"/api/account-management/users?orderBy={SortableUserProperties.UserRole}"); - - // Assert - EnsureSuccessGetRequest(response); - var userResponse = await DeserializeResponse(response); - userResponse.Should().NotBeNull(); - userResponse!.TotalCount.Should().Be(3); - userResponse.Users.First().UserRole.Should().Be(UserRole.TenantOwner); - userResponse.Users.Last().UserRole.Should().Be(UserRole.TenantUser); - } - - [Fact] - public async Task CreateUser_WhenValid_ShouldCreateUser() - { - // Arrange - var existingTenantId = DatabaseSeeder.Tenant1.Id; - var command = new CreateUserCommand(existingTenantId, Faker.Internet.Email(), UserRole.TenantUser, false); - - // Act - var response = await TestHttpClient.PostAsJsonAsync("/api/account-management/users", command); - - // Assert - await EnsureSuccessPostRequest(response, startsWith: "/api/account-management/users/"); - response.Headers.Location!.ToString().Length.Should().Be($"/api/account-management/users/{UserId.NewId()}".Length); - } - - [Fact] - public async Task CreateUser_WhenInvalidEmail_ShouldReturnBadRequest() - { - // Arrange - var existingTenantId = DatabaseSeeder.Tenant1.Id; - var invalidEmail = Faker.InvalidEmail(); - var command = new CreateUserCommand(existingTenantId, invalidEmail, UserRole.TenantUser, false); - - // Act - var response = await TestHttpClient.PostAsJsonAsync("/api/account-management/users", command); - - // Assert - var expectedErrors = new[] - { - new ErrorDetail("Email", "Email must be in a valid format and no longer than 100 characters.") - }; - await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); - } - - [Fact] - public async Task CreateUser_WhenUserExists_ShouldReturnBadRequest() - { - // Arrange - var existingTenantId = DatabaseSeeder.Tenant1.Id; - var existingUserEmail = DatabaseSeeder.User1.Email; - var command = new CreateUserCommand(existingTenantId, existingUserEmail, UserRole.TenantUser, false); - - // Act - var response = await TestHttpClient.PostAsJsonAsync("/api/account-management/users", command); - - // Assert - var expectedErrors = new[] - { - new ErrorDetail("Email", $"The email '{existingUserEmail}' is already in use by another user on this tenant.") - }; - await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); - } - - [Fact] - public async Task CreateUser_WhenTenantDoesNotExists_ShouldReturnBadRequest() - { - // Arrange - var unknownTenantId = Faker.Subdomain(); - var command = new CreateUserCommand( - new TenantId(unknownTenantId), Faker.Internet.Email(), UserRole.TenantUser, false - ); - - // Act - var response = await TestHttpClient.PostAsJsonAsync("/api/account-management/users", command); - - // Assert - var expectedErrors = new[] - { - new ErrorDetail("TenantId", $"The tenant '{unknownTenantId}' does not exist.") - }; - await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); - } - - [Fact] - public async Task UpdateUser_WhenValid_ShouldUpdateUser() - { - // Arrange - var existingUserId = DatabaseSeeder.User1.Id; - var command = new UpdateUserCommand { Email = Faker.Internet.Email(), UserRole = UserRole.TenantOwner }; - - // Act - var response = await TestHttpClient.PutAsJsonAsync($"/api/account-management/users/{existingUserId}", command); - - // Assert - EnsureSuccessWithEmptyHeaderAndLocation(response); - } - - [Fact] - public async Task UpdateUser_WhenInvalid_ShouldReturnBadRequest() - { - // Arrange - var existingUserId = DatabaseSeeder.User1.Id; - var invalidEmail = Faker.InvalidEmail(); - var command = new UpdateUserCommand { Email = invalidEmail, UserRole = UserRole.TenantAdmin }; - - // Act - var response = await TestHttpClient.PutAsJsonAsync($"/api/account-management/users/{existingUserId}", command); - - // Assert - var expectedErrors = new[] - { - new ErrorDetail("Email", "Email must be in a valid format and no longer than 100 characters.") - }; - await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); - } - - [Fact] - public async Task UpdateUser_WhenUserDoesNotExists_ShouldReturnNotFound() - { - // Arrange - var unknownUserId = UserId.NewId(); - var command = new UpdateUserCommand { Email = Faker.Internet.Email(), UserRole = UserRole.TenantAdmin }; - - // Act - var response = await TestHttpClient.PutAsJsonAsync($"/api/account-management/users/{unknownUserId}", command); - - //Assert - await EnsureErrorStatusCode(response, HttpStatusCode.NotFound, $"User with id '{unknownUserId}' not found."); - } - - [Fact] - public async Task DeleteUser_WhenUserDoesNotExists_ShouldReturnNotFound() - { - // Arrange - var unknownUserId = UserId.NewId(); - - // Act - var response = await TestHttpClient.DeleteAsync($"/api/account-management/users/{unknownUserId}"); - - //Assert - await EnsureErrorStatusCode(response, HttpStatusCode.NotFound, $"User with id '{unknownUserId}' not found."); - } - - [Fact] - public async Task DeleteUser_WhenUserExists_ShouldDeleteUser() - { - // Arrange - var existingUserId = DatabaseSeeder.User1.Id; - - // Act - var response = await TestHttpClient.DeleteAsync($"/api/account-management/users/{existingUserId}"); - - // Assert - EnsureSuccessWithEmptyHeaderAndLocation(response); - Connection.RowExists("Users", existingUserId.ToString()).Should().BeFalse(); - } -} +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using NJsonSchema; +using PlatformPlatform.AccountManagement.Application.Users; +using PlatformPlatform.AccountManagement.Infrastructure; +using PlatformPlatform.SharedKernel.ApplicationCore.Validation; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.Api.Users; + +public sealed class UserEndpointsTests : BaseApiTests +{ + [Fact] + public async Task GetUser_WhenUserExists_ShouldReturnUserWithValidContract() + { + // Arrange + var existingUserId = DatabaseSeeder.User1.Id; + + // Act + var response = await TestHttpClient.GetAsync($"/api/account-management/users/{existingUserId}"); + + // Assert + EnsureSuccessGetRequest(response); + + var schema = await JsonSchema.FromJsonAsync( + """ + { + 'type': 'object', + 'properties': { + 'id': {'type': 'long'}, + 'createdAt': {'type': 'string', 'format': 'date-time'}, + 'modifiedAt': {'type': ['null', 'string'], 'format': 'date-time'}, + 'email': {'type': 'string', 'maxLength': 100}, + 'firstName': {'type': ['null', 'string'], 'maxLength': 30}, + 'lastName': {'type': ['null', 'string'], 'maxLength': 30}, + 'userRole': {'type': 'string', 'minLength': 1, 'maxLength': 20}, + 'emailConfirmed': {'type': 'boolean'}, + 'avatarUrl': {'type': ['null', 'string'], 'maxLength': 100}, + }, + 'required': ['id', 'createdAt', 'modifiedAt', 'email', 'userRole'], + 'additionalProperties': false + } + """ + ); + + var responseBody = await response.Content.ReadAsStringAsync(); + schema.Validate(responseBody).Should().BeEmpty(); + } + + [Fact] + public async Task GetUser_WhenUserDoesNotExist_ShouldReturnNotFound() + { + // Arrange + var unknownUserId = UserId.NewId(); + + // Act + var response = await TestHttpClient.GetAsync($"/api/account-management/users/{unknownUserId}"); + + // Assert + await EnsureErrorStatusCode(response, HttpStatusCode.NotFound, $"User with id '{unknownUserId}' not found."); + } + + [Fact] + public async Task GetUser_WhenInvalidUserId_ShouldReturnBadRequest() + { + // Arrange + var invalidUserId = Faker.Random.AlphaNumeric(31); + + // Act + var response = await TestHttpClient.GetAsync($"/api/account-management/users/{invalidUserId}"); + + // Assert + await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, $"""Failed to bind parameter "UserId Id" from "{invalidUserId}"."""); + } + + [Fact] + public async Task GetUsers_WhenSearchingBasedOnUserEmail_ShouldReturnUser() + { + // Arrange + var searchString = "willgate"; + + // Act + var response = await TestHttpClient.GetAsync($"/api/account-management/users?search={searchString}"); + + // Assert + EnsureSuccessGetRequest(response); + var userResponse = await DeserializeResponse(response); + userResponse.Should().NotBeNull(); + userResponse!.TotalCount.Should().Be(1); + userResponse.Users.First().Email.Should().Be(DatabaseSeeder.User1ForSearching.Email); + } + + [Fact] + public async Task GetUsers_WhenSearchingBasedOnUserFirstName_ShouldReturnUser() + { + // Arrange + var searchString = "Will"; + + // Act + var response = await TestHttpClient.GetAsync($"/api/account-management/users?search={searchString}"); + + // Assert + EnsureSuccessGetRequest(response); + var userResponse = await DeserializeResponse(response); + userResponse.Should().NotBeNull(); + userResponse!.TotalCount.Should().Be(1); + userResponse.Users.First().FirstName.Should().Be(DatabaseSeeder.User1ForSearching.FirstName); + } + + [Fact] + public async Task GetUsers_WhenSearchingBasedOnFullName_ShouldReturnUser() + { + // Arrange + var searchString = "William Henry Gates"; + + // Act + var response = await TestHttpClient.GetAsync($"/api/account-management/users?search={searchString}"); + + // Assert + EnsureSuccessGetRequest(response); + var userResponse = await DeserializeResponse(response); + userResponse.Should().NotBeNull(); + userResponse!.TotalCount.Should().Be(1); + userResponse.Users.First().LastName.Should().Be(DatabaseSeeder.User1ForSearching.LastName); + } + + [Fact] + public async Task GetUsers_WhenSearchingBasedOnUserRole_ShouldReturnUser() + { + // Arrange + // Act + var response = await TestHttpClient.GetAsync($"/api/account-management/users?userRole={UserRole.TenantUser}"); + + // Assert + EnsureSuccessGetRequest(response); + var userResponse = await DeserializeResponse(response); + userResponse.Should().NotBeNull(); + userResponse!.TotalCount.Should().Be(1); + userResponse.Users.First().UserRole.Should().Be(DatabaseSeeder.User1ForSearching.UserRole); + } + + [Fact] + public async Task GetUsers_WhenSearchingWithSpecificOrdering_ShouldReturnOrderedUsers() + { + // Act + var response = await TestHttpClient.GetAsync($"/api/account-management/users?orderBy={SortableUserProperties.UserRole}"); + + // Assert + EnsureSuccessGetRequest(response); + var userResponse = await DeserializeResponse(response); + userResponse.Should().NotBeNull(); + userResponse!.TotalCount.Should().Be(3); + userResponse.Users.First().UserRole.Should().Be(UserRole.TenantOwner); + userResponse.Users.Last().UserRole.Should().Be(UserRole.TenantUser); + } + + [Fact] + public async Task CreateUser_WhenValid_ShouldCreateUser() + { + // Arrange + var existingTenantId = DatabaseSeeder.Tenant1.Id; + var command = new CreateUserCommand(existingTenantId, Faker.Internet.Email(), UserRole.TenantUser, false); + + // Act + var response = await TestHttpClient.PostAsJsonAsync("/api/account-management/users", command); + + // Assert + await EnsureSuccessPostRequest(response, startsWith: "/api/account-management/users/"); + response.Headers.Location!.ToString().Length.Should().Be($"/api/account-management/users/{UserId.NewId()}".Length); + } + + [Fact] + public async Task CreateUser_WhenInvalidEmail_ShouldReturnBadRequest() + { + // Arrange + var existingTenantId = DatabaseSeeder.Tenant1.Id; + var invalidEmail = Faker.InvalidEmail(); + var command = new CreateUserCommand(existingTenantId, invalidEmail, UserRole.TenantUser, false); + + // Act + var response = await TestHttpClient.PostAsJsonAsync("/api/account-management/users", command); + + // Assert + var expectedErrors = new[] + { + new ErrorDetail("Email", "Email must be in a valid format and no longer than 100 characters.") + }; + await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); + } + + [Fact] + public async Task CreateUser_WhenUserExists_ShouldReturnBadRequest() + { + // Arrange + var existingTenantId = DatabaseSeeder.Tenant1.Id; + var existingUserEmail = DatabaseSeeder.User1.Email; + var command = new CreateUserCommand(existingTenantId, existingUserEmail, UserRole.TenantUser, false); + + // Act + var response = await TestHttpClient.PostAsJsonAsync("/api/account-management/users", command); + + // Assert + var expectedErrors = new[] + { + new ErrorDetail("Email", $"The email '{existingUserEmail}' is already in use by another user on this tenant.") + }; + await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); + } + + [Fact] + public async Task CreateUser_WhenTenantDoesNotExists_ShouldReturnBadRequest() + { + // Arrange + var unknownTenantId = Faker.Subdomain(); + var command = new CreateUserCommand( + new TenantId(unknownTenantId), Faker.Internet.Email(), UserRole.TenantUser, false + ); + + // Act + var response = await TestHttpClient.PostAsJsonAsync("/api/account-management/users", command); + + // Assert + var expectedErrors = new[] + { + new ErrorDetail("TenantId", $"The tenant '{unknownTenantId}' does not exist.") + }; + await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); + } + + [Fact] + public async Task UpdateUser_WhenValid_ShouldUpdateUser() + { + // Arrange + var existingUserId = DatabaseSeeder.User1.Id; + var command = new UpdateUserCommand { Email = Faker.Internet.Email(), UserRole = UserRole.TenantOwner }; + + // Act + var response = await TestHttpClient.PutAsJsonAsync($"/api/account-management/users/{existingUserId}", command); + + // Assert + EnsureSuccessWithEmptyHeaderAndLocation(response); + } + + [Fact] + public async Task UpdateUser_WhenInvalid_ShouldReturnBadRequest() + { + // Arrange + var existingUserId = DatabaseSeeder.User1.Id; + var invalidEmail = Faker.InvalidEmail(); + var command = new UpdateUserCommand { Email = invalidEmail, UserRole = UserRole.TenantAdmin }; + + // Act + var response = await TestHttpClient.PutAsJsonAsync($"/api/account-management/users/{existingUserId}", command); + + // Assert + var expectedErrors = new[] + { + new ErrorDetail("Email", "Email must be in a valid format and no longer than 100 characters.") + }; + await EnsureErrorStatusCode(response, HttpStatusCode.BadRequest, expectedErrors); + } + + [Fact] + public async Task UpdateUser_WhenUserDoesNotExists_ShouldReturnNotFound() + { + // Arrange + var unknownUserId = UserId.NewId(); + var command = new UpdateUserCommand { Email = Faker.Internet.Email(), UserRole = UserRole.TenantAdmin }; + + // Act + var response = await TestHttpClient.PutAsJsonAsync($"/api/account-management/users/{unknownUserId}", command); + + //Assert + await EnsureErrorStatusCode(response, HttpStatusCode.NotFound, $"User with id '{unknownUserId}' not found."); + } + + [Fact] + public async Task DeleteUser_WhenUserDoesNotExists_ShouldReturnNotFound() + { + // Arrange + var unknownUserId = UserId.NewId(); + + // Act + var response = await TestHttpClient.DeleteAsync($"/api/account-management/users/{unknownUserId}"); + + //Assert + await EnsureErrorStatusCode(response, HttpStatusCode.NotFound, $"User with id '{unknownUserId}' not found."); + } + + [Fact] + public async Task DeleteUser_WhenUserExists_ShouldDeleteUser() + { + // Arrange + var existingUserId = DatabaseSeeder.User1.Id; + + // Act + var response = await TestHttpClient.DeleteAsync($"/api/account-management/users/{existingUserId}"); + + // Assert + EnsureSuccessWithEmptyHeaderAndLocation(response); + Connection.RowExists("Users", existingUserId.ToString()).Should().BeFalse(); + } +} diff --git a/application/account-management/Tests/Application/AccountRegistrations/AccountRegistrationTests.cs b/application/account-management/Tests/Application/AccountRegistrations/AccountRegistrationTests.cs index d80d1bb53..f7f309cb9 100644 --- a/application/account-management/Tests/Application/AccountRegistrations/AccountRegistrationTests.cs +++ b/application/account-management/Tests/Application/AccountRegistrations/AccountRegistrationTests.cs @@ -1,91 +1,91 @@ -using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; -using PlatformPlatform.AccountManagement.Application.AccountRegistrations; -using PlatformPlatform.AccountManagement.Application.Tenants; -using PlatformPlatform.AccountManagement.Infrastructure; -using Xunit; - -namespace PlatformPlatform.AccountManagement.Tests.Application.AccountRegistrations; - -public sealed class AccountRegistrationTests : BaseTest -{ - [Fact] - public async Task StartAccountRegistration_WhenValidCommand_ShouldReturnSuccessfulResult() - { - // Arrange - var subdomain = Faker.Subdomain(); - var email = Faker.Internet.Email(); - var command = new StartAccountRegistrationCommand(subdomain, email); - var mediator = Provider.GetRequiredService(); - - // Act - var result = await mediator.Send(command); - - // Assert - result.IsSuccess.Should().BeTrue(); - result.Errors.Should().BeNull(); - - TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); - TelemetryEventsCollectorSpy.CollectedEvents.Count(e => - e.Name == "AccountRegistrationStarted" && - e.Properties["Event_TenantId"] == subdomain - ).Should().Be(1); - - await EmailService.Received().SendAsync(email.ToLower(), "Confirm your email address", Arg.Any(), CancellationToken.None); - } - - [Fact] - public async Task StartAccountRegistration_WhenInvalidEmail_ShouldFail() - { - // Arrange - var subdomain = Faker.Subdomain(); - var command = new StartAccountRegistrationCommand(subdomain, "invalid email"); - var mediator = Provider.GetRequiredService(); - - // Act - var result = await mediator.Send(command); - - // Assert - result.IsSuccess.Should().BeFalse(); - result.Errors?.Length.Should().Be(1); - } - - [Theory] - [InlineData("Subdomain empty", "")] - [InlineData("Subdomain too short", "ab")] - [InlineData("Subdomain too long", "1234567890123456789012345678901")] - [InlineData("Subdomain with uppercase", "Tenant2")] - [InlineData("Subdomain special characters", "tenant-2")] - [InlineData("Subdomain with spaces", "tenant 2")] - public async Task StartAccountRegistration_WhenInvalidSubDomain_ShouldFail(string scenario, string subdomain) - { - // Arrange - var email = Faker.Internet.Email(); - var command = new StartAccountRegistrationCommand(subdomain, email); - var mediator = Provider.GetRequiredService(); - - // Act - var result = await mediator.Send(command); - - // Assert - result.IsSuccess.Should().BeFalse(scenario); - result.Errors?.Length.Should().Be(1, scenario); - } - - [Fact] - public async Task CompleteAccountRegistrationTests_WhenSucceded_ShouldLogCorrectInformation() - { - // Arrange - var mockLogger = Substitute.For>(); - Services.AddSingleton(mockLogger); - var mediator = Provider.GetRequiredService(); - - // Act - var command = new CompleteAccountRegistrationCommand(DatabaseSeeder.OneTimePassword); - _ = await mediator.Send(command with { Id = DatabaseSeeder.AccountRegistration1.Id }); - - // Assert - mockLogger.Received().LogInformation("Raise event to send Welcome mail to tenant."); - } -} +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using PlatformPlatform.AccountManagement.Application.AccountRegistrations; +using PlatformPlatform.AccountManagement.Application.Tenants; +using PlatformPlatform.AccountManagement.Infrastructure; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.Application.AccountRegistrations; + +public sealed class AccountRegistrationTests : BaseTest +{ + [Fact] + public async Task StartAccountRegistration_WhenValidCommand_ShouldReturnSuccessfulResult() + { + // Arrange + var subdomain = Faker.Subdomain(); + var email = Faker.Internet.Email(); + var command = new StartAccountRegistrationCommand(subdomain, email); + var mediator = Provider.GetRequiredService(); + + // Act + var result = await mediator.Send(command); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Errors.Should().BeNull(); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents.Count(e => + e.Name == "AccountRegistrationStarted" && + e.Properties["Event_TenantId"] == subdomain + ).Should().Be(1); + + await EmailService.Received().SendAsync(email.ToLower(), "Confirm your email address", Arg.Any(), CancellationToken.None); + } + + [Fact] + public async Task StartAccountRegistration_WhenInvalidEmail_ShouldFail() + { + // Arrange + var subdomain = Faker.Subdomain(); + var command = new StartAccountRegistrationCommand(subdomain, "invalid email"); + var mediator = Provider.GetRequiredService(); + + // Act + var result = await mediator.Send(command); + + // Assert + result.IsSuccess.Should().BeFalse(); + result.Errors?.Length.Should().Be(1); + } + + [Theory] + [InlineData("Subdomain empty", "")] + [InlineData("Subdomain too short", "ab")] + [InlineData("Subdomain too long", "1234567890123456789012345678901")] + [InlineData("Subdomain with uppercase", "Tenant2")] + [InlineData("Subdomain special characters", "tenant-2")] + [InlineData("Subdomain with spaces", "tenant 2")] + public async Task StartAccountRegistration_WhenInvalidSubDomain_ShouldFail(string scenario, string subdomain) + { + // Arrange + var email = Faker.Internet.Email(); + var command = new StartAccountRegistrationCommand(subdomain, email); + var mediator = Provider.GetRequiredService(); + + // Act + var result = await mediator.Send(command); + + // Assert + result.IsSuccess.Should().BeFalse(scenario); + result.Errors?.Length.Should().Be(1, scenario); + } + + [Fact] + public async Task CompleteAccountRegistrationTests_WhenSucceded_ShouldLogCorrectInformation() + { + // Arrange + var mockLogger = Substitute.For>(); + Services.AddSingleton(mockLogger); + var mediator = Provider.GetRequiredService(); + + // Act + var command = new CompleteAccountRegistrationCommand(DatabaseSeeder.OneTimePassword); + _ = await mediator.Send(command with { Id = DatabaseSeeder.AccountRegistration1.Id }); + + // Assert + mockLogger.Received().LogInformation("Raise event to send Welcome mail to tenant."); + } +} diff --git a/application/account-management/Tests/ArchitectureTests/IdPrefixForAllStronglyTypedUlidTests.cs b/application/account-management/Tests/ArchitectureTests/IdPrefixForAllStronglyTypedUlidTests.cs index 1183556bf..a125eae46 100644 --- a/application/account-management/Tests/ArchitectureTests/IdPrefixForAllStronglyTypedUlidTests.cs +++ b/application/account-management/Tests/ArchitectureTests/IdPrefixForAllStronglyTypedUlidTests.cs @@ -1,44 +1,44 @@ -using FluentAssertions; -using NetArchTest.Rules; -using PlatformPlatform.AccountManagement.Domain; -using PlatformPlatform.SharedKernel.DomainCore.Identity; -using Xunit; - -namespace PlatformPlatform.AccountManagement.Tests.ArchitectureTests; - -public class IdPrefixForAllStronglyTypedUlidTests -{ - [Fact] - public void StronglyTypedUlidsInDomain_ShouldHaveIdPrefixAttribute() - { - // Act - var result = Types - .InAssembly(DomainConfiguration.Assembly) - .That().Inherit(typeof(StronglyTypedUlid<>)) - .Should().HaveCustomAttribute(typeof(IdPrefixAttribute)) - .GetResult(); - - // Assert - var idsWithoutPrefix = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); - result.IsSuccessful.Should().BeTrue($"The following strongly typed IDs does not have an IdPrefixAttribute: {idsWithoutPrefix}"); - } - - [Fact] - public void StronglyTypedUlidsInDomain_ShouldHaveValidIdPrefix() - { - // Arrange - var stronglyTypedUlidIds = Types - .InAssembly(DomainConfiguration.Assembly) - .That().Inherit(typeof(StronglyTypedUlid<>)) - .GetTypes(); - - // Assert - foreach (var stronglyTypedId in stronglyTypedUlidIds) - { - var newId = stronglyTypedId.BaseType?.GetMethod("NewId")?.Invoke(null, null); - - // Ids must follow the pattern: {prefix}_{ULID} where prefix is lowercase and ULID is uppercase - newId?.ToString().Should().MatchRegex("^[a-z0-9]+_[A-Z0-9]{26}$"); - } - } -} +using FluentAssertions; +using NetArchTest.Rules; +using PlatformPlatform.AccountManagement.Domain; +using PlatformPlatform.SharedKernel.DomainCore.Identity; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.ArchitectureTests; + +public class IdPrefixForAllStronglyTypedUlidTests +{ + [Fact] + public void StronglyTypedUlidsInDomain_ShouldHaveIdPrefixAttribute() + { + // Act + var result = Types + .InAssembly(DomainConfiguration.Assembly) + .That().Inherit(typeof(StronglyTypedUlid<>)) + .Should().HaveCustomAttribute(typeof(IdPrefixAttribute)) + .GetResult(); + + // Assert + var idsWithoutPrefix = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); + result.IsSuccessful.Should().BeTrue($"The following strongly typed IDs does not have an IdPrefixAttribute: {idsWithoutPrefix}"); + } + + [Fact] + public void StronglyTypedUlidsInDomain_ShouldHaveValidIdPrefix() + { + // Arrange + var stronglyTypedUlidIds = Types + .InAssembly(DomainConfiguration.Assembly) + .That().Inherit(typeof(StronglyTypedUlid<>)) + .GetTypes(); + + // Assert + foreach (var stronglyTypedId in stronglyTypedUlidIds) + { + var newId = stronglyTypedId.BaseType?.GetMethod("NewId")?.Invoke(null, null); + + // Ids must follow the pattern: {prefix}_{ULID} where prefix is lowercase and ULID is uppercase + newId?.ToString().Should().MatchRegex("^[a-z0-9]+_[A-Z0-9]{26}$"); + } + } +} diff --git a/application/account-management/Tests/ArchitectureTests/PublicClassesTests.cs b/application/account-management/Tests/ArchitectureTests/PublicClassesTests.cs index dc6aae7e4..343ec060c 100644 --- a/application/account-management/Tests/ArchitectureTests/PublicClassesTests.cs +++ b/application/account-management/Tests/ArchitectureTests/PublicClassesTests.cs @@ -1,65 +1,65 @@ -using FluentAssertions; -using NetArchTest.Rules; -using PlatformPlatform.AccountManagement.Application; -using PlatformPlatform.AccountManagement.Domain; -using PlatformPlatform.AccountManagement.Infrastructure; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using Xunit; - -namespace PlatformPlatform.AccountManagement.Tests.ArchitectureTests; - -public sealed class PublicClassesTests -{ - [Fact] - public void PublicClassesInDomain_ShouldBeSealed() - { - // Act - var result = Types - .InAssembly(DomainConfiguration.Assembly) - .That().ArePublic() - .And().AreNotAbstract() - .Should().BeSealed() - .GetResult(); - - // Assert - var nonSealedTypes = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); - result.IsSuccessful.Should().BeTrue($"The following are not sealed: {nonSealedTypes}"); - } - - [Fact] - public void PublicClassesInApplication_ShouldBeSealed() - { - // Act - var types = Types - .InAssembly(ApplicationConfiguration.Assembly) - .That().ArePublic() - .And().AreNotAbstract() - .And().DoNotHaveName(typeof(Result<>).Name); - - var result = types - .Should().BeSealed() - .GetResult(); - - // Assert - var nonSealedTypes = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); - result.IsSuccessful.Should().BeTrue($"The following are not sealed: {nonSealedTypes}"); - } - - [Fact] - public void PublicClassesInInfrastructure_ShouldBeSealed() - { - // Act - var types = Types - .InAssembly(InfrastructureConfiguration.Assembly) - .That().ArePublic() - .And().AreNotAbstract(); - - var result = types - .Should().BeSealed() - .GetResult(); - - // Assert - var nonSealedTypes = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); - result.IsSuccessful.Should().BeTrue($"The following are not sealed: {nonSealedTypes}"); - } -} +using FluentAssertions; +using NetArchTest.Rules; +using PlatformPlatform.AccountManagement.Application; +using PlatformPlatform.AccountManagement.Domain; +using PlatformPlatform.AccountManagement.Infrastructure; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.ArchitectureTests; + +public sealed class PublicClassesTests +{ + [Fact] + public void PublicClassesInDomain_ShouldBeSealed() + { + // Act + var result = Types + .InAssembly(DomainConfiguration.Assembly) + .That().ArePublic() + .And().AreNotAbstract() + .Should().BeSealed() + .GetResult(); + + // Assert + var nonSealedTypes = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); + result.IsSuccessful.Should().BeTrue($"The following are not sealed: {nonSealedTypes}"); + } + + [Fact] + public void PublicClassesInApplication_ShouldBeSealed() + { + // Act + var types = Types + .InAssembly(ApplicationConfiguration.Assembly) + .That().ArePublic() + .And().AreNotAbstract() + .And().DoNotHaveName(typeof(Result<>).Name); + + var result = types + .Should().BeSealed() + .GetResult(); + + // Assert + var nonSealedTypes = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); + result.IsSuccessful.Should().BeTrue($"The following are not sealed: {nonSealedTypes}"); + } + + [Fact] + public void PublicClassesInInfrastructure_ShouldBeSealed() + { + // Act + var types = Types + .InAssembly(InfrastructureConfiguration.Assembly) + .That().ArePublic() + .And().AreNotAbstract(); + + var result = types + .Should().BeSealed() + .GetResult(); + + // Assert + var nonSealedTypes = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); + result.IsSuccessful.Should().BeTrue($"The following are not sealed: {nonSealedTypes}"); + } +} diff --git a/application/account-management/Tests/BaseTest.cs b/application/account-management/Tests/BaseTest.cs index 3a6949156..0bd53b499 100644 --- a/application/account-management/Tests/BaseTest.cs +++ b/application/account-management/Tests/BaseTest.cs @@ -1,93 +1,93 @@ -using System.Text.Json; -using Bogus; -using Microsoft.ApplicationInsights; -using Microsoft.ApplicationInsights.Channel; -using Microsoft.ApplicationInsights.Extensibility; -using Microsoft.AspNetCore.Http.Json; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using NSubstitute; -using PlatformPlatform.AccountManagement.Application; -using PlatformPlatform.AccountManagement.Infrastructure; -using PlatformPlatform.SharedKernel.ApplicationCore.Services; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; -using PlatformPlatform.SharedKernel.Tests.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.AccountManagement.Tests; - -public abstract class BaseTest : IDisposable where TContext : DbContext -{ - protected readonly IEmailService EmailService; - protected readonly Faker Faker = new(); - protected readonly JsonSerializerOptions JsonSerializerOptions; - protected readonly ServiceCollection Services; - private ServiceProvider? _provider; - protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy; - - protected BaseTest() - { - Environment.SetEnvironmentVariable( - "APPLICATIONINSIGHTS_CONNECTION_STRING", - "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost" - ); - - Services = new ServiceCollection(); - - Services.AddLogging(); - Services.AddTransient(); - - // Create connection and add DbContext to the service collection - Connection = new SqliteConnection("DataSource=:memory:"); - Connection.Open(); - Services.AddDbContext(options => { options.UseSqlite(Connection); }); - - Services - .AddApplicationServices() - .AddInfrastructureServices(); - - TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); - Services.AddScoped(_ => TelemetryEventsCollectorSpy); - - EmailService = Substitute.For(); - Services.AddScoped(_ => EmailService); - - var telemetryChannel = Substitute.For(); - Services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = telemetryChannel })); - - // Make sure database is created - using var serviceScope = Services.BuildServiceProvider().CreateScope(); - serviceScope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); - DatabaseSeeder = serviceScope.ServiceProvider.GetRequiredService(); - - JsonSerializerOptions = serviceScope.ServiceProvider.GetRequiredService>().Value.SerializerOptions; - } - - protected SqliteConnection Connection { get; } - - protected DatabaseSeeder DatabaseSeeder { get; } - - protected ServiceProvider Provider - { - get - { - // ServiceProvider is created on first access to allow Tests to configure services in the constructor - // before the ServiceProvider is created - return _provider ??= Services.BuildServiceProvider(); - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!disposing) return; - Provider.Dispose(); - Connection.Close(); - } -} +using System.Text.Json; +using Bogus; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NSubstitute; +using PlatformPlatform.AccountManagement.Application; +using PlatformPlatform.AccountManagement.Infrastructure; +using PlatformPlatform.SharedKernel.ApplicationCore.Services; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; +using PlatformPlatform.SharedKernel.Tests.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.AccountManagement.Tests; + +public abstract class BaseTest : IDisposable where TContext : DbContext +{ + protected readonly IEmailService EmailService; + protected readonly Faker Faker = new(); + protected readonly JsonSerializerOptions JsonSerializerOptions; + protected readonly ServiceCollection Services; + private ServiceProvider? _provider; + protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy; + + protected BaseTest() + { + Environment.SetEnvironmentVariable( + "APPLICATIONINSIGHTS_CONNECTION_STRING", + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost" + ); + + Services = new ServiceCollection(); + + Services.AddLogging(); + Services.AddTransient(); + + // Create connection and add DbContext to the service collection + Connection = new SqliteConnection("DataSource=:memory:"); + Connection.Open(); + Services.AddDbContext(options => { options.UseSqlite(Connection); }); + + Services + .AddApplicationServices() + .AddInfrastructureServices(); + + TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); + Services.AddScoped(_ => TelemetryEventsCollectorSpy); + + EmailService = Substitute.For(); + Services.AddScoped(_ => EmailService); + + var telemetryChannel = Substitute.For(); + Services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = telemetryChannel })); + + // Make sure database is created + using var serviceScope = Services.BuildServiceProvider().CreateScope(); + serviceScope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + DatabaseSeeder = serviceScope.ServiceProvider.GetRequiredService(); + + JsonSerializerOptions = serviceScope.ServiceProvider.GetRequiredService>().Value.SerializerOptions; + } + + protected SqliteConnection Connection { get; } + + protected DatabaseSeeder DatabaseSeeder { get; } + + protected ServiceProvider Provider + { + get + { + // ServiceProvider is created on first access to allow Tests to configure services in the constructor + // before the ServiceProvider is created + return _provider ??= Services.BuildServiceProvider(); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposing) return; + Provider.Dispose(); + Connection.Close(); + } +} diff --git a/application/account-management/Tests/DatabaseSeeder.cs b/application/account-management/Tests/DatabaseSeeder.cs index 8083b45c5..b770a047c 100644 --- a/application/account-management/Tests/DatabaseSeeder.cs +++ b/application/account-management/Tests/DatabaseSeeder.cs @@ -1,49 +1,49 @@ -using Bogus; -using Microsoft.AspNetCore.Identity; -using PlatformPlatform.AccountManagement.Application.AccountRegistrations; -using PlatformPlatform.AccountManagement.Domain.AccountRegistrations; -using PlatformPlatform.AccountManagement.Infrastructure; - -namespace PlatformPlatform.AccountManagement.Tests; - -public sealed class DatabaseSeeder -{ - private readonly Faker _faker = new(); - public readonly AccountRegistration AccountRegistration1; - public readonly string OneTimePassword; - public readonly Tenant Tenant1; - public readonly Tenant TenantForSearching; - public readonly User User1; - public readonly User User1ForSearching; - public readonly User User2ForSearching; - - public DatabaseSeeder(AccountManagementDbContext accountManagementDbContext) - { - OneTimePassword = StartAccountRegistrationCommandHandler.GenerateOneTimePassword(6); - var oneTimePasswordHash = new PasswordHasher().HashPassword(this, OneTimePassword); - - AccountRegistration1 = AccountRegistration.Create(new TenantId(_faker.Subdomain()), _faker.Internet.Email(), oneTimePasswordHash); - - accountManagementDbContext.AccountRegistrations.AddRange(AccountRegistration1); - - Tenant1 = Tenant.Create(new TenantId(_faker.Subdomain()), _faker.Internet.Email()); - accountManagementDbContext.Tenants.AddRange(Tenant1); - - User1 = User.Create(Tenant1.Id, _faker.Internet.Email(), UserRole.TenantOwner, true, null); - accountManagementDbContext.Users.AddRange(User1); - - TenantForSearching = Tenant.Create(new TenantId(_faker.Subdomain()), _faker.Internet.Email()); - accountManagementDbContext.Tenants.AddRange(TenantForSearching); - - User1ForSearching = User.Create(TenantForSearching.Id, "willgates@email.com", UserRole.TenantUser, true, null); - User1ForSearching.Update("William Henry", "Gates"); - - User2ForSearching = User.Create(TenantForSearching.Id, _faker.Internet.Email(), UserRole.TenantOwner, true, null); - - accountManagementDbContext.Users.AddRange(User1); - accountManagementDbContext.Users.AddRange(User1ForSearching); - accountManagementDbContext.Users.AddRange(User2ForSearching); - - accountManagementDbContext.SaveChanges(); - } -} +using Bogus; +using Microsoft.AspNetCore.Identity; +using PlatformPlatform.AccountManagement.Application.AccountRegistrations; +using PlatformPlatform.AccountManagement.Domain.AccountRegistrations; +using PlatformPlatform.AccountManagement.Infrastructure; + +namespace PlatformPlatform.AccountManagement.Tests; + +public sealed class DatabaseSeeder +{ + private readonly Faker _faker = new(); + public readonly AccountRegistration AccountRegistration1; + public readonly string OneTimePassword; + public readonly Tenant Tenant1; + public readonly Tenant TenantForSearching; + public readonly User User1; + public readonly User User1ForSearching; + public readonly User User2ForSearching; + + public DatabaseSeeder(AccountManagementDbContext accountManagementDbContext) + { + OneTimePassword = StartAccountRegistrationCommandHandler.GenerateOneTimePassword(6); + var oneTimePasswordHash = new PasswordHasher().HashPassword(this, OneTimePassword); + + AccountRegistration1 = AccountRegistration.Create(new TenantId(_faker.Subdomain()), _faker.Internet.Email(), oneTimePasswordHash); + + accountManagementDbContext.AccountRegistrations.AddRange(AccountRegistration1); + + Tenant1 = Tenant.Create(new TenantId(_faker.Subdomain()), _faker.Internet.Email()); + accountManagementDbContext.Tenants.AddRange(Tenant1); + + User1 = User.Create(Tenant1.Id, _faker.Internet.Email(), UserRole.TenantOwner, true, null); + accountManagementDbContext.Users.AddRange(User1); + + TenantForSearching = Tenant.Create(new TenantId(_faker.Subdomain()), _faker.Internet.Email()); + accountManagementDbContext.Tenants.AddRange(TenantForSearching); + + User1ForSearching = User.Create(TenantForSearching.Id, "willgates@email.com", UserRole.TenantUser, true, null); + User1ForSearching.Update("William Henry", "Gates"); + + User2ForSearching = User.Create(TenantForSearching.Id, _faker.Internet.Email(), UserRole.TenantOwner, true, null); + + accountManagementDbContext.Users.AddRange(User1); + accountManagementDbContext.Users.AddRange(User1ForSearching); + accountManagementDbContext.Users.AddRange(User2ForSearching); + + accountManagementDbContext.SaveChanges(); + } +} diff --git a/application/account-management/Tests/FakerExtensions.cs b/application/account-management/Tests/FakerExtensions.cs index 9aaa0c7de..37b436bfc 100644 --- a/application/account-management/Tests/FakerExtensions.cs +++ b/application/account-management/Tests/FakerExtensions.cs @@ -1,33 +1,33 @@ -using Bogus; -using PlatformPlatform.SharedKernel.DomainCore.Identity; - -namespace PlatformPlatform.AccountManagement.Tests; - -public static class FakerExtensions -{ - public static string TenantName(this Faker faker) - { - return new string(faker.Company.CompanyName().Take(30).ToArray()); - } - - public static string PhoneNumber(this Faker faker) - { - var random = new Random(); - return $"+{random.Next(1, 9)}-{faker.Phone.PhoneNumberFormat()}"; - } - - public static string Subdomain(this Faker faker) - { - return faker.Random.AlphaNumeric(10); - } - - public static string InvalidEmail(this Faker faker) - { - return faker.Internet.ExampleEmail(faker.Random.AlphaNumeric(100)); - } - - public static long RandomId(this Faker faker) - { - return IdGenerator.NewId(); - } -} +using Bogus; +using PlatformPlatform.SharedKernel.DomainCore.Identity; + +namespace PlatformPlatform.AccountManagement.Tests; + +public static class FakerExtensions +{ + public static string TenantName(this Faker faker) + { + return new string(faker.Company.CompanyName().Take(30).ToArray()); + } + + public static string PhoneNumber(this Faker faker) + { + var random = new Random(); + return $"+{random.Next(1, 9)}-{faker.Phone.PhoneNumberFormat()}"; + } + + public static string Subdomain(this Faker faker) + { + return faker.Random.AlphaNumeric(10); + } + + public static string InvalidEmail(this Faker faker) + { + return faker.Internet.ExampleEmail(faker.Random.AlphaNumeric(100)); + } + + public static long RandomId(this Faker faker) + { + return IdGenerator.NewId(); + } +} diff --git a/application/account-management/Tests/SqliteConnectionExtensions.cs b/application/account-management/Tests/SqliteConnectionExtensions.cs index 09b47ac46..00742b4f4 100644 --- a/application/account-management/Tests/SqliteConnectionExtensions.cs +++ b/application/account-management/Tests/SqliteConnectionExtensions.cs @@ -1,26 +1,26 @@ -using Microsoft.Data.Sqlite; - -namespace PlatformPlatform.AccountManagement.Tests; - -public static class SqliteConnectionExtensions -{ - public static long ExecuteScalar(this SqliteConnection connection, string sql, params object?[] parameters) - { - using var command = new SqliteCommand(sql, connection); - - foreach (var parameter in parameters) - { - foreach (var property in parameter?.GetType().GetProperties() ?? []) - { - command.Parameters.AddWithValue(property.Name, property.GetValue(parameter)); - } - } - - return (long)command.ExecuteScalar()!; - } - - public static bool RowExists(this SqliteConnection connection, string tableName, string id) - { - return connection.ExecuteScalar($"SELECT COUNT(*) FROM {tableName} WHERE Id = @id", new { id }) == 1; - } -} +using Microsoft.Data.Sqlite; + +namespace PlatformPlatform.AccountManagement.Tests; + +public static class SqliteConnectionExtensions +{ + public static long ExecuteScalar(this SqliteConnection connection, string sql, params object?[] parameters) + { + using var command = new SqliteCommand(sql, connection); + + foreach (var parameter in parameters) + { + foreach (var property in parameter?.GetType().GetProperties() ?? []) + { + command.Parameters.AddWithValue(property.Name, property.GetValue(parameter)); + } + } + + return (long)command.ExecuteScalar()!; + } + + public static bool RowExists(this SqliteConnection connection, string tableName, string id) + { + return connection.ExecuteScalar($"SELECT COUNT(*) FROM {tableName} WHERE Id = @id", new { id }) == 1; + } +} diff --git a/application/account-management/Workers/Program.cs b/application/account-management/Workers/Program.cs index 3c2f9480f..6e8b79196 100644 --- a/application/account-management/Workers/Program.cs +++ b/application/account-management/Workers/Program.cs @@ -1,29 +1,29 @@ -using PlatformPlatform.AccountManagement.Application; -using PlatformPlatform.AccountManagement.Infrastructure; -using PlatformPlatform.SharedKernel.InfrastructureCore; - -var builder = Host.CreateApplicationBuilder(args); - -builder.Services.Configure(options => - { - options.ServicesStartConcurrently = true; - options.StartupTimeout = TimeSpan.FromSeconds(60); - - options.ServicesStopConcurrently = true; - options.ShutdownTimeout = TimeSpan.FromSeconds(10); - } -); - -// Configure services for the Application, Infrastructure layers like Entity Framework, Repositories, MediatR, -// FluentValidation validators, Pipelines. -builder.Services - .AddApplicationServices() - .AddInfrastructureServices() - .AddConfigureStorage(builder); - -var host = builder.Build(); - -// Apply migrations to the database (should be moved to GitHub Actions or similar in production) -host.Services.ApplyMigrations(); - -// host.Run(); is disabled for now, as the worker is only doing database migrations, which is a one-time operation, so we just let the host finish +using PlatformPlatform.AccountManagement.Application; +using PlatformPlatform.AccountManagement.Infrastructure; +using PlatformPlatform.SharedKernel.InfrastructureCore; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.Configure(options => + { + options.ServicesStartConcurrently = true; + options.StartupTimeout = TimeSpan.FromSeconds(60); + + options.ServicesStopConcurrently = true; + options.ShutdownTimeout = TimeSpan.FromSeconds(10); + } +); + +// Configure services for the Application, Infrastructure layers like Entity Framework, Repositories, MediatR, +// FluentValidation validators, Pipelines. +builder.Services + .AddApplicationServices() + .AddInfrastructureServices() + .AddConfigureStorage(builder); + +var host = builder.Build(); + +// Apply migrations to the database (should be moved to GitHub Actions or similar in production) +host.Services.ApplyMigrations(); + +// host.Run(); is disabled for now, as the worker is only doing database migrations, which is a one-time operation, so we just let the host finish diff --git a/application/back-office/Api/Program.cs b/application/back-office/Api/Program.cs index cbf0fcd90..9fe706166 100644 --- a/application/back-office/Api/Program.cs +++ b/application/back-office/Api/Program.cs @@ -1,26 +1,26 @@ -using PlatformPlatform.BackOffice.Application; -using PlatformPlatform.BackOffice.Domain; -using PlatformPlatform.BackOffice.Infrastructure; -using PlatformPlatform.SharedKernel.ApiCore; -using PlatformPlatform.SharedKernel.ApiCore.Middleware; - -var builder = WebApplication.CreateBuilder(args); - -// Configure services for the Application, Infrastructure, and Api layers like Entity Framework, Repositories, MediatR, -// FluentValidation validators, Pipelines. -builder.Services - .AddApplicationServices() - .AddInfrastructureServices() - .AddApiCoreServices(builder, Assembly.GetExecutingAssembly(), DomainConfiguration.Assembly) - .AddConfigureStorage(builder) - .AddWebAppMiddleware(); - -var app = builder.Build(); - -// Add common configuration for all APIs like Swagger, HSTS, and DeveloperExceptionPage. -app.UseApiCoreConfiguration(); - -// Server the SPA Index.html if no other endpoints are found -app.UseWebAppMiddleware(); - -app.Run(); +using PlatformPlatform.BackOffice.Application; +using PlatformPlatform.BackOffice.Domain; +using PlatformPlatform.BackOffice.Infrastructure; +using PlatformPlatform.SharedKernel.ApiCore; +using PlatformPlatform.SharedKernel.ApiCore.Middleware; + +var builder = WebApplication.CreateBuilder(args); + +// Configure services for the Application, Infrastructure, and Api layers like Entity Framework, Repositories, MediatR, +// FluentValidation validators, Pipelines. +builder.Services + .AddApplicationServices() + .AddInfrastructureServices() + .AddApiCoreServices(builder, Assembly.GetExecutingAssembly(), DomainConfiguration.Assembly) + .AddConfigureStorage(builder) + .AddWebAppMiddleware(); + +var app = builder.Build(); + +// Add common configuration for all APIs like Swagger, HSTS, and DeveloperExceptionPage. +app.UseApiCoreConfiguration(); + +// Server the SPA Index.html if no other endpoints are found +app.UseWebAppMiddleware(); + +app.Run(); diff --git a/application/back-office/Application/ApplicationConfiguration.cs b/application/back-office/Application/ApplicationConfiguration.cs index 8c9154cf8..eb95c487d 100644 --- a/application/back-office/Application/ApplicationConfiguration.cs +++ b/application/back-office/Application/ApplicationConfiguration.cs @@ -1,16 +1,16 @@ -using Microsoft.Extensions.DependencyInjection; -using PlatformPlatform.SharedKernel.ApplicationCore; - -namespace PlatformPlatform.BackOffice.Application; - -public static class ApplicationConfiguration -{ - public static Assembly Assembly => Assembly.GetExecutingAssembly(); - - public static IServiceCollection AddApplicationServices(this IServiceCollection services) - { - services.AddApplicationCoreServices(Assembly); - - return services; - } -} +using Microsoft.Extensions.DependencyInjection; +using PlatformPlatform.SharedKernel.ApplicationCore; + +namespace PlatformPlatform.BackOffice.Application; + +public static class ApplicationConfiguration +{ + public static Assembly Assembly => Assembly.GetExecutingAssembly(); + + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + services.AddApplicationCoreServices(Assembly); + + return services; + } +} diff --git a/application/back-office/Application/TelemetryEvents/TelemetryEvents.cs b/application/back-office/Application/TelemetryEvents/TelemetryEvents.cs index 93f44a740..57d3e6d42 100644 --- a/application/back-office/Application/TelemetryEvents/TelemetryEvents.cs +++ b/application/back-office/Application/TelemetryEvents/TelemetryEvents.cs @@ -1,8 +1,8 @@ -// This file contains all the telemetry events that are collected by the application. Telemetry events are important -// to understand how the application is being used and collect valuable information for the business. Quality is -// important, and keeping all the telemetry events in one place makes it easier to maintain high quality. -// This particular includes the naming of the telemetry events (which should be in past tense) and the properties that -// are collected with each telemetry event. Since missing or bad data cannot be fixed, it is important to have a good -// data quality from the start. - - +// This file contains all the telemetry events that are collected by the application. Telemetry events are important +// to understand how the application is being used and collect valuable information for the business. Quality is +// important, and keeping all the telemetry events in one place makes it easier to maintain high quality. +// This particular includes the naming of the telemetry events (which should be in past tense) and the properties that +// are collected with each telemetry event. Since missing or bad data cannot be fixed, it is important to have a good +// data quality from the start. + + diff --git a/application/back-office/Domain/DomainConfiguration.cs b/application/back-office/Domain/DomainConfiguration.cs index cbfd0acf9..afe2b138b 100644 --- a/application/back-office/Domain/DomainConfiguration.cs +++ b/application/back-office/Domain/DomainConfiguration.cs @@ -1,6 +1,6 @@ -namespace PlatformPlatform.BackOffice.Domain; - -public static class DomainConfiguration -{ - public static Assembly Assembly => Assembly.GetExecutingAssembly(); -} +namespace PlatformPlatform.BackOffice.Domain; + +public static class DomainConfiguration +{ + public static Assembly Assembly => Assembly.GetExecutingAssembly(); +} diff --git a/application/back-office/Infrastructure/BackOfficeDbContext.cs b/application/back-office/Infrastructure/BackOfficeDbContext.cs index 44e751554..d9496a0b6 100644 --- a/application/back-office/Infrastructure/BackOfficeDbContext.cs +++ b/application/back-office/Infrastructure/BackOfficeDbContext.cs @@ -1,7 +1,7 @@ -using Microsoft.EntityFrameworkCore; -using PlatformPlatform.SharedKernel.InfrastructureCore.EntityFramework; - -namespace PlatformPlatform.BackOffice.Infrastructure; - -public sealed class BackOfficeDbContext(DbContextOptions options) - : SharedKernelDbContext(options); +using Microsoft.EntityFrameworkCore; +using PlatformPlatform.SharedKernel.InfrastructureCore.EntityFramework; + +namespace PlatformPlatform.BackOffice.Infrastructure; + +public sealed class BackOfficeDbContext(DbContextOptions options) + : SharedKernelDbContext(options); diff --git a/application/back-office/Infrastructure/InfrastructureConfiguration.cs b/application/back-office/Infrastructure/InfrastructureConfiguration.cs index d184a8089..ccbb84ef5 100644 --- a/application/back-office/Infrastructure/InfrastructureConfiguration.cs +++ b/application/back-office/Infrastructure/InfrastructureConfiguration.cs @@ -1,26 +1,26 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using PlatformPlatform.SharedKernel.InfrastructureCore; - -namespace PlatformPlatform.BackOffice.Infrastructure; - -public static class InfrastructureConfiguration -{ - public static Assembly Assembly => Assembly.GetExecutingAssembly(); - - public static IServiceCollection AddConfigureStorage(this IServiceCollection services, IHostApplicationBuilder builder) - { - // Storage is configured separately from other Infrastructure services to allow mocking in tests - services.ConfigureDatabaseContext(builder, "back-office-database"); - services.AddDefaultBlobStorage(builder); - - return services; - } - - public static IServiceCollection AddInfrastructureServices(this IServiceCollection services) - { - services.ConfigureInfrastructureCoreServices(Assembly); - - return services; - } -} +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using PlatformPlatform.SharedKernel.InfrastructureCore; + +namespace PlatformPlatform.BackOffice.Infrastructure; + +public static class InfrastructureConfiguration +{ + public static Assembly Assembly => Assembly.GetExecutingAssembly(); + + public static IServiceCollection AddConfigureStorage(this IServiceCollection services, IHostApplicationBuilder builder) + { + // Storage is configured separately from other Infrastructure services to allow mocking in tests + services.ConfigureDatabaseContext(builder, "back-office-database"); + services.AddDefaultBlobStorage(builder); + + return services; + } + + public static IServiceCollection AddInfrastructureServices(this IServiceCollection services) + { + services.ConfigureInfrastructureCoreServices(Assembly); + + return services; + } +} diff --git a/application/back-office/Infrastructure/Migrations/DatabaseMigrations.cs b/application/back-office/Infrastructure/Migrations/DatabaseMigrations.cs index 267c90d6b..9f502711e 100644 --- a/application/back-office/Infrastructure/Migrations/DatabaseMigrations.cs +++ b/application/back-office/Infrastructure/Migrations/DatabaseMigrations.cs @@ -1,19 +1,19 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace PlatformPlatform.BackOffice.Infrastructure.Migrations; - -[DbContext(typeof(BackOfficeDbContext))] -[Migration("1_Initial")] -public sealed class DatabaseMigrations : Migration -{ - protected override void Up(MigrationBuilder migrationBuilder) - { - } - - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { - modelBuilder.UseIdentityColumns(); - } -} +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace PlatformPlatform.BackOffice.Infrastructure.Migrations; + +[DbContext(typeof(BackOfficeDbContext))] +[Migration("1_Initial")] +public sealed class DatabaseMigrations : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + } + + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + modelBuilder.UseIdentityColumns(); + } +} diff --git a/application/back-office/Tests/Api/BaseApiTest.cs b/application/back-office/Tests/Api/BaseApiTest.cs index 99ff9c8c4..08ed61eb2 100644 --- a/application/back-office/Tests/Api/BaseApiTest.cs +++ b/application/back-office/Tests/Api/BaseApiTest.cs @@ -1,154 +1,154 @@ -using System.Net; -using System.Text.Json; -using FluentAssertions; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.AspNetCore.TestHost; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using PlatformPlatform.SharedKernel.ApiCore.ApiResults; -using PlatformPlatform.SharedKernel.ApiCore.Middleware; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; -using PlatformPlatform.SharedKernel.ApplicationCore.Validation; -using PlatformPlatform.SharedKernel.Tests.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.BackOffice.Tests.Api; - -public abstract class BaseApiTests : BaseTest where TContext : DbContext -{ - private readonly WebApplicationFactory _webApplicationFactory; - - protected BaseApiTests() - { - Environment.SetEnvironmentVariable(WebAppMiddlewareConfiguration.PublicUrlKey, "https://localhost:9000"); - Environment.SetEnvironmentVariable(WebAppMiddlewareConfiguration.CdnUrlKey, "https://localhost:9201"); - - _webApplicationFactory = new WebApplicationFactory().WithWebHostBuilder(builder => - { - builder.ConfigureTestServices(services => - { - // Replace the default DbContext in the WebApplication to use an in-memory SQLite database - services.Remove(services.Single(d => d.ServiceType == typeof(DbContextOptions))); - services.AddDbContext(options => { options.UseSqlite(Connection); }); - - TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); - services.AddScoped(_ => TelemetryEventsCollectorSpy); - } - ); - } - ); - - TestHttpClient = _webApplicationFactory.CreateClient(); - } - - protected HttpClient TestHttpClient { get; } - - protected override void Dispose(bool disposing) - { - _webApplicationFactory.Dispose(); - base.Dispose(disposing); - } - - protected static void EnsureSuccessGetRequest(HttpResponseMessage response) - { - response.EnsureSuccessStatusCode(); - response.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); - response.Headers.Location.Should().BeNull(); - } - - protected static async Task EnsureSuccessPostRequest( - HttpResponseMessage response, - string? exact = null, - string? startsWith = null, - bool hasLocation = true - ) - { - var responseBody = await response.Content.ReadAsStringAsync(); - responseBody.Should().BeEmpty(); - - response.EnsureSuccessStatusCode(); - response.Content.Headers.ContentType.Should().BeNull(); - - if (hasLocation) - { - response.Headers.Location.Should().NotBeNull(); - } - else - { - response.Headers.Location.Should().BeNull(); - } - - if (exact is not null) - { - response.Headers.Location!.ToString().Should().Be(exact); - } - - if (startsWith is not null) - { - response.Headers.Location!.ToString().StartsWith(startsWith).Should().BeTrue(); - } - } - - protected static void EnsureSuccessWithEmptyHeaderAndLocation(HttpResponseMessage response) - { - response.EnsureSuccessStatusCode(); - response.Content.Headers.ContentType.Should().BeNull(); - response.Headers.Location.Should().BeNull(); - } - - protected Task EnsureErrorStatusCode(HttpResponseMessage response, HttpStatusCode statusCode, IEnumerable expectedErrors) - { - return EnsureErrorStatusCode(response, statusCode, null, expectedErrors); - } - - protected async Task EnsureErrorStatusCode( - HttpResponseMessage response, - HttpStatusCode statusCode, - string? expectedDetail, - IEnumerable? expectedErrors = null, - bool hasTraceId = false - ) - { - response.StatusCode.Should().Be(statusCode); - response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); - - var problemDetails = await DeserializeProblemDetails(response); - - problemDetails.Should().NotBeNull(); - problemDetails!.Status.Should().Be((int)statusCode); - problemDetails.Type.Should().StartWith("https://tools.ietf.org/html/rfc9110#section-15."); - problemDetails.Title.Should().Be(ApiResult.GetHttpStatusDisplayName(statusCode)); - - if (expectedDetail is not null) - { - problemDetails.Detail.Should().Be(expectedDetail); - } - - if (expectedErrors is not null) - { - var actualErrorsJson = (JsonElement)problemDetails.Extensions["Errors"]!; - var actualErrors = JsonSerializer.Deserialize(actualErrorsJson.GetRawText(), JsonSerializerOptions); - - actualErrors.Should().BeEquivalentTo(expectedErrors); - } - - if (hasTraceId) - { - problemDetails.Extensions["traceId"]!.ToString().Should().NotBeEmpty(); - } - } - - protected async Task DeserializeResponse(HttpResponseMessage response) - { - var responseStream = await response.Content.ReadAsStreamAsync(); - - return await JsonSerializer.DeserializeAsync(responseStream, JsonSerializerOptions); - } - - private async Task DeserializeProblemDetails(HttpResponseMessage response) - { - var content = await response.Content.ReadAsStringAsync(); - - return JsonSerializer.Deserialize(content, JsonSerializerOptions); - } -} +using System.Net; +using System.Text.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using PlatformPlatform.SharedKernel.ApiCore.ApiResults; +using PlatformPlatform.SharedKernel.ApiCore.Middleware; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; +using PlatformPlatform.SharedKernel.ApplicationCore.Validation; +using PlatformPlatform.SharedKernel.Tests.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.BackOffice.Tests.Api; + +public abstract class BaseApiTests : BaseTest where TContext : DbContext +{ + private readonly WebApplicationFactory _webApplicationFactory; + + protected BaseApiTests() + { + Environment.SetEnvironmentVariable(WebAppMiddlewareConfiguration.PublicUrlKey, "https://localhost:9000"); + Environment.SetEnvironmentVariable(WebAppMiddlewareConfiguration.CdnUrlKey, "https://localhost:9201"); + + _webApplicationFactory = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + // Replace the default DbContext in the WebApplication to use an in-memory SQLite database + services.Remove(services.Single(d => d.ServiceType == typeof(DbContextOptions))); + services.AddDbContext(options => { options.UseSqlite(Connection); }); + + TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); + services.AddScoped(_ => TelemetryEventsCollectorSpy); + } + ); + } + ); + + TestHttpClient = _webApplicationFactory.CreateClient(); + } + + protected HttpClient TestHttpClient { get; } + + protected override void Dispose(bool disposing) + { + _webApplicationFactory.Dispose(); + base.Dispose(disposing); + } + + protected static void EnsureSuccessGetRequest(HttpResponseMessage response) + { + response.EnsureSuccessStatusCode(); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/json"); + response.Headers.Location.Should().BeNull(); + } + + protected static async Task EnsureSuccessPostRequest( + HttpResponseMessage response, + string? exact = null, + string? startsWith = null, + bool hasLocation = true + ) + { + var responseBody = await response.Content.ReadAsStringAsync(); + responseBody.Should().BeEmpty(); + + response.EnsureSuccessStatusCode(); + response.Content.Headers.ContentType.Should().BeNull(); + + if (hasLocation) + { + response.Headers.Location.Should().NotBeNull(); + } + else + { + response.Headers.Location.Should().BeNull(); + } + + if (exact is not null) + { + response.Headers.Location!.ToString().Should().Be(exact); + } + + if (startsWith is not null) + { + response.Headers.Location!.ToString().StartsWith(startsWith).Should().BeTrue(); + } + } + + protected static void EnsureSuccessWithEmptyHeaderAndLocation(HttpResponseMessage response) + { + response.EnsureSuccessStatusCode(); + response.Content.Headers.ContentType.Should().BeNull(); + response.Headers.Location.Should().BeNull(); + } + + protected Task EnsureErrorStatusCode(HttpResponseMessage response, HttpStatusCode statusCode, IEnumerable expectedErrors) + { + return EnsureErrorStatusCode(response, statusCode, null, expectedErrors); + } + + protected async Task EnsureErrorStatusCode( + HttpResponseMessage response, + HttpStatusCode statusCode, + string? expectedDetail, + IEnumerable? expectedErrors = null, + bool hasTraceId = false + ) + { + response.StatusCode.Should().Be(statusCode); + response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); + + var problemDetails = await DeserializeProblemDetails(response); + + problemDetails.Should().NotBeNull(); + problemDetails!.Status.Should().Be((int)statusCode); + problemDetails.Type.Should().StartWith("https://tools.ietf.org/html/rfc9110#section-15."); + problemDetails.Title.Should().Be(ApiResult.GetHttpStatusDisplayName(statusCode)); + + if (expectedDetail is not null) + { + problemDetails.Detail.Should().Be(expectedDetail); + } + + if (expectedErrors is not null) + { + var actualErrorsJson = (JsonElement)problemDetails.Extensions["Errors"]!; + var actualErrors = JsonSerializer.Deserialize(actualErrorsJson.GetRawText(), JsonSerializerOptions); + + actualErrors.Should().BeEquivalentTo(expectedErrors); + } + + if (hasTraceId) + { + problemDetails.Extensions["traceId"]!.ToString().Should().NotBeEmpty(); + } + } + + protected async Task DeserializeResponse(HttpResponseMessage response) + { + var responseStream = await response.Content.ReadAsStreamAsync(); + + return await JsonSerializer.DeserializeAsync(responseStream, JsonSerializerOptions); + } + + private async Task DeserializeProblemDetails(HttpResponseMessage response) + { + var content = await response.Content.ReadAsStringAsync(); + + return JsonSerializer.Deserialize(content, JsonSerializerOptions); + } +} diff --git a/application/back-office/Tests/ArchitectureTests/IdPrefixForAllStronglyTypedUlidTests.cs b/application/back-office/Tests/ArchitectureTests/IdPrefixForAllStronglyTypedUlidTests.cs index 196eb8283..9ef9d2f2b 100644 --- a/application/back-office/Tests/ArchitectureTests/IdPrefixForAllStronglyTypedUlidTests.cs +++ b/application/back-office/Tests/ArchitectureTests/IdPrefixForAllStronglyTypedUlidTests.cs @@ -1,44 +1,44 @@ -using FluentAssertions; -using NetArchTest.Rules; -using PlatformPlatform.BackOffice.Domain; -using PlatformPlatform.SharedKernel.DomainCore.Identity; -using Xunit; - -namespace PlatformPlatform.BackOffice.Tests.ArchitectureTests; - -public class IdPrefixForAllStronglyTypedUlidTests -{ - [Fact] - public void StronglyTypedUlidsInDomain_ShouldHaveIdPrefixAttribute() - { - // Act - var result = Types - .InAssembly(DomainConfiguration.Assembly) - .That().Inherit(typeof(StronglyTypedUlid<>)) - .Should().HaveCustomAttribute(typeof(IdPrefixAttribute)) - .GetResult(); - - // Assert - var idsWithoutPrefix = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); - result.IsSuccessful.Should().BeTrue($"The following strongly typed IDs does not have an IdPrefixAttribute: {idsWithoutPrefix}"); - } - - [Fact] - public void StronglyTypedUlidsInDomain_ShouldHaveValidIdPrefix() - { - // Arrange - var stronglyTypedUlidIds = Types - .InAssembly(DomainConfiguration.Assembly) - .That().Inherit(typeof(StronglyTypedUlid<>)) - .GetTypes(); - - // Assert - foreach (var stronglyTypedId in stronglyTypedUlidIds) - { - var newId = stronglyTypedId.BaseType?.GetMethod("NewId")?.Invoke(null, null); - - // Ids must follow the pattern: {prefix}_{ULID} where prefix is lowercase and ULID is uppercase - newId?.ToString().Should().MatchRegex("^[a-z0-9]+_[A-Z0-9]{26}$"); - } - } -} +using FluentAssertions; +using NetArchTest.Rules; +using PlatformPlatform.BackOffice.Domain; +using PlatformPlatform.SharedKernel.DomainCore.Identity; +using Xunit; + +namespace PlatformPlatform.BackOffice.Tests.ArchitectureTests; + +public class IdPrefixForAllStronglyTypedUlidTests +{ + [Fact] + public void StronglyTypedUlidsInDomain_ShouldHaveIdPrefixAttribute() + { + // Act + var result = Types + .InAssembly(DomainConfiguration.Assembly) + .That().Inherit(typeof(StronglyTypedUlid<>)) + .Should().HaveCustomAttribute(typeof(IdPrefixAttribute)) + .GetResult(); + + // Assert + var idsWithoutPrefix = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); + result.IsSuccessful.Should().BeTrue($"The following strongly typed IDs does not have an IdPrefixAttribute: {idsWithoutPrefix}"); + } + + [Fact] + public void StronglyTypedUlidsInDomain_ShouldHaveValidIdPrefix() + { + // Arrange + var stronglyTypedUlidIds = Types + .InAssembly(DomainConfiguration.Assembly) + .That().Inherit(typeof(StronglyTypedUlid<>)) + .GetTypes(); + + // Assert + foreach (var stronglyTypedId in stronglyTypedUlidIds) + { + var newId = stronglyTypedId.BaseType?.GetMethod("NewId")?.Invoke(null, null); + + // Ids must follow the pattern: {prefix}_{ULID} where prefix is lowercase and ULID is uppercase + newId?.ToString().Should().MatchRegex("^[a-z0-9]+_[A-Z0-9]{26}$"); + } + } +} diff --git a/application/back-office/Tests/ArchitectureTests/PublicClassesTests.cs b/application/back-office/Tests/ArchitectureTests/PublicClassesTests.cs index 118efa48c..8d9405b1c 100644 --- a/application/back-office/Tests/ArchitectureTests/PublicClassesTests.cs +++ b/application/back-office/Tests/ArchitectureTests/PublicClassesTests.cs @@ -1,65 +1,65 @@ -using FluentAssertions; -using NetArchTest.Rules; -using PlatformPlatform.BackOffice.Application; -using PlatformPlatform.BackOffice.Domain; -using PlatformPlatform.BackOffice.Infrastructure; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using Xunit; - -namespace PlatformPlatform.BackOffice.Tests.ArchitectureTests; - -public sealed class PublicClassesTests -{ - [Fact] - public void PublicClassesInDomain_ShouldBeSealed() - { - // Act - var result = Types - .InAssembly(DomainConfiguration.Assembly) - .That().ArePublic() - .And().AreNotAbstract() - .Should().BeSealed() - .GetResult(); - - // Assert - var nonSealedTypes = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); - result.IsSuccessful.Should().BeTrue($"The following are not sealed: {nonSealedTypes}"); - } - - [Fact] - public void PublicClassesInApplication_ShouldBeSealed() - { - // Act - var types = Types - .InAssembly(ApplicationConfiguration.Assembly) - .That().ArePublic() - .And().AreNotAbstract() - .And().DoNotHaveName(typeof(Result<>).Name); - - var result = types - .Should().BeSealed() - .GetResult(); - - // Assert - var nonSealedTypes = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); - result.IsSuccessful.Should().BeTrue($"The following are not sealed: {nonSealedTypes}"); - } - - [Fact] - public void PublicClassesInInfrastructure_ShouldBeSealed() - { - // Act - var types = Types - .InAssembly(InfrastructureConfiguration.Assembly) - .That().ArePublic() - .And().AreNotAbstract(); - - var result = types - .Should().BeSealed() - .GetResult(); - - // Assert - var nonSealedTypes = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); - result.IsSuccessful.Should().BeTrue($"The following are not sealed: {nonSealedTypes}"); - } -} +using FluentAssertions; +using NetArchTest.Rules; +using PlatformPlatform.BackOffice.Application; +using PlatformPlatform.BackOffice.Domain; +using PlatformPlatform.BackOffice.Infrastructure; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using Xunit; + +namespace PlatformPlatform.BackOffice.Tests.ArchitectureTests; + +public sealed class PublicClassesTests +{ + [Fact] + public void PublicClassesInDomain_ShouldBeSealed() + { + // Act + var result = Types + .InAssembly(DomainConfiguration.Assembly) + .That().ArePublic() + .And().AreNotAbstract() + .Should().BeSealed() + .GetResult(); + + // Assert + var nonSealedTypes = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); + result.IsSuccessful.Should().BeTrue($"The following are not sealed: {nonSealedTypes}"); + } + + [Fact] + public void PublicClassesInApplication_ShouldBeSealed() + { + // Act + var types = Types + .InAssembly(ApplicationConfiguration.Assembly) + .That().ArePublic() + .And().AreNotAbstract() + .And().DoNotHaveName(typeof(Result<>).Name); + + var result = types + .Should().BeSealed() + .GetResult(); + + // Assert + var nonSealedTypes = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); + result.IsSuccessful.Should().BeTrue($"The following are not sealed: {nonSealedTypes}"); + } + + [Fact] + public void PublicClassesInInfrastructure_ShouldBeSealed() + { + // Act + var types = Types + .InAssembly(InfrastructureConfiguration.Assembly) + .That().ArePublic() + .And().AreNotAbstract(); + + var result = types + .Should().BeSealed() + .GetResult(); + + // Assert + var nonSealedTypes = string.Join(", ", result.FailingTypes?.Select(t => t.Name) ?? Array.Empty()); + result.IsSuccessful.Should().BeTrue($"The following are not sealed: {nonSealedTypes}"); + } +} diff --git a/application/back-office/Tests/BaseTest.cs b/application/back-office/Tests/BaseTest.cs index 28e3ebae9..bc28f3221 100644 --- a/application/back-office/Tests/BaseTest.cs +++ b/application/back-office/Tests/BaseTest.cs @@ -1,93 +1,93 @@ -using System.Text.Json; -using Bogus; -using Microsoft.ApplicationInsights; -using Microsoft.ApplicationInsights.Channel; -using Microsoft.ApplicationInsights.Extensibility; -using Microsoft.AspNetCore.Http.Json; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using NSubstitute; -using PlatformPlatform.BackOffice.Application; -using PlatformPlatform.BackOffice.Infrastructure; -using PlatformPlatform.SharedKernel.ApplicationCore.Services; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; -using PlatformPlatform.SharedKernel.Tests.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.BackOffice.Tests; - -public abstract class BaseTest : IDisposable where TContext : DbContext -{ - protected readonly IEmailService EmailService; - protected readonly Faker Faker = new(); - protected readonly JsonSerializerOptions JsonSerializerOptions; - protected readonly ServiceCollection Services; - private ServiceProvider? _provider; - protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy; - - protected BaseTest() - { - Environment.SetEnvironmentVariable( - "APPLICATIONINSIGHTS_CONNECTION_STRING", - "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost" - ); - - Services = new ServiceCollection(); - - Services.AddLogging(); - Services.AddTransient(); - - // Create connection and add DbContext to the service collection - Connection = new SqliteConnection("DataSource=:memory:"); - Connection.Open(); - Services.AddDbContext(options => { options.UseSqlite(Connection); }); - - Services - .AddApplicationServices() - .AddInfrastructureServices(); - - TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); - Services.AddScoped(_ => TelemetryEventsCollectorSpy); - - EmailService = Substitute.For(); - Services.AddScoped(_ => EmailService); - - var telemetryChannel = Substitute.For(); - Services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = telemetryChannel })); - - // Make sure database is created - using var serviceScope = Services.BuildServiceProvider().CreateScope(); - serviceScope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); - DatabaseSeeder = serviceScope.ServiceProvider.GetRequiredService(); - - JsonSerializerOptions = serviceScope.ServiceProvider.GetRequiredService>().Value.SerializerOptions; - } - - protected SqliteConnection Connection { get; } - - protected DatabaseSeeder DatabaseSeeder { get; } - - protected ServiceProvider Provider - { - get - { - // ServiceProvider is created on first access to allow Tests to configure services in the constructor - // before the ServiceProvider is created - return _provider ??= Services.BuildServiceProvider(); - } - } - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!disposing) return; - Provider.Dispose(); - Connection.Close(); - } -} +using System.Text.Json; +using Bogus; +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.Extensibility; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using NSubstitute; +using PlatformPlatform.BackOffice.Application; +using PlatformPlatform.BackOffice.Infrastructure; +using PlatformPlatform.SharedKernel.ApplicationCore.Services; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; +using PlatformPlatform.SharedKernel.Tests.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.BackOffice.Tests; + +public abstract class BaseTest : IDisposable where TContext : DbContext +{ + protected readonly IEmailService EmailService; + protected readonly Faker Faker = new(); + protected readonly JsonSerializerOptions JsonSerializerOptions; + protected readonly ServiceCollection Services; + private ServiceProvider? _provider; + protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy; + + protected BaseTest() + { + Environment.SetEnvironmentVariable( + "APPLICATIONINSIGHTS_CONNECTION_STRING", + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost" + ); + + Services = new ServiceCollection(); + + Services.AddLogging(); + Services.AddTransient(); + + // Create connection and add DbContext to the service collection + Connection = new SqliteConnection("DataSource=:memory:"); + Connection.Open(); + Services.AddDbContext(options => { options.UseSqlite(Connection); }); + + Services + .AddApplicationServices() + .AddInfrastructureServices(); + + TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); + Services.AddScoped(_ => TelemetryEventsCollectorSpy); + + EmailService = Substitute.For(); + Services.AddScoped(_ => EmailService); + + var telemetryChannel = Substitute.For(); + Services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = telemetryChannel })); + + // Make sure database is created + using var serviceScope = Services.BuildServiceProvider().CreateScope(); + serviceScope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + DatabaseSeeder = serviceScope.ServiceProvider.GetRequiredService(); + + JsonSerializerOptions = serviceScope.ServiceProvider.GetRequiredService>().Value.SerializerOptions; + } + + protected SqliteConnection Connection { get; } + + protected DatabaseSeeder DatabaseSeeder { get; } + + protected ServiceProvider Provider + { + get + { + // ServiceProvider is created on first access to allow Tests to configure services in the constructor + // before the ServiceProvider is created + return _provider ??= Services.BuildServiceProvider(); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposing) return; + Provider.Dispose(); + Connection.Close(); + } +} diff --git a/application/back-office/Tests/DatabaseSeeder.cs b/application/back-office/Tests/DatabaseSeeder.cs index aa0ad1a02..76e502dcc 100644 --- a/application/back-office/Tests/DatabaseSeeder.cs +++ b/application/back-office/Tests/DatabaseSeeder.cs @@ -1,11 +1,11 @@ -using PlatformPlatform.BackOffice.Infrastructure; - -namespace PlatformPlatform.BackOffice.Tests; - -public sealed class DatabaseSeeder -{ - public DatabaseSeeder(BackOfficeDbContext backOfficeDbContext) - { - backOfficeDbContext.SaveChanges(); - } -} +using PlatformPlatform.BackOffice.Infrastructure; + +namespace PlatformPlatform.BackOffice.Tests; + +public sealed class DatabaseSeeder +{ + public DatabaseSeeder(BackOfficeDbContext backOfficeDbContext) + { + backOfficeDbContext.SaveChanges(); + } +} diff --git a/application/back-office/Tests/FakerExtensions.cs b/application/back-office/Tests/FakerExtensions.cs index 485d1f8a6..15bace049 100644 --- a/application/back-office/Tests/FakerExtensions.cs +++ b/application/back-office/Tests/FakerExtensions.cs @@ -1,33 +1,33 @@ -using Bogus; -using PlatformPlatform.SharedKernel.DomainCore.Identity; - -namespace PlatformPlatform.BackOffice.Tests; - -public static class FakerExtensions -{ - public static string TenantName(this Faker faker) - { - return new string(faker.Company.CompanyName().Take(30).ToArray()); - } - - public static string PhoneNumber(this Faker faker) - { - var random = new Random(); - return $"+{random.Next(1, 9)}-{faker.Phone.PhoneNumberFormat()}"; - } - - public static string Subdomain(this Faker faker) - { - return faker.Random.AlphaNumeric(10); - } - - public static string InvalidEmail(this Faker faker) - { - return faker.Internet.ExampleEmail(faker.Random.AlphaNumeric(100)); - } - - public static long RandomId(this Faker faker) - { - return IdGenerator.NewId(); - } -} +using Bogus; +using PlatformPlatform.SharedKernel.DomainCore.Identity; + +namespace PlatformPlatform.BackOffice.Tests; + +public static class FakerExtensions +{ + public static string TenantName(this Faker faker) + { + return new string(faker.Company.CompanyName().Take(30).ToArray()); + } + + public static string PhoneNumber(this Faker faker) + { + var random = new Random(); + return $"+{random.Next(1, 9)}-{faker.Phone.PhoneNumberFormat()}"; + } + + public static string Subdomain(this Faker faker) + { + return faker.Random.AlphaNumeric(10); + } + + public static string InvalidEmail(this Faker faker) + { + return faker.Internet.ExampleEmail(faker.Random.AlphaNumeric(100)); + } + + public static long RandomId(this Faker faker) + { + return IdGenerator.NewId(); + } +} diff --git a/application/back-office/Tests/SqliteConnectionExtensions.cs b/application/back-office/Tests/SqliteConnectionExtensions.cs index a6925c020..cd28ebb86 100644 --- a/application/back-office/Tests/SqliteConnectionExtensions.cs +++ b/application/back-office/Tests/SqliteConnectionExtensions.cs @@ -1,26 +1,26 @@ -using Microsoft.Data.Sqlite; - -namespace PlatformPlatform.BackOffice.Tests; - -public static class SqliteConnectionExtensions -{ - public static long ExecuteScalar(this SqliteConnection connection, string sql, params object?[] parameters) - { - using var command = new SqliteCommand(sql, connection); - - foreach (var parameter in parameters) - { - foreach (var property in parameter?.GetType().GetProperties() ?? []) - { - command.Parameters.AddWithValue(property.Name, property.GetValue(parameter)); - } - } - - return (long)command.ExecuteScalar()!; - } - - public static bool RowExists(this SqliteConnection connection, string tableName, string id) - { - return connection.ExecuteScalar($"SELECT COUNT(*) FROM {tableName} WHERE Id = @id", new { id }) == 1; - } -} +using Microsoft.Data.Sqlite; + +namespace PlatformPlatform.BackOffice.Tests; + +public static class SqliteConnectionExtensions +{ + public static long ExecuteScalar(this SqliteConnection connection, string sql, params object?[] parameters) + { + using var command = new SqliteCommand(sql, connection); + + foreach (var parameter in parameters) + { + foreach (var property in parameter?.GetType().GetProperties() ?? []) + { + command.Parameters.AddWithValue(property.Name, property.GetValue(parameter)); + } + } + + return (long)command.ExecuteScalar()!; + } + + public static bool RowExists(this SqliteConnection connection, string tableName, string id) + { + return connection.ExecuteScalar($"SELECT COUNT(*) FROM {tableName} WHERE Id = @id", new { id }) == 1; + } +} diff --git a/application/back-office/Workers/Program.cs b/application/back-office/Workers/Program.cs index f31ff6e72..e7f176ea1 100644 --- a/application/back-office/Workers/Program.cs +++ b/application/back-office/Workers/Program.cs @@ -1,29 +1,29 @@ -using PlatformPlatform.BackOffice.Application; -using PlatformPlatform.BackOffice.Infrastructure; -using PlatformPlatform.SharedKernel.InfrastructureCore; - -var builder = Host.CreateApplicationBuilder(args); - -builder.Services.Configure(options => - { - options.ServicesStartConcurrently = true; - options.StartupTimeout = TimeSpan.FromSeconds(60); - - options.ServicesStopConcurrently = true; - options.ShutdownTimeout = TimeSpan.FromSeconds(10); - } -); - -// Configure services for the Application, Infrastructure layers like Entity Framework, Repositories, MediatR, -// FluentValidation validators, Pipelines. -builder.Services - .AddApplicationServices() - .AddInfrastructureServices() - .AddConfigureStorage(builder); - -var host = builder.Build(); - -// Apply migrations to the database (should be moved to GitHub Actions or similar in production) -host.Services.ApplyMigrations(); - -// host.Run(); is disabled for now, as the worker is only doing database migrations, which is a one-time operation, so we just let the host finish +using PlatformPlatform.BackOffice.Application; +using PlatformPlatform.BackOffice.Infrastructure; +using PlatformPlatform.SharedKernel.InfrastructureCore; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.Configure(options => + { + options.ServicesStartConcurrently = true; + options.StartupTimeout = TimeSpan.FromSeconds(60); + + options.ServicesStopConcurrently = true; + options.ShutdownTimeout = TimeSpan.FromSeconds(10); + } +); + +// Configure services for the Application, Infrastructure layers like Entity Framework, Repositories, MediatR, +// FluentValidation validators, Pipelines. +builder.Services + .AddApplicationServices() + .AddInfrastructureServices() + .AddConfigureStorage(builder); + +var host = builder.Build(); + +// Apply migrations to the database (should be moved to GitHub Actions or similar in production) +host.Services.ApplyMigrations(); + +// host.Run(); is disabled for now, as the worker is only doing database migrations, which is a one-time operation, so we just let the host finish diff --git a/application/dotnet-tools.json b/application/dotnet-tools.json index db0042de6..f28066ce5 100644 --- a/application/dotnet-tools.json +++ b/application/dotnet-tools.json @@ -7,11 +7,11 @@ "commands": ["dotnet-sonarscanner"] }, "jetbrains.dotcover.globaltool": { - "version": "2023.2.4", + "version": "2023.2.5", "commands": ["dotnet-dotcover"] }, "jetbrains.resharper.globaltools": { - "version": "2024.1.0", + "version": "2024.1.2", "commands": ["jb"] } } diff --git a/application/shared-kernel/ApiCore/ApiCoreConfiguration.cs b/application/shared-kernel/ApiCore/ApiCoreConfiguration.cs index 96e2763b1..912b4b5cb 100644 --- a/application/shared-kernel/ApiCore/ApiCoreConfiguration.cs +++ b/application/shared-kernel/ApiCore/ApiCoreConfiguration.cs @@ -1,161 +1,161 @@ -using System.Text.Json; -using Microsoft.ApplicationInsights.AspNetCore.Extensions; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http.Json; -using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; -using NJsonSchema; -using NJsonSchema.Generation; -using PlatformPlatform.SharedKernel.ApiCore.Aspire; -using PlatformPlatform.SharedKernel.ApiCore.Endpoints; -using PlatformPlatform.SharedKernel.ApiCore.Filters; -using PlatformPlatform.SharedKernel.ApiCore.Middleware; -using PlatformPlatform.SharedKernel.DomainCore.Identity; - -namespace PlatformPlatform.SharedKernel.ApiCore; - -public static class ApiCoreConfiguration -{ - private const string LocalhostCorsPolicyName = "LocalhostCorsPolicy"; - - private static readonly string LocalhostUrl = - Environment.GetEnvironmentVariable(WebAppMiddlewareConfiguration.PublicUrlKey)!; - - public static IServiceCollection AddApiCoreServices( - this IServiceCollection services, - WebApplicationBuilder builder, - Assembly apiAssembly, - Assembly domainAssembly - ) - { - services.Scan(scan => scan - .FromAssemblies(apiAssembly, Assembly.GetExecutingAssembly()) - .AddClasses(classes => classes.AssignableTo()) - .AsImplementedInterfaces() - .WithScopedLifetime() - ); - - services - .AddExceptionHandler() - .AddExceptionHandler() - .AddTransient() - .AddProblemDetails() - .AddEndpointsApiExplorer(); - - var applicationInsightsServiceOptions = new ApplicationInsightsServiceOptions - { - EnableRequestTrackingTelemetryModule = false, - EnableDependencyTrackingTelemetryModule = false, - RequestCollectionOptions = { TrackExceptions = false } - }; - - services.AddApplicationInsightsTelemetry(applicationInsightsServiceOptions); - services.AddApplicationInsightsTelemetryProcessor(); - - services.AddOpenApiDocument((settings, serviceProvider) => - { - settings.DocumentName = "v1"; - settings.Title = "PlatformPlatform API"; - settings.Version = "v1"; - - var options = (SystemTextJsonSchemaGeneratorSettings)settings.SchemaSettings; - var serializerOptions = serviceProvider.GetRequiredService>().Value.SerializerOptions; - options.SerializerOptions = new JsonSerializerOptions(serializerOptions); - - // Ensure that enums are serialized as strings and use CamelCase - options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); - options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - - settings.PostProcess = document => - { - // Find all strongly typed IDs - var stronglyTypedIdNames = domainAssembly.GetTypes() - .Where(t => typeof(IStronglyTypedId).IsAssignableFrom(t)) - .Select(t => t.Name) - .ToList(); - - // Ensure the Swagger UI to correctly display strongly typed IDs as plain text instead of complex objects - foreach (var stronglyTypedIdName in stronglyTypedIdNames) - { - var schema = document.Definitions[stronglyTypedIdName]; - schema.Type = JsonObjectType.String; - schema.Properties.Clear(); - } - }; - } - ); - - // Ensure that enums are serialized as strings - services.Configure(options => - { - options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); - options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; - } - ); - - // Ensure correct client IP addresses are set for requests - // This is required when running behind a reverse proxy like YARP or Azure Container Apps - services.Configure(options => - { - // Enable support for proxy headers such as X-Forwarded-For and X-Forwarded-Proto - options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - options.KnownNetworks.Clear(); - options.KnownProxies.Clear(); - } - ); - - builder.AddServiceDefaults(); - - if (builder.Environment.IsDevelopment()) - { - builder.Services.AddCors(options => options.AddPolicy( - LocalhostCorsPolicyName, - policyBuilder => { policyBuilder.WithOrigins(LocalhostUrl).AllowAnyMethod().AllowAnyHeader(); } - ) - ); - } - else - { - builder.WebHost.ConfigureKestrel(options => { options.AddServerHeader = false; }); - } - - return services; - } - - public static WebApplication UseApiCoreConfiguration(this WebApplication app) - { - if (app.Environment.IsDevelopment()) - { - // Enable the developer exception page, which displays detailed information about exceptions that occur - app.UseDeveloperExceptionPage(); - app.UseCors(LocalhostCorsPolicyName); - } - else - { - // Configure global exception handling for the production environment - app.UseExceptionHandler(_ => { }); - } - - // Enable support for proxy headers such as X-Forwarded-For and X-Forwarded-Proto. Should run before other middleware. - app.UseForwardedHeaders(); - - // Enable Swagger UI - app.UseOpenApi(); - app.UseSwaggerUi(); - - app.UseMiddleware(); - - // Manually create all endpoints classes to call the MapEndpoints containing the mappings - using var scope = app.Services.CreateScope(); - var endpointServices = scope.ServiceProvider.GetServices(); - foreach (var endpoint in endpointServices) - { - endpoint.MapEndpoints(app); - } - - return app; - } -} +using System.Text.Json; +using Microsoft.ApplicationInsights.AspNetCore.Extensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using NJsonSchema; +using NJsonSchema.Generation; +using PlatformPlatform.SharedKernel.ApiCore.Aspire; +using PlatformPlatform.SharedKernel.ApiCore.Endpoints; +using PlatformPlatform.SharedKernel.ApiCore.Filters; +using PlatformPlatform.SharedKernel.ApiCore.Middleware; +using PlatformPlatform.SharedKernel.DomainCore.Identity; + +namespace PlatformPlatform.SharedKernel.ApiCore; + +public static class ApiCoreConfiguration +{ + private const string LocalhostCorsPolicyName = "LocalhostCorsPolicy"; + + private static readonly string LocalhostUrl = + Environment.GetEnvironmentVariable(WebAppMiddlewareConfiguration.PublicUrlKey)!; + + public static IServiceCollection AddApiCoreServices( + this IServiceCollection services, + WebApplicationBuilder builder, + Assembly apiAssembly, + Assembly domainAssembly + ) + { + services.Scan(scan => scan + .FromAssemblies(apiAssembly, Assembly.GetExecutingAssembly()) + .AddClasses(classes => classes.AssignableTo()) + .AsImplementedInterfaces() + .WithScopedLifetime() + ); + + services + .AddExceptionHandler() + .AddExceptionHandler() + .AddTransient() + .AddProblemDetails() + .AddEndpointsApiExplorer(); + + var applicationInsightsServiceOptions = new ApplicationInsightsServiceOptions + { + EnableRequestTrackingTelemetryModule = false, + EnableDependencyTrackingTelemetryModule = false, + RequestCollectionOptions = { TrackExceptions = false } + }; + + services.AddApplicationInsightsTelemetry(applicationInsightsServiceOptions); + services.AddApplicationInsightsTelemetryProcessor(); + + services.AddOpenApiDocument((settings, serviceProvider) => + { + settings.DocumentName = "v1"; + settings.Title = "PlatformPlatform API"; + settings.Version = "v1"; + + var options = (SystemTextJsonSchemaGeneratorSettings)settings.SchemaSettings; + var serializerOptions = serviceProvider.GetRequiredService>().Value.SerializerOptions; + options.SerializerOptions = new JsonSerializerOptions(serializerOptions); + + // Ensure that enums are serialized as strings and use CamelCase + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + + settings.PostProcess = document => + { + // Find all strongly typed IDs + var stronglyTypedIdNames = domainAssembly.GetTypes() + .Where(t => typeof(IStronglyTypedId).IsAssignableFrom(t)) + .Select(t => t.Name) + .ToList(); + + // Ensure the Swagger UI to correctly display strongly typed IDs as plain text instead of complex objects + foreach (var stronglyTypedIdName in stronglyTypedIdNames) + { + var schema = document.Definitions[stronglyTypedIdName]; + schema.Type = JsonObjectType.String; + schema.Properties.Clear(); + } + }; + } + ); + + // Ensure that enums are serialized as strings + services.Configure(options => + { + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + } + ); + + // Ensure correct client IP addresses are set for requests + // This is required when running behind a reverse proxy like YARP or Azure Container Apps + services.Configure(options => + { + // Enable support for proxy headers such as X-Forwarded-For and X-Forwarded-Proto + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); + } + ); + + builder.AddServiceDefaults(); + + if (builder.Environment.IsDevelopment()) + { + builder.Services.AddCors(options => options.AddPolicy( + LocalhostCorsPolicyName, + policyBuilder => { policyBuilder.WithOrigins(LocalhostUrl).AllowAnyMethod().AllowAnyHeader(); } + ) + ); + } + else + { + builder.WebHost.ConfigureKestrel(options => { options.AddServerHeader = false; }); + } + + return services; + } + + public static WebApplication UseApiCoreConfiguration(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + // Enable the developer exception page, which displays detailed information about exceptions that occur + app.UseDeveloperExceptionPage(); + app.UseCors(LocalhostCorsPolicyName); + } + else + { + // Configure global exception handling for the production environment + app.UseExceptionHandler(_ => { }); + } + + // Enable support for proxy headers such as X-Forwarded-For and X-Forwarded-Proto. Should run before other middleware. + app.UseForwardedHeaders(); + + // Enable Swagger UI + app.UseOpenApi(); + app.UseSwaggerUi(); + + app.UseMiddleware(); + + // Manually create all endpoints classes to call the MapEndpoints containing the mappings + using var scope = app.Services.CreateScope(); + var endpointServices = scope.ServiceProvider.GetServices(); + foreach (var endpoint in endpointServices) + { + endpoint.MapEndpoints(app); + } + + return app; + } +} diff --git a/application/shared-kernel/ApiCore/ApiResults/ApiResult.cs b/application/shared-kernel/ApiCore/ApiResults/ApiResult.cs index 5703b41f4..5a810671a 100644 --- a/application/shared-kernel/ApiCore/ApiResults/ApiResult.cs +++ b/application/shared-kernel/ApiCore/ApiResults/ApiResult.cs @@ -1,65 +1,65 @@ -using System.Net; -using System.Text.RegularExpressions; -using Mapster; -using Microsoft.AspNetCore.Http; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; - -namespace PlatformPlatform.SharedKernel.ApiCore.ApiResults; - -public class ApiResult(ResultBase result, string? routePrefix = null) : IResult -{ - protected string? RoutePrefix { get; } = routePrefix; - - public Task ExecuteAsync(HttpContext httpContext) - { - return ConvertResult().ExecuteAsync(httpContext); - } - - protected virtual IResult ConvertResult() - { - if (!result.IsSuccess) return GetProblemDetailsAsJson(); - - return RoutePrefix is null - ? Results.Ok() - : Results.Created($"{RoutePrefix}/{result}", null); - } - - protected IResult GetProblemDetailsAsJson() - { - return Results.Problem( - title: GetHttpStatusDisplayName(result.StatusCode), - statusCode: (int)result.StatusCode, - detail: result.ErrorMessage?.Message, - extensions: result.Errors?.Length > 0 - ? new Dictionary { { nameof(result.Errors), result.Errors } } - : null - ); - } - - public static string GetHttpStatusDisplayName(HttpStatusCode statusCode) - { - return Regex.Replace(statusCode.ToString(), "(?<=[a-z])([A-Z])", " $1", RegexOptions.None, TimeSpan.FromSeconds(1)); - } - - public static implicit operator ApiResult(Result result) - { - return new ApiResult(result); - } -} - -public sealed class ApiResult(Result result, string? routePrefix = null) : ApiResult(result, routePrefix) -{ - protected override IResult ConvertResult() - { - if (!result.IsSuccess) return GetProblemDetailsAsJson(); - - return RoutePrefix is null - ? Results.Ok(result.Value!.Adapt()) - : Results.Created($"{RoutePrefix}/{result.Value}", null); - } - - public static implicit operator ApiResult(Result result) - { - return new ApiResult(result); - } -} +using System.Net; +using System.Text.RegularExpressions; +using Mapster; +using Microsoft.AspNetCore.Http; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; + +namespace PlatformPlatform.SharedKernel.ApiCore.ApiResults; + +public class ApiResult(ResultBase result, string? routePrefix = null) : IResult +{ + protected string? RoutePrefix { get; } = routePrefix; + + public Task ExecuteAsync(HttpContext httpContext) + { + return ConvertResult().ExecuteAsync(httpContext); + } + + protected virtual IResult ConvertResult() + { + if (!result.IsSuccess) return GetProblemDetailsAsJson(); + + return RoutePrefix is null + ? Results.Ok() + : Results.Created($"{RoutePrefix}/{result}", null); + } + + protected IResult GetProblemDetailsAsJson() + { + return Results.Problem( + title: GetHttpStatusDisplayName(result.StatusCode), + statusCode: (int)result.StatusCode, + detail: result.ErrorMessage?.Message, + extensions: result.Errors?.Length > 0 + ? new Dictionary { { nameof(result.Errors), result.Errors } } + : null + ); + } + + public static string GetHttpStatusDisplayName(HttpStatusCode statusCode) + { + return Regex.Replace(statusCode.ToString(), "(?<=[a-z])([A-Z])", " $1", RegexOptions.None, TimeSpan.FromSeconds(1)); + } + + public static implicit operator ApiResult(Result result) + { + return new ApiResult(result); + } +} + +public sealed class ApiResult(Result result, string? routePrefix = null) : ApiResult(result, routePrefix) +{ + protected override IResult ConvertResult() + { + if (!result.IsSuccess) return GetProblemDetailsAsJson(); + + return RoutePrefix is null + ? Results.Ok(result.Value!.Adapt()) + : Results.Created($"{RoutePrefix}/{result.Value}", null); + } + + public static implicit operator ApiResult(Result result) + { + return new ApiResult(result); + } +} diff --git a/application/shared-kernel/ApiCore/ApiResults/ApiResultExtensions.cs b/application/shared-kernel/ApiCore/ApiResults/ApiResultExtensions.cs index 464281ba8..3d335ca32 100644 --- a/application/shared-kernel/ApiCore/ApiResults/ApiResultExtensions.cs +++ b/application/shared-kernel/ApiCore/ApiResults/ApiResultExtensions.cs @@ -1,11 +1,11 @@ -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; - -namespace PlatformPlatform.SharedKernel.ApiCore.ApiResults; - -public static class ApiResultExtensions -{ - public static ApiResult AddResourceUri(this Result result, string routePrefix) - { - return new ApiResult(result, routePrefix); - } -} +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; + +namespace PlatformPlatform.SharedKernel.ApiCore.ApiResults; + +public static class ApiResultExtensions +{ + public static ApiResult AddResourceUri(this Result result, string routePrefix) + { + return new ApiResult(result, routePrefix); + } +} diff --git a/application/shared-kernel/ApiCore/Aspire/ServiceDefaultsExtensions.cs b/application/shared-kernel/ApiCore/Aspire/ServiceDefaultsExtensions.cs index cba84c6db..89cc3f3d9 100644 --- a/application/shared-kernel/ApiCore/Aspire/ServiceDefaultsExtensions.cs +++ b/application/shared-kernel/ApiCore/Aspire/ServiceDefaultsExtensions.cs @@ -1,106 +1,106 @@ -using Azure.Monitor.OpenTelemetry.AspNetCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Hosting; -using OpenTelemetry.Instrumentation.AspNetCore; -using OpenTelemetry.Logs; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; -using PlatformPlatform.SharedKernel.ApiCore.Filters; - -namespace PlatformPlatform.SharedKernel.ApiCore.Aspire; - -public static class ServiceDefaultsExtensions -{ - public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) - { - builder.ConfigureOpenTelemetry(); - - builder.AddDefaultHealthChecks(); - - builder.Services.AddServiceDiscovery(); - - builder.Services.ConfigureHttpClientDefaults(http => - { - // Turn on resilience by default - http.AddStandardResilienceHandler(); - - // Turn on service discovery by default - http.AddServiceDiscovery(); - } - ); - - return builder; - } - - private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) - { - builder.Services.Configure(options => - { - options.Filter = httpContext => - { - // Add filtering to exclude health check endpoints - var requestPath = httpContext.Request.Path.ToString(); - return !Array.Exists(EndpointTelemetryFilter.ExcludedPaths, requestPath.StartsWith); - }; - } - ); - - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - } - ); - - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); - } - ) - .WithTracing(tracing => - { - // We want to view all traces in development - if (builder.Environment.IsDevelopment()) tracing.SetSampler(new AlwaysOnSampler()); - - tracing.AddAspNetCoreInstrumentation().AddGrpcClientInstrumentation().AddHttpClientInstrumentation(); - } - ); - - builder.AddOpenTelemetryExporters(); - - return builder; - } - - private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.Configure(logging => logging.AddOtlpExporter()); - builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); - builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); - } - - builder.Services.AddOpenTelemetry().UseAzureMonitor(options => - { - options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"] ?? - "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost"; - } - ); - - return builder; - } - - private static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) - { - // Add a default liveness check to ensure app is responsive - builder.Services.AddHealthChecks().AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - return builder; - } -} +using Azure.Monitor.OpenTelemetry.AspNetCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using OpenTelemetry.Instrumentation.AspNetCore; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using PlatformPlatform.SharedKernel.ApiCore.Filters; + +namespace PlatformPlatform.SharedKernel.ApiCore.Aspire; + +public static class ServiceDefaultsExtensions +{ + public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + } + ); + + return builder; + } + + private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Services.Configure(options => + { + options.Filter = httpContext => + { + // Add filtering to exclude health check endpoints + var requestPath = httpContext.Request.Path.ToString(); + return !Array.Exists(EndpointTelemetryFilter.ExcludedPaths, requestPath.StartsWith); + }; + } + ); + + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + } + ); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + } + ) + .WithTracing(tracing => + { + // We want to view all traces in development + if (builder.Environment.IsDevelopment()) tracing.SetSampler(new AlwaysOnSampler()); + + tracing.AddAspNetCoreInstrumentation().AddGrpcClientInstrumentation().AddHttpClientInstrumentation(); + } + ); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.Configure(logging => logging.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); + } + + builder.Services.AddOpenTelemetry().UseAzureMonitor(options => + { + options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"] ?? + "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://localhost;LiveEndpoint=https://localhost"; + } + ); + + return builder; + } + + private static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder) + { + // Add a default liveness check to ensure app is responsive + builder.Services.AddHealthChecks().AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } +} diff --git a/application/shared-kernel/ApiCore/Endpoints/HealthEndpoints.cs b/application/shared-kernel/ApiCore/Endpoints/HealthEndpoints.cs index 9ec0a2e8e..018678b1f 100644 --- a/application/shared-kernel/ApiCore/Endpoints/HealthEndpoints.cs +++ b/application/shared-kernel/ApiCore/Endpoints/HealthEndpoints.cs @@ -1,17 +1,17 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.AspNetCore.Routing; - -namespace PlatformPlatform.SharedKernel.ApiCore.Endpoints; - -public class HealthEndpoints : IEndpoints -{ - public void MapEndpoints(IEndpointRouteBuilder routes) - { - // All health checks must pass for app to be considered ready to accept traffic after starting - routes.MapHealthChecks("/health"); - - // Only health checks tagged with the "live" tag must pass for app to be considered alive - routes.MapHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); - } -} +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.Routing; + +namespace PlatformPlatform.SharedKernel.ApiCore.Endpoints; + +public class HealthEndpoints : IEndpoints +{ + public void MapEndpoints(IEndpointRouteBuilder routes) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + routes.MapHealthChecks("/health"); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + routes.MapHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") }); + } +} diff --git a/application/shared-kernel/ApiCore/Endpoints/IEndpoints.cs b/application/shared-kernel/ApiCore/Endpoints/IEndpoints.cs index a07cb29e8..395bf65d8 100644 --- a/application/shared-kernel/ApiCore/Endpoints/IEndpoints.cs +++ b/application/shared-kernel/ApiCore/Endpoints/IEndpoints.cs @@ -1,8 +1,8 @@ -using Microsoft.AspNetCore.Routing; - -namespace PlatformPlatform.SharedKernel.ApiCore.Endpoints; - -public interface IEndpoints -{ - public void MapEndpoints(IEndpointRouteBuilder routes); -} +using Microsoft.AspNetCore.Routing; + +namespace PlatformPlatform.SharedKernel.ApiCore.Endpoints; + +public interface IEndpoints +{ + public void MapEndpoints(IEndpointRouteBuilder routes); +} diff --git a/application/shared-kernel/ApiCore/Endpoints/TestEndpoints.cs b/application/shared-kernel/ApiCore/Endpoints/TestEndpoints.cs index a219b8712..5723f52d8 100644 --- a/application/shared-kernel/ApiCore/Endpoints/TestEndpoints.cs +++ b/application/shared-kernel/ApiCore/Endpoints/TestEndpoints.cs @@ -1,16 +1,16 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; - -namespace PlatformPlatform.SharedKernel.ApiCore.Endpoints; - -public class TestEndpoints : IEndpoints -{ - public void MapEndpoints(IEndpointRouteBuilder routes) - { - if (!bool.TryParse(Environment.GetEnvironmentVariable("TestEndpointsEnabled"), out _)) return; - - // Add dummy endpoints to simulate exception throwing for testing - routes.MapGet("/api/throwException", _ => throw new InvalidOperationException("Simulate an exception.")); - routes.MapGet("/api/throwTimeoutException", _ => throw new TimeoutException("Simulating a timeout exception.")); - } -} +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace PlatformPlatform.SharedKernel.ApiCore.Endpoints; + +public class TestEndpoints : IEndpoints +{ + public void MapEndpoints(IEndpointRouteBuilder routes) + { + if (!bool.TryParse(Environment.GetEnvironmentVariable("TestEndpointsEnabled"), out _)) return; + + // Add dummy endpoints to simulate exception throwing for testing + routes.MapGet("/api/throwException", _ => throw new InvalidOperationException("Simulate an exception.")); + routes.MapGet("/api/throwTimeoutException", _ => throw new TimeoutException("Simulating a timeout exception.")); + } +} diff --git a/application/shared-kernel/ApiCore/Endpoints/TrackEndpoints.cs b/application/shared-kernel/ApiCore/Endpoints/TrackEndpoints.cs index 41ea84c08..7936904db 100644 --- a/application/shared-kernel/ApiCore/Endpoints/TrackEndpoints.cs +++ b/application/shared-kernel/ApiCore/Endpoints/TrackEndpoints.cs @@ -1,241 +1,241 @@ -using Microsoft.ApplicationInsights; -using Microsoft.ApplicationInsights.DataContracts; -using Microsoft.ApplicationInsights.Extensibility.Implementation; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using NSwag.Annotations; -using StackFrame = Microsoft.ApplicationInsights.DataContracts.StackFrame; - -namespace PlatformPlatform.SharedKernel.ApiCore.Endpoints; - -public class TrackEndpoints : IEndpoints -{ - // - // Maps the track endpoints for usage of application insights in the web application - // Reason for this is to: - // * secure the instrumentation key - // * limit the amount of data that can be sent to application insights - // * allow IDE's to instrument and display telemetry data from the web application - // - public void MapEndpoints(IEndpointRouteBuilder routes) - { - routes.MapPost("/api/track", Track); - } - - [OpenApiIgnore] - private static TrackResponseSuccessDto Track( - HttpContext context, - List trackRequests, - TelemetryClient telemetryClient, - ILogger logger - ) - { - var ip = context.Connection.RemoteIpAddress?.ToString(); - foreach (var trackRequestDto in trackRequests) - { - switch (trackRequestDto.Data.BaseType) - { - case "PageviewData": - { - var telemetry = new PageViewTelemetry - { - Name = trackRequestDto.Data.BaseData.Name, - Url = new Uri(trackRequestDto.Data.BaseData.Url), - Duration = trackRequestDto.Data.BaseData.Duration, - Timestamp = trackRequestDto.Time, - Id = trackRequestDto.Data.BaseData.Id - }; - - CopyContextTags(telemetry.Context, trackRequestDto.Tags, ip); - CopyDictionary(trackRequestDto.Data.BaseData.Properties, telemetry.Properties); - CopyDictionary(trackRequestDto.Data.BaseData.Measurements, telemetry.Metrics); - - telemetryClient.TrackPageView(telemetry); - break; - } - case "PageviewPerformanceData": - { - var telemetry = new PageViewPerformanceTelemetry - { - Name = trackRequestDto.Data.BaseData.Name, - Url = new Uri(trackRequestDto.Data.BaseData.Url), - Duration = trackRequestDto.Data.BaseData.Duration, - Timestamp = trackRequestDto.Time, - Id = trackRequestDto.Data.BaseData.Id, - PerfTotal = trackRequestDto.Data.BaseData.PerfTotal, - NetworkConnect = trackRequestDto.Data.BaseData.NetworkConnect, - SentRequest = trackRequestDto.Data.BaseData.SentRequest, - ReceivedResponse = trackRequestDto.Data.BaseData.ReceivedResponse, - DomProcessing = trackRequestDto.Data.BaseData.DomProcessing - }; - - CopyContextTags(telemetry.Context, trackRequestDto.Tags, ip); - CopyDictionary(trackRequestDto.Data.BaseData.Properties, telemetry.Properties); - CopyDictionary(trackRequestDto.Data.BaseData.Measurements, telemetry.Metrics); - - telemetryClient.Track(telemetry); - break; - } - case "ExceptionData": - { - var exceptionDetailsInfos = GetExceptionDetailsInfos(trackRequestDto); - var telemetry = new ExceptionTelemetry(exceptionDetailsInfos, - trackRequestDto.Data.BaseData.SeverityLevel, trackRequestDto.Data.BaseType, - trackRequestDto.Data.BaseData.Properties, new Dictionary() - ) - { - SeverityLevel = trackRequestDto.Data.BaseData.SeverityLevel, - Timestamp = trackRequestDto.Time - }; - - CopyContextTags(telemetry.Context, trackRequestDto.Tags, ip); - CopyDictionary(trackRequestDto.Data.BaseData.Properties, telemetry.Properties); - CopyDictionary(trackRequestDto.Data.BaseData.Measurements, telemetry.Metrics); - - telemetryClient.TrackException(telemetry); - break; - } - case "MetricData": - { - foreach (var metric in trackRequestDto.Data.BaseData.Metrics) - { - var telemetry = new MetricTelemetry - { - Name = metric.Name, - Sum = metric.Value, - Count = metric.Count, - Timestamp = trackRequestDto.Time - }; - - CopyContextTags(telemetry.Context, trackRequestDto.Tags, ip); - CopyDictionary(trackRequestDto.Data.BaseData.Properties, telemetry.Properties); - - telemetryClient.TrackMetric(telemetry); - } - - break; - } - case "RemoteDependencyData": - { - // Ignore remote dependency data - break; - } - default: - { - logger.LogWarning("Unsupported telemetry type: {BaseType}", trackRequestDto.Data.BaseType); - break; - } - } - } - - return new TrackResponseSuccessDto(true, "Telemetry sent."); - } - - private static IEnumerable GetExceptionDetailsInfos(TrackRequestDto trackRequestDto) - { - var exceptionDetailsInfos = trackRequestDto.Data.BaseData.Exceptions - .Select(exception => new ExceptionDetailsInfo( - 0, - 0, - exception.TypeName, - exception.Message, - exception.HasFullStack, - exception.Stack, - exception.ParsedStack.Select(parsedStack => new StackFrame( - parsedStack.Assembly, - parsedStack.FileName, - parsedStack.Level, - parsedStack.Line, - parsedStack.Method - ) - ) - ) - ); - return exceptionDetailsInfos; - } - - private static void CopyContextTags(TelemetryContext context, Dictionary tags, string? ip) - { - context.Cloud.RoleInstance = tags.GetValueOrDefault("ai.cloud.roleInstance"); - context.Cloud.RoleName = tags.GetValueOrDefault("ai.cloud.roleName"); - - context.Component.Version = tags.GetValueOrDefault("ai.application.ver"); - - context.Device.Id = tags.GetValueOrDefault("ai.device.id"); - context.Device.Type = tags.GetValueOrDefault("ai.device.type"); - context.Device.Model = tags.GetValueOrDefault("ai.device.model"); - context.Device.OemName = tags.GetValueOrDefault("ai.device.oemName"); - context.Device.OperatingSystem = tags.GetValueOrDefault("ai.device.osVersion"); - - context.Location.Ip = ip; - - context.User.Id = tags.GetValueOrDefault("ai.user.id"); - context.User.AccountId = tags.GetValueOrDefault("ai.user.accountId"); - - context.Session.Id = tags.GetValueOrDefault("ai.session.id"); - - context.Operation.Id = tags.GetValueOrDefault("ai.operation.id"); - context.Operation.Name = tags.GetValueOrDefault("ai.operation.name"); - context.Operation.ParentId = tags.GetValueOrDefault("ai.operation.parentId"); - context.Operation.CorrelationVector = tags.GetValueOrDefault("ai.operation.correlationVector"); - context.Operation.SyntheticSource = tags.GetValueOrDefault("ai.operation.syntheticSource"); - - context.GetInternalContext().SdkVersion = tags.GetValueOrDefault("ai.internal.sdkVersion"); - context.GetInternalContext().AgentVersion = tags.GetValueOrDefault("ai.internal.agentVersion"); - context.GetInternalContext().NodeName = tags.GetValueOrDefault("ai.internal.nodeName"); - } - - private static void CopyDictionary(IDictionary? source, IDictionary target) - { - if (source == null) return; - - foreach (var pair in source) - { - if (string.IsNullOrEmpty(pair.Key) || target.ContainsKey(pair.Key)) continue; - target[pair.Key] = pair.Value; - } - } -} - -public record TrackResponseSuccessDto(bool Success, string Message); - -public record TrackRequestDto( - DateTimeOffset Time, - // ReSharper disable once InconsistentNaming - string IKey, - string Name, - Dictionary Tags, - TrackRequestDataDto Data -); - -public record TrackRequestDataDto(string BaseType, TrackRequestBaseDataDto BaseData); - -public record TrackRequestBaseDataDto( - string Name, - string Url, - TimeSpan Duration, - TimeSpan PerfTotal, - TimeSpan NetworkConnect, - TimeSpan SentRequest, - TimeSpan ReceivedResponse, - TimeSpan DomProcessing, - Dictionary Properties, - Dictionary Measurements, - List Metrics, - List Exceptions, - SeverityLevel SeverityLevel, - string Id -); - -public record TrackRequestMetricsDto(string Name, int Kind, double Value, int Count); - -public record TrackRequestExceptionDto( - string TypeName, - string Message, - bool HasFullStack, - string Stack, - List ParsedStack -); - -public record TrackRequestParsedStackDto(string Assembly, string FileName, string Method, int Line, int Level); +using Microsoft.ApplicationInsights; +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.ApplicationInsights.Extensibility.Implementation; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using NSwag.Annotations; +using StackFrame = Microsoft.ApplicationInsights.DataContracts.StackFrame; + +namespace PlatformPlatform.SharedKernel.ApiCore.Endpoints; + +public class TrackEndpoints : IEndpoints +{ + // + // Maps the track endpoints for usage of application insights in the web application + // Reason for this is to: + // * secure the instrumentation key + // * limit the amount of data that can be sent to application insights + // * allow IDE's to instrument and display telemetry data from the web application + // + public void MapEndpoints(IEndpointRouteBuilder routes) + { + routes.MapPost("/api/track", Track); + } + + [OpenApiIgnore] + private static TrackResponseSuccessDto Track( + HttpContext context, + List trackRequests, + TelemetryClient telemetryClient, + ILogger logger + ) + { + var ip = context.Connection.RemoteIpAddress?.ToString(); + foreach (var trackRequestDto in trackRequests) + { + switch (trackRequestDto.Data.BaseType) + { + case "PageviewData": + { + var telemetry = new PageViewTelemetry + { + Name = trackRequestDto.Data.BaseData.Name, + Url = new Uri(trackRequestDto.Data.BaseData.Url), + Duration = trackRequestDto.Data.BaseData.Duration, + Timestamp = trackRequestDto.Time, + Id = trackRequestDto.Data.BaseData.Id + }; + + CopyContextTags(telemetry.Context, trackRequestDto.Tags, ip); + CopyDictionary(trackRequestDto.Data.BaseData.Properties, telemetry.Properties); + CopyDictionary(trackRequestDto.Data.BaseData.Measurements, telemetry.Metrics); + + telemetryClient.TrackPageView(telemetry); + break; + } + case "PageviewPerformanceData": + { + var telemetry = new PageViewPerformanceTelemetry + { + Name = trackRequestDto.Data.BaseData.Name, + Url = new Uri(trackRequestDto.Data.BaseData.Url), + Duration = trackRequestDto.Data.BaseData.Duration, + Timestamp = trackRequestDto.Time, + Id = trackRequestDto.Data.BaseData.Id, + PerfTotal = trackRequestDto.Data.BaseData.PerfTotal, + NetworkConnect = trackRequestDto.Data.BaseData.NetworkConnect, + SentRequest = trackRequestDto.Data.BaseData.SentRequest, + ReceivedResponse = trackRequestDto.Data.BaseData.ReceivedResponse, + DomProcessing = trackRequestDto.Data.BaseData.DomProcessing + }; + + CopyContextTags(telemetry.Context, trackRequestDto.Tags, ip); + CopyDictionary(trackRequestDto.Data.BaseData.Properties, telemetry.Properties); + CopyDictionary(trackRequestDto.Data.BaseData.Measurements, telemetry.Metrics); + + telemetryClient.Track(telemetry); + break; + } + case "ExceptionData": + { + var exceptionDetailsInfos = GetExceptionDetailsInfos(trackRequestDto); + var telemetry = new ExceptionTelemetry(exceptionDetailsInfos, + trackRequestDto.Data.BaseData.SeverityLevel, trackRequestDto.Data.BaseType, + trackRequestDto.Data.BaseData.Properties, new Dictionary() + ) + { + SeverityLevel = trackRequestDto.Data.BaseData.SeverityLevel, + Timestamp = trackRequestDto.Time + }; + + CopyContextTags(telemetry.Context, trackRequestDto.Tags, ip); + CopyDictionary(trackRequestDto.Data.BaseData.Properties, telemetry.Properties); + CopyDictionary(trackRequestDto.Data.BaseData.Measurements, telemetry.Metrics); + + telemetryClient.TrackException(telemetry); + break; + } + case "MetricData": + { + foreach (var metric in trackRequestDto.Data.BaseData.Metrics) + { + var telemetry = new MetricTelemetry + { + Name = metric.Name, + Sum = metric.Value, + Count = metric.Count, + Timestamp = trackRequestDto.Time + }; + + CopyContextTags(telemetry.Context, trackRequestDto.Tags, ip); + CopyDictionary(trackRequestDto.Data.BaseData.Properties, telemetry.Properties); + + telemetryClient.TrackMetric(telemetry); + } + + break; + } + case "RemoteDependencyData": + { + // Ignore remote dependency data + break; + } + default: + { + logger.LogWarning("Unsupported telemetry type: {BaseType}", trackRequestDto.Data.BaseType); + break; + } + } + } + + return new TrackResponseSuccessDto(true, "Telemetry sent."); + } + + private static IEnumerable GetExceptionDetailsInfos(TrackRequestDto trackRequestDto) + { + var exceptionDetailsInfos = trackRequestDto.Data.BaseData.Exceptions + .Select(exception => new ExceptionDetailsInfo( + 0, + 0, + exception.TypeName, + exception.Message, + exception.HasFullStack, + exception.Stack, + exception.ParsedStack.Select(parsedStack => new StackFrame( + parsedStack.Assembly, + parsedStack.FileName, + parsedStack.Level, + parsedStack.Line, + parsedStack.Method + ) + ) + ) + ); + return exceptionDetailsInfos; + } + + private static void CopyContextTags(TelemetryContext context, Dictionary tags, string? ip) + { + context.Cloud.RoleInstance = tags.GetValueOrDefault("ai.cloud.roleInstance"); + context.Cloud.RoleName = tags.GetValueOrDefault("ai.cloud.roleName"); + + context.Component.Version = tags.GetValueOrDefault("ai.application.ver"); + + context.Device.Id = tags.GetValueOrDefault("ai.device.id"); + context.Device.Type = tags.GetValueOrDefault("ai.device.type"); + context.Device.Model = tags.GetValueOrDefault("ai.device.model"); + context.Device.OemName = tags.GetValueOrDefault("ai.device.oemName"); + context.Device.OperatingSystem = tags.GetValueOrDefault("ai.device.osVersion"); + + context.Location.Ip = ip; + + context.User.Id = tags.GetValueOrDefault("ai.user.id"); + context.User.AccountId = tags.GetValueOrDefault("ai.user.accountId"); + + context.Session.Id = tags.GetValueOrDefault("ai.session.id"); + + context.Operation.Id = tags.GetValueOrDefault("ai.operation.id"); + context.Operation.Name = tags.GetValueOrDefault("ai.operation.name"); + context.Operation.ParentId = tags.GetValueOrDefault("ai.operation.parentId"); + context.Operation.CorrelationVector = tags.GetValueOrDefault("ai.operation.correlationVector"); + context.Operation.SyntheticSource = tags.GetValueOrDefault("ai.operation.syntheticSource"); + + context.GetInternalContext().SdkVersion = tags.GetValueOrDefault("ai.internal.sdkVersion"); + context.GetInternalContext().AgentVersion = tags.GetValueOrDefault("ai.internal.agentVersion"); + context.GetInternalContext().NodeName = tags.GetValueOrDefault("ai.internal.nodeName"); + } + + private static void CopyDictionary(IDictionary? source, IDictionary target) + { + if (source == null) return; + + foreach (var pair in source) + { + if (string.IsNullOrEmpty(pair.Key) || target.ContainsKey(pair.Key)) continue; + target[pair.Key] = pair.Value; + } + } +} + +public record TrackResponseSuccessDto(bool Success, string Message); + +public record TrackRequestDto( + DateTimeOffset Time, + // ReSharper disable once InconsistentNaming + string IKey, + string Name, + Dictionary Tags, + TrackRequestDataDto Data +); + +public record TrackRequestDataDto(string BaseType, TrackRequestBaseDataDto BaseData); + +public record TrackRequestBaseDataDto( + string Name, + string Url, + TimeSpan Duration, + TimeSpan PerfTotal, + TimeSpan NetworkConnect, + TimeSpan SentRequest, + TimeSpan ReceivedResponse, + TimeSpan DomProcessing, + Dictionary Properties, + Dictionary Measurements, + List Metrics, + List Exceptions, + SeverityLevel SeverityLevel, + string Id +); + +public record TrackRequestMetricsDto(string Name, int Kind, double Value, int Count); + +public record TrackRequestExceptionDto( + string TypeName, + string Message, + bool HasFullStack, + string Stack, + List ParsedStack +); + +public record TrackRequestParsedStackDto(string Assembly, string FileName, string Method, int Line, int Level); diff --git a/application/shared-kernel/ApiCore/Filters/EndpointTelemetryFilter.cs b/application/shared-kernel/ApiCore/Filters/EndpointTelemetryFilter.cs index 7a449e931..11d532193 100644 --- a/application/shared-kernel/ApiCore/Filters/EndpointTelemetryFilter.cs +++ b/application/shared-kernel/ApiCore/Filters/EndpointTelemetryFilter.cs @@ -1,28 +1,28 @@ -using Microsoft.ApplicationInsights.Channel; -using Microsoft.ApplicationInsights.DataContracts; -using Microsoft.ApplicationInsights.Extensibility; - -namespace PlatformPlatform.SharedKernel.ApiCore.Filters; - -/// -/// Filter out telemetry from requests matching excluded paths -/// -public class EndpointTelemetryFilter(ITelemetryProcessor telemetryProcessor) : ITelemetryProcessor -{ - public static readonly string[] ExcludedPaths = ["/swagger", "/health", "/alive", "/api/track"]; - - public void Process(ITelemetry item) - { - if (item is RequestTelemetry requestTelemetry && IsExcludedPath(requestTelemetry)) - { - return; - } - - telemetryProcessor.Process(item); - } - - private bool IsExcludedPath(RequestTelemetry requestTelemetry) - { - return Array.Exists(ExcludedPaths, excludePath => requestTelemetry.Url.AbsolutePath.StartsWith(excludePath)); - } -} +using Microsoft.ApplicationInsights.Channel; +using Microsoft.ApplicationInsights.DataContracts; +using Microsoft.ApplicationInsights.Extensibility; + +namespace PlatformPlatform.SharedKernel.ApiCore.Filters; + +/// +/// Filter out telemetry from requests matching excluded paths +/// +public class EndpointTelemetryFilter(ITelemetryProcessor telemetryProcessor) : ITelemetryProcessor +{ + public static readonly string[] ExcludedPaths = ["/swagger", "/health", "/alive", "/api/track"]; + + public void Process(ITelemetry item) + { + if (item is RequestTelemetry requestTelemetry && IsExcludedPath(requestTelemetry)) + { + return; + } + + telemetryProcessor.Process(item); + } + + private bool IsExcludedPath(RequestTelemetry requestTelemetry) + { + return Array.Exists(ExcludedPaths, excludePath => requestTelemetry.Url.AbsolutePath.StartsWith(excludePath)); + } +} diff --git a/application/shared-kernel/ApiCore/Middleware/GlobalExceptionHandler.cs b/application/shared-kernel/ApiCore/Middleware/GlobalExceptionHandler.cs index 5e460592e..da53df401 100644 --- a/application/shared-kernel/ApiCore/Middleware/GlobalExceptionHandler.cs +++ b/application/shared-kernel/ApiCore/Middleware/GlobalExceptionHandler.cs @@ -1,29 +1,29 @@ -using System.Net; -using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Http; - -namespace PlatformPlatform.SharedKernel.ApiCore.Middleware; - -public sealed class GlobalExceptionHandler(ILogger logger) : IExceptionHandler -{ - public async ValueTask TryHandleAsync( - HttpContext httpContext, - Exception exception, - CancellationToken cancellationToken - ) - { - var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier; - - logger.LogError(exception, "An error occurred while processing the request. TraceId: {TraceId}.", traceId); - - await Results.Problem( - title: "Internal Server Error", - detail: "An error occurred while processing the request.", - statusCode: (int)HttpStatusCode.InternalServerError, - extensions: new Dictionary { { "traceId", traceId } } - ).ExecuteAsync(httpContext); - - // Return true to signal that this exception is handled - return true; - } -} +using System.Net; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; + +namespace PlatformPlatform.SharedKernel.ApiCore.Middleware; + +public sealed class GlobalExceptionHandler(ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken + ) + { + var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier; + + logger.LogError(exception, "An error occurred while processing the request. TraceId: {TraceId}.", traceId); + + await Results.Problem( + title: "Internal Server Error", + detail: "An error occurred while processing the request.", + statusCode: (int)HttpStatusCode.InternalServerError, + extensions: new Dictionary { { "traceId", traceId } } + ).ExecuteAsync(httpContext); + + // Return true to signal that this exception is handled + return true; + } +} diff --git a/application/shared-kernel/ApiCore/Middleware/ModelBindingExceptionHandlerMiddleware.cs b/application/shared-kernel/ApiCore/Middleware/ModelBindingExceptionHandlerMiddleware.cs index 61ed5a2fe..4fa88e1b2 100644 --- a/application/shared-kernel/ApiCore/Middleware/ModelBindingExceptionHandlerMiddleware.cs +++ b/application/shared-kernel/ApiCore/Middleware/ModelBindingExceptionHandlerMiddleware.cs @@ -1,23 +1,23 @@ -using System.Net; -using Microsoft.AspNetCore.Http; - -namespace PlatformPlatform.SharedKernel.ApiCore.Middleware; - -public sealed class ModelBindingExceptionHandlerMiddleware : IMiddleware -{ - public async Task InvokeAsync(HttpContext context, RequestDelegate next) - { - try - { - await next(context); - } - catch (BadHttpRequestException exception) - { - await Results.Problem( - title: "Bad Request", - detail: exception.Message, - statusCode: (int)HttpStatusCode.BadRequest - ).ExecuteAsync(context); - } - } -} +using System.Net; +using Microsoft.AspNetCore.Http; + +namespace PlatformPlatform.SharedKernel.ApiCore.Middleware; + +public sealed class ModelBindingExceptionHandlerMiddleware : IMiddleware +{ + public async Task InvokeAsync(HttpContext context, RequestDelegate next) + { + try + { + await next(context); + } + catch (BadHttpRequestException exception) + { + await Results.Problem( + title: "Bad Request", + detail: exception.Message, + statusCode: (int)HttpStatusCode.BadRequest + ).ExecuteAsync(context); + } + } +} diff --git a/application/shared-kernel/ApiCore/Middleware/TimeoutExceptionHandler.cs b/application/shared-kernel/ApiCore/Middleware/TimeoutExceptionHandler.cs index 1f62471a8..de036f226 100644 --- a/application/shared-kernel/ApiCore/Middleware/TimeoutExceptionHandler.cs +++ b/application/shared-kernel/ApiCore/Middleware/TimeoutExceptionHandler.cs @@ -1,31 +1,31 @@ -using System.Net; -using Microsoft.AspNetCore.Diagnostics; -using Microsoft.AspNetCore.Http; - -namespace PlatformPlatform.SharedKernel.ApiCore.Middleware; - -public sealed class TimeoutExceptionHandler(ILogger logger) : IExceptionHandler -{ - public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) - { - if (exception is not TimeoutException) - { - // Return false to continue with the default behavior - return false; - } - - var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier; - - logger.LogError(exception, "An timeout exception occurred while processing the request. TraceId: {TraceId}.", traceId); - - await Results.Problem( - title: "Request Timeout", - detail: $"{httpContext.Request.Method} {httpContext.Request.Path} {httpContext.Request.QueryString}".Trim(), - statusCode: (int)HttpStatusCode.RequestTimeout, - extensions: new Dictionary { { "traceId", traceId } } - ).ExecuteAsync(httpContext); - - // Return true to signal that this exception is handled - return true; - } -} +using System.Net; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; + +namespace PlatformPlatform.SharedKernel.ApiCore.Middleware; + +public sealed class TimeoutExceptionHandler(ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + if (exception is not TimeoutException) + { + // Return false to continue with the default behavior + return false; + } + + var traceId = Activity.Current?.Id ?? httpContext.TraceIdentifier; + + logger.LogError(exception, "An timeout exception occurred while processing the request. TraceId: {TraceId}.", traceId); + + await Results.Problem( + title: "Request Timeout", + detail: $"{httpContext.Request.Method} {httpContext.Request.Path} {httpContext.Request.QueryString}".Trim(), + statusCode: (int)HttpStatusCode.RequestTimeout, + extensions: new Dictionary { { "traceId", traceId } } + ).ExecuteAsync(httpContext); + + // Return true to signal that this exception is handled + return true; + } +} diff --git a/application/shared-kernel/ApiCore/Middleware/UserInfo.cs b/application/shared-kernel/ApiCore/Middleware/UserInfo.cs index 60234be13..bbaf07466 100644 --- a/application/shared-kernel/ApiCore/Middleware/UserInfo.cs +++ b/application/shared-kernel/ApiCore/Middleware/UserInfo.cs @@ -1,32 +1,32 @@ -using System.Security.Claims; - -namespace PlatformPlatform.SharedKernel.ApiCore.Middleware; - -public class UserInfo -{ - public UserInfo(ClaimsPrincipal user, string defaultLocale) - { - IsAuthenticated = user.Identity?.IsAuthenticated ?? false; - Locale = user.FindFirst("locale")?.Value ?? defaultLocale; - - if (IsAuthenticated) - { - Email = user.Identity?.Name; - Name = user.FindFirst(ClaimTypes.Name)?.Value; - Role = user.FindFirst(ClaimTypes.Role)?.Value; - TenantId = user.FindFirst("tenantId")?.Value; - } - } - - public bool IsAuthenticated { get; init; } - - public string Locale { get; init; } - - public string? Email { get; init; } - - public string? Name { get; init; } - - public string? Role { get; init; } - - public string? TenantId { get; init; } -} +using System.Security.Claims; + +namespace PlatformPlatform.SharedKernel.ApiCore.Middleware; + +public class UserInfo +{ + public UserInfo(ClaimsPrincipal user, string defaultLocale) + { + IsAuthenticated = user.Identity?.IsAuthenticated ?? false; + Locale = user.FindFirst("locale")?.Value ?? defaultLocale; + + if (IsAuthenticated) + { + Email = user.Identity?.Name; + Name = user.FindFirst(ClaimTypes.Name)?.Value; + Role = user.FindFirst(ClaimTypes.Role)?.Value; + TenantId = user.FindFirst("tenantId")?.Value; + } + } + + public bool IsAuthenticated { get; init; } + + public string Locale { get; init; } + + public string? Email { get; init; } + + public string? Name { get; init; } + + public string? Role { get; init; } + + public string? TenantId { get; init; } +} diff --git a/application/shared-kernel/ApiCore/Middleware/WebAppMiddleware.cs b/application/shared-kernel/ApiCore/Middleware/WebAppMiddleware.cs index 9a3fb971b..f2d9ee022 100644 --- a/application/shared-kernel/ApiCore/Middleware/WebAppMiddleware.cs +++ b/application/shared-kernel/ApiCore/Middleware/WebAppMiddleware.cs @@ -1,66 +1,66 @@ -using System.Text; -using System.Text.Json; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Json; -using Microsoft.AspNetCore.Localization; -using Microsoft.Extensions.Options; - -namespace PlatformPlatform.SharedKernel.ApiCore.Middleware; - -public sealed class WebAppMiddleware( - IOptions jsonOptions, - WebAppMiddlewareConfiguration webAppConfiguration -) - : IMiddleware -{ - public Task InvokeAsync(HttpContext context, RequestDelegate next) - { - if (context.Request.Path.ToString().StartsWith("/api/")) return next(context); - - SetResponseHttpHeaders(context.Response.Headers); - - var defaultLocale = context.Features.Get()?.RequestCulture.Culture.Name ?? "en-US"; - var userInfo = new UserInfo(context.User, defaultLocale); - var html = GetHtmlWithEnvironment(userInfo); - return context.Response.WriteAsync(html); - } - - private void SetResponseHttpHeaders(IHeaderDictionary responseHeaders) - { - // No cache headers - responseHeaders.Append("Cache-Control", "no-cache, no-store, must-revalidate"); - responseHeaders.Append("Pragma", "no-cache"); - - // Security policy headers - responseHeaders.Append("X-Content-Type-Options", "nosniff"); - responseHeaders.Append("X-Frame-Options", "DENY"); - responseHeaders.Append("X-XSS-Protection", "1; mode=block"); - responseHeaders.Append("Referrer-Policy", "no-referrer, strict-origin-when-cross-origin"); - responseHeaders.Append("Permissions-Policy", webAppConfiguration.PermissionPolicies); - - // Content security policy header - responseHeaders.Append("Content-Security-Policy", webAppConfiguration.ContentSecurityPolicies); - - // Content type header - responseHeaders.Append("Content-Type", "text/html; charset=utf-8"); - } - - private string GetHtmlWithEnvironment(UserInfo userInfo) - { - var encodedUserInfo = Convert.ToBase64String( - Encoding.UTF8.GetBytes(JsonSerializer.Serialize(userInfo, jsonOptions.Value.SerializerOptions)) - ); - - var html = webAppConfiguration.GetHtmlTemplate(); - html = html.Replace("%ENCODED_RUNTIME_ENV%", webAppConfiguration.StaticRuntimeEnvironmentEncoded); - html = html.Replace("%ENCODED_USER_INFO_ENV%", encodedUserInfo); - html = html.Replace("%LOCALE%", userInfo.Locale); - - foreach (var variable in webAppConfiguration.StaticRuntimeEnvironment) - { - html = html.Replace($"%{variable.Key}%", variable.Value); - } - - return html; - } -} +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.AspNetCore.Localization; +using Microsoft.Extensions.Options; + +namespace PlatformPlatform.SharedKernel.ApiCore.Middleware; + +public sealed class WebAppMiddleware( + IOptions jsonOptions, + WebAppMiddlewareConfiguration webAppConfiguration +) + : IMiddleware +{ + public Task InvokeAsync(HttpContext context, RequestDelegate next) + { + if (context.Request.Path.ToString().StartsWith("/api/")) return next(context); + + SetResponseHttpHeaders(context.Response.Headers); + + var defaultLocale = context.Features.Get()?.RequestCulture.Culture.Name ?? "en-US"; + var userInfo = new UserInfo(context.User, defaultLocale); + var html = GetHtmlWithEnvironment(userInfo); + return context.Response.WriteAsync(html); + } + + private void SetResponseHttpHeaders(IHeaderDictionary responseHeaders) + { + // No cache headers + responseHeaders.Append("Cache-Control", "no-cache, no-store, must-revalidate"); + responseHeaders.Append("Pragma", "no-cache"); + + // Security policy headers + responseHeaders.Append("X-Content-Type-Options", "nosniff"); + responseHeaders.Append("X-Frame-Options", "DENY"); + responseHeaders.Append("X-XSS-Protection", "1; mode=block"); + responseHeaders.Append("Referrer-Policy", "no-referrer, strict-origin-when-cross-origin"); + responseHeaders.Append("Permissions-Policy", webAppConfiguration.PermissionPolicies); + + // Content security policy header + responseHeaders.Append("Content-Security-Policy", webAppConfiguration.ContentSecurityPolicies); + + // Content type header + responseHeaders.Append("Content-Type", "text/html; charset=utf-8"); + } + + private string GetHtmlWithEnvironment(UserInfo userInfo) + { + var encodedUserInfo = Convert.ToBase64String( + Encoding.UTF8.GetBytes(JsonSerializer.Serialize(userInfo, jsonOptions.Value.SerializerOptions)) + ); + + var html = webAppConfiguration.GetHtmlTemplate(); + html = html.Replace("%ENCODED_RUNTIME_ENV%", webAppConfiguration.StaticRuntimeEnvironmentEncoded); + html = html.Replace("%ENCODED_USER_INFO_ENV%", encodedUserInfo); + html = html.Replace("%LOCALE%", userInfo.Locale); + + foreach (var variable in webAppConfiguration.StaticRuntimeEnvironment) + { + html = html.Replace($"%{variable.Key}%", variable.Value); + } + + return html; + } +} diff --git a/application/shared-kernel/ApiCore/Middleware/WebAppMiddlewareConfiguration.cs b/application/shared-kernel/ApiCore/Middleware/WebAppMiddlewareConfiguration.cs index 63cdf9f7a..8e25a00f4 100644 --- a/application/shared-kernel/ApiCore/Middleware/WebAppMiddlewareConfiguration.cs +++ b/application/shared-kernel/ApiCore/Middleware/WebAppMiddlewareConfiguration.cs @@ -1,150 +1,150 @@ -using System.Security; -using System.Text; -using System.Text.Json; -using Microsoft.AspNetCore.Http.Json; -using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; - -namespace PlatformPlatform.SharedKernel.ApiCore.Middleware; - -public class WebAppMiddlewareConfiguration -{ - public const string PublicUrlKey = "PUBLIC_URL"; - public const string CdnUrlKey = "CDN_URL"; - private const string PublicKeyPrefix = "PUBLIC_"; - private const string ApplicationVersionKey = "APPLICATION_VERSION"; - - public static readonly string HtmlTemplatePath = Path.Combine(GetWebAppDistRoot("WebApp", "dist"), "index.html"); - private readonly string[] _publicAllowedKeys = [CdnUrlKey, ApplicationVersionKey]; - private string? _htmlTemplate; - - public WebAppMiddlewareConfiguration(IOptions jsonOptions, bool isDevelopment) - { - // Environment variables are empty when generating EF Core migrations - PublicUrl = Environment.GetEnvironmentVariable(PublicUrlKey) ?? string.Empty; - CdnUrl = Environment.GetEnvironmentVariable(CdnUrlKey) ?? string.Empty; - var applicationVersion = Assembly.GetEntryAssembly()!.GetName().Version!.ToString(); - - StaticRuntimeEnvironment = new Dictionary - { - { PublicUrlKey, PublicUrl }, - { CdnUrlKey, CdnUrl }, - { ApplicationVersionKey, applicationVersion } - }; - - var json = JsonSerializer.Serialize(StaticRuntimeEnvironment, jsonOptions.Value.SerializerOptions); - StaticRuntimeEnvironmentEncoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); - - VerifyRuntimeEnvironment(StaticRuntimeEnvironment); - - BuildRootPath = GetWebAppDistRoot("WebApp", "dist"); - PermissionPolicies = GetPermissionsPolicies(); - ContentSecurityPolicies = GetContentSecurityPolicies(isDevelopment); - } - - private string CdnUrl { get; } - - private string PublicUrl { get; } - - public string BuildRootPath { get; } - - public Dictionary StaticRuntimeEnvironment { get; } - - public string StaticRuntimeEnvironmentEncoded { get; } - - public StringValues PermissionPolicies { get; } - - public string ContentSecurityPolicies { get; } - - public string GetHtmlTemplate() - { - if (_htmlTemplate is not null) - { - return _htmlTemplate; - } - - var retryCount = 0; - while (!File.Exists(HtmlTemplatePath) && retryCount++ < 10) - { - // When running locally, this code might be called while index.html is recreated, give it a few seconds to finish. - Thread.Sleep(TimeSpan.FromSeconds(1)); - } - - if (!File.Exists(HtmlTemplatePath)) - { - throw new FileNotFoundException("index.html does not exist.", HtmlTemplatePath); - } - - _htmlTemplate = File.ReadAllText(HtmlTemplatePath, new UTF8Encoding()); - return _htmlTemplate; - } - - private static string GetWebAppDistRoot(string webAppProjectName, string webAppDistRootName) - { - var assemblyPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; - - var directoryInfo = new DirectoryInfo(assemblyPath); - while (directoryInfo is not null && - directoryInfo.GetDirectories(webAppProjectName).Length == 0 && - !Path.Exists(Path.Join(directoryInfo.FullName, webAppProjectName, webAppDistRootName)) - ) - { - directoryInfo = directoryInfo.Parent; - } - - return Path.Join(directoryInfo!.FullName, webAppProjectName, webAppDistRootName); - } - - private StringValues GetPermissionsPolicies() - { - var permissionsPolicies = new Dictionary - { - { "geolocation", [] }, - { "microphone", [] }, - { "camera", [] }, - { "picture-in-picture", [] }, - { "display-capture", [] }, - { "fullscreen", [] }, - { "web-share", [] }, - { "identity-credentials-get", [] } - }; - - return string.Join(", ", permissionsPolicies.Select(p => $"{p.Key}=({string.Join(", ", p.Value)})")); - } - - private string GetContentSecurityPolicies(bool isDevelopment) - { - var trustedCdnHosts = "https://platformplatformgithub.blob.core.windows.net"; - var trustedHosts = $"{PublicUrl} {CdnUrl} {trustedCdnHosts}"; - - if (isDevelopment) - { - var webSocketHost = CdnUrl.Replace("https", "wss"); - trustedHosts += $" {webSocketHost}"; - } - - var contentSecurityPolicies = new[] - { - $"script-src {trustedHosts} 'strict-dynamic' https:", - $"script-src-elem {trustedHosts}", - $"default-src {trustedHosts}", - $"connect-src {trustedHosts}", - $"img-src {trustedHosts} data:", - "object-src 'none'", - "base-uri 'none'" - // "require-trusted-types-for 'script'" - }; - - return string.Join(";", contentSecurityPolicies); - } - - private void VerifyRuntimeEnvironment(Dictionary environmentVariables) - { - foreach (var key in environmentVariables.Keys) - { - if (key.StartsWith(PublicKeyPrefix) || _publicAllowedKeys.Contains(key)) continue; - - throw new SecurityException($"Environment variable '{key}' is not allowed to be public."); - } - } -} +using System.Security; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +namespace PlatformPlatform.SharedKernel.ApiCore.Middleware; + +public class WebAppMiddlewareConfiguration +{ + public const string PublicUrlKey = "PUBLIC_URL"; + public const string CdnUrlKey = "CDN_URL"; + private const string PublicKeyPrefix = "PUBLIC_"; + private const string ApplicationVersionKey = "APPLICATION_VERSION"; + + public static readonly string HtmlTemplatePath = Path.Combine(GetWebAppDistRoot("WebApp", "dist"), "index.html"); + private readonly string[] _publicAllowedKeys = [CdnUrlKey, ApplicationVersionKey]; + private string? _htmlTemplate; + + public WebAppMiddlewareConfiguration(IOptions jsonOptions, bool isDevelopment) + { + // Environment variables are empty when generating EF Core migrations + PublicUrl = Environment.GetEnvironmentVariable(PublicUrlKey) ?? string.Empty; + CdnUrl = Environment.GetEnvironmentVariable(CdnUrlKey) ?? string.Empty; + var applicationVersion = Assembly.GetEntryAssembly()!.GetName().Version!.ToString(); + + StaticRuntimeEnvironment = new Dictionary + { + { PublicUrlKey, PublicUrl }, + { CdnUrlKey, CdnUrl }, + { ApplicationVersionKey, applicationVersion } + }; + + var json = JsonSerializer.Serialize(StaticRuntimeEnvironment, jsonOptions.Value.SerializerOptions); + StaticRuntimeEnvironmentEncoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + + VerifyRuntimeEnvironment(StaticRuntimeEnvironment); + + BuildRootPath = GetWebAppDistRoot("WebApp", "dist"); + PermissionPolicies = GetPermissionsPolicies(); + ContentSecurityPolicies = GetContentSecurityPolicies(isDevelopment); + } + + private string CdnUrl { get; } + + private string PublicUrl { get; } + + public string BuildRootPath { get; } + + public Dictionary StaticRuntimeEnvironment { get; } + + public string StaticRuntimeEnvironmentEncoded { get; } + + public StringValues PermissionPolicies { get; } + + public string ContentSecurityPolicies { get; } + + public string GetHtmlTemplate() + { + if (_htmlTemplate is not null) + { + return _htmlTemplate; + } + + var retryCount = 0; + while (!File.Exists(HtmlTemplatePath) && retryCount++ < 10) + { + // When running locally, this code might be called while index.html is recreated, give it a few seconds to finish. + Thread.Sleep(TimeSpan.FromSeconds(1)); + } + + if (!File.Exists(HtmlTemplatePath)) + { + throw new FileNotFoundException("index.html does not exist.", HtmlTemplatePath); + } + + _htmlTemplate = File.ReadAllText(HtmlTemplatePath, new UTF8Encoding()); + return _htmlTemplate; + } + + private static string GetWebAppDistRoot(string webAppProjectName, string webAppDistRootName) + { + var assemblyPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; + + var directoryInfo = new DirectoryInfo(assemblyPath); + while (directoryInfo is not null && + directoryInfo.GetDirectories(webAppProjectName).Length == 0 && + !Path.Exists(Path.Join(directoryInfo.FullName, webAppProjectName, webAppDistRootName)) + ) + { + directoryInfo = directoryInfo.Parent; + } + + return Path.Join(directoryInfo!.FullName, webAppProjectName, webAppDistRootName); + } + + private StringValues GetPermissionsPolicies() + { + var permissionsPolicies = new Dictionary + { + { "geolocation", [] }, + { "microphone", [] }, + { "camera", [] }, + { "picture-in-picture", [] }, + { "display-capture", [] }, + { "fullscreen", [] }, + { "web-share", [] }, + { "identity-credentials-get", [] } + }; + + return string.Join(", ", permissionsPolicies.Select(p => $"{p.Key}=({string.Join(", ", p.Value)})")); + } + + private string GetContentSecurityPolicies(bool isDevelopment) + { + var trustedCdnHosts = "https://platformplatformgithub.blob.core.windows.net"; + var trustedHosts = $"{PublicUrl} {CdnUrl} {trustedCdnHosts}"; + + if (isDevelopment) + { + var webSocketHost = CdnUrl.Replace("https", "wss"); + trustedHosts += $" {webSocketHost}"; + } + + var contentSecurityPolicies = new[] + { + $"script-src {trustedHosts} 'strict-dynamic' https:", + $"script-src-elem {trustedHosts}", + $"default-src {trustedHosts}", + $"connect-src {trustedHosts}", + $"img-src {trustedHosts} data:", + "object-src 'none'", + "base-uri 'none'" + // "require-trusted-types-for 'script'" + }; + + return string.Join(";", contentSecurityPolicies); + } + + private void VerifyRuntimeEnvironment(Dictionary environmentVariables) + { + foreach (var key in environmentVariables.Keys) + { + if (key.StartsWith(PublicKeyPrefix) || _publicAllowedKeys.Contains(key)) continue; + + throw new SecurityException($"Environment variable '{key}' is not allowed to be public."); + } + } +} diff --git a/application/shared-kernel/ApiCore/Middleware/WebAppMiddlewareExtensions.cs b/application/shared-kernel/ApiCore/Middleware/WebAppMiddlewareExtensions.cs index a5da1b6f1..9e851b82f 100644 --- a/application/shared-kernel/ApiCore/Middleware/WebAppMiddlewareExtensions.cs +++ b/application/shared-kernel/ApiCore/Middleware/WebAppMiddlewareExtensions.cs @@ -1,34 +1,42 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http.Json; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; - -namespace PlatformPlatform.SharedKernel.ApiCore.Middleware; - -public static class WebAppMiddlewareExtensions -{ - public static IServiceCollection AddWebAppMiddleware(this IServiceCollection services) - { - return services.AddSingleton(serviceProvider => - { - var jsonOptions = serviceProvider.GetRequiredService>(); - var environment = serviceProvider.GetRequiredService(); - return new WebAppMiddlewareConfiguration(jsonOptions, environment.IsDevelopment()); - } - ) - .AddTransient(); - } - - public static IApplicationBuilder UseWebAppMiddleware(this IApplicationBuilder app) - { - var webAppConfiguration = app.ApplicationServices.GetRequiredService(); - - return app - .UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(webAppConfiguration.BuildRootPath) }) - .UseRequestLocalization("en-US", "da-DK") - .UseMiddleware(); - } -} +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace PlatformPlatform.SharedKernel.ApiCore.Middleware; + +public static class WebAppMiddlewareExtensions +{ + public static IServiceCollection AddWebAppMiddleware(this IServiceCollection services) + { + return services.AddSingleton(serviceProvider => + { + var jsonOptions = serviceProvider.GetRequiredService>(); + var environment = serviceProvider.GetRequiredService(); + return new WebAppMiddlewareConfiguration(jsonOptions, environment.IsDevelopment()); + } + ) + .AddTransient(); + } + + public static IApplicationBuilder UseWebAppMiddleware(this IApplicationBuilder app) + { + var webAppConfiguration = app.ApplicationServices.GetRequiredService(); + + // loop for max 10 seconds until the file webAppConfiguration.BuildRootPath exists + var timeout = DateTime.UtcNow.AddSeconds(10); + while (!Directory.Exists(webAppConfiguration.BuildRootPath) && DateTime.UtcNow < timeout) + { + if (File.Exists(webAppConfiguration.BuildRootPath)) break; + Thread.Sleep(100); + } + + return app + .UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(webAppConfiguration.BuildRootPath) }) + .UseRequestLocalization("en-US", "da-DK") + .UseMiddleware(); + } +} diff --git a/application/shared-kernel/ApplicationCore/ApplicationCoreConfiguration.cs b/application/shared-kernel/ApplicationCore/ApplicationCoreConfiguration.cs index 0ddedc349..1988d3453 100644 --- a/application/shared-kernel/ApplicationCore/ApplicationCoreConfiguration.cs +++ b/application/shared-kernel/ApplicationCore/ApplicationCoreConfiguration.cs @@ -1,43 +1,43 @@ -using FluentValidation; -using Microsoft.Extensions.DependencyInjection; -using PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.SharedKernel.ApplicationCore; - -public static class ApplicationCoreConfiguration -{ - public static IServiceCollection AddApplicationCoreServices(this IServiceCollection services, Assembly applicationAssembly) - { - // Order is important! First all Pre behaviors run, then the command is handled, then all Post behaviors run. - // So Validation -> Command -> PublishDomainEvents -> UnitOfWork -> PublishTelemetryEvents. - services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationPipelineBehavior<,>)); // Pre - services.AddTransient(typeof(IPipelineBehavior<,>), typeof(PublishTelemetryEventsPipelineBehavior<,>)); // Post - services.AddTransient(typeof(IPipelineBehavior<,>), typeof(UnitOfWorkPipelineBehavior<,>)); // Post - services.AddTransient(typeof(IPipelineBehavior<,>), typeof(PublishDomainEventsPipelineBehavior<,>)); // Post - services.AddScoped(); - services.AddScoped(); - - services.AddMediatR(configuration => configuration.RegisterServicesFromAssemblies(applicationAssembly)); - services.AddNonGenericValidators(applicationAssembly); - - return services; - } - - /// - /// Registers all non-generic and non-abstract validators in the specified assembly. This is necessary because - /// services.AddValidatorsFromAssembly() includes registration of generic and abstract validators. - /// - private static void AddNonGenericValidators(this IServiceCollection services, Assembly assembly) - { - var validators = assembly.GetTypes() - .Where(type => type is { IsClass: true, IsAbstract: false, IsGenericTypeDefinition: false }) - .SelectMany(type => type.GetInterfaces(), (type, interfaceType) => new { type, interfaceType }) - .Where(t => t.interfaceType.IsGenericType && t.interfaceType.GetGenericTypeDefinition() == typeof(IValidator<>)); - - foreach (var validator in validators) - { - services.AddTransient(validator.interfaceType, validator.type); - } - } -} +using FluentValidation; +using Microsoft.Extensions.DependencyInjection; +using PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.SharedKernel.ApplicationCore; + +public static class ApplicationCoreConfiguration +{ + public static IServiceCollection AddApplicationCoreServices(this IServiceCollection services, Assembly applicationAssembly) + { + // Order is important! First all Pre behaviors run, then the command is handled, then all Post behaviors run. + // So Validation -> Command -> PublishDomainEvents -> UnitOfWork -> PublishTelemetryEvents. + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationPipelineBehavior<,>)); // Pre + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(PublishTelemetryEventsPipelineBehavior<,>)); // Post + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(UnitOfWorkPipelineBehavior<,>)); // Post + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(PublishDomainEventsPipelineBehavior<,>)); // Post + services.AddScoped(); + services.AddScoped(); + + services.AddMediatR(configuration => configuration.RegisterServicesFromAssemblies(applicationAssembly)); + services.AddNonGenericValidators(applicationAssembly); + + return services; + } + + /// + /// Registers all non-generic and non-abstract validators in the specified assembly. This is necessary because + /// services.AddValidatorsFromAssembly() includes registration of generic and abstract validators. + /// + private static void AddNonGenericValidators(this IServiceCollection services, Assembly assembly) + { + var validators = assembly.GetTypes() + .Where(type => type is { IsClass: true, IsAbstract: false, IsGenericTypeDefinition: false }) + .SelectMany(type => type.GetInterfaces(), (type, interfaceType) => new { type, interfaceType }) + .Where(t => t.interfaceType.IsGenericType && t.interfaceType.GetGenericTypeDefinition() == typeof(IValidator<>)); + + foreach (var validator in validators) + { + services.AddTransient(validator.interfaceType, validator.type); + } + } +} diff --git a/application/shared-kernel/ApplicationCore/Behaviors/ConcurrentCommandCounter.cs b/application/shared-kernel/ApplicationCore/Behaviors/ConcurrentCommandCounter.cs index 7977545f9..aa20571d4 100644 --- a/application/shared-kernel/ApplicationCore/Behaviors/ConcurrentCommandCounter.cs +++ b/application/shared-kernel/ApplicationCore/Behaviors/ConcurrentCommandCounter.cs @@ -1,29 +1,29 @@ -namespace PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; - -/// -/// The ConcurrentCommandCounter class is a concurrent counter used to count the number of concurrent commands that -/// are being handled. It is used by only commit changes to the database when all commands have been handled. -/// This is to ensure that all changes to all aggregates and entities are committed to the database only after all -/// command and domain events are successfully handled. -/// Additionally, this also ensures that Telemetry is only sent to Application Insights after all commands and -/// domain events are successfully handled. -/// -public sealed class ConcurrentCommandCounter -{ - private int _concurrentCount; - - public void Increment() - { - Interlocked.Increment(ref _concurrentCount); - } - - public void Decrement() - { - Interlocked.Decrement(ref _concurrentCount); - } - - public bool IsZero() - { - return _concurrentCount == 0; - } -} +namespace PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; + +/// +/// The ConcurrentCommandCounter class is a concurrent counter used to count the number of concurrent commands that +/// are being handled. It is used by only commit changes to the database when all commands have been handled. +/// This is to ensure that all changes to all aggregates and entities are committed to the database only after all +/// command and domain events are successfully handled. +/// Additionally, this also ensures that Telemetry is only sent to Application Insights after all commands and +/// domain events are successfully handled. +/// +public sealed class ConcurrentCommandCounter +{ + private int _concurrentCount; + + public void Increment() + { + Interlocked.Increment(ref _concurrentCount); + } + + public void Decrement() + { + Interlocked.Decrement(ref _concurrentCount); + } + + public bool IsZero() + { + return _concurrentCount == 0; + } +} diff --git a/application/shared-kernel/ApplicationCore/Behaviors/PublishDomainEventsPipelineBehavior.cs b/application/shared-kernel/ApplicationCore/Behaviors/PublishDomainEventsPipelineBehavior.cs index 77ae4a850..ec3bacca3 100644 --- a/application/shared-kernel/ApplicationCore/Behaviors/PublishDomainEventsPipelineBehavior.cs +++ b/application/shared-kernel/ApplicationCore/Behaviors/PublishDomainEventsPipelineBehavior.cs @@ -1,40 +1,40 @@ -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.DomainCore.DomainEvents; - -namespace PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; - -/// -/// This method publishes any domain events were generated during the execution of a command (and added to aggregates). -/// It's crucial to understand that domain event handlers are not supposed to produce any external side effects outside -/// the current transaction/UnitOfWork. For instance, they can be utilized to create, update, or delete aggregates, -/// or they could be used to update read models. However, they should not be used to invoke other services (e.g., send -/// -public sealed class PublishDomainEventsPipelineBehavior( - IDomainEventCollector domainEventCollector, - IPublisher mediator -) : IPipelineBehavior where TRequest : ICommand where TResponse : ResultBase -{ - public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) - { - var response = await next(); - - while (true) - { - var aggregatesWithDomainEvents = domainEventCollector.GetAggregatesWithDomainEvents(); - - if (aggregatesWithDomainEvents.Length == 0) break; - - foreach (var aggregate in aggregatesWithDomainEvents) - { - var domainEvents = aggregate.GetAndClearDomainEvents(); - foreach (var domainEvent in domainEvents) - { - // Publish the domain event using MediatR. Registered event handlers will be invoked immediately - await mediator.Publish(domainEvent, cancellationToken); - } - } - } - - return response; - } -} +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.DomainCore.DomainEvents; + +namespace PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; + +/// +/// This method publishes any domain events were generated during the execution of a command (and added to aggregates). +/// It's crucial to understand that domain event handlers are not supposed to produce any external side effects outside +/// the current transaction/UnitOfWork. For instance, they can be utilized to create, update, or delete aggregates, +/// or they could be used to update read models. However, they should not be used to invoke other services (e.g., send +/// +public sealed class PublishDomainEventsPipelineBehavior( + IDomainEventCollector domainEventCollector, + IPublisher mediator +) : IPipelineBehavior where TRequest : ICommand where TResponse : ResultBase +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var response = await next(); + + while (true) + { + var aggregatesWithDomainEvents = domainEventCollector.GetAggregatesWithDomainEvents(); + + if (aggregatesWithDomainEvents.Length == 0) break; + + foreach (var aggregate in aggregatesWithDomainEvents) + { + var domainEvents = aggregate.GetAndClearDomainEvents(); + foreach (var domainEvent in domainEvents) + { + // Publish the domain event using MediatR. Registered event handlers will be invoked immediately + await mediator.Publish(domainEvent, cancellationToken); + } + } + } + + return response; + } +} diff --git a/application/shared-kernel/ApplicationCore/Behaviors/PublishTelemetryEventsPipelineBehavior.cs b/application/shared-kernel/ApplicationCore/Behaviors/PublishTelemetryEventsPipelineBehavior.cs index 4658571ef..4d6d29f8e 100644 --- a/application/shared-kernel/ApplicationCore/Behaviors/PublishTelemetryEventsPipelineBehavior.cs +++ b/application/shared-kernel/ApplicationCore/Behaviors/PublishTelemetryEventsPipelineBehavior.cs @@ -1,28 +1,28 @@ -using Microsoft.ApplicationInsights; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; - -public sealed class PublishTelemetryEventsPipelineBehavior( - ITelemetryEventsCollector telemetryEventsCollector, - TelemetryClient telemetryClient, - ConcurrentCommandCounter concurrentCommandCounter -) : IPipelineBehavior where TRequest : ICommand where TResponse : ResultBase -{ - public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) - { - var response = await next(); - - if (concurrentCommandCounter.IsZero()) - { - while (telemetryEventsCollector.HasEvents) - { - var telemetryEvent = telemetryEventsCollector.Dequeue(); - telemetryClient.TrackEvent(telemetryEvent.Name, telemetryEvent.Properties); - } - } - - return response; - } -} +using Microsoft.ApplicationInsights; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; + +public sealed class PublishTelemetryEventsPipelineBehavior( + ITelemetryEventsCollector telemetryEventsCollector, + TelemetryClient telemetryClient, + ConcurrentCommandCounter concurrentCommandCounter +) : IPipelineBehavior where TRequest : ICommand where TResponse : ResultBase +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + var response = await next(); + + if (concurrentCommandCounter.IsZero()) + { + while (telemetryEventsCollector.HasEvents) + { + var telemetryEvent = telemetryEventsCollector.Dequeue(); + telemetryClient.TrackEvent(telemetryEvent.Name, telemetryEvent.Properties); + } + } + + return response; + } +} diff --git a/application/shared-kernel/ApplicationCore/Behaviors/UnitOfWorkPipelineBehavior.cs b/application/shared-kernel/ApplicationCore/Behaviors/UnitOfWorkPipelineBehavior.cs index 15b56abfe..1b2f886ef 100644 --- a/application/shared-kernel/ApplicationCore/Behaviors/UnitOfWorkPipelineBehavior.cs +++ b/application/shared-kernel/ApplicationCore/Behaviors/UnitOfWorkPipelineBehavior.cs @@ -1,32 +1,32 @@ -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.DomainCore.Persistence; - -namespace PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; - -/// -/// The UnitOfWorkPipelineBehavior class is a MediatR pipeline behavior that encapsulates the unit of work pattern. -/// It is called after the handling of a Command and after handling all Domain Events. The pipeline ensures that all -/// changes to all aggregates and entities are committed to the database only after the command and domain events -/// are successfully handled. If an exception occurs the UnitOfWork.Commit will never be called, and all changes -/// will be lost. -/// -public sealed class UnitOfWorkPipelineBehavior(IUnitOfWork unitOfWork, ConcurrentCommandCounter concurrentCommandCounter) - : IPipelineBehavior where TRequest : ICommand where TResponse : ResultBase -{ - public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) - { - concurrentCommandCounter.Increment(); - var response = await next(); - - if (response is ResultBase { IsSuccess: true } || response.CommitChangesOnFailure) - { - concurrentCommandCounter.Decrement(); - if (concurrentCommandCounter.IsZero()) - { - await unitOfWork.CommitAsync(cancellationToken); - } - } - - return response; - } -} +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.DomainCore.Persistence; + +namespace PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; + +/// +/// The UnitOfWorkPipelineBehavior class is a MediatR pipeline behavior that encapsulates the unit of work pattern. +/// It is called after the handling of a Command and after handling all Domain Events. The pipeline ensures that all +/// changes to all aggregates and entities are committed to the database only after the command and domain events +/// are successfully handled. If an exception occurs the UnitOfWork.Commit will never be called, and all changes +/// will be lost. +/// +public sealed class UnitOfWorkPipelineBehavior(IUnitOfWork unitOfWork, ConcurrentCommandCounter concurrentCommandCounter) + : IPipelineBehavior where TRequest : ICommand where TResponse : ResultBase +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + concurrentCommandCounter.Increment(); + var response = await next(); + + if (response is ResultBase { IsSuccess: true } || response.CommitChangesOnFailure) + { + concurrentCommandCounter.Decrement(); + if (concurrentCommandCounter.IsZero()) + { + await unitOfWork.CommitAsync(cancellationToken); + } + } + + return response; + } +} diff --git a/application/shared-kernel/ApplicationCore/Behaviors/ValidationPipelineBehavior.cs b/application/shared-kernel/ApplicationCore/Behaviors/ValidationPipelineBehavior.cs index 325cfc672..411162501 100644 --- a/application/shared-kernel/ApplicationCore/Behaviors/ValidationPipelineBehavior.cs +++ b/application/shared-kernel/ApplicationCore/Behaviors/ValidationPipelineBehavior.cs @@ -1,52 +1,52 @@ -using System.Net; -using FluentValidation; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.ApplicationCore.Validation; - -namespace PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; - -/// -/// The ValidationPipelineBehavior class is a MediatR pipeline behavior that validates the request using -/// FluentValidation. If the request is not valid, the pipeline will be short-circuited and the request will not be -/// handled. If the request is valid, the next pipeline behavior will be called. -/// -public sealed class ValidationPipelineBehavior(IEnumerable> validators) - : IPipelineBehavior where TRequest : ICommand where TResponse : ResultBase -{ - public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) - { - if (validators.Any()) - { - var context = new ValidationContext(request); - - // Run all validators in parallel and await the results - var validationResults = - await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken))); - - // Aggregate the results from all validators into a distinct list of errorDetails - var errorDetails = validationResults - .SelectMany(result => result.Errors) - .Where(failure => failure != null) - .Select(failure => new ErrorDetail(failure.PropertyName.Split('.')[0], failure.ErrorMessage)) - .Distinct() - .ToArray(); - - if (errorDetails.Length > 0) - { - return CreateValidationResult(errorDetails); - } - } - - return await next(); - } - - /// - /// Uses reflection to create a new instance of the specified Result type, passing the errorDetails to the - /// constructor. - /// - private static TResult CreateValidationResult(ErrorDetail[] errorDetails) - where TResult : ResultBase - { - return (TResult)Activator.CreateInstance(typeof(TResult), HttpStatusCode.BadRequest, null, false, errorDetails)!; - } -} +using System.Net; +using FluentValidation; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.ApplicationCore.Validation; + +namespace PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; + +/// +/// The ValidationPipelineBehavior class is a MediatR pipeline behavior that validates the request using +/// FluentValidation. If the request is not valid, the pipeline will be short-circuited and the request will not be +/// handled. If the request is valid, the next pipeline behavior will be called. +/// +public sealed class ValidationPipelineBehavior(IEnumerable> validators) + : IPipelineBehavior where TRequest : ICommand where TResponse : ResultBase +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) + { + if (validators.Any()) + { + var context = new ValidationContext(request); + + // Run all validators in parallel and await the results + var validationResults = + await Task.WhenAll(validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + // Aggregate the results from all validators into a distinct list of errorDetails + var errorDetails = validationResults + .SelectMany(result => result.Errors) + .Where(failure => failure != null) + .Select(failure => new ErrorDetail(failure.PropertyName.Split('.')[0], failure.ErrorMessage)) + .Distinct() + .ToArray(); + + if (errorDetails.Length > 0) + { + return CreateValidationResult(errorDetails); + } + } + + return await next(); + } + + /// + /// Uses reflection to create a new instance of the specified Result type, passing the errorDetails to the + /// constructor. + /// + private static TResult CreateValidationResult(ErrorDetail[] errorDetails) + where TResult : ResultBase + { + return (TResult)Activator.CreateInstance(typeof(TResult), HttpStatusCode.BadRequest, null, false, errorDetails)!; + } +} diff --git a/application/shared-kernel/ApplicationCore/Cqrs/Result.cs b/application/shared-kernel/ApplicationCore/Cqrs/Result.cs index d45ff0fe1..fd0bfb7e2 100644 --- a/application/shared-kernel/ApplicationCore/Cqrs/Result.cs +++ b/application/shared-kernel/ApplicationCore/Cqrs/Result.cs @@ -1,157 +1,157 @@ -using System.Net; -using PlatformPlatform.SharedKernel.ApplicationCore.Validation; - -namespace PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; - -public abstract class ResultBase -{ - protected ResultBase(HttpStatusCode httpStatusCode) - { - IsSuccess = true; - StatusCode = httpStatusCode; - } - - protected ResultBase(HttpStatusCode statusCode, ErrorMessage errorMessage, bool commitChanges, ErrorDetail[] errors) - { - IsSuccess = false; - StatusCode = statusCode; - ErrorMessage = errorMessage; - CommitChangesOnFailure = commitChanges; - Errors = errors; - } - - public bool IsSuccess { get; } - - public HttpStatusCode StatusCode { get; } - - public ErrorMessage? ErrorMessage { get; } - - public bool CommitChangesOnFailure { get; } - - public ErrorDetail[]? Errors { get; } - - public string GetErrorSummary() - { - return ErrorMessage?.Message ?? string.Join(Environment.NewLine, Errors!.Select(ed => $"{ed.Code}: {ed.Message}")); - } -} - -/// -/// The Result class is used when a successful result is not returning any value (e.g. in the case of an Update or -/// Delete). On success the HttpStatusCode NoContent will be returned. In the case of a failure, the result will -/// contain either an or a collection of a . -/// -public sealed class Result : ResultBase -{ - private Result(HttpStatusCode httpStatusCode) : base(httpStatusCode) - { - } - - public Result(HttpStatusCode statusCode, ErrorMessage errorMessage, bool commitChanges, ErrorDetail[] errors) - : base(statusCode, errorMessage, commitChanges, errors) - { - } - - public static Result Success() - { - return new Result(HttpStatusCode.NoContent); - } - - public static Result BadRequest(string message, bool commitChanges = false) - { - return new Result(HttpStatusCode.BadRequest, new ErrorMessage(message), commitChanges, []); - } - - public static Result Unauthorized(string message, bool commitChanges = false) - { - return new Result(HttpStatusCode.Unauthorized, new ErrorMessage(message), commitChanges, []); - } - - public static Result Forbidden(string message, bool commitChanges = false) - { - return new Result(HttpStatusCode.Forbidden, new ErrorMessage(message), commitChanges, []); - } - - public static Result NotFound(string message, bool commitChanges = false) - { - return new Result(HttpStatusCode.NotFound, new ErrorMessage(message), commitChanges, []); - } - - public static Result Conflict(string message, bool commitChanges = false) - { - return new Result(HttpStatusCode.Conflict, new ErrorMessage(message), commitChanges, []); - } - - public static Result TooManyRequests(string message, bool commitChanges = false) - { - return new Result(HttpStatusCode.TooManyRequests, new ErrorMessage(message), commitChanges, []); - } -} - -/// -/// The ResultT class is used when a successful command or query is returning value (e.g. in the case of an Get or -/// Create). On success the HttpStatusCode OK will be returned. In the case of a failure, the result will -/// contain either an or a collection of a . -/// -public sealed class Result : ResultBase -{ - private Result(T value, HttpStatusCode httpStatusCode) : base(httpStatusCode) - { - Value = value; - } - - public Result(HttpStatusCode statusCode, ErrorMessage errorMessage, bool commitChanges, ErrorDetail[] errors) - : base(statusCode, errorMessage, commitChanges, errors) - { - } - - public T? Value { get; } - - /// - /// Use this to indicate a successful command. There is a implicit conversion from T to - /// , so you can also just return T from a command handler. - /// - public static Result Success(T value) - { - return new Result(value, HttpStatusCode.OK); - } - - public static Result BadRequest(string message, bool commitChanges = false) - { - return new Result(HttpStatusCode.BadRequest, new ErrorMessage(message), commitChanges, []); - } - - public static Result Unauthorized(string message, bool commitChanges = false) - { - return new Result(HttpStatusCode.Unauthorized, new ErrorMessage(message), commitChanges, []); - } - - public static Result Forbidden(string message, bool commitChanges = false) - { - return new Result(HttpStatusCode.Forbidden, new ErrorMessage(message), commitChanges, []); - } - - public static Result NotFound(string message, bool commitChanges = false) - { - return new Result(HttpStatusCode.NotFound, new ErrorMessage(message), commitChanges, []); - } - - public static Result Conflict(string message, bool commitChanges = false) - { - return new Result(HttpStatusCode.Conflict, new ErrorMessage(message), commitChanges, []); - } - - public static Result TooManyRequests(string message, bool commitChanges = false) - { - return new Result(HttpStatusCode.TooManyRequests, new ErrorMessage(message), commitChanges, []); - } - - /// - /// This is an implicit conversion from T to . This is used to easily return a - /// successful from a command handler. - /// - public static implicit operator Result(T value) - { - return Success(value); - } -} +using System.Net; +using PlatformPlatform.SharedKernel.ApplicationCore.Validation; + +namespace PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; + +public abstract class ResultBase +{ + protected ResultBase(HttpStatusCode httpStatusCode) + { + IsSuccess = true; + StatusCode = httpStatusCode; + } + + protected ResultBase(HttpStatusCode statusCode, ErrorMessage errorMessage, bool commitChanges, ErrorDetail[] errors) + { + IsSuccess = false; + StatusCode = statusCode; + ErrorMessage = errorMessage; + CommitChangesOnFailure = commitChanges; + Errors = errors; + } + + public bool IsSuccess { get; } + + public HttpStatusCode StatusCode { get; } + + public ErrorMessage? ErrorMessage { get; } + + public bool CommitChangesOnFailure { get; } + + public ErrorDetail[]? Errors { get; } + + public string GetErrorSummary() + { + return ErrorMessage?.Message ?? string.Join(Environment.NewLine, Errors!.Select(ed => $"{ed.Code}: {ed.Message}")); + } +} + +/// +/// The Result class is used when a successful result is not returning any value (e.g. in the case of an Update or +/// Delete). On success the HttpStatusCode NoContent will be returned. In the case of a failure, the result will +/// contain either an or a collection of a . +/// +public sealed class Result : ResultBase +{ + private Result(HttpStatusCode httpStatusCode) : base(httpStatusCode) + { + } + + public Result(HttpStatusCode statusCode, ErrorMessage errorMessage, bool commitChanges, ErrorDetail[] errors) + : base(statusCode, errorMessage, commitChanges, errors) + { + } + + public static Result Success() + { + return new Result(HttpStatusCode.NoContent); + } + + public static Result BadRequest(string message, bool commitChanges = false) + { + return new Result(HttpStatusCode.BadRequest, new ErrorMessage(message), commitChanges, []); + } + + public static Result Unauthorized(string message, bool commitChanges = false) + { + return new Result(HttpStatusCode.Unauthorized, new ErrorMessage(message), commitChanges, []); + } + + public static Result Forbidden(string message, bool commitChanges = false) + { + return new Result(HttpStatusCode.Forbidden, new ErrorMessage(message), commitChanges, []); + } + + public static Result NotFound(string message, bool commitChanges = false) + { + return new Result(HttpStatusCode.NotFound, new ErrorMessage(message), commitChanges, []); + } + + public static Result Conflict(string message, bool commitChanges = false) + { + return new Result(HttpStatusCode.Conflict, new ErrorMessage(message), commitChanges, []); + } + + public static Result TooManyRequests(string message, bool commitChanges = false) + { + return new Result(HttpStatusCode.TooManyRequests, new ErrorMessage(message), commitChanges, []); + } +} + +/// +/// The ResultT class is used when a successful command or query is returning value (e.g. in the case of an Get or +/// Create). On success the HttpStatusCode OK will be returned. In the case of a failure, the result will +/// contain either an or a collection of a . +/// +public sealed class Result : ResultBase +{ + private Result(T value, HttpStatusCode httpStatusCode) : base(httpStatusCode) + { + Value = value; + } + + public Result(HttpStatusCode statusCode, ErrorMessage errorMessage, bool commitChanges, ErrorDetail[] errors) + : base(statusCode, errorMessage, commitChanges, errors) + { + } + + public T? Value { get; } + + /// + /// Use this to indicate a successful command. There is a implicit conversion from T to + /// , so you can also just return T from a command handler. + /// + public static Result Success(T value) + { + return new Result(value, HttpStatusCode.OK); + } + + public static Result BadRequest(string message, bool commitChanges = false) + { + return new Result(HttpStatusCode.BadRequest, new ErrorMessage(message), commitChanges, []); + } + + public static Result Unauthorized(string message, bool commitChanges = false) + { + return new Result(HttpStatusCode.Unauthorized, new ErrorMessage(message), commitChanges, []); + } + + public static Result Forbidden(string message, bool commitChanges = false) + { + return new Result(HttpStatusCode.Forbidden, new ErrorMessage(message), commitChanges, []); + } + + public static Result NotFound(string message, bool commitChanges = false) + { + return new Result(HttpStatusCode.NotFound, new ErrorMessage(message), commitChanges, []); + } + + public static Result Conflict(string message, bool commitChanges = false) + { + return new Result(HttpStatusCode.Conflict, new ErrorMessage(message), commitChanges, []); + } + + public static Result TooManyRequests(string message, bool commitChanges = false) + { + return new Result(HttpStatusCode.TooManyRequests, new ErrorMessage(message), commitChanges, []); + } + + /// + /// This is an implicit conversion from T to . This is used to easily return a + /// successful from a command handler. + /// + public static implicit operator Result(T value) + { + return Success(value); + } +} diff --git a/application/shared-kernel/ApplicationCore/Services/IBlobStorage.cs b/application/shared-kernel/ApplicationCore/Services/IBlobStorage.cs index dd17ab4a1..6e269e6be 100644 --- a/application/shared-kernel/ApplicationCore/Services/IBlobStorage.cs +++ b/application/shared-kernel/ApplicationCore/Services/IBlobStorage.cs @@ -1,10 +1,10 @@ -namespace PlatformPlatform.SharedKernel.ApplicationCore.Services; - -public interface IBlobStorage -{ - Task UploadAsync(string containerName, string blobName, string contentType, Stream stream, CancellationToken cancellationToken); - - string GetBlobUrl(string container, string blobName); - - string GetSharedAccessSignature(string container, TimeSpan expiresIn); -} +namespace PlatformPlatform.SharedKernel.ApplicationCore.Services; + +public interface IBlobStorage +{ + Task UploadAsync(string containerName, string blobName, string contentType, Stream stream, CancellationToken cancellationToken); + + string GetBlobUrl(string container, string blobName); + + string GetSharedAccessSignature(string container, TimeSpan expiresIn); +} diff --git a/application/shared-kernel/ApplicationCore/Services/IEmailService.cs b/application/shared-kernel/ApplicationCore/Services/IEmailService.cs index c32466229..159cc1528 100644 --- a/application/shared-kernel/ApplicationCore/Services/IEmailService.cs +++ b/application/shared-kernel/ApplicationCore/Services/IEmailService.cs @@ -1,6 +1,6 @@ -namespace PlatformPlatform.SharedKernel.ApplicationCore.Services; - -public interface IEmailService -{ - Task SendAsync(string recipient, string subject, string htmlContent, CancellationToken cancellationToken); -} +namespace PlatformPlatform.SharedKernel.ApplicationCore.Services; + +public interface IEmailService +{ + Task SendAsync(string recipient, string subject, string htmlContent, CancellationToken cancellationToken); +} diff --git a/application/shared-kernel/ApplicationCore/TelemetryEvents/TelemetryEventsCollector.cs b/application/shared-kernel/ApplicationCore/TelemetryEvents/TelemetryEventsCollector.cs index 21073d351..fe6440c57 100644 --- a/application/shared-kernel/ApplicationCore/TelemetryEvents/TelemetryEventsCollector.cs +++ b/application/shared-kernel/ApplicationCore/TelemetryEvents/TelemetryEventsCollector.cs @@ -1,34 +1,34 @@ -namespace PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; - -public interface ITelemetryEventsCollector -{ - bool HasEvents { get; } - - void CollectEvent(TelemetryEvent telemetryEvent); - - TelemetryEvent Dequeue(); -} - -public class TelemetryEventsCollector : ITelemetryEventsCollector -{ - private readonly Queue _events = new(); - - public bool HasEvents => _events.Count > 0; - - public void CollectEvent(TelemetryEvent telemetryEvent) - { - _events.Enqueue(telemetryEvent); - } - - public TelemetryEvent Dequeue() - { - return _events.Dequeue(); - } -} - -public abstract class TelemetryEvent(string name, params (string Key, string Value)[] properties) -{ - public string Name { get; } = name; - - public Dictionary Properties { get; } = properties.ToDictionary(p => $"Event_{p.Key}", p => p.Value); -} +namespace PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; + +public interface ITelemetryEventsCollector +{ + bool HasEvents { get; } + + void CollectEvent(TelemetryEvent telemetryEvent); + + TelemetryEvent Dequeue(); +} + +public class TelemetryEventsCollector : ITelemetryEventsCollector +{ + private readonly Queue _events = new(); + + public bool HasEvents => _events.Count > 0; + + public void CollectEvent(TelemetryEvent telemetryEvent) + { + _events.Enqueue(telemetryEvent); + } + + public TelemetryEvent Dequeue() + { + return _events.Dequeue(); + } +} + +public abstract class TelemetryEvent(string name, params (string Key, string Value)[] properties) +{ + public string Name { get; } = name; + + public Dictionary Properties { get; } = properties.ToDictionary(p => $"Event_{p.Key}", p => p.Value); +} diff --git a/application/shared-kernel/ApplicationCore/Validation/ErrorDetail.cs b/application/shared-kernel/ApplicationCore/Validation/ErrorDetail.cs index ea04d40fd..9c07d2b4a 100644 --- a/application/shared-kernel/ApplicationCore/Validation/ErrorDetail.cs +++ b/application/shared-kernel/ApplicationCore/Validation/ErrorDetail.cs @@ -1,3 +1,3 @@ -namespace PlatformPlatform.SharedKernel.ApplicationCore.Validation; - -public sealed record ErrorDetail(string? Code, string Message); +namespace PlatformPlatform.SharedKernel.ApplicationCore.Validation; + +public sealed record ErrorDetail(string? Code, string Message); diff --git a/application/shared-kernel/ApplicationCore/Validation/ErrorMessage.cs b/application/shared-kernel/ApplicationCore/Validation/ErrorMessage.cs index 8f0f1d28b..29f2d2c2e 100644 --- a/application/shared-kernel/ApplicationCore/Validation/ErrorMessage.cs +++ b/application/shared-kernel/ApplicationCore/Validation/ErrorMessage.cs @@ -1,3 +1,3 @@ -namespace PlatformPlatform.SharedKernel.ApplicationCore.Validation; - -public sealed record ErrorMessage(string Message); +namespace PlatformPlatform.SharedKernel.ApplicationCore.Validation; + +public sealed record ErrorMessage(string Message); diff --git a/application/shared-kernel/ApplicationCore/Validation/SharedValidations.cs b/application/shared-kernel/ApplicationCore/Validation/SharedValidations.cs index 3511a37c5..90afa2910 100644 --- a/application/shared-kernel/ApplicationCore/Validation/SharedValidations.cs +++ b/application/shared-kernel/ApplicationCore/Validation/SharedValidations.cs @@ -1,43 +1,43 @@ -using FluentValidation; - -namespace PlatformPlatform.SharedKernel.ApplicationCore.Validation; - -public static class SharedValidations -{ - 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; - - public Email(string emailName = nameof(Email)) - { - const string errorMessage = "Email must be in a valid format and no longer than 100 characters."; - RuleFor(email => email) - .EmailAddress() - .WithName(emailName) - .WithMessage(errorMessage) - .MaximumLength(EmailMaxLength) - .WithMessage(errorMessage) - .When(email => !string.IsNullOrEmpty(email)); - } - } - - public sealed class Phone : AbstractValidator - { - // The ITU-T E.164 standard limits phone numbers to 15 digits (including country code), - // Additional 5 characters are added to allow for spaces, dashes, parentheses, etc. - private const int PhoneMaxLength = 20; - - public Phone(string phoneName = nameof(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) - .When(phone => !string.IsNullOrEmpty(phone)); - } - } -} +using FluentValidation; + +namespace PlatformPlatform.SharedKernel.ApplicationCore.Validation; + +public static class SharedValidations +{ + 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; + + public Email(string emailName = nameof(Email)) + { + const string errorMessage = "Email must be in a valid format and no longer than 100 characters."; + RuleFor(email => email) + .EmailAddress() + .WithName(emailName) + .WithMessage(errorMessage) + .MaximumLength(EmailMaxLength) + .WithMessage(errorMessage) + .When(email => !string.IsNullOrEmpty(email)); + } + } + + public sealed class Phone : AbstractValidator + { + // The ITU-T E.164 standard limits phone numbers to 15 digits (including country code), + // Additional 5 characters are added to allow for spaces, dashes, parentheses, etc. + private const int PhoneMaxLength = 20; + + public Phone(string phoneName = nameof(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) + .When(phone => !string.IsNullOrEmpty(phone)); + } + } +} diff --git a/application/shared-kernel/DomainCore/DomainEvents/IDomainEventCollector.cs b/application/shared-kernel/DomainCore/DomainEvents/IDomainEventCollector.cs index 6cfae9c86..1f183f205 100644 --- a/application/shared-kernel/DomainCore/DomainEvents/IDomainEventCollector.cs +++ b/application/shared-kernel/DomainCore/DomainEvents/IDomainEventCollector.cs @@ -1,8 +1,8 @@ -using PlatformPlatform.SharedKernel.DomainCore.Entities; - -namespace PlatformPlatform.SharedKernel.DomainCore.DomainEvents; - -public interface IDomainEventCollector -{ - IAggregateRoot[] GetAggregatesWithDomainEvents(); -} +using PlatformPlatform.SharedKernel.DomainCore.Entities; + +namespace PlatformPlatform.SharedKernel.DomainCore.DomainEvents; + +public interface IDomainEventCollector +{ + IAggregateRoot[] GetAggregatesWithDomainEvents(); +} diff --git a/application/shared-kernel/DomainCore/Entities/AggregateRoot.cs b/application/shared-kernel/DomainCore/Entities/AggregateRoot.cs index 403dfe795..249b0a209 100644 --- a/application/shared-kernel/DomainCore/Entities/AggregateRoot.cs +++ b/application/shared-kernel/DomainCore/Entities/AggregateRoot.cs @@ -1,40 +1,40 @@ -using System.ComponentModel.DataAnnotations.Schema; -using PlatformPlatform.SharedKernel.DomainCore.DomainEvents; - -namespace PlatformPlatform.SharedKernel.DomainCore.Entities; - -/// -/// Interface for aggregate roots, which also implements IAuditableEntity. Aggregate roots are a concept in -/// Domain-Driven Design (DDD). An aggregate is an entity, but only some entities are aggregates. For example, an -/// Order in an e-commerce system is an aggregate, but an OrderLine is not. An aggregate is a cluster of associated -/// objects that are treated as a unit. -/// In DDD, Repositories are used to read and write aggregates in the database. For example, when an aggregate is -/// deleted, all entities belonging to the aggregate are deleted as well. Also, only aggregates can be fetched from -/// the database, while entities that are not aggregates cannot (fetch the aggregate to get access to the entities). -/// -public interface IAggregateRoot : IAuditableEntity -{ - IReadOnlyCollection DomainEvents { get; } - - IDomainEvent[] GetAndClearDomainEvents(); -} - -public abstract class AggregateRoot(T id) : AudibleEntity(id), IAggregateRoot where T : IComparable -{ - private readonly List _domainEvents = []; - - [NotMapped] - public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); - - public IDomainEvent[] GetAndClearDomainEvents() - { - var domainEvents = _domainEvents.ToArray(); - _domainEvents.Clear(); - return domainEvents; - } - - protected void AddDomainEvent(IDomainEvent domainEvent) - { - _domainEvents.Add(domainEvent); - } -} +using System.ComponentModel.DataAnnotations.Schema; +using PlatformPlatform.SharedKernel.DomainCore.DomainEvents; + +namespace PlatformPlatform.SharedKernel.DomainCore.Entities; + +/// +/// Interface for aggregate roots, which also implements IAuditableEntity. Aggregate roots are a concept in +/// Domain-Driven Design (DDD). An aggregate is an entity, but only some entities are aggregates. For example, an +/// Order in an e-commerce system is an aggregate, but an OrderLine is not. An aggregate is a cluster of associated +/// objects that are treated as a unit. +/// In DDD, Repositories are used to read and write aggregates in the database. For example, when an aggregate is +/// deleted, all entities belonging to the aggregate are deleted as well. Also, only aggregates can be fetched from +/// the database, while entities that are not aggregates cannot (fetch the aggregate to get access to the entities). +/// +public interface IAggregateRoot : IAuditableEntity +{ + IReadOnlyCollection DomainEvents { get; } + + IDomainEvent[] GetAndClearDomainEvents(); +} + +public abstract class AggregateRoot(T id) : AudibleEntity(id), IAggregateRoot where T : IComparable +{ + private readonly List _domainEvents = []; + + [NotMapped] + public IReadOnlyCollection DomainEvents => _domainEvents.AsReadOnly(); + + public IDomainEvent[] GetAndClearDomainEvents() + { + var domainEvents = _domainEvents.ToArray(); + _domainEvents.Clear(); + return domainEvents; + } + + protected void AddDomainEvent(IDomainEvent domainEvent) + { + _domainEvents.Add(domainEvent); + } +} diff --git a/application/shared-kernel/DomainCore/Entities/AudibleEntity.cs b/application/shared-kernel/DomainCore/Entities/AudibleEntity.cs index e30262213..8986f7e7d 100644 --- a/application/shared-kernel/DomainCore/Entities/AudibleEntity.cs +++ b/application/shared-kernel/DomainCore/Entities/AudibleEntity.cs @@ -1,24 +1,24 @@ -using System.ComponentModel.DataAnnotations; - +using System.ComponentModel.DataAnnotations; + namespace PlatformPlatform.SharedKernel.DomainCore.Entities; /// /// The AudibleEntity class extends Entity and implements IAuditableEntity, which adds /// a readonly CreatedAt and private ModifiedAt properties to derived entities. /// -public abstract class AudibleEntity(T id) : Entity(id), IAuditableEntity where T : IComparable -{ - public DateTimeOffset CreatedAt { get; init; } = TimeProvider.System.GetUtcNow(); - - [ConcurrencyCheck] +public abstract class AudibleEntity(T id) : Entity(id), IAuditableEntity where T : IComparable +{ + public DateTimeOffset CreatedAt { get; init; } = TimeProvider.System.GetUtcNow(); + + [ConcurrencyCheck] public DateTimeOffset? ModifiedAt { get; private set; } /// /// This method is used by the UpdateAuditableEntitiesInterceptor in the Infrastructure layer. /// It's not intended to be used by the application, which is why it is implemented using an explicit interface. /// - void IAuditableEntity.UpdateModifiedAt(DateTimeOffset? modifiedAt) - { - ModifiedAt = modifiedAt; - } + void IAuditableEntity.UpdateModifiedAt(DateTimeOffset? modifiedAt) + { + ModifiedAt = modifiedAt; + } } diff --git a/application/shared-kernel/DomainCore/Entities/Entity.cs b/application/shared-kernel/DomainCore/Entities/Entity.cs index 504102b0b..5f5fcd2a4 100644 --- a/application/shared-kernel/DomainCore/Entities/Entity.cs +++ b/application/shared-kernel/DomainCore/Entities/Entity.cs @@ -1,44 +1,44 @@ -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using PlatformPlatform.SharedKernel.DomainCore.Identity; - -namespace PlatformPlatform.SharedKernel.DomainCore.Entities; - -/// -/// The Entity class is a base class for entities which represents business objects. -/// Entities are a DDD concept, where an entity is a business object that has a unique identity. -/// If two entities have the same identity, they are considered to be the same entity. -/// It is recommended to use a for the ID to make the domain more meaningful. -/// -public abstract class Entity(T id) : IEquatable> where T : IComparable -{ - [Key] - [DatabaseGenerated(DatabaseGeneratedOption.None)] - public T Id { get; init; } = id; - - public virtual bool Equals(Entity? other) - { - return !ReferenceEquals(null, other) - && (ReferenceEquals(this, other) || EqualityComparer.Default.Equals(Id, other.Id)); - } - - public override bool Equals(object? obj) - { - return Equals(obj as Entity); - } - - public override int GetHashCode() - { - return EqualityComparer.Default.GetHashCode(Id); - } - - public static bool operator ==(Entity? a, Entity? b) - { - return a?.Equals(b) == true; - } - - public static bool operator !=(Entity? a, Entity? b) - { - return !(a == b); - } -} +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using PlatformPlatform.SharedKernel.DomainCore.Identity; + +namespace PlatformPlatform.SharedKernel.DomainCore.Entities; + +/// +/// The Entity class is a base class for entities which represents business objects. +/// Entities are a DDD concept, where an entity is a business object that has a unique identity. +/// If two entities have the same identity, they are considered to be the same entity. +/// It is recommended to use a for the ID to make the domain more meaningful. +/// +public abstract class Entity(T id) : IEquatable> where T : IComparable +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.None)] + public T Id { get; init; } = id; + + public virtual bool Equals(Entity? other) + { + return !ReferenceEquals(null, other) + && (ReferenceEquals(this, other) || EqualityComparer.Default.Equals(Id, other.Id)); + } + + public override bool Equals(object? obj) + { + return Equals(obj as Entity); + } + + public override int GetHashCode() + { + return EqualityComparer.Default.GetHashCode(Id); + } + + public static bool operator ==(Entity? a, Entity? b) + { + return a?.Equals(b) == true; + } + + public static bool operator !=(Entity? a, Entity? b) + { + return !(a == b); + } +} diff --git a/application/shared-kernel/DomainCore/Entities/EntityEqualityComparer.cs b/application/shared-kernel/DomainCore/Entities/EntityEqualityComparer.cs index 96bc653ae..af42b9cc2 100644 --- a/application/shared-kernel/DomainCore/Entities/EntityEqualityComparer.cs +++ b/application/shared-kernel/DomainCore/Entities/EntityEqualityComparer.cs @@ -1,18 +1,18 @@ -namespace PlatformPlatform.SharedKernel.DomainCore.Entities; - -public sealed class EntityEqualityComparer : IEqualityComparer> where T : IComparable -{ - public bool Equals(Entity? x, Entity? y) - { - return ReferenceEquals(x, y) || - (!ReferenceEquals(x, null) && - !ReferenceEquals(y, null) && - EqualityComparer.Default.Equals(x.Id, y.Id) - ); - } - - public int GetHashCode(Entity obj) - { - return EqualityComparer.Default.GetHashCode(obj.Id); - } -} +namespace PlatformPlatform.SharedKernel.DomainCore.Entities; + +public sealed class EntityEqualityComparer : IEqualityComparer> where T : IComparable +{ + public bool Equals(Entity? x, Entity? y) + { + return ReferenceEquals(x, y) || + (!ReferenceEquals(x, null) && + !ReferenceEquals(y, null) && + EqualityComparer.Default.Equals(x.Id, y.Id) + ); + } + + public int GetHashCode(Entity obj) + { + return EqualityComparer.Default.GetHashCode(obj.Id); + } +} diff --git a/application/shared-kernel/DomainCore/Entities/IAuditableEntity.cs b/application/shared-kernel/DomainCore/Entities/IAuditableEntity.cs index 25969fa08..5d4f44f6a 100644 --- a/application/shared-kernel/DomainCore/Entities/IAuditableEntity.cs +++ b/application/shared-kernel/DomainCore/Entities/IAuditableEntity.cs @@ -4,11 +4,11 @@ namespace PlatformPlatform.SharedKernel.DomainCore.Entities; /// IAuditableEntity interface contains properties and methods for maintaining audit information for when /// an entity was created and when it was last modified. /// -public interface IAuditableEntity -{ - DateTimeOffset CreatedAt { get; } - - DateTimeOffset? ModifiedAt { get; } - - void UpdateModifiedAt(DateTimeOffset? modifiedAt); +public interface IAuditableEntity +{ + DateTimeOffset CreatedAt { get; } + + DateTimeOffset? ModifiedAt { get; } + + void UpdateModifiedAt(DateTimeOffset? modifiedAt); } diff --git a/application/shared-kernel/DomainCore/Entities/ICrudRepository.cs b/application/shared-kernel/DomainCore/Entities/ICrudRepository.cs index 2543702ac..1150216d7 100644 --- a/application/shared-kernel/DomainCore/Entities/ICrudRepository.cs +++ b/application/shared-kernel/DomainCore/Entities/ICrudRepository.cs @@ -1,12 +1,12 @@ -namespace PlatformPlatform.SharedKernel.DomainCore.Entities; - -public interface ICrudRepository where T : IAggregateRoot -{ - Task GetByIdAsync(TId id, CancellationToken cancellationToken); - - Task AddAsync(T aggregate, CancellationToken cancellationToken); - - void Update(T aggregate); - - void Remove(T aggregate); -} +namespace PlatformPlatform.SharedKernel.DomainCore.Entities; + +public interface ICrudRepository where T : IAggregateRoot +{ + Task GetByIdAsync(TId id, CancellationToken cancellationToken); + + Task AddAsync(T aggregate, CancellationToken cancellationToken); + + void Update(T aggregate); + + void Remove(T aggregate); +} diff --git a/application/shared-kernel/DomainCore/Identity/IdGenerator.cs b/application/shared-kernel/DomainCore/Identity/IdGenerator.cs index fc0e0bb54..0135498a4 100644 --- a/application/shared-kernel/DomainCore/Identity/IdGenerator.cs +++ b/application/shared-kernel/DomainCore/Identity/IdGenerator.cs @@ -1,6 +1,6 @@ -using System.Net; -using System.Net.Sockets; - +using System.Net; +using System.Net.Sockets; + namespace PlatformPlatform.SharedKernel.DomainCore.Identity; /// @@ -16,29 +16,29 @@ namespace PlatformPlatform.SharedKernel.DomainCore.Identity; /// the distributed system is assigned a unique generator ID (based on its IPv4 address), and it allows up to 1024 /// nodes. With this setting, it allows generating up to 4096 unique IDs per ms per node. /// -public static class IdGenerator -{ +public static class IdGenerator +{ private static readonly IdGen.IdGenerator Generator = new(GetUniqueGeneratorIdFromIpAddress()); /// /// Generates a new unique ID based on the Twitter Snowflake algorithm. /// - public static long NewId() - { - return Generator.CreateId(); + public static long NewId() + { + return Generator.CreateId(); } /// /// Retrieves a unique generator ID based on the machine's IPv4 address. /// - private static int GetUniqueGeneratorIdFromIpAddress() - { - var host = Dns.GetHostEntry(Dns.GetHostName()); - const string noNetworkAdapters = "No network adapters with an IPv4 address in the system. IdGenerator is meant to create unique IDs across multiple machines, and requires an IP address to do so."; - var ipAddress = Array.Find(host.AddressList, ip => ip.AddressFamily == AddressFamily.InterNetwork) - ?? throw new InvalidOperationException(noNetworkAdapters); - - var lastSegment = ipAddress.ToString().Split('.')[3]; - return int.Parse(lastSegment); - } + private static int GetUniqueGeneratorIdFromIpAddress() + { + var host = Dns.GetHostEntry(Dns.GetHostName()); + const string noNetworkAdapters = "No network adapters with an IPv4 address in the system. IdGenerator is meant to create unique IDs across multiple machines, and requires an IP address to do so."; + var ipAddress = Array.Find(host.AddressList, ip => ip.AddressFamily == AddressFamily.InterNetwork) + ?? throw new InvalidOperationException(noNetworkAdapters); + + var lastSegment = ipAddress.ToString().Split('.')[3]; + return int.Parse(lastSegment); + } } diff --git a/application/shared-kernel/DomainCore/Identity/IdPrefixAttribute.cs b/application/shared-kernel/DomainCore/Identity/IdPrefixAttribute.cs index 3ace97069..94dc928e6 100644 --- a/application/shared-kernel/DomainCore/Identity/IdPrefixAttribute.cs +++ b/application/shared-kernel/DomainCore/Identity/IdPrefixAttribute.cs @@ -1,7 +1,7 @@ -namespace PlatformPlatform.SharedKernel.DomainCore.Identity; - -[AttributeUsage(AttributeTargets.Class)] -public sealed class IdPrefixAttribute(string prefix) : Attribute -{ - public string Prefix { get; } = prefix; -} +namespace PlatformPlatform.SharedKernel.DomainCore.Identity; + +[AttributeUsage(AttributeTargets.Class)] +public sealed class IdPrefixAttribute(string prefix) : Attribute +{ + public string Prefix { get; } = prefix; +} diff --git a/application/shared-kernel/DomainCore/Identity/StronglyTypedId.cs b/application/shared-kernel/DomainCore/Identity/StronglyTypedId.cs index a5ee0d88f..a44541914 100644 --- a/application/shared-kernel/DomainCore/Identity/StronglyTypedId.cs +++ b/application/shared-kernel/DomainCore/Identity/StronglyTypedId.cs @@ -1,35 +1,35 @@ -namespace PlatformPlatform.SharedKernel.DomainCore.Identity; - -/// -/// StronglyTypedId is an abstract record type for creating strongly typed IDs with a specified value type. It makes -/// the code clearer and more meaningful in the domain, and the type safety helps prevent bugs. E.g., a method like -/// AddToOrder(CustomerId customerId, OrderId orderId, ProductId productId, int quantity) is clearer and provides -/// better type safety than AddToOrder(long customerId, long orderId, long productId, int quantity). -/// When used with Entity Framework, make sure to register the type in the OnModelCreating method in the DbContext. -/// -public interface IStronglyTypedId; - -public abstract record StronglyTypedId(TValue Value) - : IStronglyTypedId, IComparable> - where T : StronglyTypedId where TValue : IComparable -{ - public int CompareTo(StronglyTypedId? other) - { - return other is null ? 1 : Value.CompareTo(other.Value); - } - - public virtual bool Equals(StronglyTypedId? other) - { - return other != null && Value.Equals(other.Value); - } - - public override int GetHashCode() - { - return Value.GetHashCode(); - } - - public static implicit operator TValue(StronglyTypedId stronglyTypedId) - { - return stronglyTypedId.Value; - } -} +namespace PlatformPlatform.SharedKernel.DomainCore.Identity; + +/// +/// StronglyTypedId is an abstract record type for creating strongly typed IDs with a specified value type. It makes +/// the code clearer and more meaningful in the domain, and the type safety helps prevent bugs. E.g., a method like +/// AddToOrder(CustomerId customerId, OrderId orderId, ProductId productId, int quantity) is clearer and provides +/// better type safety than AddToOrder(long customerId, long orderId, long productId, int quantity). +/// When used with Entity Framework, make sure to register the type in the OnModelCreating method in the DbContext. +/// +public interface IStronglyTypedId; + +public abstract record StronglyTypedId(TValue Value) + : IStronglyTypedId, IComparable> + where T : StronglyTypedId where TValue : IComparable +{ + public int CompareTo(StronglyTypedId? other) + { + return other is null ? 1 : Value.CompareTo(other.Value); + } + + public virtual bool Equals(StronglyTypedId? other) + { + return other != null && Value.Equals(other.Value); + } + + public override int GetHashCode() + { + return Value.GetHashCode(); + } + + public static implicit operator TValue(StronglyTypedId stronglyTypedId) + { + return stronglyTypedId.Value; + } +} diff --git a/application/shared-kernel/DomainCore/Identity/StronglyTypedIdTypeConverter.cs b/application/shared-kernel/DomainCore/Identity/StronglyTypedIdTypeConverter.cs index d2038a370..d5a612f1e 100644 --- a/application/shared-kernel/DomainCore/Identity/StronglyTypedIdTypeConverter.cs +++ b/application/shared-kernel/DomainCore/Identity/StronglyTypedIdTypeConverter.cs @@ -1,26 +1,26 @@ -using System.Globalization; - -namespace PlatformPlatform.SharedKernel.DomainCore.Identity; - -public abstract class StronglyTypedIdTypeConverter : TypeConverter - where T : StronglyTypedId where TValue : IComparable -{ - private static readonly MethodInfo? TryParseMethod = typeof(T).GetMethod("TryParse"); - - public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) - { - if (value is not string valueAsString || TryParseMethod is null) - { - return base.ConvertFrom(context, culture, value); - } - - var parameters = new object?[] { valueAsString, null }; - - if ((bool)TryParseMethod.Invoke(null, parameters)!) - { - return (T?)parameters[1]; - } - - return base.ConvertFrom(context, culture, value); - } -} +using System.Globalization; + +namespace PlatformPlatform.SharedKernel.DomainCore.Identity; + +public abstract class StronglyTypedIdTypeConverter : TypeConverter + where T : StronglyTypedId where TValue : IComparable +{ + private static readonly MethodInfo? TryParseMethod = typeof(T).GetMethod("TryParse"); + + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) + { + if (value is not string valueAsString || TryParseMethod is null) + { + return base.ConvertFrom(context, culture, value); + } + + var parameters = new object?[] { valueAsString, null }; + + if ((bool)TryParseMethod.Invoke(null, parameters)!) + { + return (T?)parameters[1]; + } + + return base.ConvertFrom(context, culture, value); + } +} diff --git a/application/shared-kernel/DomainCore/Identity/StronglyTypedLongId.cs b/application/shared-kernel/DomainCore/Identity/StronglyTypedLongId.cs index 6adf4dbd2..9c5289186 100644 --- a/application/shared-kernel/DomainCore/Identity/StronglyTypedLongId.cs +++ b/application/shared-kernel/DomainCore/Identity/StronglyTypedLongId.cs @@ -1,33 +1,33 @@ -namespace PlatformPlatform.SharedKernel.DomainCore.Identity; - -/// -/// This is a special version of for longs which is the recommended type to use. -/// It uses the to create IDs that are chronological and guaranteed to be unique. -/// -public abstract record StronglyTypedLongId(long Value) : StronglyTypedId(Value) - where T : StronglyTypedLongId -{ - public static T NewId() - { - var newValue = IdGenerator.NewId(); - return FormLong(newValue); - } - - public static bool TryParse(string? value, out T? result) - { - var success = long.TryParse(value, out var parsedValue); - result = success ? FormLong(parsedValue) : null; - return success; - } - - private static T FormLong(long newValue) - { - return (T)Activator.CreateInstance( - typeof(T), - BindingFlags.Instance | BindingFlags.Public, - null, - [newValue], - null - )!; - } -} +namespace PlatformPlatform.SharedKernel.DomainCore.Identity; + +/// +/// This is a special version of for longs which is the recommended type to use. +/// It uses the to create IDs that are chronological and guaranteed to be unique. +/// +public abstract record StronglyTypedLongId(long Value) : StronglyTypedId(Value) + where T : StronglyTypedLongId +{ + public static T NewId() + { + var newValue = IdGenerator.NewId(); + return FormLong(newValue); + } + + public static bool TryParse(string? value, out T? result) + { + var success = long.TryParse(value, out var parsedValue); + result = success ? FormLong(parsedValue) : null; + return success; + } + + private static T FormLong(long newValue) + { + return (T)Activator.CreateInstance( + typeof(T), + BindingFlags.Instance | BindingFlags.Public, + null, + [newValue], + null + )!; + } +} diff --git a/application/shared-kernel/DomainCore/Identity/StronglyTypedUlid.cs b/application/shared-kernel/DomainCore/Identity/StronglyTypedUlid.cs index 7f5580820..de4491fd4 100644 --- a/application/shared-kernel/DomainCore/Identity/StronglyTypedUlid.cs +++ b/application/shared-kernel/DomainCore/Identity/StronglyTypedUlid.cs @@ -1,49 +1,49 @@ -using NUlid; - -namespace PlatformPlatform.SharedKernel.DomainCore.Identity; - -/// -/// This is the recommended ID type to use. It uses the to create unique chronological IDs. -/// IDs are prefixed with the value of the inspired by Stripe's API. -/// -public abstract record StronglyTypedUlid(string Value) : StronglyTypedId(Value) - where T : StronglyTypedUlid -{ - private static readonly string Prefix = typeof(T).GetCustomAttribute()?.Prefix - ?? throw new InvalidOperationException("IdPrefixAttribute is required."); - - public static T NewId() - { - var newValue = Ulid.NewUlid(); - return FormUlid(newValue); - } - - public static bool TryParse(string? value, out T? result) - { - if (value is null || !value.StartsWith($"{Prefix}_")) - { - result = null; - return false; - } - - if (!Ulid.TryParse(value.Replace($"{Prefix}_", ""), out var parsedValue)) - { - result = null; - return false; - } - - result = FormUlid(parsedValue); - return true; - } - - private static T FormUlid(Ulid newValue) - { - return (T)Activator.CreateInstance( - typeof(T), - BindingFlags.Instance | BindingFlags.Public, - null, - [$"{Prefix}_{newValue}"], - null - )!; - } -} +using NUlid; + +namespace PlatformPlatform.SharedKernel.DomainCore.Identity; + +/// +/// This is the recommended ID type to use. It uses the to create unique chronological IDs. +/// IDs are prefixed with the value of the inspired by Stripe's API. +/// +public abstract record StronglyTypedUlid(string Value) : StronglyTypedId(Value) + where T : StronglyTypedUlid +{ + private static readonly string Prefix = typeof(T).GetCustomAttribute()?.Prefix + ?? throw new InvalidOperationException("IdPrefixAttribute is required."); + + public static T NewId() + { + var newValue = Ulid.NewUlid(); + return FormUlid(newValue); + } + + public static bool TryParse(string? value, out T? result) + { + if (value is null || !value.StartsWith($"{Prefix}_")) + { + result = null; + return false; + } + + if (!Ulid.TryParse(value.Replace($"{Prefix}_", ""), out var parsedValue)) + { + result = null; + return false; + } + + result = FormUlid(parsedValue); + return true; + } + + private static T FormUlid(Ulid newValue) + { + return (T)Activator.CreateInstance( + typeof(T), + BindingFlags.Instance | BindingFlags.Public, + null, + [$"{Prefix}_{newValue}"], + null + )!; + } +} diff --git a/application/shared-kernel/DomainCore/Persistence/IUnitOfWork.cs b/application/shared-kernel/DomainCore/Persistence/IUnitOfWork.cs index 8f372bc92..adecbe4f5 100644 --- a/application/shared-kernel/DomainCore/Persistence/IUnitOfWork.cs +++ b/application/shared-kernel/DomainCore/Persistence/IUnitOfWork.cs @@ -8,7 +8,7 @@ namespace PlatformPlatform.SharedKernel.DomainCore.Persistence; /// Use to add, update, and delete aggregates to a unit of work. When the unit of work is committed, the changes are /// persisted to the database. /// -public interface IUnitOfWork -{ - Task CommitAsync(CancellationToken cancellationToken); +public interface IUnitOfWork +{ + Task CommitAsync(CancellationToken cancellationToken); } diff --git a/application/shared-kernel/DomainCore/Persistence/SortOrder.cs b/application/shared-kernel/DomainCore/Persistence/SortOrder.cs index 6350ff234..04bb36924 100644 --- a/application/shared-kernel/DomainCore/Persistence/SortOrder.cs +++ b/application/shared-kernel/DomainCore/Persistence/SortOrder.cs @@ -1,7 +1,7 @@ -namespace PlatformPlatform.SharedKernel.DomainCore.Persistence; - -public enum SortOrder -{ - Ascending, - Descending -} +namespace PlatformPlatform.SharedKernel.DomainCore.Persistence; + +public enum SortOrder +{ + Ascending, + Descending +} diff --git a/application/shared-kernel/InfrastructureCore/EntityFramework/ModelBuilderExtensions.cs b/application/shared-kernel/InfrastructureCore/EntityFramework/ModelBuilderExtensions.cs index 38b9dcbc6..c18527d37 100644 --- a/application/shared-kernel/InfrastructureCore/EntityFramework/ModelBuilderExtensions.cs +++ b/application/shared-kernel/InfrastructureCore/EntityFramework/ModelBuilderExtensions.cs @@ -1,82 +1,82 @@ -using System.Linq.Expressions; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using PlatformPlatform.SharedKernel.DomainCore.Identity; - -namespace PlatformPlatform.SharedKernel.InfrastructureCore.EntityFramework; - -public static class ModelBuilderExtensions -{ - /// - /// This method is used to tell Entity Framework how to map a strongly typed ID to a SQL column using the - /// underlying type of the strongly-typed ID. - /// - public static void MapStronglyTypedLongId(this ModelBuilder modelBuilder, Expression> expression) - where T : class where TId : StronglyTypedLongId - { - modelBuilder - .Entity() - .Property(expression) - .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); - } - - public static void MapStronglyTypedUuid(this ModelBuilder modelBuilder, Expression> expression) - where T : class where TId : StronglyTypedUlid - { - modelBuilder - .Entity() - .Property(expression) - .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); - } - - public static void MapStronglyTypedId(this ModelBuilder modelBuilder, Expression> expression) - where T : class - where TValue : IComparable - where TId : StronglyTypedId - { - modelBuilder - .Entity() - .Property(expression) - .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); - } - - public static void MapStronglyTypedNullableId( - this ModelBuilder modelBuilder, - Expression> idExpression - ) - where T : class - 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, nameof(StronglyTypedId.Value)); - var idCoalesceExpression = - Expression.Lambda>(Expression.Coalesce(idValueProperty, nullConstant), idParameter); - - modelBuilder - .Entity() - .Property(idExpression) - .HasConversion(idCoalesceExpression!, v => Activator.CreateInstance(typeof(TId), v) as TId); - } - - /// - /// This method is used to tell Entity Framework to store all enum properties as strings in the database. - /// - public static ModelBuilder UseStringForEnums(this ModelBuilder modelBuilder) - { - foreach (var entityType in modelBuilder.Model.GetEntityTypes()) - { - foreach (var property in entityType.GetProperties()) - { - if (!property.ClrType.IsEnum) continue; - - var converterType = typeof(EnumToStringConverter<>).MakeGenericType(property.ClrType); - var converterInstance = (ValueConverter)Activator.CreateInstance(converterType)!; - property.SetValueConverter(converterInstance); - } - } - - return modelBuilder; - } -} +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PlatformPlatform.SharedKernel.DomainCore.Identity; + +namespace PlatformPlatform.SharedKernel.InfrastructureCore.EntityFramework; + +public static class ModelBuilderExtensions +{ + /// + /// This method is used to tell Entity Framework how to map a strongly typed ID to a SQL column using the + /// underlying type of the strongly-typed ID. + /// + public static void MapStronglyTypedLongId(this ModelBuilder modelBuilder, Expression> expression) + where T : class where TId : StronglyTypedLongId + { + modelBuilder + .Entity() + .Property(expression) + .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); + } + + public static void MapStronglyTypedUuid(this ModelBuilder modelBuilder, Expression> expression) + where T : class where TId : StronglyTypedUlid + { + modelBuilder + .Entity() + .Property(expression) + .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); + } + + public static void MapStronglyTypedId(this ModelBuilder modelBuilder, Expression> expression) + where T : class + where TValue : IComparable + where TId : StronglyTypedId + { + modelBuilder + .Entity() + .Property(expression) + .HasConversion(v => v.Value, v => (Activator.CreateInstance(typeof(TId), v) as TId)!); + } + + public static void MapStronglyTypedNullableId( + this ModelBuilder modelBuilder, + Expression> idExpression + ) + where T : class + 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, nameof(StronglyTypedId.Value)); + var idCoalesceExpression = + Expression.Lambda>(Expression.Coalesce(idValueProperty, nullConstant), idParameter); + + modelBuilder + .Entity() + .Property(idExpression) + .HasConversion(idCoalesceExpression!, v => Activator.CreateInstance(typeof(TId), v) as TId); + } + + /// + /// This method is used to tell Entity Framework to store all enum properties as strings in the database. + /// + public static ModelBuilder UseStringForEnums(this ModelBuilder modelBuilder) + { + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + foreach (var property in entityType.GetProperties()) + { + if (!property.ClrType.IsEnum) continue; + + var converterType = typeof(EnumToStringConverter<>).MakeGenericType(property.ClrType); + var converterInstance = (ValueConverter)Activator.CreateInstance(converterType)!; + property.SetValueConverter(converterInstance); + } + } + + return modelBuilder; + } +} diff --git a/application/shared-kernel/InfrastructureCore/EntityFramework/SharedKernelDbContext.cs b/application/shared-kernel/InfrastructureCore/EntityFramework/SharedKernelDbContext.cs index 5e82719ce..14a3fa55f 100644 --- a/application/shared-kernel/InfrastructureCore/EntityFramework/SharedKernelDbContext.cs +++ b/application/shared-kernel/InfrastructureCore/EntityFramework/SharedKernelDbContext.cs @@ -1,28 +1,28 @@ -using Microsoft.EntityFrameworkCore; -using PlatformPlatform.SharedKernel.DomainCore.Entities; - -namespace PlatformPlatform.SharedKernel.InfrastructureCore.EntityFramework; - -/// -/// The SharedKernelDbContext class represents the Entity Framework Core DbContext for managing data access to the -/// database, like creation, querying, and updating of entities. -/// -public abstract class SharedKernelDbContext(DbContextOptions options) - : DbContext(options) where TContext : DbContext -{ - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); - optionsBuilder.AddInterceptors(new UpdateAuditableEntitiesInterceptor()); - - base.OnConfiguring(optionsBuilder); - } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - // Ensures that all enum properties are stored as strings in the database. - modelBuilder.UseStringForEnums(); - - base.OnModelCreating(modelBuilder); - } -} +using Microsoft.EntityFrameworkCore; +using PlatformPlatform.SharedKernel.DomainCore.Entities; + +namespace PlatformPlatform.SharedKernel.InfrastructureCore.EntityFramework; + +/// +/// The SharedKernelDbContext class represents the Entity Framework Core DbContext for managing data access to the +/// database, like creation, querying, and updating of entities. +/// +public abstract class SharedKernelDbContext(DbContextOptions options) + : DbContext(options) where TContext : DbContext +{ + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + optionsBuilder.AddInterceptors(new UpdateAuditableEntitiesInterceptor()); + + base.OnConfiguring(optionsBuilder); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Ensures that all enum properties are stored as strings in the database. + modelBuilder.UseStringForEnums(); + + base.OnModelCreating(modelBuilder); + } +} diff --git a/application/shared-kernel/InfrastructureCore/EntityFramework/UpdateAuditableEntitiesInterceptor.cs b/application/shared-kernel/InfrastructureCore/EntityFramework/UpdateAuditableEntitiesInterceptor.cs index 8d4095c40..1011464a9 100644 --- a/application/shared-kernel/InfrastructureCore/EntityFramework/UpdateAuditableEntitiesInterceptor.cs +++ b/application/shared-kernel/InfrastructureCore/EntityFramework/UpdateAuditableEntitiesInterceptor.cs @@ -1,50 +1,50 @@ -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; -using PlatformPlatform.SharedKernel.DomainCore.Entities; - -namespace PlatformPlatform.SharedKernel.InfrastructureCore.EntityFramework; - -/// -/// The UpdateAuditableEntitiesInterceptor is a SaveChangesInterceptor that updates the ModifiedAt property -/// for IAuditableEntity instances when changes are made to the database. -/// -public sealed class UpdateAuditableEntitiesInterceptor : SaveChangesInterceptor -{ - public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) - { - UpdateEntities(eventData); - - return base.SavingChanges(eventData, result); - } - - public override ValueTask> SavingChangesAsync( - DbContextEventData eventData, - InterceptionResult result, - CancellationToken cancellationToken = default - ) - { - UpdateEntities(eventData); - - return base.SavingChangesAsync(eventData, result, cancellationToken); - } - - private static void UpdateEntities(DbContextEventData eventData) - { - var dbContext = eventData.Context ?? throw new UnreachableException("The 'eventData.Context' property is unexpectedly null."); - - var audibleEntities = dbContext.ChangeTracker.Entries(); - - foreach (var entityEntry in audibleEntities) - { - // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - switch (entityEntry.State) - { - case EntityState.Added when entityEntry.Entity.CreatedAt == default: - throw new UnreachableException("CreatedAt must be set before saving."); - case EntityState.Modified: - entityEntry.Entity.UpdateModifiedAt(DateTime.UtcNow); - break; - } - } - } -} +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using PlatformPlatform.SharedKernel.DomainCore.Entities; + +namespace PlatformPlatform.SharedKernel.InfrastructureCore.EntityFramework; + +/// +/// The UpdateAuditableEntitiesInterceptor is a SaveChangesInterceptor that updates the ModifiedAt property +/// for IAuditableEntity instances when changes are made to the database. +/// +public sealed class UpdateAuditableEntitiesInterceptor : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + UpdateEntities(eventData); + + return base.SavingChanges(eventData, result); + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default + ) + { + UpdateEntities(eventData); + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private static void UpdateEntities(DbContextEventData eventData) + { + var dbContext = eventData.Context ?? throw new UnreachableException("The 'eventData.Context' property is unexpectedly null."); + + var audibleEntities = dbContext.ChangeTracker.Entries(); + + foreach (var entityEntry in audibleEntities) + { + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + switch (entityEntry.State) + { + case EntityState.Added when entityEntry.Entity.CreatedAt == default: + throw new UnreachableException("CreatedAt must be set before saving."); + case EntityState.Modified: + entityEntry.Entity.UpdateModifiedAt(DateTime.UtcNow); + break; + } + } + } +} diff --git a/application/shared-kernel/InfrastructureCore/InfrastructureCoreConfiguration.cs b/application/shared-kernel/InfrastructureCore/InfrastructureCoreConfiguration.cs index da6663c85..056080129 100644 --- a/application/shared-kernel/InfrastructureCore/InfrastructureCoreConfiguration.cs +++ b/application/shared-kernel/InfrastructureCore/InfrastructureCoreConfiguration.cs @@ -1,190 +1,157 @@ -using System.Net.Sockets; -using Azure.Identity; -using Azure.Security.KeyVault.Secrets; -using Azure.Storage.Blobs; -using Microsoft.Data.SqlClient; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using PlatformPlatform.SharedKernel.ApplicationCore.Services; -using PlatformPlatform.SharedKernel.DomainCore.DomainEvents; -using PlatformPlatform.SharedKernel.DomainCore.Persistence; -using PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; -using PlatformPlatform.SharedKernel.InfrastructureCore.Services; - -namespace PlatformPlatform.SharedKernel.InfrastructureCore; - -public static class InfrastructureCoreConfiguration -{ - public static readonly bool IsRunningInAzure = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID") is not null; - - public static IServiceCollection ConfigureDatabaseContext( - this IServiceCollection services, - IHostApplicationBuilder builder, - string connectionName - ) where T : DbContext - { - var connectionString = IsRunningInAzure - ? Environment.GetEnvironmentVariable("DATABASE_CONNECTION_STRING") - : builder.Configuration.GetConnectionString(connectionName); - - builder.Services.AddSqlServer(connectionString, optionsBuilder => { optionsBuilder.UseAzureSqlDefaults(); }); - builder.EnrichSqlServerDbContext(); - - return services; - } - - // Register the default storage account for IBlobStorage - public static IServiceCollection AddDefaultBlobStorage(this IServiceCollection services, IHostApplicationBuilder builder) - { - if (IsRunningInAzure) - { - var defaultBlobStorageUri = new Uri(Environment.GetEnvironmentVariable("BLOB_STORAGE_URL")!); - services.AddSingleton( - _ => new BlobStorage(new BlobServiceClient(defaultBlobStorageUri, GetDefaultAzureCredential())) - ); - } - else - { - var connectionString = builder.Configuration.GetConnectionString("blob-storage"); - services.AddSingleton(_ => new BlobStorage(new BlobServiceClient(connectionString))); - } - - return services; - } - - // Register different storage accounts for IBlobStorage using .NET Keyed services, when a service needs to access multiple storage accounts - public static IServiceCollection AddNamedBlobStorages( - this IServiceCollection services, - IHostApplicationBuilder builder, - params (string ConnectionName, string EnvironmentVariable)[] connections - ) - { - if (IsRunningInAzure) - { - var defaultAzureCredential = GetDefaultAzureCredential(); - foreach (var connection in connections) - { - var storageEndpointUri = new Uri(Environment.GetEnvironmentVariable(connection.EnvironmentVariable)!); - services.AddKeyedSingleton(connection.ConnectionName, - (_, _) => new BlobStorage(new BlobServiceClient(storageEndpointUri, defaultAzureCredential)) - ); - } - } - else - { - var connectionString = builder.Configuration.GetConnectionString("blob-storage"); - services.AddSingleton(_ => new BlobStorage(new BlobServiceClient(connectionString))); - } - - return services; - } - - public static IServiceCollection ConfigureInfrastructureCoreServices( - this IServiceCollection services, - Assembly assembly - ) - where T : DbContext - { - services.AddScoped(provider => new UnitOfWork(provider.GetRequiredService())); - services.AddScoped(provider => - new DomainEventCollector(provider.GetRequiredService()) - ); - - services.RegisterRepositories(assembly); - - if (IsRunningInAzure) - { - var keyVaultUri = new Uri(Environment.GetEnvironmentVariable("KEYVAULT_URL")!); - services.AddSingleton(_ => new SecretClient(keyVaultUri, GetDefaultAzureCredential())); - - services.AddTransient(); - } - else - { - services.AddTransient(); - } - - return services; - } - - public static DefaultAzureCredential GetDefaultAzureCredential() - { - // Hack. Remove trailing whitespace from the environment variable, Bicep of bug in Bicep - var managedIdentityClientId = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID")!.Trim(); - var credentialOptions = new DefaultAzureCredentialOptions { ManagedIdentityClientId = managedIdentityClientId }; - return new DefaultAzureCredential(credentialOptions); - } - - private static IServiceCollection RegisterRepositories(this IServiceCollection services, Assembly assembly) - { - // Scrutor will scan the assembly for all classes that implement the IRepository - // and register them as a service in the container. - services.Scan(scan => scan - .FromAssemblies(assembly) - .AddClasses(classes => classes.Where(type => - type.IsClass && (type.IsNotPublic || type.IsPublic) - && type.BaseType is { IsGenericType: true } && - type.BaseType.GetGenericTypeDefinition() == typeof(RepositoryBase<,>) - ) - ) - .AsImplementedInterfaces() - .WithScopedLifetime() - ); - - return services; - } - - public static void ApplyMigrations(this IServiceProvider services) where T : DbContext - { - using var scope = services.CreateScope(); - - var loggerFactory = scope.ServiceProvider.GetRequiredService(); - var logger = loggerFactory.CreateLogger(nameof(InfrastructureCoreConfiguration)); - - var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown"; - logger.LogInformation("Applying database migrations. Version: {Version}.", version); - - var retryCount = 1; - while (retryCount <= 20) - { - try - { - if (retryCount % 5 == 0) logger.LogInformation("Waiting for databases to be ready..."); - - var dbContext = scope.ServiceProvider.GetService() ?? - throw new UnreachableException("Missing DbContext."); - - var strategy = dbContext.Database.CreateExecutionStrategy(); - - strategy.Execute(() => dbContext.Database.Migrate()); - - logger.LogInformation("Finished migrating database."); - - break; - } - catch (SqlException ex) when (ex.Message.Contains("an error occurred during the pre-login handshake")) - { - // Known error in Aspire, when SQL Server is not ready - retryCount++; - Thread.Sleep(TimeSpan.FromSeconds(1)); - } - catch (SocketException ex) when (ex.Message.Contains("Invalid argument")) - { - // Known error in Aspire, when SQL Server is not ready - retryCount++; - Thread.Sleep(TimeSpan.FromSeconds(1)); - } - catch (Exception ex) - { - logger.LogError(ex, "An error occurred while applying migrations."); - - // Wait for the logger to flush - Thread.Sleep(TimeSpan.FromSeconds(1)); - - break; - } - } - } -} +using Azure.Identity; +using Azure.Security.KeyVault.Secrets; +using Azure.Storage.Blobs; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using PlatformPlatform.SharedKernel.ApplicationCore.Services; +using PlatformPlatform.SharedKernel.DomainCore.DomainEvents; +using PlatformPlatform.SharedKernel.DomainCore.Persistence; +using PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; +using PlatformPlatform.SharedKernel.InfrastructureCore.Services; + +namespace PlatformPlatform.SharedKernel.InfrastructureCore; + +public static class InfrastructureCoreConfiguration +{ + public static readonly bool IsRunningInAzure = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID") is not null; + + public static IServiceCollection ConfigureDatabaseContext( + this IServiceCollection services, + IHostApplicationBuilder builder, + string connectionName + ) where T : DbContext + { + var connectionString = IsRunningInAzure + ? Environment.GetEnvironmentVariable("DATABASE_CONNECTION_STRING") + : builder.Configuration.GetConnectionString(connectionName); + + builder.Services.AddSqlServer(connectionString, optionsBuilder => { optionsBuilder.UseAzureSqlDefaults(); }); + builder.EnrichSqlServerDbContext(); + + return services; + } + + // Register the default storage account for IBlobStorage + public static IServiceCollection AddDefaultBlobStorage(this IServiceCollection services, IHostApplicationBuilder builder) + { + if (IsRunningInAzure) + { + var defaultBlobStorageUri = new Uri(Environment.GetEnvironmentVariable("BLOB_STORAGE_URL")!); + services.AddSingleton( + _ => new BlobStorage(new BlobServiceClient(defaultBlobStorageUri, GetDefaultAzureCredential())) + ); + } + else + { + var connectionString = builder.Configuration.GetConnectionString("blob-storage"); + services.AddSingleton(_ => new BlobStorage(new BlobServiceClient(connectionString))); + } + + return services; + } + + // Register different storage accounts for IBlobStorage using .NET Keyed services, when a service needs to access multiple storage accounts + public static IServiceCollection AddNamedBlobStorages( + this IServiceCollection services, + IHostApplicationBuilder builder, + params (string ConnectionName, string EnvironmentVariable)[] connections + ) + { + if (IsRunningInAzure) + { + var defaultAzureCredential = GetDefaultAzureCredential(); + foreach (var connection in connections) + { + var storageEndpointUri = new Uri(Environment.GetEnvironmentVariable(connection.EnvironmentVariable)!); + services.AddKeyedSingleton(connection.ConnectionName, + (_, _) => new BlobStorage(new BlobServiceClient(storageEndpointUri, defaultAzureCredential)) + ); + } + } + else + { + var connectionString = builder.Configuration.GetConnectionString("blob-storage"); + services.AddSingleton(_ => new BlobStorage(new BlobServiceClient(connectionString))); + } + + return services; + } + + public static IServiceCollection ConfigureInfrastructureCoreServices( + this IServiceCollection services, + Assembly assembly + ) + where T : DbContext + { + services.AddScoped(provider => new UnitOfWork(provider.GetRequiredService())); + services.AddScoped(provider => + new DomainEventCollector(provider.GetRequiredService()) + ); + + services.RegisterRepositories(assembly); + + if (IsRunningInAzure) + { + var keyVaultUri = new Uri(Environment.GetEnvironmentVariable("KEYVAULT_URL")!); + services.AddSingleton(_ => new SecretClient(keyVaultUri, GetDefaultAzureCredential())); + + services.AddTransient(); + } + else + { + services.AddTransient(); + } + + return services; + } + + public static DefaultAzureCredential GetDefaultAzureCredential() + { + // Hack. Remove trailing whitespace from the environment variable, Bicep of bug in Bicep + var managedIdentityClientId = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID")!.Trim(); + var credentialOptions = new DefaultAzureCredentialOptions { ManagedIdentityClientId = managedIdentityClientId }; + return new DefaultAzureCredential(credentialOptions); + } + + private static IServiceCollection RegisterRepositories(this IServiceCollection services, Assembly assembly) + { + // Scrutor will scan the assembly for all classes that implement the IRepository + // and register them as a service in the container. + services.Scan(scan => scan + .FromAssemblies(assembly) + .AddClasses(classes => classes.Where(type => + type.IsClass && (type.IsNotPublic || type.IsPublic) + && type.BaseType is { IsGenericType: true } && + type.BaseType.GetGenericTypeDefinition() == typeof(RepositoryBase<,>) + ) + ) + .AsImplementedInterfaces() + .WithScopedLifetime() + ); + + return services; + } + + public static void ApplyMigrations(this IServiceProvider services) where T : DbContext + { + using var scope = services.CreateScope(); + + var loggerFactory = scope.ServiceProvider.GetRequiredService(); + var logger = loggerFactory.CreateLogger(nameof(InfrastructureCoreConfiguration)); + + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown"; + logger.LogInformation("Applying database migrations. Version: {Version}.", version); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var errorNumbersToAdd = new[] { 0 }; // On macOS and Linux exceptions with Error Number 0 are thrown when SQL Server is starting up + var maxRetryDelay = TimeSpan.FromSeconds(20); + var strategy = new SqlServerRetryingExecutionStrategy(dbContext, 10, maxRetryDelay, errorNumbersToAdd); + + strategy.Execute(() => dbContext.Database.Migrate()); + + logger.LogInformation("Finished migrating database."); + } +} diff --git a/application/shared-kernel/InfrastructureCore/Persistence/DomainEventCollector.cs b/application/shared-kernel/InfrastructureCore/Persistence/DomainEventCollector.cs index c0edab207..a413466ad 100644 --- a/application/shared-kernel/InfrastructureCore/Persistence/DomainEventCollector.cs +++ b/application/shared-kernel/InfrastructureCore/Persistence/DomainEventCollector.cs @@ -1,17 +1,17 @@ -using Microsoft.EntityFrameworkCore; -using PlatformPlatform.SharedKernel.DomainCore.DomainEvents; -using PlatformPlatform.SharedKernel.DomainCore.Entities; - -namespace PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; - -public sealed class DomainEventCollector(DbContext dbContext) : IDomainEventCollector -{ - public IAggregateRoot[] GetAggregatesWithDomainEvents() - { - return dbContext.ChangeTracker - .Entries() - .Where(e => e.Entity.DomainEvents.Count != 0) - .Select(e => e.Entity) - .ToArray(); - } -} +using Microsoft.EntityFrameworkCore; +using PlatformPlatform.SharedKernel.DomainCore.DomainEvents; +using PlatformPlatform.SharedKernel.DomainCore.Entities; + +namespace PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; + +public sealed class DomainEventCollector(DbContext dbContext) : IDomainEventCollector +{ + public IAggregateRoot[] GetAggregatesWithDomainEvents() + { + return dbContext.ChangeTracker + .Entries() + .Where(e => e.Entity.DomainEvents.Count != 0) + .Select(e => e.Entity) + .ToArray(); + } +} diff --git a/application/shared-kernel/InfrastructureCore/Persistence/RepositoryBase.cs b/application/shared-kernel/InfrastructureCore/Persistence/RepositoryBase.cs index 50a88eaf9..712343a3c 100644 --- a/application/shared-kernel/InfrastructureCore/Persistence/RepositoryBase.cs +++ b/application/shared-kernel/InfrastructureCore/Persistence/RepositoryBase.cs @@ -1,51 +1,51 @@ -using Microsoft.EntityFrameworkCore; -using PlatformPlatform.SharedKernel.DomainCore.Entities; -using PlatformPlatform.SharedKernel.DomainCore.Persistence; - -namespace PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; - -/// -/// RepositoryBase contains implementations for generic repository features. Repositories are a DDD concept, and are -/// used as an abstraction over the database. In DDD, repositories are used to persist . -/// that are not aggregates cannot be fetched from the database using a repository. They -/// must be fetched indirectly by fetching the aggregate that they belong to. E.g., to fetch an OrderLine entity, -/// you must do it by fetching the Order aggregate that it belongs to. -/// Repositories are not responsible for commiting the changes to the database, which is handled by the -/// . What this means is that when you add, update, or delete aggregates, they are just -/// marked to be added, updated, or deleted, and it's not until the is committed that the -/// changes are actually persisted to the database. -/// -public abstract class RepositoryBase(DbContext context) - where T : AggregateRoot where TId : IComparable -{ - protected readonly DbSet DbSet = context.Set(); - - public async Task GetByIdAsync(TId id, CancellationToken cancellationToken) - { - var keyValues = new object?[] { id }; - return await DbSet.FindAsync(keyValues, cancellationToken); - } - - public async Task ExistsAsync(TId id, CancellationToken cancellationToken) - { - return DbSet.Local.Any(e => e.Id.Equals(id)) || await DbSet.AnyAsync(e => e.Id.Equals(id), cancellationToken); - } - - public async Task AddAsync(T aggregate, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(aggregate); - await DbSet.AddAsync(aggregate, cancellationToken); - } - - public void Update(T aggregate) - { - ArgumentNullException.ThrowIfNull(aggregate); - DbSet.Update(aggregate); - } - - public void Remove(T aggregate) - { - ArgumentNullException.ThrowIfNull(aggregate); - DbSet.Remove(aggregate); - } -} +using Microsoft.EntityFrameworkCore; +using PlatformPlatform.SharedKernel.DomainCore.Entities; +using PlatformPlatform.SharedKernel.DomainCore.Persistence; + +namespace PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; + +/// +/// RepositoryBase contains implementations for generic repository features. Repositories are a DDD concept, and are +/// used as an abstraction over the database. In DDD, repositories are used to persist . +/// that are not aggregates cannot be fetched from the database using a repository. They +/// must be fetched indirectly by fetching the aggregate that they belong to. E.g., to fetch an OrderLine entity, +/// you must do it by fetching the Order aggregate that it belongs to. +/// Repositories are not responsible for commiting the changes to the database, which is handled by the +/// . What this means is that when you add, update, or delete aggregates, they are just +/// marked to be added, updated, or deleted, and it's not until the is committed that the +/// changes are actually persisted to the database. +/// +public abstract class RepositoryBase(DbContext context) + where T : AggregateRoot where TId : IComparable +{ + protected readonly DbSet DbSet = context.Set(); + + public async Task GetByIdAsync(TId id, CancellationToken cancellationToken) + { + var keyValues = new object?[] { id }; + return await DbSet.FindAsync(keyValues, cancellationToken); + } + + public async Task ExistsAsync(TId id, CancellationToken cancellationToken) + { + return DbSet.Local.Any(e => e.Id.Equals(id)) || await DbSet.AnyAsync(e => e.Id.Equals(id), cancellationToken); + } + + public async Task AddAsync(T aggregate, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(aggregate); + await DbSet.AddAsync(aggregate, cancellationToken); + } + + public void Update(T aggregate) + { + ArgumentNullException.ThrowIfNull(aggregate); + DbSet.Update(aggregate); + } + + public void Remove(T aggregate) + { + ArgumentNullException.ThrowIfNull(aggregate); + DbSet.Remove(aggregate); + } +} diff --git a/application/shared-kernel/InfrastructureCore/Persistence/UnitOfWork.cs b/application/shared-kernel/InfrastructureCore/Persistence/UnitOfWork.cs index 7ce9bfda3..8da4850da 100644 --- a/application/shared-kernel/InfrastructureCore/Persistence/UnitOfWork.cs +++ b/application/shared-kernel/InfrastructureCore/Persistence/UnitOfWork.cs @@ -1,24 +1,24 @@ -using Microsoft.EntityFrameworkCore; -using PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; -using PlatformPlatform.SharedKernel.DomainCore.Entities; -using PlatformPlatform.SharedKernel.DomainCore.Persistence; - -namespace PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; - -/// -/// UnitOfWork is an implementation of the IUnitOfWork interface from the Domain layer. It is responsible for -/// committing any changes to the application specific DbContext and saving them to the database. The UnitOfWork is -/// called from the in the Application layer. -/// -public sealed class UnitOfWork(DbContext dbContext) : IUnitOfWork -{ - public async Task CommitAsync(CancellationToken cancellationToken) - { - if (dbContext.ChangeTracker.Entries().Any(e => e.Entity.DomainEvents.Count != 0)) - { - throw new InvalidOperationException("Domain events must be handled before committing the UnitOfWork."); - } - - await dbContext.SaveChangesAsync(cancellationToken); - } -} +using Microsoft.EntityFrameworkCore; +using PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; +using PlatformPlatform.SharedKernel.DomainCore.Entities; +using PlatformPlatform.SharedKernel.DomainCore.Persistence; + +namespace PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; + +/// +/// UnitOfWork is an implementation of the IUnitOfWork interface from the Domain layer. It is responsible for +/// committing any changes to the application specific DbContext and saving them to the database. The UnitOfWork is +/// called from the in the Application layer. +/// +public sealed class UnitOfWork(DbContext dbContext) : IUnitOfWork +{ + public async Task CommitAsync(CancellationToken cancellationToken) + { + if (dbContext.ChangeTracker.Entries().Any(e => e.Entity.DomainEvents.Count != 0)) + { + throw new InvalidOperationException("Domain events must be handled before committing the UnitOfWork."); + } + + await dbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/application/shared-kernel/InfrastructureCore/Services/AzureEmailService.cs b/application/shared-kernel/InfrastructureCore/Services/AzureEmailService.cs index f2068f630..7ba9f84bc 100644 --- a/application/shared-kernel/InfrastructureCore/Services/AzureEmailService.cs +++ b/application/shared-kernel/InfrastructureCore/Services/AzureEmailService.cs @@ -1,22 +1,22 @@ -using Azure; -using Azure.Communication.Email; -using Azure.Security.KeyVault.Secrets; -using PlatformPlatform.SharedKernel.ApplicationCore.Services; - -namespace PlatformPlatform.SharedKernel.InfrastructureCore.Services; - -public sealed class AzureEmailService(SecretClient secretClient) : IEmailService -{ - private const string SecretName = "communication-services-connection-string"; - - private static readonly string Sender = Environment.GetEnvironmentVariable("SENDER_EMAIL_ADDRESS")!; - - public async Task SendAsync(string recipient, string subject, string htmlContent, CancellationToken cancellationToken) - { - var connectionString = await secretClient.GetSecretAsync(SecretName, cancellationToken: cancellationToken); - - var emailClient = new EmailClient(connectionString.Value.Value); - EmailMessage message = new(Sender, recipient, new EmailContent(subject) { Html = htmlContent }); - await emailClient.SendAsync(WaitUntil.Completed, message, cancellationToken); - } -} +using Azure; +using Azure.Communication.Email; +using Azure.Security.KeyVault.Secrets; +using PlatformPlatform.SharedKernel.ApplicationCore.Services; + +namespace PlatformPlatform.SharedKernel.InfrastructureCore.Services; + +public sealed class AzureEmailService(SecretClient secretClient) : IEmailService +{ + private const string SecretName = "communication-services-connection-string"; + + private static readonly string Sender = Environment.GetEnvironmentVariable("SENDER_EMAIL_ADDRESS")!; + + public async Task SendAsync(string recipient, string subject, string htmlContent, CancellationToken cancellationToken) + { + var connectionString = await secretClient.GetSecretAsync(SecretName, cancellationToken: cancellationToken); + + var emailClient = new EmailClient(connectionString.Value.Value); + EmailMessage message = new(Sender, recipient, new EmailContent(subject) { Html = htmlContent }); + await emailClient.SendAsync(WaitUntil.Completed, message, cancellationToken); + } +} diff --git a/application/shared-kernel/InfrastructureCore/Services/BlobStorage.cs b/application/shared-kernel/InfrastructureCore/Services/BlobStorage.cs index 29f7262de..a2cc38a74 100644 --- a/application/shared-kernel/InfrastructureCore/Services/BlobStorage.cs +++ b/application/shared-kernel/InfrastructureCore/Services/BlobStorage.cs @@ -1,30 +1,30 @@ -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Models; -using Azure.Storage.Sas; -using PlatformPlatform.SharedKernel.ApplicationCore.Services; - -namespace PlatformPlatform.SharedKernel.InfrastructureCore.Services; - -public class BlobStorage(BlobServiceClient blobServiceClient) : IBlobStorage -{ - public async Task UploadAsync(string containerName, string blobName, string contentType, Stream stream, CancellationToken cancellationToken) - { - var blobContainerClient = blobServiceClient.GetBlobContainerClient(containerName); - var blobClient = blobContainerClient.GetBlobClient(blobName); - var blobHttpHeaders = new BlobHttpHeaders { ContentType = contentType }; - await blobClient.UploadAsync(stream, blobHttpHeaders, cancellationToken: cancellationToken); - } - - public string GetBlobUrl(string container, string blobName) - { - return $"{blobServiceClient.Uri}/{container}/{blobName}"; - } - - 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; - } -} +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using Azure.Storage.Sas; +using PlatformPlatform.SharedKernel.ApplicationCore.Services; + +namespace PlatformPlatform.SharedKernel.InfrastructureCore.Services; + +public class BlobStorage(BlobServiceClient blobServiceClient) : IBlobStorage +{ + public async Task UploadAsync(string containerName, string blobName, string contentType, Stream stream, CancellationToken cancellationToken) + { + var blobContainerClient = blobServiceClient.GetBlobContainerClient(containerName); + var blobClient = blobContainerClient.GetBlobClient(blobName); + var blobHttpHeaders = new BlobHttpHeaders { ContentType = contentType }; + await blobClient.UploadAsync(stream, blobHttpHeaders, cancellationToken: cancellationToken); + } + + public string GetBlobUrl(string container, string blobName) + { + return $"{blobServiceClient.Uri}/{container}/{blobName}"; + } + + 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; + } +} diff --git a/application/shared-kernel/InfrastructureCore/Services/DevelopmentEmailService.cs b/application/shared-kernel/InfrastructureCore/Services/DevelopmentEmailService.cs index 8dbd7127d..1f0c08b9c 100644 --- a/application/shared-kernel/InfrastructureCore/Services/DevelopmentEmailService.cs +++ b/application/shared-kernel/InfrastructureCore/Services/DevelopmentEmailService.cs @@ -1,16 +1,16 @@ -using System.Net.Mail; -using PlatformPlatform.SharedKernel.ApplicationCore.Services; - -namespace PlatformPlatform.SharedKernel.InfrastructureCore.Services; - -public sealed class DevelopmentEmailService : IEmailService -{ - private const string Sender = "no-reply@localhost"; - private readonly SmtpClient _emailSender = new("localhost", 9004); - - public Task SendAsync(string recipient, string subject, string htmlContent, CancellationToken cancellationToken) - { - var mailMessage = new MailMessage(Sender, recipient, subject, htmlContent) { IsBodyHtml = true }; - return _emailSender.SendMailAsync(mailMessage, cancellationToken); - } -} +using System.Net.Mail; +using PlatformPlatform.SharedKernel.ApplicationCore.Services; + +namespace PlatformPlatform.SharedKernel.InfrastructureCore.Services; + +public sealed class DevelopmentEmailService : IEmailService +{ + private const string Sender = "no-reply@localhost"; + private readonly SmtpClient _emailSender = new("localhost", 9004); + + public Task SendAsync(string recipient, string subject, string htmlContent, CancellationToken cancellationToken) + { + var mailMessage = new MailMessage(Sender, recipient, subject, htmlContent) { IsBodyHtml = true }; + return _emailSender.SendMailAsync(mailMessage, cancellationToken); + } +} diff --git a/application/shared-kernel/Tests/ApplicationCore/Behaviors/PublishDomainEventsPipelineBehaviorTests.cs b/application/shared-kernel/Tests/ApplicationCore/Behaviors/PublishDomainEventsPipelineBehaviorTests.cs index 1bdc66106..cdec037f1 100644 --- a/application/shared-kernel/Tests/ApplicationCore/Behaviors/PublishDomainEventsPipelineBehaviorTests.cs +++ b/application/shared-kernel/Tests/ApplicationCore/Behaviors/PublishDomainEventsPipelineBehaviorTests.cs @@ -1,41 +1,41 @@ -using FluentAssertions; -using NSubstitute; -using PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.DomainCore.DomainEvents; -using PlatformPlatform.SharedKernel.Tests.TestEntities; -using Xunit; - -namespace PlatformPlatform.SharedKernel.Tests.ApplicationCore.Behaviors; - -public sealed class PublishDomainEventsPipelineBehaviorTests -{ - [Fact] - public async Task Handle_WhenCalled_ShouldPublishDomainEvents() - { - // Arrange - var domainEventCollector = Substitute.For(); - var publisher = Substitute.For(); - var behavior = new PublishDomainEventsPipelineBehavior>( - domainEventCollector, - publisher - ); - var request = new TestCommand(); - var cancellationToken = new CancellationToken(); - var next = Substitute.For>>(); - next.Invoke().Returns(TestAggregate.Create("Test")); - - var testAggregate = TestAggregate.Create("TestAggregate"); - var domainEvent = testAggregate.DomainEvents.Single(); // Get the domain events that were created - domainEventCollector.GetAggregatesWithDomainEvents().Returns( - _ => testAggregate.DomainEvents.Count == 0 ? [] : [testAggregate] - ); - - // Act - await behavior.Handle(request, next, cancellationToken); - - // Assert - await publisher.Received(1).Publish(domainEvent, cancellationToken); - testAggregate.DomainEvents.Should().BeEmpty(); - } -} +using FluentAssertions; +using NSubstitute; +using PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.DomainCore.DomainEvents; +using PlatformPlatform.SharedKernel.Tests.TestEntities; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.ApplicationCore.Behaviors; + +public sealed class PublishDomainEventsPipelineBehaviorTests +{ + [Fact] + public async Task Handle_WhenCalled_ShouldPublishDomainEvents() + { + // Arrange + var domainEventCollector = Substitute.For(); + var publisher = Substitute.For(); + var behavior = new PublishDomainEventsPipelineBehavior>( + domainEventCollector, + publisher + ); + var request = new TestCommand(); + var cancellationToken = new CancellationToken(); + var next = Substitute.For>>(); + next.Invoke().Returns(TestAggregate.Create("Test")); + + var testAggregate = TestAggregate.Create("TestAggregate"); + var domainEvent = testAggregate.DomainEvents.Single(); // Get the domain events that were created + domainEventCollector.GetAggregatesWithDomainEvents().Returns( + _ => testAggregate.DomainEvents.Count == 0 ? [] : [testAggregate] + ); + + // Act + await behavior.Handle(request, next, cancellationToken); + + // Assert + await publisher.Received(1).Publish(domainEvent, cancellationToken); + testAggregate.DomainEvents.Should().BeEmpty(); + } +} diff --git a/application/shared-kernel/Tests/ApplicationCore/Behaviors/UnitOfWorkPipelineBehaviorTests.cs b/application/shared-kernel/Tests/ApplicationCore/Behaviors/UnitOfWorkPipelineBehaviorTests.cs index 7d146d4ac..b27cd27be 100644 --- a/application/shared-kernel/Tests/ApplicationCore/Behaviors/UnitOfWorkPipelineBehaviorTests.cs +++ b/application/shared-kernel/Tests/ApplicationCore/Behaviors/UnitOfWorkPipelineBehaviorTests.cs @@ -1,67 +1,67 @@ -using Microsoft.Extensions.DependencyInjection; -using NSubstitute; -using PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; -using PlatformPlatform.SharedKernel.DomainCore.Persistence; -using PlatformPlatform.SharedKernel.Tests.TestEntities; -using Xunit; - -namespace PlatformPlatform.SharedKernel.Tests.ApplicationCore.Behaviors; - -public sealed class UnitOfWorkPipelineBehaviorTests -{ - private readonly UnitOfWorkPipelineBehavior> _behavior; - private readonly IUnitOfWork _unitOfWork; - - public UnitOfWorkPipelineBehaviorTests() - { - var services = new ServiceCollection(); - _unitOfWork = Substitute.For(); - services.AddSingleton(_unitOfWork); - _behavior = new UnitOfWorkPipelineBehavior>( - _unitOfWork, - new ConcurrentCommandCounter() - ); - } - - [Fact] - public async Task Handle_WhenSuccessfulCommand_ShouldCallNextAndCommitChanges() - { - // Arrange - var command = new TestCommand(); - var cancellationToken = new CancellationToken(); - var next = Substitute.For>>(); - var successfulCommandResult = Result.Success(TestAggregate.Create("Foo")); - next.Invoke().Returns(Task.FromResult(successfulCommandResult)); - - // Act - _ = await _behavior.Handle(command, next, cancellationToken); - - // Assert - await _unitOfWork.Received().CommitAsync(cancellationToken); - Received.InOrder(() => - { - next.Invoke(); - _unitOfWork.CommitAsync(cancellationToken); - } - ); - } - - [Fact] - public async Task Handle_WhenNonSuccessfulCommand_ShouldCallNextButNotCommitChanges() - { - // Arrange - var command = new TestCommand(); - var cancellationToken = new CancellationToken(); - var next = Substitute.For>>(); - var successfulCommandResult = Result.BadRequest("Fail"); - next.Invoke().Returns(Task.FromResult(successfulCommandResult)); - - // Act - _ = await _behavior.Handle(command, next, cancellationToken); - - // Assert - await _unitOfWork.DidNotReceive().CommitAsync(cancellationToken); - await next.Received().Invoke(); - } -} +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using PlatformPlatform.SharedKernel.ApplicationCore.Behaviors; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; +using PlatformPlatform.SharedKernel.DomainCore.Persistence; +using PlatformPlatform.SharedKernel.Tests.TestEntities; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.ApplicationCore.Behaviors; + +public sealed class UnitOfWorkPipelineBehaviorTests +{ + private readonly UnitOfWorkPipelineBehavior> _behavior; + private readonly IUnitOfWork _unitOfWork; + + public UnitOfWorkPipelineBehaviorTests() + { + var services = new ServiceCollection(); + _unitOfWork = Substitute.For(); + services.AddSingleton(_unitOfWork); + _behavior = new UnitOfWorkPipelineBehavior>( + _unitOfWork, + new ConcurrentCommandCounter() + ); + } + + [Fact] + public async Task Handle_WhenSuccessfulCommand_ShouldCallNextAndCommitChanges() + { + // Arrange + var command = new TestCommand(); + var cancellationToken = new CancellationToken(); + var next = Substitute.For>>(); + var successfulCommandResult = Result.Success(TestAggregate.Create("Foo")); + next.Invoke().Returns(Task.FromResult(successfulCommandResult)); + + // Act + _ = await _behavior.Handle(command, next, cancellationToken); + + // Assert + await _unitOfWork.Received().CommitAsync(cancellationToken); + Received.InOrder(() => + { + next.Invoke(); + _unitOfWork.CommitAsync(cancellationToken); + } + ); + } + + [Fact] + public async Task Handle_WhenNonSuccessfulCommand_ShouldCallNextButNotCommitChanges() + { + // Arrange + var command = new TestCommand(); + var cancellationToken = new CancellationToken(); + var next = Substitute.For>>(); + var successfulCommandResult = Result.BadRequest("Fail"); + next.Invoke().Returns(Task.FromResult(successfulCommandResult)); + + // Act + _ = await _behavior.Handle(command, next, cancellationToken); + + // Assert + await _unitOfWork.DidNotReceive().CommitAsync(cancellationToken); + await next.Received().Invoke(); + } +} diff --git a/application/shared-kernel/Tests/ApplicationCore/TelemetryEvents/TelemetryEventsCollectorSpy.cs b/application/shared-kernel/Tests/ApplicationCore/TelemetryEvents/TelemetryEventsCollectorSpy.cs index 7a911e8d7..d325e63f5 100644 --- a/application/shared-kernel/Tests/ApplicationCore/TelemetryEvents/TelemetryEventsCollectorSpy.cs +++ b/application/shared-kernel/Tests/ApplicationCore/TelemetryEvents/TelemetryEventsCollectorSpy.cs @@ -1,39 +1,39 @@ -using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; - -namespace PlatformPlatform.SharedKernel.Tests.ApplicationCore.TelemetryEvents; - -public class TelemetryEventsCollectorSpy(ITelemetryEventsCollector realTelemetryEventsCollector) - : ITelemetryEventsCollector -{ - private readonly List _collectedEvents = new(); - - public IReadOnlyList CollectedEvents => _collectedEvents; - - public bool AreAllEventsDispatched { get; private set; } - - public bool HasEvents => realTelemetryEventsCollector.HasEvents; - - public TelemetryEvent Dequeue() - { - var telemetryEvent = realTelemetryEventsCollector.Dequeue(); - AreAllEventsDispatched = !realTelemetryEventsCollector.HasEvents; - return telemetryEvent; - } - - public void CollectEvent(TelemetryEvent telemetryEvent) - { - realTelemetryEventsCollector.CollectEvent(telemetryEvent); - _collectedEvents.Add(telemetryEvent); - } - - public void Reset() - { - while (realTelemetryEventsCollector.HasEvents) - { - realTelemetryEventsCollector.Dequeue(); - } - - _collectedEvents.Clear(); - AreAllEventsDispatched = false; - } -} +using PlatformPlatform.SharedKernel.ApplicationCore.TelemetryEvents; + +namespace PlatformPlatform.SharedKernel.Tests.ApplicationCore.TelemetryEvents; + +public class TelemetryEventsCollectorSpy(ITelemetryEventsCollector realTelemetryEventsCollector) + : ITelemetryEventsCollector +{ + private readonly List _collectedEvents = new(); + + public IReadOnlyList CollectedEvents => _collectedEvents; + + public bool AreAllEventsDispatched { get; private set; } + + public bool HasEvents => realTelemetryEventsCollector.HasEvents; + + public TelemetryEvent Dequeue() + { + var telemetryEvent = realTelemetryEventsCollector.Dequeue(); + AreAllEventsDispatched = !realTelemetryEventsCollector.HasEvents; + return telemetryEvent; + } + + public void CollectEvent(TelemetryEvent telemetryEvent) + { + realTelemetryEventsCollector.CollectEvent(telemetryEvent); + _collectedEvents.Add(telemetryEvent); + } + + public void Reset() + { + while (realTelemetryEventsCollector.HasEvents) + { + realTelemetryEventsCollector.Dequeue(); + } + + _collectedEvents.Clear(); + AreAllEventsDispatched = false; + } +} diff --git a/application/shared-kernel/Tests/DomainCore/DomainEvents/DomainEventTests.cs b/application/shared-kernel/Tests/DomainCore/DomainEvents/DomainEventTests.cs index 0d60fa3b0..196a6b483 100644 --- a/application/shared-kernel/Tests/DomainCore/DomainEvents/DomainEventTests.cs +++ b/application/shared-kernel/Tests/DomainCore/DomainEvents/DomainEventTests.cs @@ -1,21 +1,21 @@ -using FluentAssertions; -using PlatformPlatform.SharedKernel.Tests.TestEntities; -using Xunit; - -namespace PlatformPlatform.SharedKernel.Tests.DomainCore.DomainEvents; - -public sealed class DomainEventTests -{ - [Fact] - public void DomainEvent_WhenCreatingDomainEvents_ShouldTrackEventWithCorrectOccurrence() - { - // Act - var testAggregate = TestAggregate.Create("test"); - - // Assert - testAggregate.DomainEvents.Count.Should().Be(1); - var domainEvent = (TestAggregateCreatedEvent)testAggregate.DomainEvents.Single(); - domainEvent.TestAggregateId.Should().Be(testAggregate.Id); - domainEvent.Name.Should().Be("test"); - } -} +using FluentAssertions; +using PlatformPlatform.SharedKernel.Tests.TestEntities; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.DomainCore.DomainEvents; + +public sealed class DomainEventTests +{ + [Fact] + public void DomainEvent_WhenCreatingDomainEvents_ShouldTrackEventWithCorrectOccurrence() + { + // Act + var testAggregate = TestAggregate.Create("test"); + + // Assert + testAggregate.DomainEvents.Count.Should().Be(1); + var domainEvent = (TestAggregateCreatedEvent)testAggregate.DomainEvents.Single(); + domainEvent.TestAggregateId.Should().Be(testAggregate.Id); + domainEvent.Name.Should().Be("test"); + } +} diff --git a/application/shared-kernel/Tests/DomainCore/Entities/EntityEqualityComparerTests.cs b/application/shared-kernel/Tests/DomainCore/Entities/EntityEqualityComparerTests.cs index 7f2a6b81f..cffa19261 100644 --- a/application/shared-kernel/Tests/DomainCore/Entities/EntityEqualityComparerTests.cs +++ b/application/shared-kernel/Tests/DomainCore/Entities/EntityEqualityComparerTests.cs @@ -1,83 +1,83 @@ -using FluentAssertions; -using PlatformPlatform.SharedKernel.DomainCore.Entities; -using Xunit; - -namespace PlatformPlatform.SharedKernel.Tests.DomainCore.Entities; - -public sealed class EntityEqualityComparerTests -{ - private readonly EntityEqualityComparer _comparer = new(); - - [Fact] - public void Equals_WithSameEntity_ShouldReturnTrue() - { - // Arrange - var entity = new GuidEntity(Guid.NewGuid()) { Name = "Test" }; - - // Act - var isEqual = _comparer.Equals(entity, entity); - - // Assert - isEqual.Should().BeTrue(); - } - - [Fact] - public void Equals_WithSameIdDifferentProperty_ShouldReturnTrue() - { - // Arrange - var id = Guid.NewGuid(); - var entity1 = new GuidEntity(id) { Name = "Test" }; - var entity2 = new GuidEntity(id) { Name = "Different" }; - - // Act - var isEqual = _comparer.Equals(entity1, entity2); - - // Assert - isEqual.Should().BeTrue(); - } - - [Fact] - public void Equals_WithDifferentIds_ShouldReturnFalse() - { - // Arrange - var entity1 = new GuidEntity(Guid.NewGuid()) { Name = "Test" }; - var entity2 = new GuidEntity(Guid.NewGuid()) { Name = "Test" }; - - // Act - var isEqual = _comparer.Equals(entity1, entity2); - - // Assert - isEqual.Should().BeFalse(); - } - - [Fact] - public void GetHashCode_WithSameId_ShouldReturnSameHashCode() - { - // Arrange - var id = Guid.NewGuid(); - var entity1 = new GuidEntity(id) { Name = "Test" }; - var entity2 = new GuidEntity(id) { Name = "Different" }; - - // Act - var hashCode1 = _comparer.GetHashCode(entity1); - var hashCode2 = _comparer.GetHashCode(entity2); - - // Assert - hashCode1.Should().Be(hashCode2); - } - - [Fact] - public void GetHashCode_WithDifferentIds_ShouldReturnDifferentHashCodes() - { - // Arrange - var entity1 = new GuidEntity(Guid.NewGuid()) { Name = "Test" }; - var entity2 = new GuidEntity(Guid.NewGuid()) { Name = "Test" }; - - // Act - var hashCode1 = _comparer.GetHashCode(entity1); - var hashCode2 = _comparer.GetHashCode(entity2); - - // Assert - hashCode1.Should().NotBe(hashCode2); - } -} +using FluentAssertions; +using PlatformPlatform.SharedKernel.DomainCore.Entities; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.DomainCore.Entities; + +public sealed class EntityEqualityComparerTests +{ + private readonly EntityEqualityComparer _comparer = new(); + + [Fact] + public void Equals_WithSameEntity_ShouldReturnTrue() + { + // Arrange + var entity = new GuidEntity(Guid.NewGuid()) { Name = "Test" }; + + // Act + var isEqual = _comparer.Equals(entity, entity); + + // Assert + isEqual.Should().BeTrue(); + } + + [Fact] + public void Equals_WithSameIdDifferentProperty_ShouldReturnTrue() + { + // Arrange + var id = Guid.NewGuid(); + var entity1 = new GuidEntity(id) { Name = "Test" }; + var entity2 = new GuidEntity(id) { Name = "Different" }; + + // Act + var isEqual = _comparer.Equals(entity1, entity2); + + // Assert + isEqual.Should().BeTrue(); + } + + [Fact] + public void Equals_WithDifferentIds_ShouldReturnFalse() + { + // Arrange + var entity1 = new GuidEntity(Guid.NewGuid()) { Name = "Test" }; + var entity2 = new GuidEntity(Guid.NewGuid()) { Name = "Test" }; + + // Act + var isEqual = _comparer.Equals(entity1, entity2); + + // Assert + isEqual.Should().BeFalse(); + } + + [Fact] + public void GetHashCode_WithSameId_ShouldReturnSameHashCode() + { + // Arrange + var id = Guid.NewGuid(); + var entity1 = new GuidEntity(id) { Name = "Test" }; + var entity2 = new GuidEntity(id) { Name = "Different" }; + + // Act + var hashCode1 = _comparer.GetHashCode(entity1); + var hashCode2 = _comparer.GetHashCode(entity2); + + // Assert + hashCode1.Should().Be(hashCode2); + } + + [Fact] + public void GetHashCode_WithDifferentIds_ShouldReturnDifferentHashCodes() + { + // Arrange + var entity1 = new GuidEntity(Guid.NewGuid()) { Name = "Test" }; + var entity2 = new GuidEntity(Guid.NewGuid()) { Name = "Test" }; + + // Act + var hashCode1 = _comparer.GetHashCode(entity1); + var hashCode2 = _comparer.GetHashCode(entity2); + + // Assert + hashCode1.Should().NotBe(hashCode2); + } +} diff --git a/application/shared-kernel/Tests/DomainCore/Entities/EntityTests.cs b/application/shared-kernel/Tests/DomainCore/Entities/EntityTests.cs index 03fa6ae75..bd5c0c29f 100644 --- a/application/shared-kernel/Tests/DomainCore/Entities/EntityTests.cs +++ b/application/shared-kernel/Tests/DomainCore/Entities/EntityTests.cs @@ -1,181 +1,181 @@ -using FluentAssertions; -using PlatformPlatform.SharedKernel.DomainCore.Entities; -using PlatformPlatform.SharedKernel.DomainCore.Identity; -using Xunit; - -namespace PlatformPlatform.SharedKernel.Tests.DomainCore.Entities; - -public static class EntityTests -{ - public sealed class OperatorOverloadTests - { - [Fact] - public void EqualsOperator_WhenIdIsAreGenerated_ShouldReturnFalse() - { - // Arrange - var entity1 = new StronglyTypedIdEntity { Name = "Test" }; - var entity2 = new StronglyTypedIdEntity { Name = "Test" }; - - // Act - var isEqual = entity1 == entity2; - - // Assert - isEqual.Should().BeFalse(); - } - - [Fact] - public void EqualsOperator_WhenIdsAreSame_ShouldReturnTrue() - { - // Arrange - var guid = Guid.NewGuid(); - var entity1 = new GuidEntity(guid) { Name = "Test" }; - var entity2 = new GuidEntity(guid) { Name = "Different" }; - - // Act - var isEqual = entity1 == entity2; - - // Assert - isEqual.Should().BeTrue(); - } - - [Fact] - public void EqualsOperator_WhenIdsAreDifferent_ShouldReturnFalse() - { - // Arrange - var entity1 = new StringEntity("id1") { Name = "Test" }; - var entity2 = new StringEntity("id2") { Name = "Test" }; - - // Act - var isEqual = entity1 == entity2; - - // Assert - isEqual.Should().BeFalse(); - } - - [Fact] - public void NotOperator_WhenIdsAreEqual_ShouldReturnFalse() - { - // Arrange - var guidId = Guid.NewGuid(); - var entity1 = new GuidEntity(guidId) { Name = "Test" }; - var entity2 = new GuidEntity(guidId) { Name = "Different" }; - - // Act - var isNotEqual = entity1 != entity2; - - // Assert - isNotEqual.Should().BeFalse(); - } - - [Fact] - public void NotOperator_WhenIdsAreDifferent_ShouldReturnTrue() - { - // Arrange - var entity1 = new StronglyTypedIdEntity { Name = "Test" }; - var entity2 = new StronglyTypedIdEntity { Name = "Test" }; - - // Act - var isNotEqual = entity1 != entity2; - - // Assert - isNotEqual.Should().BeTrue(); - } - } - - public sealed class EqualMethodTests - { - [Fact] - public void Equal_WhenIdIsAreGenerated_ShouldReturnFalse() - { - // Arrange - var entity1 = new StronglyTypedIdEntity { Name = "Test" }; - var entity2 = new StronglyTypedIdEntity { Name = "Test" }; - - // Act - var isEqual = entity1.Equals(entity2); - - // Assert - isEqual.Should().BeFalse(); - } - - [Fact] - public void Equal_WhenIdsAreEqual_ShouldReturnTrue() - { - // Arrange - const string stringId = "id1"; - var entity1 = new StringEntity(stringId) { Name = "Test" }; - var entity2 = new StringEntity(stringId) { Name = "Different" }; - - // Act - var isEqual = entity1.Equals(entity2); - - // Assert - isEqual.Should().BeTrue(); - } - - [Fact] - public void Equal_DifferentIds_ShouldReturnFalse() - { - // Arrange - var entity1 = new GuidEntity(Guid.NewGuid()) { Name = "Test" }; - var entity2 = new GuidEntity(Guid.NewGuid()) { Name = "Test" }; - - // Act - var isEqual = entity1.Equals(entity2); - - // Assert - isEqual.Should().BeFalse(); - } - } - - public sealed class GetHashCodeTests - { - [Fact] - public void GetHashCode_DifferentIdsSameProperties_ShouldHaveDifferentHashCode() - { - // Arrange - var entity1 = new StronglyTypedIdEntity { Name = "Test" }; - var entity2 = new StronglyTypedIdEntity { Name = "Test" }; - - // Act - var hashCode1 = entity1.GetHashCode(); - var hashCode2 = entity2.GetHashCode(); - - // Assert - hashCode1.Should().NotBe(hashCode2); - } - - [Fact] - public void GetHashCode_SameIdsDifferentProperties_ShouldHaveSameHashCode() - { - // Arrange - var id = Guid.NewGuid(); - var entity1 = new GuidEntity(id) { Name = "Test" }; - var entity2 = new GuidEntity(id) { Name = "Different" }; - - // Act - var hashCode1 = entity1.GetHashCode(); - var hashCode2 = entity2.GetHashCode(); - - // Assert - hashCode1.Should().Be(hashCode2); - } - } -} - -public sealed record StronglyTypedId(long Value) : StronglyTypedLongId(Value); - -public sealed class StronglyTypedIdEntity() : Entity(StronglyTypedId.NewId()) -{ - public required string Name { get; init; } -} - -public sealed class GuidEntity(Guid id) : Entity(id) -{ - public required string Name { get; init; } -} - -public sealed class StringEntity(string id) : Entity(id) -{ - public required string Name { get; init; } -} +using FluentAssertions; +using PlatformPlatform.SharedKernel.DomainCore.Entities; +using PlatformPlatform.SharedKernel.DomainCore.Identity; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.DomainCore.Entities; + +public static class EntityTests +{ + public sealed class OperatorOverloadTests + { + [Fact] + public void EqualsOperator_WhenIdIsAreGenerated_ShouldReturnFalse() + { + // Arrange + var entity1 = new StronglyTypedIdEntity { Name = "Test" }; + var entity2 = new StronglyTypedIdEntity { Name = "Test" }; + + // Act + var isEqual = entity1 == entity2; + + // Assert + isEqual.Should().BeFalse(); + } + + [Fact] + public void EqualsOperator_WhenIdsAreSame_ShouldReturnTrue() + { + // Arrange + var guid = Guid.NewGuid(); + var entity1 = new GuidEntity(guid) { Name = "Test" }; + var entity2 = new GuidEntity(guid) { Name = "Different" }; + + // Act + var isEqual = entity1 == entity2; + + // Assert + isEqual.Should().BeTrue(); + } + + [Fact] + public void EqualsOperator_WhenIdsAreDifferent_ShouldReturnFalse() + { + // Arrange + var entity1 = new StringEntity("id1") { Name = "Test" }; + var entity2 = new StringEntity("id2") { Name = "Test" }; + + // Act + var isEqual = entity1 == entity2; + + // Assert + isEqual.Should().BeFalse(); + } + + [Fact] + public void NotOperator_WhenIdsAreEqual_ShouldReturnFalse() + { + // Arrange + var guidId = Guid.NewGuid(); + var entity1 = new GuidEntity(guidId) { Name = "Test" }; + var entity2 = new GuidEntity(guidId) { Name = "Different" }; + + // Act + var isNotEqual = entity1 != entity2; + + // Assert + isNotEqual.Should().BeFalse(); + } + + [Fact] + public void NotOperator_WhenIdsAreDifferent_ShouldReturnTrue() + { + // Arrange + var entity1 = new StronglyTypedIdEntity { Name = "Test" }; + var entity2 = new StronglyTypedIdEntity { Name = "Test" }; + + // Act + var isNotEqual = entity1 != entity2; + + // Assert + isNotEqual.Should().BeTrue(); + } + } + + public sealed class EqualMethodTests + { + [Fact] + public void Equal_WhenIdIsAreGenerated_ShouldReturnFalse() + { + // Arrange + var entity1 = new StronglyTypedIdEntity { Name = "Test" }; + var entity2 = new StronglyTypedIdEntity { Name = "Test" }; + + // Act + var isEqual = entity1.Equals(entity2); + + // Assert + isEqual.Should().BeFalse(); + } + + [Fact] + public void Equal_WhenIdsAreEqual_ShouldReturnTrue() + { + // Arrange + const string stringId = "id1"; + var entity1 = new StringEntity(stringId) { Name = "Test" }; + var entity2 = new StringEntity(stringId) { Name = "Different" }; + + // Act + var isEqual = entity1.Equals(entity2); + + // Assert + isEqual.Should().BeTrue(); + } + + [Fact] + public void Equal_DifferentIds_ShouldReturnFalse() + { + // Arrange + var entity1 = new GuidEntity(Guid.NewGuid()) { Name = "Test" }; + var entity2 = new GuidEntity(Guid.NewGuid()) { Name = "Test" }; + + // Act + var isEqual = entity1.Equals(entity2); + + // Assert + isEqual.Should().BeFalse(); + } + } + + public sealed class GetHashCodeTests + { + [Fact] + public void GetHashCode_DifferentIdsSameProperties_ShouldHaveDifferentHashCode() + { + // Arrange + var entity1 = new StronglyTypedIdEntity { Name = "Test" }; + var entity2 = new StronglyTypedIdEntity { Name = "Test" }; + + // Act + var hashCode1 = entity1.GetHashCode(); + var hashCode2 = entity2.GetHashCode(); + + // Assert + hashCode1.Should().NotBe(hashCode2); + } + + [Fact] + public void GetHashCode_SameIdsDifferentProperties_ShouldHaveSameHashCode() + { + // Arrange + var id = Guid.NewGuid(); + var entity1 = new GuidEntity(id) { Name = "Test" }; + var entity2 = new GuidEntity(id) { Name = "Different" }; + + // Act + var hashCode1 = entity1.GetHashCode(); + var hashCode2 = entity2.GetHashCode(); + + // Assert + hashCode1.Should().Be(hashCode2); + } + } +} + +public sealed record StronglyTypedId(long Value) : StronglyTypedLongId(Value); + +public sealed class StronglyTypedIdEntity() : Entity(StronglyTypedId.NewId()) +{ + public required string Name { get; init; } +} + +public sealed class GuidEntity(Guid id) : Entity(id) +{ + public required string Name { get; init; } +} + +public sealed class StringEntity(string id) : Entity(id) +{ + public required string Name { get; init; } +} diff --git a/application/shared-kernel/Tests/DomainCore/Identity/IdGeneratorTests.cs b/application/shared-kernel/Tests/DomainCore/Identity/IdGeneratorTests.cs index 53bd0989a..6882a4711 100644 --- a/application/shared-kernel/Tests/DomainCore/Identity/IdGeneratorTests.cs +++ b/application/shared-kernel/Tests/DomainCore/Identity/IdGeneratorTests.cs @@ -1,41 +1,41 @@ -using FluentAssertions; -using PlatformPlatform.SharedKernel.DomainCore.Identity; -using Xunit; - -namespace PlatformPlatform.SharedKernel.Tests.DomainCore.Identity; - -public sealed class IdGeneratorTests -{ - [Fact] - public void NewId_WhenGeneratingIds_IdsShouldBeUnique() - { - // Arrange - const int idCount = 1000; - var generatedIds = new HashSet(); - - // Act - for (var i = 0; i < idCount; i++) - { - generatedIds.Add(IdGenerator.NewId()); - } - - // Assert - generatedIds.Count.Should().Be(idCount); - } - - [Fact] - public void NewId_WhenGeneratingIds_IdsShouldBeIncreasing() - { - // Arrange - const int idCount = 1000; - var previousId = 0L; - - // Act & Assert - for (var i = 0; i < idCount; i++) - { - var currentId = IdGenerator.NewId(); - currentId.Should().BeGreaterThan(previousId); - previousId = currentId; - } - } -} +using FluentAssertions; +using PlatformPlatform.SharedKernel.DomainCore.Identity; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.DomainCore.Identity; + +public sealed class IdGeneratorTests +{ + [Fact] + public void NewId_WhenGeneratingIds_IdsShouldBeUnique() + { + // Arrange + const int idCount = 1000; + var generatedIds = new HashSet(); + + // Act + for (var i = 0; i < idCount; i++) + { + generatedIds.Add(IdGenerator.NewId()); + } + + // Assert + generatedIds.Count.Should().Be(idCount); + } + + [Fact] + public void NewId_WhenGeneratingIds_IdsShouldBeIncreasing() + { + // Arrange + const int idCount = 1000; + var previousId = 0L; + + // Act & Assert + for (var i = 0; i < idCount; i++) + { + var currentId = IdGenerator.NewId(); + currentId.Should().BeGreaterThan(previousId); + previousId = currentId; + } + } +} diff --git a/application/shared-kernel/Tests/DomainCore/Identity/StronglyTypedUlidTests.cs b/application/shared-kernel/Tests/DomainCore/Identity/StronglyTypedUlidTests.cs index 41a159526..cd69f1d8d 100644 --- a/application/shared-kernel/Tests/DomainCore/Identity/StronglyTypedUlidTests.cs +++ b/application/shared-kernel/Tests/DomainCore/Identity/StronglyTypedUlidTests.cs @@ -1,36 +1,36 @@ -using FluentAssertions; -using PlatformPlatform.SharedKernel.DomainCore.Identity; -using Xunit; - -namespace PlatformPlatform.SharedKernel.Tests.DomainCore.Identity; - -public class StronglyTypedUlidTests -{ - [Fact] - public void NewId_WhenGenerating_ShouldHavePrefix() - { - // Arrange - // Act - var id = IdWithPrefix.NewId(); - - // Assert - id.Value.Should().StartWith("prefix_"); - } - - [Fact] - public void TryParse_WhenValidId_ShouldSucceed() - { - // Arrange - var id = IdWithPrefix.NewId(); - - // Act - var isParsedSuccessfully = IdWithPrefix.TryParse(id, out var result); - - // Assert - isParsedSuccessfully.Should().BeTrue(); - result.Should().NotBeNull(); - } - - [IdPrefix("prefix")] - public record IdWithPrefix(string Value) : StronglyTypedUlid(Value); -} +using FluentAssertions; +using PlatformPlatform.SharedKernel.DomainCore.Identity; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.DomainCore.Identity; + +public class StronglyTypedUlidTests +{ + [Fact] + public void NewId_WhenGenerating_ShouldHavePrefix() + { + // Arrange + // Act + var id = IdWithPrefix.NewId(); + + // Assert + id.Value.Should().StartWith("prefix_"); + } + + [Fact] + public void TryParse_WhenValidId_ShouldSucceed() + { + // Arrange + var id = IdWithPrefix.NewId(); + + // Act + var isParsedSuccessfully = IdWithPrefix.TryParse(id, out var result); + + // Assert + isParsedSuccessfully.Should().BeTrue(); + result.Should().NotBeNull(); + } + + [IdPrefix("prefix")] + public record IdWithPrefix(string Value) : StronglyTypedUlid(Value); +} diff --git a/application/shared-kernel/Tests/InfrastructureCore/EntityFramework/SaveChangesInterceptorTests.cs b/application/shared-kernel/Tests/InfrastructureCore/EntityFramework/SaveChangesInterceptorTests.cs index 5c45cd482..6582fd7ba 100644 --- a/application/shared-kernel/Tests/InfrastructureCore/EntityFramework/SaveChangesInterceptorTests.cs +++ b/application/shared-kernel/Tests/InfrastructureCore/EntityFramework/SaveChangesInterceptorTests.cs @@ -1,57 +1,57 @@ -using FluentAssertions; -using PlatformPlatform.SharedKernel.Tests.TestEntities; -using Xunit; - -namespace PlatformPlatform.SharedKernel.Tests.InfrastructureCore.EntityFramework; - -public sealed class SaveChangesInterceptorTests : IDisposable -{ - private readonly SqliteInMemoryDbContextFactory _sqliteInMemoryDbContextFactory; - private readonly TestDbContext _testDbContext; - - public SaveChangesInterceptorTests() - { - _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(); - _testDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); - } - - public void Dispose() - { - _sqliteInMemoryDbContextFactory.Dispose(); - } - - [Fact] - public async Task SavingChangesAsync_WhenEntityIsAdded_ShouldSetCreatedAt() - { - // Arrange - var newTestAggregate = TestAggregate.Create("TestAggregate"); - - // Act - _testDbContext.TestAggregates.Add(newTestAggregate); - await _testDbContext.SaveChangesAsync(); - - // Assert - newTestAggregate.CreatedAt.Should().NotBe(default); - newTestAggregate.ModifiedAt.Should().BeNull(); - } - - [Fact] - public async Task SavingChangesAsync_WhenEntityIsModified_ShouldUpdateModifiedAt() - { - // Arrange - var newTestAggregate = TestAggregate.Create("TestAggregate"); - _testDbContext.TestAggregates.Add(newTestAggregate); - await _testDbContext.SaveChangesAsync(); - var initialCreatedAt = newTestAggregate.CreatedAt; - var initialModifiedAt = newTestAggregate.ModifiedAt; - - // Act - newTestAggregate.Name = "UpdatedTestAggregate"; - await _testDbContext.SaveChangesAsync(); - - // Assert - newTestAggregate.ModifiedAt.Should().NotBe(default); - newTestAggregate.ModifiedAt.Should().NotBe(initialModifiedAt); - newTestAggregate.CreatedAt.Should().Be(initialCreatedAt); - } -} +using FluentAssertions; +using PlatformPlatform.SharedKernel.Tests.TestEntities; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.InfrastructureCore.EntityFramework; + +public sealed class SaveChangesInterceptorTests : IDisposable +{ + private readonly SqliteInMemoryDbContextFactory _sqliteInMemoryDbContextFactory; + private readonly TestDbContext _testDbContext; + + public SaveChangesInterceptorTests() + { + _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(); + _testDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); + } + + public void Dispose() + { + _sqliteInMemoryDbContextFactory.Dispose(); + } + + [Fact] + public async Task SavingChangesAsync_WhenEntityIsAdded_ShouldSetCreatedAt() + { + // Arrange + var newTestAggregate = TestAggregate.Create("TestAggregate"); + + // Act + _testDbContext.TestAggregates.Add(newTestAggregate); + await _testDbContext.SaveChangesAsync(); + + // Assert + newTestAggregate.CreatedAt.Should().NotBe(default); + newTestAggregate.ModifiedAt.Should().BeNull(); + } + + [Fact] + public async Task SavingChangesAsync_WhenEntityIsModified_ShouldUpdateModifiedAt() + { + // Arrange + var newTestAggregate = TestAggregate.Create("TestAggregate"); + _testDbContext.TestAggregates.Add(newTestAggregate); + await _testDbContext.SaveChangesAsync(); + var initialCreatedAt = newTestAggregate.CreatedAt; + var initialModifiedAt = newTestAggregate.ModifiedAt; + + // Act + newTestAggregate.Name = "UpdatedTestAggregate"; + await _testDbContext.SaveChangesAsync(); + + // Assert + newTestAggregate.ModifiedAt.Should().NotBe(default); + newTestAggregate.ModifiedAt.Should().NotBe(initialModifiedAt); + newTestAggregate.CreatedAt.Should().Be(initialCreatedAt); + } +} diff --git a/application/shared-kernel/Tests/InfrastructureCore/Persistence/RepositoryTests.cs b/application/shared-kernel/Tests/InfrastructureCore/Persistence/RepositoryTests.cs index 5c6b71728..6a6e7b87a 100644 --- a/application/shared-kernel/Tests/InfrastructureCore/Persistence/RepositoryTests.cs +++ b/application/shared-kernel/Tests/InfrastructureCore/Persistence/RepositoryTests.cs @@ -1,158 +1,158 @@ -using FluentAssertions; -using Microsoft.EntityFrameworkCore; -using PlatformPlatform.SharedKernel.DomainCore.Identity; -using PlatformPlatform.SharedKernel.Tests.TestEntities; -using Xunit; - -namespace PlatformPlatform.SharedKernel.Tests.InfrastructureCore.Persistence; - -public sealed class RepositoryTests : IDisposable -{ - private readonly SqliteInMemoryDbContextFactory _sqliteInMemoryDbContextFactory; - private readonly TestAggregateRepository _testAggregateRepository; - private readonly TestDbContext _testDbContext; - - public RepositoryTests() - { - _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(); - _testDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); - _testAggregateRepository = new TestAggregateRepository(_testDbContext); - } - - public void Dispose() - { - _sqliteInMemoryDbContextFactory.Dispose(); - } - - [Fact] - public async Task Add_WhenNewAggregate_ShouldAddToDatabase() - { - // Arrange - var testAggregate = TestAggregate.Create("TestAggregate"); - var cancellationToken = new CancellationToken(); - - // Act - await _testAggregateRepository.AddAsync(testAggregate, cancellationToken); - await _testDbContext.SaveChangesAsync(cancellationToken); - - // Assert - var retrievedAggregate = await _testAggregateRepository.GetByIdAsync(testAggregate.Id, cancellationToken); - retrievedAggregate.Should().NotBeNull(); - retrievedAggregate!.Id.Should().Be(testAggregate.Id); - } - - [Fact] - public async Task GetByIdAsync_WhenAggregateExists_ShouldRetrieveFromDatabase() - { - // Arrange - var testAggregate = TestAggregate.Create("TestAggregate"); - _testDbContext.TestAggregates.Add(testAggregate); - await _testDbContext.SaveChangesAsync(); - - // Act - var retrievedAggregate = await _testAggregateRepository.GetByIdAsync(testAggregate.Id, CancellationToken.None); - - // Assert - retrievedAggregate.Should().NotBeNull(); - retrievedAggregate!.Id.Should().Be(testAggregate.Id); - } - - [Fact] - public async Task GetByIdAsync_WhenAggregateDoesNotExist_ShouldReturnNull() - { - // Arrange - var nonExistentId = IdGenerator.NewId(); - - // Act - var retrievedAggregate = await _testAggregateRepository.GetByIdAsync(nonExistentId, CancellationToken.None); - - // Assert - retrievedAggregate.Should().BeNull(); - } - - [Fact] - public async Task Update_WhenExistingAggregate_ShouldUpdateDatabase() - { - // Arrange - var testAggregate = TestAggregate.Create("TestAggregate"); - _testDbContext.TestAggregates.Add(testAggregate); - await _testDbContext.SaveChangesAsync(); - var initialName = testAggregate.Name; - - // Act - testAggregate.Name = "UpdatedName"; - _testAggregateRepository.Update(testAggregate); - await _testDbContext.SaveChangesAsync(); - - // Assert - var updatedAggregate = await _testAggregateRepository.GetByIdAsync(testAggregate.Id, CancellationToken.None); - updatedAggregate.Should().NotBeNull(); - updatedAggregate!.Name.Should().NotBe(initialName); - updatedAggregate.Name.Should().Be("UpdatedName"); - } - - [Fact] - public async Task Remove_WhenExistingAggregate_ShouldRemoveFromDatabase() - { - // Arrange - var testAggregate = TestAggregate.Create("TestAggregate"); - _testDbContext.TestAggregates.Add(testAggregate); - await _testDbContext.SaveChangesAsync(); - - // Act - _testAggregateRepository.Remove(testAggregate); - await _testDbContext.SaveChangesAsync(); - - // Assert - var retrievedAggregate = await _testAggregateRepository.GetByIdAsync(testAggregate.Id, CancellationToken.None); - retrievedAggregate.Should().BeNull(); - } - - [Fact] - public async Task Update_WhenEntityIsModifiedByAnotherUser_ShouldThrowConcurrencyException() - { - // Arrange - var primaryRepository = new TestAggregateRepository(_testDbContext); - var originalTestAggregate = TestAggregate.Create("TestAggregate"); - var cancellationToken = new CancellationToken(); - await primaryRepository.AddAsync(originalTestAggregate, cancellationToken); - await _testDbContext.SaveChangesAsync(cancellationToken); - - // Simulate another user by creating a new DbContext and repository instance - var secondaryDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); - var secondaryRepository = new TestAggregateRepository(secondaryDbContext); - - // Act - var concurrentTestAggregate = - (await secondaryRepository.GetByIdAsync(originalTestAggregate.Id, cancellationToken))!; - concurrentTestAggregate.Name = "UpdatedTestAggregateByAnotherUser"; - secondaryRepository.Update(concurrentTestAggregate); - await secondaryDbContext.SaveChangesAsync(cancellationToken); - - originalTestAggregate.Name = "UpdatedTestAggregate"; - primaryRepository.Update(originalTestAggregate); - - // Assert - await Assert.ThrowsAsync(() => _testDbContext.SaveChangesAsync(cancellationToken)); - } - - [Fact] - public async Task EntityModification_WhenRepositoryUpdateNotCalled_ShouldNotTrackChanges() - { - // Arrange - var seedingTestAggregate = TestAggregate.Create("TestAggregate"); - var seedingTestDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); - seedingTestDbContext.TestAggregates.Add(seedingTestAggregate); - await seedingTestDbContext.SaveChangesAsync(); - var testAggregateId = seedingTestAggregate.Id; - - // Act - var testAggregate = (await _testAggregateRepository.GetByIdAsync(testAggregateId, CancellationToken.None))!; - testAggregate.Name = "UpdatedTestAggregate"; - - // Assert - _testDbContext.ChangeTracker.Entries().Count().Should().Be(0); - var affectedRows = await _testDbContext.SaveChangesAsync(); - affectedRows.Should().Be(0); - } -} +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using PlatformPlatform.SharedKernel.DomainCore.Identity; +using PlatformPlatform.SharedKernel.Tests.TestEntities; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.InfrastructureCore.Persistence; + +public sealed class RepositoryTests : IDisposable +{ + private readonly SqliteInMemoryDbContextFactory _sqliteInMemoryDbContextFactory; + private readonly TestAggregateRepository _testAggregateRepository; + private readonly TestDbContext _testDbContext; + + public RepositoryTests() + { + _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(); + _testDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); + _testAggregateRepository = new TestAggregateRepository(_testDbContext); + } + + public void Dispose() + { + _sqliteInMemoryDbContextFactory.Dispose(); + } + + [Fact] + public async Task Add_WhenNewAggregate_ShouldAddToDatabase() + { + // Arrange + var testAggregate = TestAggregate.Create("TestAggregate"); + var cancellationToken = new CancellationToken(); + + // Act + await _testAggregateRepository.AddAsync(testAggregate, cancellationToken); + await _testDbContext.SaveChangesAsync(cancellationToken); + + // Assert + var retrievedAggregate = await _testAggregateRepository.GetByIdAsync(testAggregate.Id, cancellationToken); + retrievedAggregate.Should().NotBeNull(); + retrievedAggregate!.Id.Should().Be(testAggregate.Id); + } + + [Fact] + public async Task GetByIdAsync_WhenAggregateExists_ShouldRetrieveFromDatabase() + { + // Arrange + var testAggregate = TestAggregate.Create("TestAggregate"); + _testDbContext.TestAggregates.Add(testAggregate); + await _testDbContext.SaveChangesAsync(); + + // Act + var retrievedAggregate = await _testAggregateRepository.GetByIdAsync(testAggregate.Id, CancellationToken.None); + + // Assert + retrievedAggregate.Should().NotBeNull(); + retrievedAggregate!.Id.Should().Be(testAggregate.Id); + } + + [Fact] + public async Task GetByIdAsync_WhenAggregateDoesNotExist_ShouldReturnNull() + { + // Arrange + var nonExistentId = IdGenerator.NewId(); + + // Act + var retrievedAggregate = await _testAggregateRepository.GetByIdAsync(nonExistentId, CancellationToken.None); + + // Assert + retrievedAggregate.Should().BeNull(); + } + + [Fact] + public async Task Update_WhenExistingAggregate_ShouldUpdateDatabase() + { + // Arrange + var testAggregate = TestAggregate.Create("TestAggregate"); + _testDbContext.TestAggregates.Add(testAggregate); + await _testDbContext.SaveChangesAsync(); + var initialName = testAggregate.Name; + + // Act + testAggregate.Name = "UpdatedName"; + _testAggregateRepository.Update(testAggregate); + await _testDbContext.SaveChangesAsync(); + + // Assert + var updatedAggregate = await _testAggregateRepository.GetByIdAsync(testAggregate.Id, CancellationToken.None); + updatedAggregate.Should().NotBeNull(); + updatedAggregate!.Name.Should().NotBe(initialName); + updatedAggregate.Name.Should().Be("UpdatedName"); + } + + [Fact] + public async Task Remove_WhenExistingAggregate_ShouldRemoveFromDatabase() + { + // Arrange + var testAggregate = TestAggregate.Create("TestAggregate"); + _testDbContext.TestAggregates.Add(testAggregate); + await _testDbContext.SaveChangesAsync(); + + // Act + _testAggregateRepository.Remove(testAggregate); + await _testDbContext.SaveChangesAsync(); + + // Assert + var retrievedAggregate = await _testAggregateRepository.GetByIdAsync(testAggregate.Id, CancellationToken.None); + retrievedAggregate.Should().BeNull(); + } + + [Fact] + public async Task Update_WhenEntityIsModifiedByAnotherUser_ShouldThrowConcurrencyException() + { + // Arrange + var primaryRepository = new TestAggregateRepository(_testDbContext); + var originalTestAggregate = TestAggregate.Create("TestAggregate"); + var cancellationToken = new CancellationToken(); + await primaryRepository.AddAsync(originalTestAggregate, cancellationToken); + await _testDbContext.SaveChangesAsync(cancellationToken); + + // Simulate another user by creating a new DbContext and repository instance + var secondaryDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); + var secondaryRepository = new TestAggregateRepository(secondaryDbContext); + + // Act + var concurrentTestAggregate = + (await secondaryRepository.GetByIdAsync(originalTestAggregate.Id, cancellationToken))!; + concurrentTestAggregate.Name = "UpdatedTestAggregateByAnotherUser"; + secondaryRepository.Update(concurrentTestAggregate); + await secondaryDbContext.SaveChangesAsync(cancellationToken); + + originalTestAggregate.Name = "UpdatedTestAggregate"; + primaryRepository.Update(originalTestAggregate); + + // Assert + await Assert.ThrowsAsync(() => _testDbContext.SaveChangesAsync(cancellationToken)); + } + + [Fact] + public async Task EntityModification_WhenRepositoryUpdateNotCalled_ShouldNotTrackChanges() + { + // Arrange + var seedingTestAggregate = TestAggregate.Create("TestAggregate"); + var seedingTestDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); + seedingTestDbContext.TestAggregates.Add(seedingTestAggregate); + await seedingTestDbContext.SaveChangesAsync(); + var testAggregateId = seedingTestAggregate.Id; + + // Act + var testAggregate = (await _testAggregateRepository.GetByIdAsync(testAggregateId, CancellationToken.None))!; + testAggregate.Name = "UpdatedTestAggregate"; + + // Assert + _testDbContext.ChangeTracker.Entries().Count().Should().Be(0); + var affectedRows = await _testDbContext.SaveChangesAsync(); + affectedRows.Should().Be(0); + } +} diff --git a/application/shared-kernel/Tests/InfrastructureCore/Persistence/UnitOfWorkTests.cs b/application/shared-kernel/Tests/InfrastructureCore/Persistence/UnitOfWorkTests.cs index 1abf21872..cfdd387af 100644 --- a/application/shared-kernel/Tests/InfrastructureCore/Persistence/UnitOfWorkTests.cs +++ b/application/shared-kernel/Tests/InfrastructureCore/Persistence/UnitOfWorkTests.cs @@ -1,101 +1,101 @@ -using FluentAssertions; -using Microsoft.EntityFrameworkCore; -using PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; -using PlatformPlatform.SharedKernel.Tests.TestEntities; -using Xunit; - -namespace PlatformPlatform.SharedKernel.Tests.InfrastructureCore.Persistence; - -public sealed class UnitOfWorkTests : IDisposable -{ - private readonly SqliteInMemoryDbContextFactory _sqliteInMemoryDbContextFactory; - private readonly TestDbContext _testDbContext; - private readonly UnitOfWork _unitOfWork; - - public UnitOfWorkTests() - { - _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(); - _testDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); - _unitOfWork = new UnitOfWork(_testDbContext); - } - - public void Dispose() - { - _sqliteInMemoryDbContextFactory.Dispose(); - } - - [Fact] - public async Task CommitAsync_WhenAggregateIsAdded_ShouldSetCreatedAt() - { - // Arrange - var newTestAggregate = TestAggregate.Create("TestAggregate"); - - // Act - _testDbContext.TestAggregates.Add(newTestAggregate); - _ = newTestAggregate.GetAndClearDomainEvents(); // Simulate that domain events have been handled - await _unitOfWork.CommitAsync(CancellationToken.None); - - // Assert - newTestAggregate.CreatedAt.Should().NotBe(default); - newTestAggregate.ModifiedAt.Should().BeNull(); - } - - [Fact] - public async Task CommitAsync_WhenAggregateIsModified_ShouldUpdateModifiedAt() - { - // Arrange - var newTestAggregate = TestAggregate.Create("TestAggregate"); - _testDbContext.TestAggregates.Add(newTestAggregate); - _ = newTestAggregate.GetAndClearDomainEvents(); // Simulate that domain events have been handled - await _unitOfWork.CommitAsync(CancellationToken.None); - var initialCreatedAt = newTestAggregate.CreatedAt; - - // Act - newTestAggregate.Name = "UpdatedTestAggregate"; - await _unitOfWork.CommitAsync(CancellationToken.None); - - // Assert - newTestAggregate.ModifiedAt.Should().NotBeNull(); - newTestAggregate.ModifiedAt.Should().BeAfter(initialCreatedAt); - newTestAggregate.CreatedAt.Should().Be(initialCreatedAt); - } - - [Fact] - public async Task CommitAsync_WhenAggregateHasUnhandledDomainEvents_ShouldThrowException() - { - // Arrange - var newTestAggregate = TestAggregate.Create("TestAggregate"); - _testDbContext.TestAggregates.Add(newTestAggregate); - - // Act - await Assert.ThrowsAsync(() => _unitOfWork.CommitAsync(CancellationToken.None)); - } - - [Fact] - public async Task CommitAsync_WhenUpdateCalledOnNonExistingAggregate_ShouldThrowException() - { - // Arrange - var nonExistingTestAggregate = TestAggregate.Create("NonExistingTestAggregate"); - _ = nonExistingTestAggregate.GetAndClearDomainEvents(); // Simulate that domain events have been handled - - // Act - _testDbContext.TestAggregates.Update(nonExistingTestAggregate); - - // Assert - await Assert.ThrowsAsync(() => _unitOfWork.CommitAsync(CancellationToken.None)); - } - - [Fact] - public async Task CommitAsync_WhenRemoveCalledOnNonExistingAggregate_ShouldThrowException() - { - // Arrange - var nonExistingTestAggregate = TestAggregate.Create("NonExistingTestAggregate"); - _ = nonExistingTestAggregate.GetAndClearDomainEvents(); // Simulate that domain events have been handled - - // Act - _testDbContext.TestAggregates.Remove(nonExistingTestAggregate); - - // Assert - await Assert.ThrowsAsync(() => _unitOfWork.CommitAsync(CancellationToken.None)); - } -} +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; +using PlatformPlatform.SharedKernel.Tests.TestEntities; +using Xunit; + +namespace PlatformPlatform.SharedKernel.Tests.InfrastructureCore.Persistence; + +public sealed class UnitOfWorkTests : IDisposable +{ + private readonly SqliteInMemoryDbContextFactory _sqliteInMemoryDbContextFactory; + private readonly TestDbContext _testDbContext; + private readonly UnitOfWork _unitOfWork; + + public UnitOfWorkTests() + { + _sqliteInMemoryDbContextFactory = new SqliteInMemoryDbContextFactory(); + _testDbContext = _sqliteInMemoryDbContextFactory.CreateContext(); + _unitOfWork = new UnitOfWork(_testDbContext); + } + + public void Dispose() + { + _sqliteInMemoryDbContextFactory.Dispose(); + } + + [Fact] + public async Task CommitAsync_WhenAggregateIsAdded_ShouldSetCreatedAt() + { + // Arrange + var newTestAggregate = TestAggregate.Create("TestAggregate"); + + // Act + _testDbContext.TestAggregates.Add(newTestAggregate); + _ = newTestAggregate.GetAndClearDomainEvents(); // Simulate that domain events have been handled + await _unitOfWork.CommitAsync(CancellationToken.None); + + // Assert + newTestAggregate.CreatedAt.Should().NotBe(default); + newTestAggregate.ModifiedAt.Should().BeNull(); + } + + [Fact] + public async Task CommitAsync_WhenAggregateIsModified_ShouldUpdateModifiedAt() + { + // Arrange + var newTestAggregate = TestAggregate.Create("TestAggregate"); + _testDbContext.TestAggregates.Add(newTestAggregate); + _ = newTestAggregate.GetAndClearDomainEvents(); // Simulate that domain events have been handled + await _unitOfWork.CommitAsync(CancellationToken.None); + var initialCreatedAt = newTestAggregate.CreatedAt; + + // Act + newTestAggregate.Name = "UpdatedTestAggregate"; + await _unitOfWork.CommitAsync(CancellationToken.None); + + // Assert + newTestAggregate.ModifiedAt.Should().NotBeNull(); + newTestAggregate.ModifiedAt.Should().BeAfter(initialCreatedAt); + newTestAggregate.CreatedAt.Should().Be(initialCreatedAt); + } + + [Fact] + public async Task CommitAsync_WhenAggregateHasUnhandledDomainEvents_ShouldThrowException() + { + // Arrange + var newTestAggregate = TestAggregate.Create("TestAggregate"); + _testDbContext.TestAggregates.Add(newTestAggregate); + + // Act + await Assert.ThrowsAsync(() => _unitOfWork.CommitAsync(CancellationToken.None)); + } + + [Fact] + public async Task CommitAsync_WhenUpdateCalledOnNonExistingAggregate_ShouldThrowException() + { + // Arrange + var nonExistingTestAggregate = TestAggregate.Create("NonExistingTestAggregate"); + _ = nonExistingTestAggregate.GetAndClearDomainEvents(); // Simulate that domain events have been handled + + // Act + _testDbContext.TestAggregates.Update(nonExistingTestAggregate); + + // Assert + await Assert.ThrowsAsync(() => _unitOfWork.CommitAsync(CancellationToken.None)); + } + + [Fact] + public async Task CommitAsync_WhenRemoveCalledOnNonExistingAggregate_ShouldThrowException() + { + // Arrange + var nonExistingTestAggregate = TestAggregate.Create("NonExistingTestAggregate"); + _ = nonExistingTestAggregate.GetAndClearDomainEvents(); // Simulate that domain events have been handled + + // Act + _testDbContext.TestAggregates.Remove(nonExistingTestAggregate); + + // Assert + await Assert.ThrowsAsync(() => _unitOfWork.CommitAsync(CancellationToken.None)); + } +} diff --git a/application/shared-kernel/Tests/TestEntities/ITestAggregateRepository.cs b/application/shared-kernel/Tests/TestEntities/ITestAggregateRepository.cs index 58992d870..987d8788d 100644 --- a/application/shared-kernel/Tests/TestEntities/ITestAggregateRepository.cs +++ b/application/shared-kernel/Tests/TestEntities/ITestAggregateRepository.cs @@ -1,3 +1,3 @@ -namespace PlatformPlatform.SharedKernel.Tests.TestEntities; - -public interface ITestAggregateRepository; +namespace PlatformPlatform.SharedKernel.Tests.TestEntities; + +public interface ITestAggregateRepository; diff --git a/application/shared-kernel/Tests/TestEntities/SqliteInMemoryDbContextFactory.cs b/application/shared-kernel/Tests/TestEntities/SqliteInMemoryDbContextFactory.cs index f7748b4fe..e888d5551 100644 --- a/application/shared-kernel/Tests/TestEntities/SqliteInMemoryDbContextFactory.cs +++ b/application/shared-kernel/Tests/TestEntities/SqliteInMemoryDbContextFactory.cs @@ -1,35 +1,35 @@ -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; - -namespace PlatformPlatform.SharedKernel.Tests.TestEntities; - -public sealed class SqliteInMemoryDbContextFactory : IDisposable where T : DbContext -{ - private readonly SqliteConnection _sqliteConnection; - - public SqliteInMemoryDbContextFactory() - { - _sqliteConnection = new SqliteConnection("DataSource=:memory:"); - _sqliteConnection.Open(); - } - - public void Dispose() - { - _sqliteConnection.Close(); - } - - public T CreateContext() - { - var options = CreateOptions(); - - var context = (T)Activator.CreateInstance(typeof(T), options)!; - context.Database.EnsureCreated(); - - return context; - } - - private DbContextOptions CreateOptions() - { - return new DbContextOptionsBuilder().UseSqlite(_sqliteConnection).Options; - } -} +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace PlatformPlatform.SharedKernel.Tests.TestEntities; + +public sealed class SqliteInMemoryDbContextFactory : IDisposable where T : DbContext +{ + private readonly SqliteConnection _sqliteConnection; + + public SqliteInMemoryDbContextFactory() + { + _sqliteConnection = new SqliteConnection("DataSource=:memory:"); + _sqliteConnection.Open(); + } + + public void Dispose() + { + _sqliteConnection.Close(); + } + + public T CreateContext() + { + var options = CreateOptions(); + + var context = (T)Activator.CreateInstance(typeof(T), options)!; + context.Database.EnsureCreated(); + + return context; + } + + private DbContextOptions CreateOptions() + { + return new DbContextOptionsBuilder().UseSqlite(_sqliteConnection).Options; + } +} diff --git a/application/shared-kernel/Tests/TestEntities/TestAggregate.cs b/application/shared-kernel/Tests/TestEntities/TestAggregate.cs index 40c6d0ba2..35cbdee29 100644 --- a/application/shared-kernel/Tests/TestEntities/TestAggregate.cs +++ b/application/shared-kernel/Tests/TestEntities/TestAggregate.cs @@ -1,18 +1,18 @@ -using PlatformPlatform.SharedKernel.DomainCore.Entities; -using PlatformPlatform.SharedKernel.DomainCore.Identity; - -namespace PlatformPlatform.SharedKernel.Tests.TestEntities; - -public sealed class TestAggregate(string name) : AggregateRoot(IdGenerator.NewId()) -{ - // ReSharper disable once EntityFramework.ModelValidation.UnlimitedStringLength - public string Name { get; set; } = name; - - public static TestAggregate Create(string name) - { - var testAggregate = new TestAggregate(name); - var testAggregateCreatedEvent = new TestAggregateCreatedEvent(testAggregate.Id, testAggregate.Name); - testAggregate.AddDomainEvent(testAggregateCreatedEvent); - return testAggregate; - } -} +using PlatformPlatform.SharedKernel.DomainCore.Entities; +using PlatformPlatform.SharedKernel.DomainCore.Identity; + +namespace PlatformPlatform.SharedKernel.Tests.TestEntities; + +public sealed class TestAggregate(string name) : AggregateRoot(IdGenerator.NewId()) +{ + // ReSharper disable once EntityFramework.ModelValidation.UnlimitedStringLength + public string Name { get; set; } = name; + + public static TestAggregate Create(string name) + { + var testAggregate = new TestAggregate(name); + var testAggregateCreatedEvent = new TestAggregateCreatedEvent(testAggregate.Id, testAggregate.Name); + testAggregate.AddDomainEvent(testAggregateCreatedEvent); + return testAggregate; + } +} diff --git a/application/shared-kernel/Tests/TestEntities/TestAggregateCreatedEvent.cs b/application/shared-kernel/Tests/TestEntities/TestAggregateCreatedEvent.cs index ebf74ca41..2568a95b6 100644 --- a/application/shared-kernel/Tests/TestEntities/TestAggregateCreatedEvent.cs +++ b/application/shared-kernel/Tests/TestEntities/TestAggregateCreatedEvent.cs @@ -1,5 +1,5 @@ -using PlatformPlatform.SharedKernel.DomainCore.DomainEvents; - -namespace PlatformPlatform.SharedKernel.Tests.TestEntities; - -public record TestAggregateCreatedEvent(long TestAggregateId, string Name) : IDomainEvent; +using PlatformPlatform.SharedKernel.DomainCore.DomainEvents; + +namespace PlatformPlatform.SharedKernel.Tests.TestEntities; + +public record TestAggregateCreatedEvent(long TestAggregateId, string Name) : IDomainEvent; diff --git a/application/shared-kernel/Tests/TestEntities/TestAggregateRepository.cs b/application/shared-kernel/Tests/TestEntities/TestAggregateRepository.cs index 837c1de2b..b43d16fd1 100644 --- a/application/shared-kernel/Tests/TestEntities/TestAggregateRepository.cs +++ b/application/shared-kernel/Tests/TestEntities/TestAggregateRepository.cs @@ -1,6 +1,6 @@ -using PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; - -namespace PlatformPlatform.SharedKernel.Tests.TestEntities; - -public sealed class TestAggregateRepository(TestDbContext testDbContext) - : RepositoryBase(testDbContext), ITestAggregateRepository; +using PlatformPlatform.SharedKernel.InfrastructureCore.Persistence; + +namespace PlatformPlatform.SharedKernel.Tests.TestEntities; + +public sealed class TestAggregateRepository(TestDbContext testDbContext) + : RepositoryBase(testDbContext), ITestAggregateRepository; diff --git a/application/shared-kernel/Tests/TestEntities/TestCommand.cs b/application/shared-kernel/Tests/TestEntities/TestCommand.cs index a95eab6b3..1ac7e1486 100644 --- a/application/shared-kernel/Tests/TestEntities/TestCommand.cs +++ b/application/shared-kernel/Tests/TestEntities/TestCommand.cs @@ -1,5 +1,5 @@ -using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; - -namespace PlatformPlatform.SharedKernel.Tests.TestEntities; - -public record TestCommand : ICommand, IRequest>; +using PlatformPlatform.SharedKernel.ApplicationCore.Cqrs; + +namespace PlatformPlatform.SharedKernel.Tests.TestEntities; + +public record TestCommand : ICommand, IRequest>; diff --git a/application/shared-kernel/Tests/TestEntities/TestDbContext.cs b/application/shared-kernel/Tests/TestEntities/TestDbContext.cs index 1fe573671..62c80439c 100644 --- a/application/shared-kernel/Tests/TestEntities/TestDbContext.cs +++ b/application/shared-kernel/Tests/TestEntities/TestDbContext.cs @@ -1,17 +1,17 @@ -using Microsoft.EntityFrameworkCore; -using PlatformPlatform.SharedKernel.InfrastructureCore.EntityFramework; - -namespace PlatformPlatform.SharedKernel.Tests.TestEntities; - -public sealed class TestDbContext(DbContextOptions options) - : SharedKernelDbContext(options) -{ - public DbSet TestAggregates => Set(); - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.UseStringForEnums(); - } -} +using Microsoft.EntityFrameworkCore; +using PlatformPlatform.SharedKernel.InfrastructureCore.EntityFramework; + +namespace PlatformPlatform.SharedKernel.Tests.TestEntities; + +public sealed class TestDbContext(DbContextOptions options) + : SharedKernelDbContext(options) +{ + public DbSet TestAggregates => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.UseStringForEnums(); + } +} diff --git a/cloud-infrastructure/cluster/deploy-cluster.sh b/cloud-infrastructure/cluster/deploy-cluster.sh index 817ba13fb..47719cd40 100755 --- a/cloud-infrastructure/cluster/deploy-cluster.sh +++ b/cloud-infrastructure/cluster/deploy-cluster.sh @@ -22,7 +22,7 @@ get_active_version() function is_domain_configured() { # Get details about the container apps local app_details=$(az containerapp show --name "$1" --resource-group "$2" 2>&1) - if [[ "$app_details" == *"ResourceNotFound"* ]] || [[ "$app_details" == *"ResourceGroupNotFound"* ]]; then + if [[ "$app_details" == *"ResourceNotFound"* ]] || [[ "$app_details" == *"ResourceGroupNotFound"* ]] || [[ "$app_details" == *"ERROR"* ]] ; then echo "false" else local result=$(echo "$app_details" | jq -r '.properties.configuration.ingress.customDomains') diff --git a/cloud-infrastructure/cluster/main-cluster.bicep b/cloud-infrastructure/cluster/main-cluster.bicep index 79b343e68..e740c3d8a 100644 --- a/cloud-infrastructure/cluster/main-cluster.bicep +++ b/cloud-infrastructure/cluster/main-cluster.bicep @@ -465,7 +465,7 @@ module appGatwayAccountManagementStorageBlobDataReaderRoleAssignment '../modules storageAccountName: accountManagementStorageAccountName userAssignedIdentityName: appGatewayIdentityName } - dependsOn: [ appGateway, accountManagementStorageAccount ] + dependsOn: [appGateway, accountManagementStorageAccount] } output accountManagementIdentityClientId string = accountManagementIdentity.outputs.clientId diff --git a/developer-cli/.editorconfig b/developer-cli/.editorconfig new file mode 100644 index 000000000..93fdb958b --- /dev/null +++ b/developer-cli/.editorconfig @@ -0,0 +1,17 @@ +[*] +charset = utf-8 +end_of_line = lf +trim_trailing_whitespace = false +insert_final_newline = true +indent_style = space +indent_size = 4 +tab_width = 4 + +[*.cs] +end_of_line = crlf + +[*.{ts,tsx,js,jsx,json,md,mdx,.prettierrc,.eslintrc,yml,Dockerfile}] +indent_size = 2 +tab_width = 2 +trim_trailing_whitespace = true +max_line_length = 120 diff --git a/developer-cli/Commands/BuildCommand.cs b/developer-cli/Commands/BuildCommand.cs index 830b2d5c8..e81e0b31f 100644 --- a/developer-cli/Commands/BuildCommand.cs +++ b/developer-cli/Commands/BuildCommand.cs @@ -1,10 +1,8 @@ using System.CommandLine; using System.CommandLine.NamingConventionBinder; -using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; -using Spectre.Console; namespace PlatformPlatform.DeveloperCli.Commands; @@ -17,20 +15,20 @@ public BuildCommand() : base("build", "Builds a self-contained system") ["", "--solution-name", "-s"], "The name of the self-contained system to build" ); - + AddOption(solutionNameOption); - + Handler = CommandHandler.Create(Execute); } - + private int Execute(string? solutionName) { PrerequisitesChecker.Check("dotnet", "aspire", "node", "yarn"); - + var solutionFile = SolutionHelper.GetSolution(solutionName); - + ProcessHelper.StartProcess($"dotnet build {solutionFile.Name}", solutionFile.Directory?.FullName); - + return 0; } } diff --git a/developer-cli/Commands/CodeCleanupCommand.cs b/developer-cli/Commands/CodeCleanupCommand.cs index dd3fc66a0..f8f45ed31 100644 --- a/developer-cli/Commands/CodeCleanupCommand.cs +++ b/developer-cli/Commands/CodeCleanupCommand.cs @@ -16,23 +16,23 @@ public CodeCleanupCommand() : base("code-cleanup", "Run JetBrains Code Cleanup") ["", "--solution-name", "-s"], "The name of the self-contained system to build" ); - + AddOption(solutionNameOption); - + Handler = CommandHandler.Create(Execute); } - + private int Execute(string? solutionName) { PrerequisitesChecker.Check("dotnet"); - + var solutionFile = SolutionHelper.GetSolution(solutionName); - + ProcessHelper.StartProcess("dotnet tool restore", solutionFile.Directory!.FullName); ProcessHelper.StartProcess($"dotnet jb cleanupcode {solutionFile.Name} --profile=\".NET only\"", solutionFile.Directory!.FullName); - + AnsiConsole.MarkupLine("[green]Code cleanup completed. Check Git to see any changes![/]"); - + return 0; } } diff --git a/developer-cli/Commands/CodeCoverageCommand.cs b/developer-cli/Commands/CodeCoverageCommand.cs index 672be9171..cfe23b9d3 100644 --- a/developer-cli/Commands/CodeCoverageCommand.cs +++ b/developer-cli/Commands/CodeCoverageCommand.cs @@ -16,22 +16,22 @@ public CodeCoverageCommand() : base("code-coverage", "Run JetBrains Code Coverag ["", "--solution-name", "-s"], "The name of the self-contained system to build" ); - + AddOption(solutionNameOption); Handler = CommandHandler.Create(Execute); } - + private int Execute(string? solutionName) { PrerequisitesChecker.Check("dotnet"); - + var workingDirectory = new DirectoryInfo(Path.Combine(Configuration.GetSourceCodeFolder(), "..", "application")).FullName; - + var solutionFile = SolutionHelper.GetSolution(solutionName); - + ProcessHelper.StartProcess("dotnet tool restore", solutionFile.Directory!.FullName); - + ProcessHelper.StartProcess("dotnet build", solutionFile.Directory!.FullName); var solutionFileWithoutExtentsion = solutionFile.Name.Replace(solutionFile.Extension, ""); @@ -40,11 +40,11 @@ private int Execute(string? solutionName) $"dotnet dotcover test {solutionFile.Name} --no-build --dcOutput=coverage/dotCover.html --dcReportType=HTML --dcFilters=\"+:{solutionFileWithoutExtentsion}.*;-:*.Tests;-:type=*.AppHost.*\"", workingDirectory ); - + var codeCoverageReport = Path.Combine(workingDirectory, "coverage", "dotCover.html"); AnsiConsole.MarkupLine($"[green]Code Coverage Report[/] {codeCoverageReport}"); ProcessHelper.StartProcess($"open {codeCoverageReport}", workingDirectory); - + return 0; } } diff --git a/developer-cli/Commands/CodeInspectionsCommand.cs b/developer-cli/Commands/CodeInspectionsCommand.cs index d14514c3d..9ce9c1ad6 100644 --- a/developer-cli/Commands/CodeInspectionsCommand.cs +++ b/developer-cli/Commands/CodeInspectionsCommand.cs @@ -16,25 +16,25 @@ public CodeInspectionsCommand() : base("code-inspections", "Run JetBrains Code I ["", "--solution-name", "-s"], "The name of the self-contained system to build" ); - + AddOption(solutionNameOption); - + Handler = CommandHandler.Create(Execute); } - + private int Execute(string? solutionName) { PrerequisitesChecker.Check("dotnet"); - + var solutionFile = SolutionHelper.GetSolution(solutionName); - + ProcessHelper.StartProcess("dotnet tool restore", solutionFile.Directory!.FullName); - + ProcessHelper.StartProcess( $"dotnet jb inspectcode {solutionFile.Name} --build --output=result.json --severity=SUGGESTION", solutionFile.Directory!.FullName ); - + var resultXml = File.ReadAllText(Path.Combine(solutionFile.Directory!.FullName, "result.json")); if (resultXml.Contains("\"results\": [],")) { @@ -44,7 +44,7 @@ private int Execute(string? solutionName) { ProcessHelper.StartProcess("code result.json", solutionFile.Directory!.FullName); } - + return 0; } } diff --git a/developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs b/developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs index 6227cc53f..fd4f493c7 100644 --- a/developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs +++ b/developer-cli/Commands/ConfigureContinuousDeploymentsCommand.cs @@ -14,94 +14,94 @@ namespace PlatformPlatform.DeveloperCli.Commands; public class ConfigureContinuousDeploymentsCommand : Command { private static readonly JsonSerializerOptions? JsonSerializerOptions = new() { PropertyNameCaseInsensitive = true }; - + private static readonly Config Config = new(); - + private static readonly Dictionary AzureLocations = GetAzureLocations(); - + public ConfigureContinuousDeploymentsCommand() : base( "configure-continuous-deployments", "Set up trust between Azure and GitHub for passwordless deployments using OpenID." ) { AddOption(new Option(["--verbose-logging"], "Print Azure and GitHub CLI commands and output")); - + Handler = CommandHandler.Create(Execute); } - + private int Execute(bool verboseLogging = false) { PrerequisitesChecker.Check("dotnet", "az", "gh"); - + Configuration.VerboseLogging = verboseLogging; - + PrintHeader("Introduction"); - + ShowIntroPrompt(); - + PrintHeader("Collecting data"); - + SetGithubInfo(); - + LoginToGithub(); - + PublishExistingGithubVariables(); - + ShowWarningIfGithubRepositoryIsAlreadyInitialized(); - + SelectAzureSubscriptions(); - + CollectLocations(); - + CollectUniquePrefix(); - + ConfirmReuseIfAppRegistrationsExists(); - + ConfirmReuseIfSqlAdminSecurityGroupsExists(); - + PrintHeader("Confirm changes"); - + ConfirmChangesPrompt(); - + var startNew = Stopwatch.StartNew(); - + PrintHeader("Configuring Azure and GitHub"); - + PrepareSubscriptionsForContainerAppsEnvironment(); - + CreateAppRegistrationsIfNotExists(); - + CreateAppRegistrationCredentials(); - + GrantSubscriptionPermissionsToServicePrincipals(); - + CreateAzureSqlServerSecurityGroups(); - + CreateGithubEnvironments(); - + CreateGithubSecretsAndVariables(); - + DisableReusableWorkflows(); - + TriggerAndMonitorWorkflows(); - + PrintHeader($"Configuration of GitHub and Azure completed in {startNew.Elapsed:g} 🎉"); - + ShowSuccessMessage(); - + return 0; } - + private void PrintHeader(string heading) { var separator = new string('-', Console.WindowWidth - heading.Length - 1); AnsiConsole.MarkupLine($"\n[bold][green]{heading}[/] {separator}[/]\n"); } - + private void ShowIntroPrompt() { var loginToGitHub = Config.IsLoggedIn() ? "" : " * Prompt you to log in to GitHub\n"; - + var setupIntroPrompt = $""" This command will configure passwordless deployments from GitHub to Azure. If you continue, this command will do the following: @@ -112,24 +112,24 @@ private void ShowIntroPrompt() [bold]Would you like to continue?[/] """; - + if (!AnsiConsole.Confirm(setupIntroPrompt.Replace("\n\n", "\n"))) Environment.Exit(0); AnsiConsole.WriteLine(); } - + private void SetGithubInfo() { // Get all Git remotes var output = ProcessHelper.StartProcess("git remote -v", Configuration.GetSourceCodeFolder(), true); - + // Sort the output lines so that the "origin" is at the top output = string.Join('\n', output.Split('\n').OrderBy(line => line.Contains("origin") ? 0 : 1)); - + var regex = new Regex(@"(?(https://github\.com/.*/.*\.git)|(git@github\.com:.*/.*\.git)) \(push\)"); var matches = regex.Matches(output); - + var gitRemoteMatches = matches.Select(m => m.Groups["githubUri"].Value).ToArray(); - + var githubUri = string.Empty; switch (gitRemoteMatches.Length) { @@ -148,25 +148,25 @@ private void SetGithubInfo() ProcessHelper.StartProcess($"gh repo set-default {githubUri}"); break; } - + Config.InitializeFromUri(githubUri); } - + private void LoginToGithub() { if (!Config.IsLoggedIn()) { ProcessHelper.StartProcess("gh auth login --git-protocol https --web"); - + if (!Config.IsLoggedIn()) Environment.Exit(0); - + AnsiConsole.WriteLine(); } - + var githubApiJson = ProcessHelper.StartProcess($"gh api repos/{Config.GithubInfo?.Path}", redirectOutput: true); - + using var githubApi = JsonDocument.Parse(githubApiJson); - + githubApi.RootElement.TryGetProperty("permissions", out var githubRepositoryPermissions); if (!githubRepositoryPermissions.GetProperty("admin").GetBoolean()) { @@ -174,85 +174,85 @@ private void LoginToGithub() Environment.Exit(0); } } - + private static void PublishExistingGithubVariables() { var githubVariablesJson = ProcessHelper.StartProcess( $"gh api repos/{Config.GithubInfo?.Path}/actions/variables --paginate", redirectOutput: true ); - + var configGithubVariables = JsonDocument.Parse(githubVariablesJson).RootElement.GetProperty("variables").EnumerateArray(); foreach (var variable in configGithubVariables) { var variableName = variable.GetProperty("name").GetString()!; var variableValue = variable.GetProperty("value").GetString()!; - + Config.GithubVariables.Add(variableName, variableValue); } } - + private static void ShowWarningIfGithubRepositoryIsAlreadyInitialized() { if (Config.GithubVariables.Count(variable => Enum.GetNames(typeof(VariableNames)).Contains(variable.Key)) == 0) { return; } - + AnsiConsole.MarkupLine("[yellow]This Github Repository has already been initialized. If you continue existing GitHub variables will be overridden.[/]"); if (AnsiConsole.Confirm("Do you want to continue, and override existing GitHub variables?")) { AnsiConsole.WriteLine(); return; } - + Environment.Exit(0); } - + private void SelectAzureSubscriptions() { // `az login` returns a JSON array of subscriptions var subscriptionListJson = RunAzureCliCommand("login"); - + // Regular expression to match JSON part var jsonRegex = new Regex(@"\[.*\]", RegexOptions.Singleline); var match = jsonRegex.Match(subscriptionListJson); - + List? azureSubscriptions = null; if (match.Success) { azureSubscriptions = JsonSerializer.Deserialize>(match.Value, JsonSerializerOptions); } - + if (azureSubscriptions == null) { AnsiConsole.MarkupLine("[red]ERROR:[/] No subscriptions found."); Environment.Exit(1); } - + Config.StagingSubscription = SelectSubscription("Staging"); Config.ProductionSubscription = SelectSubscription("Production"); - + return; - + Subscription SelectSubscription(string environmentName) { var activeSubscriptions = azureSubscriptions.Where(s => s.State == "Enabled").ToList(); - + var selectedDisplayName = AnsiConsole.Prompt(new SelectionPrompt() .Title($"[bold]Please select an Azure subscription for [yellow]{environmentName}[/][/]") .AddChoices(activeSubscriptions.Select(s => s.Name)) ); - + var selectedSubscriptions = activeSubscriptions.Where(s => s.Name == selectedDisplayName).ToArray(); if (selectedSubscriptions.Length > 1) { AnsiConsole.MarkupLine($"[red]ERROR:[/] Found two subscriptions with the name {selectedDisplayName}."); Environment.Exit(1); } - + var azureSubscription = selectedSubscriptions.Single(); - + return new Subscription( azureSubscription.Id, azureSubscription.Name, @@ -262,35 +262,35 @@ Subscription SelectSubscription(string environmentName) ); } } - + private void CollectLocations() { var location = CollectLocation(); - + Config.StagingLocation = location; Config.ProductionLocation = location; - + Location CollectLocation() { var locationDisplayName = AnsiConsole.Prompt(new SelectionPrompt() .Title("[bold]Please select a location where Azure Resource can be deployed [/]") .AddChoices(AzureLocations.Keys) ); - + var locationAcronym = AzureLocations[locationDisplayName]; var locationCode = locationDisplayName.Replace(" ", "").ToLower(); return new Location(locationCode, locationCode, locationAcronym); } } - + private void CollectUniquePrefix() { var uniquePrefix = Config.GithubVariables.GetValueOrDefault(nameof(VariableNames.UNIQUE_PREFIX)); - + AnsiConsole.MarkupLine( "When creating Azure resources like Azure Container Registry, SQL Server, Blob storage, Service Bus, Key Vaults, etc., a global unique name is required. To do this we use a prefix of 2-6 characters, which allows for flexibility for the rest of the name. E.g. if you select 'acme' the production SQL Server in West Europe will be named 'acme-prod-euw'." ); - + if (uniquePrefix is not null) { AnsiConsole.MarkupLine($"[yellow]The unique prefix '{uniquePrefix}' already specified. Changing this will recreate all Azure resources![/]"); @@ -299,7 +299,7 @@ private void CollectUniquePrefix() { uniquePrefix = Config.GithubInfo!.OrganizationName.ToLower().Substring(0, Math.Min(6, Config.GithubInfo.OrganizationName.Length)); } - + while (true) { uniquePrefix = AnsiConsole.Prompt( @@ -311,7 +311,7 @@ private void CollectUniquePrefix() : ValidationResult.Error("[red]ERROR:[/]The unique prefix must be 2-6 characters and contain only lowercase letters a-z or numbers 0-9.") ) ); - + if (IsContainerRegistryConflicting(Config.StagingSubscription.Id, Config.StagingLocation.SharedLocation, $"{uniquePrefix}-stage", $"{uniquePrefix}stage") || IsContainerRegistryConflicting(Config.ProductionSubscription.Id, Config.ProductionLocation.SharedLocation, $"{uniquePrefix}-prod", $"{uniquePrefix}prod")) { @@ -320,68 +320,68 @@ private void CollectUniquePrefix() ); continue; } - + AnsiConsole.WriteLine(); Config.UniquePrefix = uniquePrefix; return; } - + bool IsContainerRegistryConflicting(string subscriptionId, string location, string resourceGroup, string azureContainerRegistryName) { var checkAvailability = RunAzureCliCommand($"acr check-name --name {azureContainerRegistryName} --query \"nameAvailable\" -o tsv"); if (bool.Parse(checkAvailability)) return false; - + var showExistingRegistry = RunAzureCliCommand($"acr show --name {azureContainerRegistryName} --subscription {subscriptionId} --output json"); - + var jsonRegex = new Regex(@"\{.*\}", RegexOptions.Singleline); var match = jsonRegex.Match(showExistingRegistry); - + if (!match.Success) return true; var jsonDocument = JsonDocument.Parse(match.Value); var sameSubscription = jsonDocument.RootElement.GetProperty("id").GetString()?.Contains(subscriptionId) == true; var sameResourceGroup = jsonDocument.RootElement.GetProperty("resourceGroup").GetString() == resourceGroup; var sameLocation = jsonDocument.RootElement.GetProperty("location").GetString() == location; - + return !(sameSubscription && sameResourceGroup && sameLocation); } } - + private void ConfirmReuseIfAppRegistrationsExists() { ConfirmReuseIfAppRegistrationExist(Config.StagingSubscription.AppRegistration); ConfirmReuseIfAppRegistrationExist(Config.ProductionSubscription.AppRegistration); return; - + void ConfirmReuseIfAppRegistrationExist(AppRegistration appRegistration) { appRegistration.AppRegistrationId = RunAzureCliCommand( $"""ad app list --display-name "{appRegistration.Name}" --query "[].appId" -o tsv""" ).Trim(); - + appRegistration.ServicePrincipalId = RunAzureCliCommand( $"""ad sp list --display-name "{appRegistration.Name}" --query "[].appId" -o tsv""" ).Trim(); - + appRegistration.ServicePrincipalObjectId = RunAzureCliCommand( $"""ad sp list --filter "appId eq '{appRegistration.AppRegistrationId}'" --query "[].id" -o tsv""" ).Trim(); - + if (appRegistration.AppRegistrationId != string.Empty && appRegistration.ServicePrincipalId != string.Empty) { AnsiConsole.MarkupLine( $"[yellow]The App Registration '{appRegistration.Name}' already exists with App ID: {appRegistration.ServicePrincipalId}[/]" ); - + if (AnsiConsole.Confirm("The existing App Registration will be reused. Do you want to continue?")) { AnsiConsole.WriteLine(); return; } - + AnsiConsole.MarkupLine("[red]Please delete the existing App Registration and try again.[/]"); Environment.Exit(1); } - + if (appRegistration.AppRegistrationId != string.Empty || appRegistration.ServicePrincipalId != string.Empty) { AnsiConsole.MarkupLine($"[red]The App Registration or Service Principal '{appRegistration}' exists, but not both. Please manually delete and retry.[/]"); @@ -389,39 +389,39 @@ void ConfirmReuseIfAppRegistrationExist(AppRegistration appRegistration) } } } - + private void ConfirmReuseIfSqlAdminSecurityGroupsExists() { Config.StagingSubscription.SqlAdminsGroup.ObjectId = ConfirmReuseIfSqlAdminSecurityGroupExist(Config.StagingSubscription.SqlAdminsGroup.Name); Config.ProductionSubscription.SqlAdminsGroup.ObjectId = ConfirmReuseIfSqlAdminSecurityGroupExist(Config.ProductionSubscription.SqlAdminsGroup.Name); - + string? ConfirmReuseIfSqlAdminSecurityGroupExist(string sqlAdminsSecurityGroupName) { var sqlAdminsObjectId = RunAzureCliCommand( $"""ad group list --display-name "{sqlAdminsSecurityGroupName}" --query "[].id" -o tsv""" ).Trim(); - + if (sqlAdminsObjectId == string.Empty) { return null; } - + AnsiConsole.MarkupLine( $"[yellow]The AD Security Group '{sqlAdminsSecurityGroupName}' already exists with ID: {sqlAdminsObjectId}[/]" ); - + if (AnsiConsole.Confirm("The existing AD Security Group will be reused. Do you want to continue?") == false) { AnsiConsole.MarkupLine("[red]Please delete the existing AD Security Group and try again.[/]"); Environment.Exit(0); } - + AnsiConsole.WriteLine(); - + return sqlAdminsObjectId; } } - + private void ConfirmChangesPrompt() { var stagingServicePrincipal = Config.StagingSubscription.AppRegistration.Exists @@ -436,11 +436,11 @@ private void ConfirmChangesPrompt() var productionSqlAdminObject = Config.ProductionSubscription.SqlAdminsGroup.Exists ? Config.ProductionSubscription.SqlAdminsGroup.ObjectId : "Will be generated"; - + var setupConfirmPrompt = $""" [bold]Please review planned changes before continuing.[/] - + 1. The following will be created or updated in Azure: [bold]Active Directory App Registrations/Service Principals:[/] @@ -454,13 +454,13 @@ [bold]Please review planned changes before continuing.[/] * [blue]{Config.ProductionSubscription.SqlAdminsGroup.Name}[/] [yellow]** The SQL Admins Security Groups are used to grant Managed Identities and CI/CD permissions to SQL Databases.[/] - + 2. The following GitHub environments will be created if not exists: * [blue]staging[/] * [blue]production[/] [yellow]** Environments are used to require approval when infrastructure is deployed. In private GitHub repositories, this requires a paid plan.[/] - + 3. The following GitHub repository variables will be created: [bold]Shared Variables:[/] @@ -492,29 +492,29 @@ [bold]Please review planned changes before continuing.[/] * PRODUCTION_CLUSTER1_LOCATION_ACRONYM: [blue]{Config.ProductionLocation.ClusterLocationAcronym}[/] [yellow]** All variables can be changed on the GitHub Settings page. For example, if you want to deploy production or staging to different locations.[/] - + 4. Disable the reusable GitHub workflows [blue]Deploy Container[/] and [blue]Plan and Deploy Infrastructure[/]. - + 5. The [blue]Cloud Infrastructure - Deployment[/] GitHub Actions will be triggered deployment of Azure Infrastructure. This will take [yellow]between 15 and 45 minutes[/]. - + 6. The [blue]Build and Deploy[/] GitHub Action will be triggered to deploy the Application Code. This will take [yellow]between 5 and 10 minutes[/]. - + 7. You will receive recommendations on how to further secure and optimize your setup. - + [bold]Would you like to continue?[/] """; - + if (!AnsiConsole.Confirm($"{setupConfirmPrompt}", false)) Environment.Exit(0); } - + private void PrepareSubscriptionsForContainerAppsEnvironment() { PrepareSubscription(Config.StagingSubscription.Id); PrepareSubscription(Config.ProductionSubscription.Id); - + AnsiConsole.MarkupLine("[green]Successfully ensured deployment of Azure Container Apps Environment is enabled on Azure Subscriptions.[/]"); return; - + void PrepareSubscription(string subscriptionId) { RunAzureCliCommand( @@ -523,56 +523,56 @@ void PrepareSubscription(string subscriptionId) ); } } - + private void CreateAppRegistrationsIfNotExists() { if (!Config.StagingSubscription.AppRegistration.Exists) { CreateAppRegistration(Config.StagingSubscription.AppRegistration); } - + if (!Config.ProductionSubscription.AppRegistration.Exists) { CreateAppRegistration(Config.ProductionSubscription.AppRegistration); } - + return; - + void CreateAppRegistration(AppRegistration appRegistration) { appRegistration.AppRegistrationId = RunAzureCliCommand( $"""ad app create --display-name "{appRegistration.Name}" --query appId -o tsv""" ).Trim(); - + appRegistration.ServicePrincipalId = RunAzureCliCommand( $"ad sp create --id {appRegistration.AppRegistrationId} --query appId -o tsv" ).Trim(); - + appRegistration.ServicePrincipalObjectId = RunAzureCliCommand( $"""ad sp list --filter "appId eq '{appRegistration.AppRegistrationId}'" --query "[].id" -o tsv""" ).Trim(); - + AnsiConsole.MarkupLine( $"[green]Successfully created an App Registration '{appRegistration.Name}' ({appRegistration.AppRegistrationId}).[/]" ); } } - + private void CreateAppRegistrationCredentials() { // Staging CreateFederatedCredential(Config.StagingSubscription.AppRegistration.AppRegistrationId!, "MainBranch", "ref:refs/heads/main"); CreateFederatedCredential(Config.StagingSubscription.AppRegistration.AppRegistrationId!, "StagingEnvironment", "environment:staging"); CreateFederatedCredential(Config.StagingSubscription.AppRegistration.AppRegistrationId!, "PullRequests", "pull_request"); - + // Production CreateFederatedCredential(Config.ProductionSubscription.AppRegistration.AppRegistrationId!, "MainBranch", "ref:refs/heads/main"); CreateFederatedCredential(Config.ProductionSubscription.AppRegistration.AppRegistrationId!, "ProductionEnvironment", "environment:production"); - + AnsiConsole.MarkupLine( $"[green]Successfully created App Registration with Federated Credentials allowing passwordless deployments from {Config.GithubInfo?.Url}.[/]" ); - + void CreateFederatedCredential(string appRegistrationId, string displayName, string refRefsHeadsMain) { var parameters = JsonSerializer.Serialize(new @@ -583,7 +583,7 @@ void CreateFederatedCredential(string appRegistrationId, string displayName, str audiences = new[] { "api://AzureADTokenExchange" } } ); - + ProcessHelper.StartProcess(new ProcessStartInfo { FileName = Configuration.IsWindows ? "cmd.exe" : "az", @@ -596,16 +596,16 @@ void CreateFederatedCredential(string appRegistrationId, string displayName, str ); } } - + private void GrantSubscriptionPermissionsToServicePrincipals() { GrantAccess(Config.StagingSubscription, Config.StagingSubscription.AppRegistration.Name); GrantAccess(Config.ProductionSubscription, Config.ProductionSubscription.AppRegistration.Name); - + void GrantAccess(Subscription subscription, string appRegistrationName) { var servicePrincipalId = subscription.AppRegistration.ServicePrincipalId!; - + RunAzureCliCommand( $"role assignment create --assignee {servicePrincipalId} --role \"Contributor\" --scope /subscriptions/{subscription.Id}", !Configuration.VerboseLogging @@ -614,18 +614,18 @@ void GrantAccess(Subscription subscription, string appRegistrationName) $"role assignment create --assignee {servicePrincipalId} --role \"User Access Administrator\" --scope /subscriptions/{subscription.Id}", !Configuration.VerboseLogging ); - + AnsiConsole.MarkupLine( $"[green]Successfully granted Service Principal ('{appRegistrationName}') 'Contributor' and `User Access Administrator` rights to Azure Subscription '{subscription.Name}'.[/]" ); } } - + private void CreateAzureSqlServerSecurityGroups() { CreateAzureSqlServerSecurityGroup(Config.StagingSubscription.SqlAdminsGroup, Config.StagingSubscription.AppRegistration); CreateAzureSqlServerSecurityGroup(Config.ProductionSubscription.SqlAdminsGroup, Config.ProductionSubscription.AppRegistration); - + void CreateAzureSqlServerSecurityGroup(SqlAdminsGroup sqlAdminGroup, AppRegistration appRegistration) { if (!sqlAdminGroup.Exists) @@ -634,103 +634,103 @@ void CreateAzureSqlServerSecurityGroup(SqlAdminsGroup sqlAdminGroup, AppRegistra $"""ad group create --display-name "{sqlAdminGroup.Name}" --mail-nickname "{sqlAdminGroup.NickName}" --query "id" -o tsv""" ).Trim(); } - + RunAzureCliCommand( $"ad group member add --group {sqlAdminGroup.ObjectId} --member-id {appRegistration.ServicePrincipalObjectId}", !Configuration.VerboseLogging ); - + AnsiConsole.MarkupLine( $"[green]Successfully created AD Security Group '{sqlAdminGroup.Name}' and assigned the App Registration '{appRegistration.Name}' owner role.[/]" ); } } - + private void CreateGithubEnvironments() { ProcessHelper.StartProcess( $"""gh api --method PUT -H "Accept: application/vnd.github+json" repos/{Config.GithubInfo?.Path}/environments/staging""", redirectOutput: true ); - + ProcessHelper.StartProcess( $"""gh api --method PUT -H "Accept: application/vnd.github+json" repos/{Config.GithubInfo?.Path}/environments/production""", redirectOutput: true ); - + AnsiConsole.MarkupLine( "[green]Successfully created 'staging' and 'production' environments in the GitHub repository.[/]" ); } - + private void CreateGithubSecretsAndVariables() { SetGithubVariable(VariableNames.TENANT_ID, Config.TenantId); SetGithubVariable(VariableNames.UNIQUE_PREFIX, Config.UniquePrefix); - + SetGithubVariable(VariableNames.STAGING_SUBSCRIPTION_ID, Config.StagingSubscription.Id); SetGithubVariable(VariableNames.STAGING_SERVICE_PRINCIPAL_ID, Config.StagingSubscription.AppRegistration.ServicePrincipalId!); SetGithubVariable(VariableNames.STAGING_SHARED_LOCATION, Config.StagingLocation.SharedLocation); SetGithubVariable(VariableNames.STAGING_SQL_ADMIN_OBJECT_ID, Config.StagingSubscription.SqlAdminsGroup.ObjectId!); SetGithubVariable(VariableNames.STAGING_DOMAIN_NAME, "-"); - + SetGithubVariable(VariableNames.STAGING_CLUSTER_ENABLED, "true"); SetGithubVariable(VariableNames.STAGING_CLUSTER_LOCATION, Config.StagingLocation.ClusterLocation); SetGithubVariable(VariableNames.STAGING_CLUSTER_LOCATION_ACRONYM, Config.StagingLocation.ClusterLocationAcronym); - + SetGithubVariable(VariableNames.PRODUCTION_SUBSCRIPTION_ID, Config.ProductionSubscription.Id); SetGithubVariable(VariableNames.PRODUCTION_SERVICE_PRINCIPAL_ID, Config.ProductionSubscription.AppRegistration.ServicePrincipalId!); SetGithubVariable(VariableNames.PRODUCTION_SHARED_LOCATION, Config.ProductionLocation.SharedLocation); SetGithubVariable(VariableNames.PRODUCTION_SQL_ADMIN_OBJECT_ID, Config.ProductionSubscription.SqlAdminsGroup.ObjectId!); SetGithubVariable(VariableNames.PRODUCTION_DOMAIN_NAME, "-"); - + SetGithubVariable(VariableNames.PRODUCTION_CLUSTER1_ENABLED, "false"); SetGithubVariable(VariableNames.PRODUCTION_CLUSTER1_LOCATION, Config.ProductionLocation.ClusterLocation); SetGithubVariable(VariableNames.PRODUCTION_CLUSTER1_LOCATION_ACRONYM, Config.ProductionLocation.ClusterLocationAcronym); - + AnsiConsole.MarkupLine("[green]Successfully created secrets in GitHub.[/]"); return; - + void SetGithubVariable(VariableNames name, string value) { ProcessHelper.StartProcess($"gh variable set {Enum.GetName(name)} -b\"{value}\" --repo={Config.GithubInfo?.Path}"); } } - + private void DisableReusableWorkflows() { // Disable reusable workflows DisableActiveWorkflow("Deploy Container"); DisableActiveWorkflow("Plan and Deploy Infrastructure"); return; - + void DisableActiveWorkflow(string workflowName) { // Command to list workflows var listWorkflowsCommand = "gh workflow list --json name,state,id"; var workflowsJson = ProcessHelper.StartProcess(listWorkflowsCommand, Configuration.GetSourceCodeFolder(), true); - + // Parse JSON to find the specific workflow and check if it's active using var jsonDocument = JsonDocument.Parse(workflowsJson); foreach (var element in jsonDocument.RootElement.EnumerateArray()) { var name = element.GetProperty("name").GetString()!; var state = element.GetProperty("state").GetString()!; - + if (name != workflowName || state != "active") continue; - + // Disable the workflow if it is active var workflowId = element.GetProperty("id").GetInt64(); var disableCommand = $"gh workflow disable {workflowId}"; ProcessHelper.StartProcess(disableCommand, Configuration.GetSourceCodeFolder(), true); - + AnsiConsole.MarkupLine($"[green]Reusable Git Workflow '{workflowName}' has been disabled.[/]"); - + break; } } } - + private void TriggerAndMonitorWorkflows() { AnsiConsole.Status().Start("Begin deployment.", ctx => @@ -741,41 +741,41 @@ private void TriggerAndMonitorWorkflows() { break; } - + ctx.Status($"Deployment of Cloud Infrastructure and Application code will automatically start in {i} seconds. Press 'Ctrl+C' to exit or 'Enter' to continue."); Thread.Sleep(TimeSpan.FromSeconds(1)); } } ); - + StartGithubWorkflow("Cloud Infrastructure - Deployment", "cloud-infrastructure.yml"); StartGithubWorkflow("Account Management - Build and Deploy", "account-management.yml"); StartGithubWorkflow("AppGateway - Build and Deploy", "app-gateway.yml"); return; - + void StartGithubWorkflow(string workflowName, string workflowFileName) { try { AnsiConsole.MarkupLine($"[green]Starting {workflowName} GitHub workflow...[/]"); - + var runWorkflowCommand = $"gh workflow run {workflowFileName} --ref main"; ProcessHelper.StartProcess(runWorkflowCommand, Configuration.GetSourceCodeFolder(), true); - + // Wait briefly to ensure the run has started Thread.Sleep(TimeSpan.FromSeconds(15)); - + // Fetch and filter the workflows to find a "running" one var listWorkflowRunsCommand = $"gh run list --workflow={workflowFileName} --json databaseId,status"; var workflowsJson = ProcessHelper.StartProcess(listWorkflowRunsCommand, Configuration.GetSourceCodeFolder(), true); - + long? workflowId = null; using (var jsonDocument = JsonDocument.Parse(workflowsJson)) { foreach (var element in jsonDocument.RootElement.EnumerateArray()) { var status = element.GetProperty("status").GetString()!; - + if (status.Equals("in_progress", StringComparison.OrdinalIgnoreCase)) { workflowId = element.GetProperty("databaseId").GetInt64(); @@ -783,20 +783,20 @@ void StartGithubWorkflow(string workflowName, string workflowFileName) } } } - + if (workflowId is null) { AnsiConsole.MarkupLine("[red]Failed to retrieve a running workflow ID. Please check the GitHub Actions page for more info.[/]"); Environment.Exit(1); } - + var watchWorkflowRunCommand = $"gh run watch {workflowId.Value}"; ProcessHelper.StartProcessWithSystemShell(watchWorkflowRunCommand, Configuration.GetSourceCodeFolder()); // Run the command one more time to get the result var runResult = ProcessHelper.StartProcess(watchWorkflowRunCommand, Configuration.GetSourceCodeFolder(), true); if (runResult.Contains("completed") && runResult.Contains("success")) return; - + AnsiConsole.MarkupLine($"[red]Error: Failed to run the {workflowName} GitHub workflow.[/]"); AnsiConsole.MarkupLine($"[red]{runResult}[/]"); Environment.Exit(1); @@ -808,39 +808,39 @@ void StartGithubWorkflow(string workflowName, string workflowFileName) } } } - + private void ShowSuccessMessage() { var setupIntroPrompt = $""" So far so good. The configuration of GitHub and Azure is now complete. Here are some recommendations to further secure and optimize your setup: - + - For protecting the [blue]main[/] branch, configure branch protection rules to necessitate pull request reviews before merging can occur. Visit [blue]{Config.GithubInfo?.Url}/settings/branches[/], click ""Add Branch protection rule"", and set it up for the [bold]main[/] branch. Requires a paid GitHub plan for private repositories. - + - To add a step for manual approval during infrastructure deployment to the staging and production environments, set up required reviewers on GitHub environments. Visit [blue]{Config.GithubInfo?.Url}/settings/environments[/] and enable [blue]Required reviewers[/] for the [bold]staging[/] and [bold]production[/] environments. Requires a paid GitHub plan for private repositories. - + - Configure the Domain Name for the staging and production environments. This involves two steps: a. Go to [blue]{Config.GithubInfo?.Url}/settings/variables/actions[/] to set the [blue]DOMAIN_NAME_STAGING[/] and [blue]DOMAIN_NAME_PRODUCTION[/] variables. E.g. [blue]staging.your-saas-company.com[/] and [blue]your-saas-company.com[/]. b. Run the [blue]Cloud Infrastructure - Deployment[/] workflow again. Note that it might fail with an error message to set up a DNS TXT and CNAME record. Once done, re-run the failed jobs. - + - Set up SonarCloud for code quality and security analysis. This service is free for public repositories. Visit [blue]https://sonarcloud.io[/] to connect your GitHub account. Add the [blue]SONAR_TOKEN[/] secret, and the [blue]SONAR_ORGANIZATION[/] and [blue]SONAR_PROJECT_KEY[/] variables to the GitHub repository. The workflows are already configured for SonarCloud analysis. - + - Enable Microsoft Defender for Cloud (also known as Azure Security Center) once the system evolves for added security recommendations. """; - + AnsiConsole.MarkupLine($"{setupIntroPrompt}"); AnsiConsole.WriteLine(); } - + private string RunAzureCliCommand(string arguments, bool redirectOutput = true) { var azureCliCommand = Configuration.IsWindows ? "cmd.exe /C az" : "az"; - + return ProcessHelper.StartProcess($"{azureCliCommand} {arguments}", redirectOutput: redirectOutput); } - + private static Dictionary GetAzureLocations() { // List of global available regions extracted by running: @@ -900,21 +900,21 @@ private static Dictionary GetAzureLocations() public class Config { public string TenantId => StagingSubscription.TenantId; - + public string UniquePrefix { get; set; } = default!; - + public GithubInfo? GithubInfo { get; private set; } - + public Subscription StagingSubscription { get; set; } = default!; - + public Location StagingLocation { get; set; } = default!; - + public Subscription ProductionSubscription { get; set; } = default!; - + public Location ProductionLocation { get; set; } = default!; - + public Dictionary GithubVariables { get; set; } = new(); - + public void InitializeFromUri(string gitUri) { string remote; @@ -930,15 +930,15 @@ public void InitializeFromUri(string gitUri) { throw new ArgumentException($"Invalid Git URI: {gitUri}. Only https:// and git@ formatted is supported.", nameof(gitUri)); } - + var parts = remote.Split("/"); GithubInfo = new GithubInfo(parts[0], parts[1]); } - + public bool IsLoggedIn() { var githubAuthStatus = ProcessHelper.StartProcess("gh auth status", redirectOutput: true); - + return githubAuthStatus.Contains("Logged in to github.com"); } } @@ -946,11 +946,11 @@ public bool IsLoggedIn() public class GithubInfo(string organizationName, string repositoryName) { public string OrganizationName { get; } = organizationName; - + public string RepositoryName { get; } = repositoryName; - + public string Path => $"{OrganizationName}/{RepositoryName}"; - + public string Url => $"https://github.com/{Path}"; } @@ -959,37 +959,37 @@ public record AzureSubscription(string Id, string Name, string TenantId, string public class Subscription(string id, string name, string tenantId, GithubInfo githubInfo, string environmentName) { public string Id { get; } = id; - + public string Name { get; } = name; - + public string TenantId { get; } = tenantId; - + public AppRegistration AppRegistration { get; } = new(githubInfo, environmentName); - + public SqlAdminsGroup SqlAdminsGroup { get; } = new(githubInfo, environmentName); } public class AppRegistration(GithubInfo githubInfo, string environmentName) { public string Name => $"GitHub - {githubInfo.OrganizationName}/{githubInfo.RepositoryName} - {environmentName}"; - + public bool Exists => !string.IsNullOrEmpty(AppRegistrationId); - + public string? AppRegistrationId { get; set; } - + public string? ServicePrincipalId { get; set; } - + public string? ServicePrincipalObjectId { get; set; } } public class SqlAdminsGroup(GithubInfo githubInfo, string enviromentName) { public string Name => $"SQL Admins - {githubInfo.OrganizationName}/{githubInfo.RepositoryName} - {enviromentName}"; - + public string NickName => $"SQLServerAdmins{githubInfo.OrganizationName}{githubInfo.RepositoryName}{enviromentName}"; - + public bool Exists => !string.IsNullOrEmpty(ObjectId); - + public string? ObjectId { get; set; } } @@ -1000,26 +1000,26 @@ public enum VariableNames // ReSharper disable InconsistentNaming TENANT_ID, UNIQUE_PREFIX, - + STAGING_SUBSCRIPTION_ID, STAGING_SERVICE_PRINCIPAL_ID, STAGING_SHARED_LOCATION, STAGING_SQL_ADMIN_OBJECT_ID, STAGING_DOMAIN_NAME, - + STAGING_CLUSTER_ENABLED, STAGING_CLUSTER_LOCATION, STAGING_CLUSTER_LOCATION_ACRONYM, - + PRODUCTION_SUBSCRIPTION_ID, PRODUCTION_SERVICE_PRINCIPAL_ID, PRODUCTION_SHARED_LOCATION, PRODUCTION_SQL_ADMIN_OBJECT_ID, PRODUCTION_DOMAIN_NAME, - + PRODUCTION_CLUSTER1_ENABLED, PRODUCTION_CLUSTER1_LOCATION, - + PRODUCTION_CLUSTER1_LOCATION_ACRONYM // ReSharper restore InconsistentNaming } diff --git a/developer-cli/Commands/DevCommand.cs b/developer-cli/Commands/DevCommand.cs index 0d9d5fc0a..0fb45f176 100644 --- a/developer-cli/Commands/DevCommand.cs +++ b/developer-cli/Commands/DevCommand.cs @@ -15,28 +15,28 @@ public DevCommand() : base("dev", "Run the Aspire AppHost with all self-containe { Handler = CommandHandler.Create(Execute); } - + private void Execute() { PrerequisitesChecker.Check("dotnet", "docker", "aspire", "node", "yarn"); - + var workingDirectory = Path.Combine(Configuration.GetSourceCodeFolder(), "..", "application", "AppHost"); - + if (!ProcessHelper.IsProcessRunning("Docker")) { AnsiConsole.MarkupLine("[green]Starting Docker Desktop[/]"); ProcessHelper.StartProcess("open -a Docker", waitForExit: true); } - + AnsiConsole.MarkupLine("\n[green]Ensuring Docker image for SQL Server is up to date ...[/]"); ProcessHelper.StartProcessWithSystemShell("docker pull mcr.microsoft.com/mssql/server:2022-latest"); - + AnsiConsole.MarkupLine("\n[green]Ensuring Docker image for Azure Blob Storage Emulator ...[/]"); ProcessHelper.StartProcessWithSystemShell("docker pull mcr.microsoft.com/azure-storage/azurite:latest"); - + AnsiConsole.MarkupLine("\n[green]Ensuring Docker image for Mail Server and Web Client is up to date ...[/]"); ProcessHelper.StartProcessWithSystemShell("docker pull axllent/mailpit:latest"); - + Task.Run(async () => { // Start a background task that monitors the websites and opens the browser when ready @@ -46,24 +46,24 @@ private void Execute() await StartBrowserWhenSiteIsReady(appPort); } ); - + AnsiConsole.MarkupLine("\n[green]Starting the Aspire AppHost...[/]"); ProcessHelper.StartProcess("dotnet run", workingDirectory); } - + private static async Task StartBrowserWhenSiteIsReady(int port) { var url = $"https://localhost:{port}"; - + var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) }; httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("PlatformPlatform Developer CLI"); - + while (true) { try { var response = await httpClient.GetAsync(url); - + if (response.IsSuccessStatusCode) { break; @@ -74,7 +74,7 @@ private static async Task StartBrowserWhenSiteIsReady(int port) await Task.Delay(TimeSpan.FromSeconds(1)); } } - + ProcessHelper.StartProcess(new ProcessStartInfo("open", url), waitForExit: false); } } diff --git a/developer-cli/Commands/InstallCommand.cs b/developer-cli/Commands/InstallCommand.cs index 984e941c9..3025aeb0e 100644 --- a/developer-cli/Commands/InstallCommand.cs +++ b/developer-cli/Commands/InstallCommand.cs @@ -14,30 +14,30 @@ public class InstallCommand : Command [green]Welcome to:[/] To get the full benefit of PlatformPlatform, allow this tool to register an alias on your machine. This will allow you to run PlatformPlatform commands from anywhere on your machine by typing [green]{Configuration.AliasName}[/]. - + [green]The CLI can be used to:[/] * Start all PlatformPlatform services locally in one command * Be guided through setting up secure passwordless continuous deployments between GitHub and Azure * Run static code analysis on your codebase to ensure it does not fail when running in GitHub Workflows * Run tests and show code coverage reports locally * Much more is coming soon! - + [green]Best of all, you can easily create your own commands to automate your own workflows![/] The CLI will automatically detect any changes and recompile itself whenever your team does a [bold][grey]git pull[/][/]. It's a great way to automate workflows and share them with your team. - + [green]Is this secure?[/] Yes. But, like any code you copy from the internet, you should always review it before you run it. Just open the project in your IDE and review the code. - + [green]How does it work?[/] The CLI has several commands that you can run from anywhere on your machine. Each command is one C# class that can be customized to automate your own workflows. Each command check for its prerequisites (e.g., Docker, Node, Yarn, .NET Aspire, Azure CLI, etc.) To remove the alias, just run [green]{Configuration.AliasName} uninstall[/]. - + """; - + public InstallCommand() : base( "install", $"This will register the alias {Configuration.AliasName} so it will be available everywhere." @@ -45,26 +45,26 @@ public InstallCommand() : base( { Handler = CommandHandler.Create(Execute); } - + private void Execute() { PrerequisitesChecker.Check("dotnet"); - + if (IsAliasRegistered()) { AnsiConsole.MarkupLine($"[yellow]The CLI is already installed please run {Configuration.AliasName} to use it.[/]"); return; } - + AnsiConsole.Write(new Markup(Intro)); AnsiConsole.WriteLine(); - + if (AnsiConsole.Confirm($"This will register the alias '[green]{Configuration.AliasName}[/]', so it will be available everywhere.")) { AnsiConsole.WriteLine(); RegisterAlias(); } - + if (Configuration.IsWindows) { AnsiConsole.MarkupLine("Please restart your terminal to update your PATH."); @@ -76,14 +76,14 @@ private void Execute() ); } } - + private static bool IsAliasRegistered() { return Configuration.IsWindows ? Configuration.Windows.IsFolderInPath(Configuration.PublishFolder) : Configuration.MacOs.IsAliasRegisteredMacOs(); } - + private static void RegisterAlias() { if (Configuration.IsWindows) diff --git a/developer-cli/Commands/TestCommand.cs b/developer-cli/Commands/TestCommand.cs index d51d5bffb..6950521b6 100644 --- a/developer-cli/Commands/TestCommand.cs +++ b/developer-cli/Commands/TestCommand.cs @@ -3,7 +3,6 @@ using JetBrains.Annotations; using PlatformPlatform.DeveloperCli.Installation; using PlatformPlatform.DeveloperCli.Utilities; -using Spectre.Console; namespace PlatformPlatform.DeveloperCli.Commands; @@ -16,20 +15,20 @@ public TestCommand() : base("test", "Runs tests from a solution") ["", "--solution-name", "-s"], "The name of the solution file containing the tests to run" ); - + AddOption(solutionNameOption); - + Handler = CommandHandler.Create(Execute); } - + private int Execute(string? solutionName) { PrerequisitesChecker.Check("dotnet"); - + var solutionFile = SolutionHelper.GetSolution(solutionName); ProcessHelper.StartProcess($"dotnet test {solutionFile.Name}", solutionFile.Directory?.FullName); - + return 0; } } diff --git a/developer-cli/Commands/TranslateCommand.cs b/developer-cli/Commands/TranslateCommand.cs index 3bc6731fa..b1e298d4e 100644 --- a/developer-cli/Commands/TranslateCommand.cs +++ b/developer-cli/Commands/TranslateCommand.cs @@ -17,7 +17,7 @@ public class TranslateCommand : Command private const string DockerImageName = "ollama/ollama"; private const int Port = 11434; private const string ModelName = "llama2"; - + public TranslateCommand() : base( "translate", $"Update language files with missing translations 🐡 (ALPHA) powered by {ModelName}" @@ -27,25 +27,25 @@ public TranslateCommand() : base( ["", "--language", "-l"], "The name of the language to translate (e.g `da-DK`)" ); - + AddOption(languageOption); - + Handler = CommandHandler.Create(Execute); } - + private async Task Execute(string? language) { PrerequisitesChecker.Check("dotnet", "docker"); - + var dockerServer = new DockerServer(DockerImageName, InstanceName, Port, "/root/.ollama"); try { var translationFile = GetTranslationFile(language); - + dockerServer.StartServer(); - + await RunTranslation(translationFile); - + return 0; } catch (Exception e) @@ -58,7 +58,7 @@ private async Task Execute(string? language) dockerServer.StopServer(); } } - + private string GetTranslationFile(string? language) { var workingDirectory = new DirectoryInfo(Path.Combine(Configuration.GetSourceCodeFolder(), "..", "application")); @@ -69,36 +69,36 @@ private string GetTranslationFile(string? language) !f.FullName.EndsWith("pseudo.po") ) .ToDictionary(s => s.FullName.Replace(workingDirectory.FullName, ""), f => f); - + if (language is not null) { var translationFile = translationFiles.Values .FirstOrDefault(f => f.Name.Equals($"{language}.po", StringComparison.OrdinalIgnoreCase)); - + return translationFile?.FullName ?? throw new InvalidOperationException($"Translation file for language '{language}' not found."); } - + var prompt = new SelectionPrompt() .Title("Please select the file to translate") .AddChoices(translationFiles.Keys); - + var selection = AnsiConsole.Prompt(prompt); return translationFiles[selection].FullName; } - + private async Task RunTranslation(string translationFile) { AnsiConsole.MarkupLine("[green]Connecting to Ollama API.[/]"); var ollamaApiClient = new OllamaApiClient( new HttpClient { BaseAddress = new Uri($"http://localhost:{Port}"), Timeout = TimeSpan.FromMinutes(15) } ); - + await AnsiConsole.Status().StartAsync("Checking base model...", async context => { var models = (await ollamaApiClient.ListLocalModels()).ToArray(); var baseModel = models.FirstOrDefault(m => m.Name.StartsWith($"{ModelName}:")); - + context.Status("Checking base model."); if (baseModel is null) { @@ -111,12 +111,12 @@ await ollamaApiClient.PullModel( } } ); - + var poParseResult = await ReadTranslationFile(translationFile); - + var poCatalog = poParseResult.Catalog; AnsiConsole.MarkupLine($"Language detected: {poCatalog.Language}"); - + await AnsiConsole.Status().StartAsync("Initialize translation...", async context => { var missingTranslations = new List(); @@ -131,7 +131,7 @@ You are a translation service translating from English to {poCatalog.Language}. """ } }; - + foreach (var key in poCatalog.Keys) { var translation = poCatalog.GetTranslation(key); @@ -145,24 +145,24 @@ You are a translation service translating from English to {poCatalog.Language}. messages.Add(new Message { Role = "assistant", Content = translation }); } } - + AnsiConsole.MarkupLine($"Keys missing translation: {missingTranslations.Count}"); if (missingTranslations.Count == 0) { AnsiConsole.MarkupLine("[green]Translation completed, nothing to translate.[/]"); return; } - + for (var index = 0; index < missingTranslations.Count; index++) { var key = missingTranslations[index]; var content = ""; - + AnsiConsole.MarkupLine($"[green]Translating {key.Id}[/]"); context.Status($"Translating {index + 1}/{missingTranslations.Count} (thinking...)"); - + messages.Add(new Message { Role = "user", Content = key.Id }); - + messages = (await ollamaApiClient.SendChat( new ChatRequest { Model = ModelName, Messages = messages }, status => @@ -172,17 +172,17 @@ You are a translation service translating from English to {poCatalog.Language}. context.Status($"Translating {index + 1}/{missingTranslations.Count} ({Math.Min(100, percent)}%)"); } )).ToList(); - + UpdateCatalogTranslation(poCatalog, key, messages.Last().Content); } - + AnsiConsole.MarkupLine("[green]Translation completed.[/]"); - + await WriteTranslationFile(translationFile, poCatalog); } ); } - + private static async Task ReadTranslationFile(string translationFile) { var translationContent = await File.ReadAllTextAsync(translationFile); @@ -192,15 +192,15 @@ private static async Task ReadTranslationFile(string translationF { throw new InvalidOperationException($"Failed to parse PO file. {poParseResult.Diagnostics}"); } - + if (poParseResult.Catalog.Language is null) { throw new InvalidOperationException($"Failed to parse PO file {translationFile}. Language not found."); } - + return poParseResult; } - + private static async Task WriteTranslationFile(string translationFile, POCatalog poCatalog) { var poGenerator = new POGenerator(new POGeneratorSettings { IgnoreEncoding = true }); @@ -208,11 +208,11 @@ private static async Task WriteTranslationFile(string translationFile, POCatalog poGenerator.Generate(fileStream, poCatalog); await fileStream.FlushAsync(); fileStream.Close(); - + AnsiConsole.MarkupLine($"[green]Translated file saved to {translationFile}[/]"); AnsiConsole.MarkupLine("[yellow]WARNING: Please proofread to make sure the language is inclusive.[/]"); } - + private static void UpdateCatalogTranslation(POCatalog poCatalog, POKey key, string translation) { var poEntry = poCatalog[key]; diff --git a/developer-cli/Commands/UninstallCommand.cs b/developer-cli/Commands/UninstallCommand.cs index 6f90a4141..1adca18bb 100644 --- a/developer-cli/Commands/UninstallCommand.cs +++ b/developer-cli/Commands/UninstallCommand.cs @@ -16,7 +16,7 @@ public UninstallCommand() : base( { Handler = CommandHandler.Create(Execute); } - + private void Execute() { if (Configuration.IsWindows && !Configuration.IsDebugMode) @@ -24,28 +24,28 @@ private void Execute() AnsiConsole.MarkupLine($"[yellow]Please run 'dotnet run uninstall' from {Configuration.GetSourceCodeFolder()}.[/]"); Environment.Exit(0); } - + var prompt = $""" Confirm uninstallation: - + This will do the following: - Remove the PlatformPlatform Developer CLI alias (on Mac) and remove the CLI from the PATH (Windows) - Delete the {Configuration.PublishFolder}/{Configuration.AliasName}.* files - Remove the {Configuration.PublishFolder} folder if empty - + Are you sure you want to uninstall the PlatformPlatform Developer CLI? """; - + if (AnsiConsole.Confirm(prompt)) { DeleteFilesFolder(); RemoveAlias(); - + AnsiConsole.MarkupLine("[green]Please restart your terminal.[/]"); } } - + private void RemoveAlias() { if (Configuration.IsWindows) @@ -63,17 +63,17 @@ private void RemoveAlias() AnsiConsole.MarkupLine("[green]Alias has been removed.[/]"); } } - + private void DeleteFilesFolder() { if (!Directory.Exists(Configuration.PublishFolder)) return; - + // Multiple CLIs can be running side by side. Only delete the files belonging to the current version. foreach (var file in Directory.GetFiles(Configuration.PublishFolder, $"{Configuration.AliasName}.*")) { File.Delete(file); } - + // Delete the Configuration.PublishFolder if empty if (!Directory.EnumerateFileSystemEntries(Configuration.PublishFolder).Any()) { diff --git a/developer-cli/DeveloperCli.sln.DotSettings b/developer-cli/DeveloperCli.sln.DotSettings index 649c80faf..5720f4d4c 100644 --- a/developer-cli/DeveloperCli.sln.DotSettings +++ b/developer-cli/DeveloperCli.sln.DotSettings @@ -11,6 +11,7 @@ HINT HINT HINT + True <?xml version="1.0" encoding="utf-16"?><Profile name=".NET only"><CppCodeStyleCleanupDescriptor /><CSReorderTypeMembers>True</CSReorderTypeMembers><CSCodeStyleAttributes ArrangeVarStyle="True" ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" ArrangeArgumentsStyle="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeCodeBodyStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" ArrangeNullCheckingPattern="True" /><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><IDEA_SETTINGS>&lt;profile version="1.0"&gt; &lt;option name="myName" value=".NET only" /&gt; &lt;inspection_tool class="ES6ShorthandObjectProperty" enabled="false" level="WARNING" enabled_by_default="false" /&gt; diff --git a/developer-cli/Installation/ChangeDetection.cs b/developer-cli/Installation/ChangeDetection.cs index ff96ddb02..75a8fc39f 100644 --- a/developer-cli/Installation/ChangeDetection.cs +++ b/developer-cli/Installation/ChangeDetection.cs @@ -14,21 +14,21 @@ internal static void EnsureCliIsCompiledWithLatestChanges(bool isDebugBuild) // We delete the previous executable the next time the process is started File.Delete(Environment.ProcessPath!.Replace(".exe", ".previous.exe")); } - + var currentHash = CalculateMd5HashForSolution(); if (currentHash == Configuration.GetConfigurationSetting().Hash) return; - + PublishDeveloperCli(currentHash); - + // When running in debug mode, we want to avoid restarting the process if (isDebugBuild) return; - + AnsiConsole.WriteLine(); AnsiConsole.MarkupLine("[green]The CLI was successfully updated. Please rerun the command.[/]"); AnsiConsole.WriteLine(); Environment.Exit(0); } - + private static string CalculateMd5HashForSolution() { // Get all files C# and C# project files in the Developer CLI solution @@ -36,33 +36,33 @@ private static string CalculateMd5HashForSolution() .EnumerateFiles(Configuration.GetSourceCodeFolder(), "*.cs*", SearchOption.AllDirectories) .Where(f => !f.Contains("artifacts")) .ToList(); - + using var sha256 = SHA256.Create(); using var combinedStream = new MemoryStream(); - + foreach (var file in solutionFiles) { using var fileStream = File.OpenRead(file); var hash = sha256.ComputeHash(fileStream); combinedStream.Write(hash, 0, hash.Length); } - + combinedStream.Position = 0; return BitConverter.ToString(sha256.ComputeHash(combinedStream)); } - + private static void PublishDeveloperCli(string currentHash) { AnsiConsole.MarkupLine("[green]Changes detected, rebuilding and publishing new CLI.[/]"); - + var currentExecutablePath = Environment.ProcessPath!; var renamedExecutablePath = ""; - + try { // Build project before renaming exe on Windows ProcessHelper.StartProcess("dotnet build", Configuration.GetSourceCodeFolder()); - + if (Configuration.IsWindows) { // In Windows the executing assembly is locked by the process, blocking overwriting it, but not renaming @@ -70,13 +70,13 @@ private static void PublishDeveloperCli(string currentHash) renamedExecutablePath = currentExecutablePath.Replace(".exe", ".previous.exe"); File.Move(currentExecutablePath, renamedExecutablePath, true); } - + // Call "dotnet publish" to create a new executable ProcessHelper.StartProcess( $"dotnet publish DeveloperCli.csproj -o \"{Configuration.PublishFolder}\"", Configuration.GetSourceCodeFolder() ); - + var configurationSetting = Configuration.GetConfigurationSetting(); configurationSetting.SourceCodeFolder = Configuration.GetSourceCodeFolder(); configurationSetting.Hash = currentHash; diff --git a/developer-cli/Installation/Configuration.cs b/developer-cli/Installation/Configuration.cs index a0c03e873..10a11e59c 100644 --- a/developer-cli/Installation/Configuration.cs +++ b/developer-cli/Installation/Configuration.cs @@ -11,21 +11,21 @@ public static class Configuration public static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); public static readonly bool IsMacOs = RuntimeInformation.IsOSPlatform(OSPlatform.OSX); public static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); - + private static readonly string UserFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - + public static readonly string PublishFolder = IsWindows ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PlatformPlatform") : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".PlatformPlatform"); - + public static readonly string AliasName = Assembly.GetExecutingAssembly().GetName().Name!; - + private static string ConfigFile => Path.Combine(PublishFolder, $"{AliasName}.json"); - + public static bool VerboseLogging { get; set; } - + public static bool IsDebugMode => Environment.ProcessPath!.Contains("debug"); - + public static string GetSourceCodeFolder() { if (IsDebugMode) @@ -33,29 +33,29 @@ public static string GetSourceCodeFolder() // In debug mode the ProcessPath is in developer-cli/artifacts/bin/DeveloperCli/debug/pp.exe return new DirectoryInfo(Environment.ProcessPath!).Parent!.Parent!.Parent!.Parent!.Parent!.FullName; } - + return GetConfigurationSetting().SourceCodeFolder!; } - + public static ConfigurationSetting GetConfigurationSetting() { if (!File.Exists(ConfigFile) && IsDebugMode) { return new ConfigurationSetting(); } - + try { var readAllText = File.ReadAllText(ConfigFile); var configurationSetting = JsonSerializer.Deserialize(readAllText)!; - + if (configurationSetting.IsValid) return configurationSetting; } catch (Exception e) { AnsiConsole.MarkupLine($"[red]Error: {e.Message}[/]"); } - + if (IsDebugMode) { Directory.Delete(PublishFolder, true); @@ -65,30 +65,30 @@ public static ConfigurationSetting GetConfigurationSetting() { AnsiConsole.MarkupLine("[red]Invalid configuration. Please run `dotnet run` from the `/developer-cli` folder of PlatformPlatform.[/]"); } - + Environment.Exit(1); return null; } - + public static void SaveConfigurationSetting(ConfigurationSetting configurationSetting) { var jsonSerializerOptions = new JsonSerializerOptions { WriteIndented = true }; var configuration = JsonSerializer.Serialize(configurationSetting, jsonSerializerOptions); File.WriteAllText(ConfigFile, configuration); } - + public static class Windows { private const char PathDelimiter = ';'; private const string PathName = "PATH"; public static readonly string LocalhostPfxWindows = $"{UserFolder}/.aspnet/https/localhost.pfx"; - + internal static bool IsFolderInPath(string folder) { var paths = Environment.GetEnvironmentVariable(PathName)!.Split(PathDelimiter); return paths.Contains(folder); } - + public static void AddFolderToPath(string folder) { if (IsFolderInPath(folder)) return; @@ -96,28 +96,28 @@ public static void AddFolderToPath(string folder) var newPath = existingPath.EndsWith(PathDelimiter) ? $"{existingPath}{folder}{PathDelimiter}" : $"{existingPath}{PathDelimiter}{folder}{PathDelimiter}"; - + Environment.SetEnvironmentVariable(PathName, newPath, EnvironmentVariableTarget.User); } - + public static void RemoveFolderFromPath(string folder) { // Get existing PATH on Windows var existingPath = Environment.GetEnvironmentVariable(PathName); - + // Remove the from the PATH environment variable and replace any double ;; left behind var newPath = existingPath!.Replace(folder, string.Empty).Replace(";;", ";"); - + Environment.SetEnvironmentVariable(PathName, newPath, EnvironmentVariableTarget.User); } } - + public static class MacOs { private static string CliPath => Path.Combine(PublishFolder, new FileInfo(Environment.ProcessPath!).Name); - + private static string AliasLineRepresentation => $"alias {AliasName}='{CliPath}'"; - + internal static bool IsAliasRegisteredMacOs() { if (!File.Exists(GetShellInfo().ProfilePath)) @@ -125,10 +125,10 @@ internal static bool IsAliasRegisteredMacOs() AnsiConsole.MarkupLine($"[red]Your shell [bold]{GetShellInfo().ShellName}[/] is not supported.[/]"); return false; } - + return Array.Exists(File.ReadAllLines(GetShellInfo().ProfilePath), line => line == AliasLineRepresentation); } - + internal static void RegisterAliasMacOs() { if (!File.Exists(GetShellInfo().ProfilePath)) @@ -136,10 +136,10 @@ internal static void RegisterAliasMacOs() AnsiConsole.MarkupLine($"[red]Your shell [bold]{GetShellInfo().ShellName}[/] is not supported.[/]"); return; } - + File.AppendAllLines(GetShellInfo().ProfilePath, new[] { AliasLineRepresentation }); } - + public static void DeleteAlias() { var lineRepresentation = AliasLineRepresentation; @@ -149,12 +149,12 @@ public static void DeleteAlias() File.WriteAllLines(tempFilePath, linesToKeep); File.Replace(tempFilePath, profilePath, null); } - + public static (string ShellName, string ProfileName, string ProfilePath) GetShellInfo() { var shellName = Environment.GetEnvironmentVariable("SHELL")!; var profileName = string.Empty; - + if (shellName.Contains("zsh")) { profileName = ".zshrc"; @@ -163,9 +163,9 @@ public static (string ShellName, string ProfileName, string ProfilePath) GetShel { profileName = ".bashrc"; } - + var profilePath = profileName == string.Empty ? string.Empty : Path.Combine(UserFolder, profileName); - + return (shellName, profileName, profilePath); } } @@ -174,16 +174,16 @@ public static (string ShellName, string ProfileName, string ProfilePath) GetShel public class ConfigurationSetting { public string? SourceCodeFolder { get; set; } - + public string? Hash { get; set; } - + [JsonIgnore] public bool IsValid { get { if (string.IsNullOrEmpty(SourceCodeFolder)) return false; - + return !string.IsNullOrEmpty(Hash); } } diff --git a/developer-cli/Installation/PrerequisitesChecker.cs b/developer-cli/Installation/PrerequisitesChecker.cs index b75380ca5..a190dfc1c 100644 --- a/developer-cli/Installation/PrerequisitesChecker.cs +++ b/developer-cli/Installation/PrerequisitesChecker.cs @@ -25,7 +25,7 @@ public static class PrerequisitesChecker new Prerequisite(PrerequisiteType.CommandLineTool, "gh", "GitHub CLI", new Version(2, 41)), new Prerequisite(PrerequisiteType.DotnetWorkload, "aspire", "Aspire", Regex: """aspire\s*8\.0\.1""") ]; - + public static void Check(params string[] prerequisiteName) { var invalid = false; @@ -38,7 +38,7 @@ public static void Check(params string[] prerequisiteName) invalid = true; continue; } - + switch (prerequisite.Type) { case PrerequisiteType.CommandLineTool: @@ -46,24 +46,24 @@ public static void Check(params string[] prerequisiteName) { invalid = true; } - + break; case PrerequisiteType.DotnetWorkload: if (!IsDotnetWorkloadValid(prerequisite.Name, prerequisite.DisplayName!, prerequisite.Regex!)) { invalid = true; } - + break; } } - + if (invalid) { Environment.Exit(1); } } - + private static bool IsCommandLineToolValid(string command, string displayName, Version minVersion) { // Check if the command line tool is installed @@ -75,16 +75,16 @@ private static bool IsCommandLineToolValid(string command, string displayName, V RedirectStandardError = true } ); - + var possibleFileLocations = checkOutput.Split(Environment.NewLine); - + if (string.IsNullOrWhiteSpace(checkOutput) || !possibleFileLocations.Any() || !File.Exists(possibleFileLocations[0])) { AnsiConsole.MarkupLine($"[red]{displayName} of minimum version {minVersion} should be installed.[/]"); - + return false; } - + // Get the version of the command line tool var output = ProcessHelper.StartProcess(new ProcessStartInfo { @@ -94,7 +94,7 @@ private static bool IsCommandLineToolValid(string command, string displayName, V RedirectStandardError = true } ); - + var versionRegex = new Regex(@"\d+\.\d+\.\d+(\.\d+)?"); var match = versionRegex.Match(output); if (match.Success) @@ -104,38 +104,38 @@ private static bool IsCommandLineToolValid(string command, string displayName, V AnsiConsole.MarkupLine( $"[red]Please update '[bold]{displayName}[/]' from version [bold]{version}[/] to [bold]{minVersion}[/] or later.[/]" ); - + return false; } - + // If the version could not be determined please change the logic here to check for the correct version AnsiConsole.MarkupLine( $"[red]Command '[bold]{command}[/]' is installed but version could not be determined. Please update the CLI to check for correct version.[/]" ); - + return false; } - + private static bool IsDotnetWorkloadValid(string workloadName, string displayName, string workloadRegex) { var output = ProcessHelper.StartProcess("dotnet workload list", redirectOutput: true); - + if (!output.Contains(workloadName)) { AnsiConsole.MarkupLine( $"[red].NET '[bold]{displayName}[/]' should be installed. Please run '[bold]dotnet workload update[/]' and then '[bold]dotnet workload install {workloadName}[/]'.[/]" ); - + return false; } - + /* The output is on the form: - + Installed Workload Id Manifest Version Installation Source -------------------------------------------------------------------- aspire 8.0.1/8.0.100 SDK 8.0.300 - + Use `dotnet workload search` to find additional workloads to install. */ var regex = new Regex(workloadRegex); @@ -146,10 +146,10 @@ aspire 8.0.1/8.0.100 SDK 8.0.300 AnsiConsole.MarkupLine( $"[red].NET '[bold]{displayName}[/]' is installed but not in the expected version. Please run '[bold]dotnet workload update[/]'.[/]" ); - + return false; } - + return true; } } diff --git a/developer-cli/Utilities/DockerServer.cs b/developer-cli/Utilities/DockerServer.cs index 9b95ada2d..ba5dcb890 100644 --- a/developer-cli/Utilities/DockerServer.cs +++ b/developer-cli/Utilities/DockerServer.cs @@ -12,7 +12,7 @@ public void StartServer() AnsiConsole.MarkupLine($"[green]Pulling {imageName} Docker Image.[/]"); ProcessHelper.StartProcess(new ProcessStartInfo { FileName = "docker", Arguments = $"pull {imageName}" }); } - + AnsiConsole.MarkupLine($"[green]Starting {instanceName} server.[/]"); var portArguments = port.HasValue ? $"-p {port}:{port}" : ""; var volumeArguments = volume is not null ? $"-v {instanceName}:{volume}" : ""; @@ -24,17 +24,17 @@ public void StartServer() RedirectStandardError = true } ); - + if (output.Contains("Error")) { throw new InvalidOperationException($"Failed to start {instanceName} server. {output}"); } } - + public void StopServer() { AnsiConsole.MarkupLine($"[green]Stopping {instanceName} server.[/]"); - + var output = ProcessHelper.StartProcess(new ProcessStartInfo { FileName = "docker", @@ -43,17 +43,17 @@ public void StopServer() RedirectStandardError = true } ); - + if (output.Contains("Error")) { AnsiConsole.MarkupLine($"[red]Failed to stop {instanceName} server. {output}[/]"); } } - + private bool DockerImageExists() { AnsiConsole.MarkupLine("[green]Checking for existing Docker image.[/]"); - + var output = ProcessHelper.StartProcess(new ProcessStartInfo { FileName = "docker", @@ -62,7 +62,7 @@ private bool DockerImageExists() RedirectStandardError = true } ); - + return output.Contains("Digest"); } } diff --git a/developer-cli/Utilities/ProcessHelper.cs b/developer-cli/Utilities/ProcessHelper.cs index 2fe3cd775..b6175657b 100644 --- a/developer-cli/Utilities/ProcessHelper.cs +++ b/developer-cli/Utilities/ProcessHelper.cs @@ -12,7 +12,7 @@ public static void StartProcessWithSystemShell(string command, string? solutionF using var process = Process.Start(processStartInfo)!; process.WaitForExit(); } - + public static string StartProcess( string command, string? solutionFolder = null, @@ -23,7 +23,7 @@ public static string StartProcess( var processStartInfo = CreateProcessStartInfo(command, solutionFolder, redirectOutput); return StartProcess(processStartInfo, waitForExit: waitForExit); } - + private static ProcessStartInfo CreateProcessStartInfo( string command, string? solutionFolder, @@ -43,15 +43,15 @@ private static ProcessStartInfo CreateProcessStartInfo( UseShellExecute = useShellExecute, CreateNoWindow = createNoWindow }; - + if (solutionFolder is not null) { processStartInfo.WorkingDirectory = solutionFolder; } - + return processStartInfo; } - + public static string StartProcess(ProcessStartInfo processStartInfo, string? input = null, bool waitForExit = true) { if (Configuration.VerboseLogging) @@ -59,24 +59,24 @@ public static string StartProcess(ProcessStartInfo processStartInfo, string? inp var escapedArguments = Markup.Escape(processStartInfo.Arguments); AnsiConsole.MarkupLine($"[cyan]{processStartInfo.FileName} {escapedArguments}[/]"); } - + var process = Process.Start(processStartInfo)!; if (input is not null) { process.StandardInput.WriteLine(input); process.StandardInput.Close(); } - + var output = string.Empty; if (processStartInfo.RedirectStandardOutput) output += process.StandardOutput.ReadToEnd(); if (processStartInfo.RedirectStandardError) output += process.StandardError.ReadToEnd(); - + if (!waitForExit) return string.Empty; process.WaitForExit(); - + return output; } - + public static bool IsProcessRunning(string process) { return Process.GetProcessesByName(process).Length > 0; diff --git a/developer-cli/Utilities/SolutionHelper.cs b/developer-cli/Utilities/SolutionHelper.cs index 429570225..fa7ac25f3 100644 --- a/developer-cli/Utilities/SolutionHelper.cs +++ b/developer-cli/Utilities/SolutionHelper.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using PlatformPlatform.DeveloperCli.Installation; using Spectre.Console; @@ -9,31 +8,31 @@ public class SolutionHelper public static FileInfo GetSolution(string? solutionName) { var workingDirectory = Path.Combine(Configuration.GetSourceCodeFolder(), "..", "application"); - + var solutionsFiles = Directory .GetFiles(workingDirectory, "*.sln", SearchOption.AllDirectories) .ToDictionary(s => new FileInfo(s).Name.Replace(".sln", ""), s => s); - + if (solutionName is not null && !solutionsFiles.ContainsKey(solutionName)) { AnsiConsole.MarkupLine($"[red]ERROR:[/] Solution [yellow]{solutionName}[/] not found."); Environment.Exit(1); } - + if (solutionsFiles.Count == 1) { solutionName = solutionsFiles.Keys.Single(); } - + if (solutionName is null) { var prompt = new SelectionPrompt() .Title("Please select an option") .AddChoices(solutionsFiles.Keys); - + solutionName = AnsiConsole.Prompt(prompt); } - + return new FileInfo(solutionsFiles[solutionName]); } }