commit 599ecd66a520932427e59048105eb8b2df76595a Author: Dualxyz Date: Sun Mar 15 11:22:01 2026 +0100 init diff --git a/.files/mccn-realm-export.json b/.files/mccn-realm-export.json new file mode 100644 index 0000000..e79dc15 --- /dev/null +++ b/.files/mccn-realm-export.json @@ -0,0 +1,81 @@ +{ + "realm": "mccn", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "accessTokenLifespan": 1800, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "offlineSessionIdleTimeout": 2592000, + "clients": [ + { + "clientId": "mccn-api", + "name": "Mccn API", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "change-this-secret", + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": false, + "publicClient": false, + "protocol": "openid-connect", + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false + }, + { + "clientId": "mccn-swagger", + "name": "Mccn Swagger UI", + "enabled": true, + "publicClient": true, + "protocol": "openid-connect", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "redirectUris": [ + "http://localhost:5000/*", + "http://localhost:5001/*" + ], + "webOrigins": [ + "http://localhost:5000", + "http://localhost:5001" + ] + } + ], + "roles": { + "realm": [ + { + "name": "user", + "description": "Regular user" + }, + { + "name": "admin", + "description": "Administrator" + } + ] + }, + "users": [ + { + "username": "service-account-mccn-api", + "enabled": true, + "serviceAccountClientId": "mccn-api", + "clientRoleMappings": { + "realm-management": [ + { "name": "manage-users" }, + { "name": "view-users" } + ] + } + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aec645f --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +obj/ +bin/ +.vs/ +*.user +.env +.containers/ +.claude/ +src/API/Mccn.Api/appsettings.Development.json +.idea/ \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..5e39428 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,8 @@ + + + net10.0 + enable + enable + false + + diff --git a/Mccn.slnx b/Mccn.slnx new file mode 100644 index 0000000..5547c63 --- /dev/null +++ b/Mccn.slnx @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7d4aada --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,51 @@ +services: + mccn.database: + image: postgres:17 + container_name: mccn.database + environment: + POSTGRES_DB: mccn + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - ./.containers/db:/var/lib/postgresql/data + ports: + - 5432:5432 + + mccn.identity: + image: quay.io/keycloak/keycloak:26.5.1 + container_name: mccn.identity + command: start-dev --import-realm + environment: + KC_HEALTH_ENABLED: true + KC_HOSTNAME: localhost + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + volumes: + - ./.files:/opt/keycloak/data/import + ports: + - 18080:8080 + - 9000:9000 + + mccn.seq: + image: datalust/seq:2025.2 + container_name: mccn.seq + environment: + ACCEPT_EULA: Y + SEQ_FIRSTRUN_NOAUTHENTICATION: true # local dev only + ports: + - 5341:5341 + - 9081:80 + + mccn.redis: + image: redis:latest + container_name: mccn.redis + ports: + - 6379:6379 + + mccn.jaeger: + image: jaegertracing/all-in-one:latest + container_name: mccn.jaeger + ports: + - 4317:4317 + - 4318:4318 + - 16686:16686 diff --git a/src/API/Mccn.Api/Extensions/MigrationExtensions.cs b/src/API/Mccn.Api/Extensions/MigrationExtensions.cs new file mode 100644 index 0000000..1898896 --- /dev/null +++ b/src/API/Mccn.Api/Extensions/MigrationExtensions.cs @@ -0,0 +1,26 @@ +using Mccn.Modules.Users.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace Mccn.Api.Extensions; + +internal static class MigrationExtensions +{ + internal static async Task ApplyMigrationsAsync(this WebApplication app) + { + using IServiceScope scope = app.Services.CreateScope(); + await MigrateAsync(scope.ServiceProvider); + } + + private static async Task MigrateAsync(IServiceProvider serviceProvider) where TContext : DbContext + { + TContext dbContext = serviceProvider.GetRequiredService(); + + // Use EnsureCreatedAsync when no migrations exist yet. + // Once you add migrations via `dotnet ef migrations add`, switch this to MigrateAsync(). + IEnumerable pendingMigrations = await dbContext.Database.GetPendingMigrationsAsync(); + if (pendingMigrations.Any()) + await dbContext.Database.MigrateAsync(); + else + await dbContext.Database.EnsureCreatedAsync(); + } +} \ No newline at end of file diff --git a/src/API/Mccn.Api/Mccn.Api.csproj b/src/API/Mccn.Api/Mccn.Api.csproj new file mode 100644 index 0000000..0f88e0f --- /dev/null +++ b/src/API/Mccn.Api/Mccn.Api.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + diff --git a/src/API/Mccn.Api/Mccn.Api.http b/src/API/Mccn.Api/Mccn.Api.http new file mode 100644 index 0000000..ff48036 --- /dev/null +++ b/src/API/Mccn.Api/Mccn.Api.http @@ -0,0 +1,70 @@ +### Variables +@keycloak = http://localhost:18080 +@realm = mccn +@client_id = mccn-swagger +@content_type = application/json + + +############################################################################### +# HEALTH +############################################################################### + +### Health check +GET {{baseAddress}}/health + + +############################################################################### +# AUTH — get a token from Keycloak +# Uses the mccn-swagger public client with direct access grants (Resource Owner +# Password Credentials). Replace the username/password with a registered user. +############################################################################### + +### Get access token +# @name login +POST {{keycloak}}/realms/{{realm}}/protocol/openid-connect/token +Content-Type: application/x-www-form-urlencoded + +grant_type = password & +client_id = {{client_id}} & +username = user@example.com & +password = Password1! + +### Store the token for use in subsequent requests +@token = {{login.response.body.access_token}} + + +############################################################################### +# USERS +############################################################################### + +### Register a new user (public) +# @name register +POST {{baseAddress}}/users/register +Content-Type: {{content_type}} + +{ + "email": "user@example.com", + "firstName": "Jane", + "lastName": "Doe", + "password": "Test1234!@#$anin149141" +} + +### Get user profile (requires auth) +# Replace the GUID below with the ID returned from the register response +GET {{baseAddress}}/users/{{register.response.body.value}} +Authorization: Bearer {{token}} + + +############################################################################### +# HELLO +############################################################################### + +### Say hello — no name (public) +GET {{baseAddress}}/hello + +### Say hello — with a name (public) +GET {{baseAddress}}/hello?name=World + +### Say hello to the authenticated user (requires auth) +GET {{baseAddress}}/hello/me +Authorization: Bearer {{token}} diff --git a/src/API/Mccn.Api/Program.cs b/src/API/Mccn.Api/Program.cs new file mode 100644 index 0000000..4f697ae --- /dev/null +++ b/src/API/Mccn.Api/Program.cs @@ -0,0 +1,61 @@ +using Mccn.Api.Extensions; +using Mccn.Common.Application; +using Mccn.Common.Infrastructure; +using Mccn.Common.Presentation.Endpoints; +using Mccn.Modules.Hello.Application.Hello.SayHello; +using Mccn.Modules.Hello.Infrastructure; +using Mccn.Modules.Hello.Presentation; +using Mccn.Modules.Users.Application.Users.Register; +using Mccn.Modules.Users.Infrastructure; +using Mccn.Modules.Users.Presentation; +using Serilog; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Host.UseSerilog((context, config) => { config.ReadFrom.Configuration(context.Configuration); }); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddInfrastructure(builder.Configuration); + +builder.Services.AddApplication( + typeof(RegisterUserCommand).Assembly, + typeof(SayHelloQuery).Assembly); + +// Register modules. +builder.Services.AddUsersModule(builder.Configuration); +builder.Services.AddHelloModule(builder.Configuration); + +// Register endpoints. +builder.Services.AddUsersPresentationServices(); +builder.Services.AddHelloPresentationServices(); + +WebApplication app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); + + // Auto-apply migrations in development. + await app.ApplyMigrationsAsync(); +} + +app.UseSerilogRequestLogging(); + +app.UseExceptionHandler(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapEndpoints(); + +app.MapGet("health", () => Results.Ok(new { Status = "Healthy" })) + .WithTags("Health") + .AllowAnonymous(); + +app.Run(); + +// Make Program accessible to integration test projects. +public partial class Program; \ No newline at end of file diff --git a/src/API/Mccn.Api/Properties/launchSettings.json b/src/API/Mccn.Api/Properties/launchSettings.json new file mode 100644 index 0000000..1f6b90f --- /dev/null +++ b/src/API/Mccn.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:8080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:8081;http://localhost:8080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/API/Mccn.Api/appsettings.json b/src/API/Mccn.Api/appsettings.json new file mode 100644 index 0000000..af5252d --- /dev/null +++ b/src/API/Mccn.Api/appsettings.json @@ -0,0 +1,55 @@ +{ + "Serilog": { + "Using": [ + "Serilog.Sinks.Console", + "Serilog.Sinks.Seq" + ], + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console" + }, + { + "Name": "Seq", + "Args": { + "serverUrl": "http://localhost:9081" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithThreadId" + ] + }, + "Authentication": { + "Audience": "account", + "ValidIssuers": [ + "http://mccn.identity:8080/realms/mccn", + "http://localhost:18080/realms/mccn" + ], + "MetadataAddress": "http://localhost:18080/realms/mccn/.well-known/openid-configuration", + "RequireHttpsMetadata": false + }, + "Observability": { + "OtlpEndpoint": "http://localhost:4317" + }, + "ConnectionStrings": { + "Database": "Host=localhost;Port=5432;Database=mccn;Username=postgres;Password=postgres", + "Cache": "localhost:6379" + }, + "Users": { + "Keycloak": { + "AdminUrl": "http://localhost:18080/admin/realms/mccn/", + "TokenUrl": "http://localhost:18080/realms/mccn/protocol/openid-connect/token", + "ClientId": "mccn-api", + "ClientSecret": "change-this-secret" + } + } +} diff --git a/src/API/Mccn.Api/http-client.env.json b/src/API/Mccn.Api/http-client.env.json new file mode 100644 index 0000000..4f40f46 --- /dev/null +++ b/src/API/Mccn.Api/http-client.env.json @@ -0,0 +1,5 @@ +{ + "dev": { + "baseAddress": "https://localhost:8081" + } +} diff --git a/src/Common/Mccn.Common.Application/Abstractions/ICommand.cs b/src/Common/Mccn.Common.Application/Abstractions/ICommand.cs new file mode 100644 index 0000000..c30d801 --- /dev/null +++ b/src/Common/Mccn.Common.Application/Abstractions/ICommand.cs @@ -0,0 +1,12 @@ +using Mccn.Common.Domain.Abstractions; +using MediatR; + +namespace Mccn.Common.Application.Abstractions; + +public interface ICommand : IRequest +{ +} + +public interface ICommand : IRequest> +{ +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Application/Abstractions/ICommandHandler.cs b/src/Common/Mccn.Common.Application/Abstractions/ICommandHandler.cs new file mode 100644 index 0000000..acf1135 --- /dev/null +++ b/src/Common/Mccn.Common.Application/Abstractions/ICommandHandler.cs @@ -0,0 +1,14 @@ +using Mccn.Common.Domain.Abstractions; +using MediatR; + +namespace Mccn.Common.Application.Abstractions; + +public interface ICommandHandler : IRequestHandler + where TCommand : ICommand +{ +} + +public interface ICommandHandler : IRequestHandler> + where TCommand : ICommand +{ +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Application/Abstractions/IQuery.cs b/src/Common/Mccn.Common.Application/Abstractions/IQuery.cs new file mode 100644 index 0000000..d965fb9 --- /dev/null +++ b/src/Common/Mccn.Common.Application/Abstractions/IQuery.cs @@ -0,0 +1,8 @@ +using Mccn.Common.Domain.Abstractions; +using MediatR; + +namespace Mccn.Common.Application.Abstractions; + +public interface IQuery : IRequest> +{ +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Application/Abstractions/IQueryHandler.cs b/src/Common/Mccn.Common.Application/Abstractions/IQueryHandler.cs new file mode 100644 index 0000000..c882294 --- /dev/null +++ b/src/Common/Mccn.Common.Application/Abstractions/IQueryHandler.cs @@ -0,0 +1,9 @@ +using Mccn.Common.Domain.Abstractions; +using MediatR; + +namespace Mccn.Common.Application.Abstractions; + +public interface IQueryHandler : IRequestHandler> + where TQuery : IQuery +{ +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Application/Behaviors/ValidationBehavior.cs b/src/Common/Mccn.Common.Application/Behaviors/ValidationBehavior.cs new file mode 100644 index 0000000..8cc957f --- /dev/null +++ b/src/Common/Mccn.Common.Application/Behaviors/ValidationBehavior.cs @@ -0,0 +1,41 @@ +using FluentValidation; +using FluentValidation.Results; +using Mccn.Common.Application.Exceptions; +using Mccn.Common.Domain.Abstractions; +using MediatR; +using ValidationException = Mccn.Common.Application.Exceptions.ValidationException; + +namespace Mccn.Common.Application.Behaviors; + +public sealed class ValidationBehavior : IPipelineBehavior + where TRequest : IBaseRequest + where TResponse : Result +{ + private readonly IEnumerable> _validators; + + public ValidationBehavior(IEnumerable> validators) + { + _validators = validators; + } + + public async Task Handle( + TRequest request, + RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + ValidationContext context = new(request); + + ValidationResult[] validationFailures = await Task.WhenAll( + _validators.Select(v => v.ValidateAsync(context, cancellationToken))); + + List errors = validationFailures + .Where(r => !r.IsValid) + .SelectMany(r => r.Errors) + .Select(f => new ValidationError(f.PropertyName, f.ErrorMessage)) + .ToList(); + + if (errors.Count != 0) throw new ValidationException(errors); + + return await next(); + } +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Application/DependencyInjection.cs b/src/Common/Mccn.Common.Application/DependencyInjection.cs new file mode 100644 index 0000000..03e4a34 --- /dev/null +++ b/src/Common/Mccn.Common.Application/DependencyInjection.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using FluentValidation; +using Mccn.Common.Application.Behaviors; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace Mccn.Common.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication( + this IServiceCollection services, + params Assembly[] moduleAssemblies) + { + services.AddMediatR(config => { config.RegisterServicesFromAssemblies(moduleAssemblies); }); + + services.AddValidatorsFromAssemblies(moduleAssemblies); + + services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); + + return services; + } +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Application/Exceptions/ValidationError.cs b/src/Common/Mccn.Common.Application/Exceptions/ValidationError.cs new file mode 100644 index 0000000..e8cd459 --- /dev/null +++ b/src/Common/Mccn.Common.Application/Exceptions/ValidationError.cs @@ -0,0 +1,3 @@ +namespace Mccn.Common.Application.Exceptions; + +public sealed record ValidationError(string PropertyName, string ErrorMessage); \ No newline at end of file diff --git a/src/Common/Mccn.Common.Application/Exceptions/ValidationException.cs b/src/Common/Mccn.Common.Application/Exceptions/ValidationException.cs new file mode 100644 index 0000000..5264f9f --- /dev/null +++ b/src/Common/Mccn.Common.Application/Exceptions/ValidationException.cs @@ -0,0 +1,12 @@ +namespace Mccn.Common.Application.Exceptions; + +public sealed class ValidationException : Exception +{ + public ValidationException(IEnumerable errors) + : base("One or more validation failures has occurred.") + { + Errors = errors; + } + + public IEnumerable Errors { get; } +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Application/Mccn.Common.Application.csproj b/src/Common/Mccn.Common.Application/Mccn.Common.Application.csproj new file mode 100644 index 0000000..090e12f --- /dev/null +++ b/src/Common/Mccn.Common.Application/Mccn.Common.Application.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/Common/Mccn.Common.Domain/Abstractions/Entity.cs b/src/Common/Mccn.Common.Domain/Abstractions/Entity.cs new file mode 100644 index 0000000..bc393eb --- /dev/null +++ b/src/Common/Mccn.Common.Domain/Abstractions/Entity.cs @@ -0,0 +1,15 @@ +namespace Mccn.Common.Domain.Abstractions; + +public abstract class Entity +{ + protected Entity(Guid id) + { + Id = id; + } + + protected Entity() + { + } + + public Guid Id { get; init; } +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Domain/Abstractions/Error.cs b/src/Common/Mccn.Common.Domain/Abstractions/Error.cs new file mode 100644 index 0000000..ac7c186 --- /dev/null +++ b/src/Common/Mccn.Common.Domain/Abstractions/Error.cs @@ -0,0 +1,35 @@ +namespace Mccn.Common.Domain.Abstractions; + +public sealed record Error(string Code, string Description, ErrorType Type) +{ + public static readonly Error None = new(string.Empty, string.Empty, ErrorType.Failure); + public static readonly Error NullValue = new("General.Null", "Null value was provided", ErrorType.Failure); + + public static Error NotFound(string code, string description) + { + return new Error(code, description, ErrorType.NotFound); + } + + public static Error Validation(string code, string description) + { + return new Error(code, description, ErrorType.Validation); + } + + public static Error Conflict(string code, string description) + { + return new Error(code, description, ErrorType.Conflict); + } + + public static Error Failure(string code, string description) + { + return new Error(code, description, ErrorType.Failure); + } +} + +public enum ErrorType +{ + Failure = 0, + Validation = 1, + NotFound = 2, + Conflict = 3 +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Domain/Abstractions/Result.cs b/src/Common/Mccn.Common.Domain/Abstractions/Result.cs new file mode 100644 index 0000000..1417b09 --- /dev/null +++ b/src/Common/Mccn.Common.Domain/Abstractions/Result.cs @@ -0,0 +1,58 @@ +namespace Mccn.Common.Domain.Abstractions; + +public class Result +{ + protected Result(bool isSuccess, Error error) + { + if (isSuccess && error != Error.None) + throw new InvalidOperationException(); + if (!isSuccess && error == Error.None) + throw new InvalidOperationException(); + + IsSuccess = isSuccess; + Error = error; + } + + public bool IsSuccess { get; } + public bool IsFailure => !IsSuccess; + public Error Error { get; } + + public static Result Success() + { + return new Result(true, Error.None); + } + + public static Result Failure(Error error) + { + return new Result(false, error); + } + + public static Result Success(TValue value) + { + return new Result(value, true, Error.None); + } + + public static Result Failure(Error error) + { + return new Result(default, false, error); + } +} + +public sealed class Result : Result +{ + private readonly TValue? _value; + + internal Result(TValue? value, bool isSuccess, Error error) : base(isSuccess, error) + { + _value = value; + } + + public TValue Value => IsSuccess + ? _value! + : throw new InvalidOperationException("The value of a failure result cannot be accessed."); + + public static implicit operator Result(TValue? value) + { + return value is not null ? Success(value) : Failure(Error.NullValue); + } +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Domain/Mccn.Common.Domain.csproj b/src/Common/Mccn.Common.Domain/Mccn.Common.Domain.csproj new file mode 100644 index 0000000..237d661 --- /dev/null +++ b/src/Common/Mccn.Common.Domain/Mccn.Common.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/Common/Mccn.Common.Infrastructure/Authentication/AuthenticationExtensions.cs b/src/Common/Mccn.Common.Infrastructure/Authentication/AuthenticationExtensions.cs new file mode 100644 index 0000000..1dca77a --- /dev/null +++ b/src/Common/Mccn.Common.Infrastructure/Authentication/AuthenticationExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Mccn.Common.Infrastructure.Authentication; + +public static class AuthenticationExtensions +{ + public static IServiceCollection AddJwtAuthentication( + this IServiceCollection services, + IConfiguration configuration) + { + services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); + + services.ConfigureOptions(); + + return services; + } +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Infrastructure/Authentication/JwtBearerConfigureOptions.cs b/src/Common/Mccn.Common.Infrastructure/Authentication/JwtBearerConfigureOptions.cs new file mode 100644 index 0000000..5b1fb22 --- /dev/null +++ b/src/Common/Mccn.Common.Infrastructure/Authentication/JwtBearerConfigureOptions.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace Mccn.Common.Infrastructure.Authentication; + +internal sealed class JwtBearerConfigureOptions : IConfigureNamedOptions +{ + private const string ConfigSection = "Authentication"; + private readonly IConfiguration _configuration; + + public JwtBearerConfigureOptions(IConfiguration configuration) + { + _configuration = configuration; + } + + public void Configure(JwtBearerOptions options) + { + _configuration.GetSection(ConfigSection).Bind(options); + } + + public void Configure(string? name, JwtBearerOptions options) + { + Configure(options); + } +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Infrastructure/Caching/CachingExtensions.cs b/src/Common/Mccn.Common.Infrastructure/Caching/CachingExtensions.cs new file mode 100644 index 0000000..84a1c90 --- /dev/null +++ b/src/Common/Mccn.Common.Infrastructure/Caching/CachingExtensions.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Mccn.Common.Infrastructure.Caching; + +public static class CachingExtensions +{ + public static IServiceCollection AddCaching( + this IServiceCollection services, + IConfiguration configuration) + { + string? redisConnectionString = configuration.GetConnectionString("Cache"); + + if (!string.IsNullOrEmpty(redisConnectionString)) + services.AddStackExchangeRedisCache(options => { options.Configuration = redisConnectionString; }); + else + services.AddDistributedMemoryCache(); + + return services; + } +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs b/src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..8144b54 --- /dev/null +++ b/src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs @@ -0,0 +1,31 @@ +using Mccn.Common.Infrastructure.Authentication; +using Mccn.Common.Infrastructure.Caching; +using Mccn.Common.Infrastructure.ExceptionHandlers; +using Mccn.Common.Infrastructure.Observability; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Mccn.Common.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddJwtAuthentication(configuration); + + services.AddAuthorization(); + + services.AddCaching(configuration); + + services.AddObservability("Mccn", configuration); + + services.AddExceptionHandler(); + services.AddProblemDetails(); + + services.AddHttpContextAccessor(); + + return services; + } +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Infrastructure/ExceptionHandlers/GlobalExceptionHandler.cs b/src/Common/Mccn.Common.Infrastructure/ExceptionHandlers/GlobalExceptionHandler.cs new file mode 100644 index 0000000..9a6a665 --- /dev/null +++ b/src/Common/Mccn.Common.Infrastructure/ExceptionHandlers/GlobalExceptionHandler.cs @@ -0,0 +1,67 @@ +using Mccn.Common.Application.Exceptions; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Mccn.Common.Infrastructure.ExceptionHandlers; + +internal sealed class GlobalExceptionHandler : IExceptionHandler +{ + private readonly IWebHostEnvironment _env; + private readonly ILogger _logger; + + public GlobalExceptionHandler(ILogger logger, IWebHostEnvironment env) + { + _logger = logger; + _env = env; + } + + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + _logger.LogError(exception, "Unhandled exception occurred"); + + if (exception is ValidationException validationException) + { + ValidationProblemDetails validationProblem = new() + { + Status = StatusCodes.Status400BadRequest, + Title = "Validation Error", + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1" + }; + + foreach (ValidationError error in validationException.Errors) + validationProblem.Errors[error.PropertyName] = [error.ErrorMessage]; + + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + await httpContext.Response.WriteAsJsonAsync(validationProblem, cancellationToken); + } + else + { + ProblemDetails problem = new() + { + Status = StatusCodes.Status500InternalServerError, + Title = "Internal Server Error", + Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1" + }; + + // Include exception details in development to make debugging easier. + if (_env.IsDevelopment()) + { + problem.Detail = exception.Message; + problem.Extensions["exceptionType"] = exception.GetType().Name; + problem.Extensions["stackTrace"] = exception.StackTrace; + } + + httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + await httpContext.Response.WriteAsJsonAsync(problem, cancellationToken); + } + + return true; + } +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj b/src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj new file mode 100644 index 0000000..d6ec4a2 --- /dev/null +++ b/src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/Common/Mccn.Common.Infrastructure/Observability/ObservabilityExtensions.cs b/src/Common/Mccn.Common.Infrastructure/Observability/ObservabilityExtensions.cs new file mode 100644 index 0000000..8225517 --- /dev/null +++ b/src/Common/Mccn.Common.Infrastructure/Observability/ObservabilityExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace Mccn.Common.Infrastructure.Observability; + +public static class ObservabilityExtensions +{ + public static IServiceCollection AddObservability( + this IServiceCollection services, + string serviceName, + IConfiguration configuration) + { + services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService(serviceName)) + .WithTracing(tracing => + { + tracing + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddOtlpExporter(options => + { + options.Endpoint = new Uri( + configuration["Observability:OtlpEndpoint"] ?? "http://localhost:4317"); + }); + }); + + return services; + } +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Presentation/Endpoints/EndpointExtensions.cs b/src/Common/Mccn.Common.Presentation/Endpoints/EndpointExtensions.cs new file mode 100644 index 0000000..0b040f7 --- /dev/null +++ b/src/Common/Mccn.Common.Presentation/Endpoints/EndpointExtensions.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Mccn.Common.Presentation.Endpoints; + +public static class EndpointExtensions +{ + public static IServiceCollection AddEndpoints( + this IServiceCollection services, + params Assembly[] assemblies) + { + IEnumerable endpointTypes = assemblies + .SelectMany(a => a.GetTypes()) + .Where(t => t is { IsAbstract: false, IsInterface: false } && + t.IsAssignableTo(typeof(IEndpoint))); + + foreach (Type endpointType in endpointTypes) services.AddTransient(typeof(IEndpoint), endpointType); + + return services; + } + + public static IApplicationBuilder MapEndpoints( + this WebApplication app, + RouteGroupBuilder? routeGroupBuilder = null) + { + IEnumerable endpoints = app.Services.GetRequiredService>(); + + IEndpointRouteBuilder builder = routeGroupBuilder is null ? app : routeGroupBuilder; + + foreach (IEndpoint endpoint in endpoints) endpoint.MapEndpoint(builder); + + return app; + } +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Presentation/Endpoints/IEndpoint.cs b/src/Common/Mccn.Common.Presentation/Endpoints/IEndpoint.cs new file mode 100644 index 0000000..f203d79 --- /dev/null +++ b/src/Common/Mccn.Common.Presentation/Endpoints/IEndpoint.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Routing; + +namespace Mccn.Common.Presentation.Endpoints; + +public interface IEndpoint +{ + void MapEndpoint(IEndpointRouteBuilder app); +} \ No newline at end of file diff --git a/src/Common/Mccn.Common.Presentation/Mccn.Common.Presentation.csproj b/src/Common/Mccn.Common.Presentation/Mccn.Common.Presentation.csproj new file mode 100644 index 0000000..154fb1a --- /dev/null +++ b/src/Common/Mccn.Common.Presentation/Mccn.Common.Presentation.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/AssemblyReference.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/AssemblyReference.cs new file mode 100644 index 0000000..c4062dd --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/AssemblyReference.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace Mccn.Modules.Hello.Application; + +public static class AssemblyReference +{ + public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly; +} \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/DependencyInjection.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/DependencyInjection.cs new file mode 100644 index 0000000..854021d --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/DependencyInjection.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Mccn.Modules.Hello.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddHelloApplication(this IServiceCollection services) + { + return services; + } +} \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/Hello/SayHello/HelloResponse.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/Hello/SayHello/HelloResponse.cs new file mode 100644 index 0000000..4252176 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/Hello/SayHello/HelloResponse.cs @@ -0,0 +1,3 @@ +namespace Mccn.Modules.Hello.Application.Hello.SayHello; + +public sealed record HelloResponse(string Message); \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/Hello/SayHello/SayHelloQuery.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/Hello/SayHello/SayHelloQuery.cs new file mode 100644 index 0000000..0d7b225 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/Hello/SayHello/SayHelloQuery.cs @@ -0,0 +1,5 @@ +using Mccn.Common.Application.Abstractions; + +namespace Mccn.Modules.Hello.Application.Hello.SayHello; + +public sealed record SayHelloQuery(string? Name) : IQuery; \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/Hello/SayHello/SayHelloQueryHandler.cs b/src/Modules/Hello/Mccn.Modules.Hello.Application/Hello/SayHello/SayHelloQueryHandler.cs new file mode 100644 index 0000000..a2320e7 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/Hello/SayHello/SayHelloQueryHandler.cs @@ -0,0 +1,16 @@ +using Mccn.Common.Application.Abstractions; +using Mccn.Common.Domain.Abstractions; + +namespace Mccn.Modules.Hello.Application.Hello.SayHello; + +internal sealed class SayHelloQueryHandler : IQueryHandler +{ + public Task> Handle(SayHelloQuery request, CancellationToken cancellationToken) + { + string greeting = string.IsNullOrWhiteSpace(request.Name) + ? "Hello, World!" + : $"Hello, {request.Name}!"; + + return Task.FromResult(Result.Success(new HelloResponse(greeting))); + } +} \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj b/src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj new file mode 100644 index 0000000..28d75a0 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj @@ -0,0 +1,13 @@ + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Abstractions/BaseTest.cs b/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Abstractions/BaseTest.cs new file mode 100644 index 0000000..4c8c1e9 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Abstractions/BaseTest.cs @@ -0,0 +1,17 @@ +using System.Reflection; +using Mccn.Modules.Hello.Application; +using DependencyInjection = Mccn.Modules.Hello.Infrastructure.DependencyInjection; + +namespace Mccn.Modules.Hello.ArchitectureTests.Abstractions; + +public abstract class BaseTest +{ + protected static readonly Assembly ApplicationAssembly = + AssemblyReference.Assembly; + + protected static readonly Assembly InfrastructureAssembly = + typeof(DependencyInjection).Assembly; + + protected static readonly Assembly PresentationAssembly = + Presentation.AssemblyReference.Assembly; +} \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Abstractions/TestResultExtensions.cs b/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Abstractions/TestResultExtensions.cs new file mode 100644 index 0000000..54fc42d --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Abstractions/TestResultExtensions.cs @@ -0,0 +1,12 @@ +using FluentAssertions; +using NetArchTest.Rules; + +namespace Mccn.Modules.Hello.ArchitectureTests.Abstractions; + +internal static class TestResultExtensions +{ + internal static void ShouldBeSuccessful(this TestResult testResult) + { + testResult.FailingTypes?.Should().BeEmpty(); + } +} \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Application/ApplicationTests.cs b/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Application/ApplicationTests.cs new file mode 100644 index 0000000..afcc04c --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Application/ApplicationTests.cs @@ -0,0 +1,68 @@ +using Mccn.Common.Application.Abstractions; +using Mccn.Modules.Hello.ArchitectureTests.Abstractions; +using NetArchTest.Rules; + +namespace Mccn.Modules.Hello.ArchitectureTests.Application; + +public class ApplicationTests : BaseTest +{ + [Fact] + public void Query_Should_BeSealed() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQuery<>)) + .Should() + .BeSealed() + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void Query_ShouldHave_NameEndingWith_Query() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQuery<>)) + .Should() + .HaveNameEndingWith("Query") + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void QueryHandler_Should_NotBePublic() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQueryHandler<,>)) + .Should() + .NotBePublic() + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void QueryHandler_Should_BeSealed() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQueryHandler<,>)) + .Should() + .BeSealed() + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void QueryHandler_ShouldHave_NameEndingWith_QueryHandler() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQueryHandler<,>)) + .Should() + .HaveNameEndingWith("QueryHandler") + .GetResult() + .ShouldBeSuccessful(); + } +} \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Layers/LayerTests.cs b/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Layers/LayerTests.cs new file mode 100644 index 0000000..114e702 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Layers/LayerTests.cs @@ -0,0 +1,37 @@ +using Mccn.Modules.Hello.ArchitectureTests.Abstractions; +using NetArchTest.Rules; + +namespace Mccn.Modules.Hello.ArchitectureTests.Layers; + +public class LayerTests : BaseTest +{ + [Fact] + public void ApplicationLayer_ShouldNotHaveDependencyOn_InfrastructureLayer() + { + Types.InAssembly(ApplicationAssembly) + .Should() + .NotHaveDependencyOn(InfrastructureAssembly.GetName().Name) + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void ApplicationLayer_ShouldNotHaveDependencyOn_PresentationLayer() + { + Types.InAssembly(ApplicationAssembly) + .Should() + .NotHaveDependencyOn(PresentationAssembly.GetName().Name) + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void PresentationLayer_ShouldNotHaveDependencyOn_InfrastructureLayer() + { + Types.InAssembly(PresentationAssembly) + .Should() + .NotHaveDependencyOn(InfrastructureAssembly.GetName().Name) + .GetResult() + .ShouldBeSuccessful(); + } +} \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Mccn.Modules.Hello.ArchitectureTests.csproj b/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Mccn.Modules.Hello.ArchitectureTests.csproj new file mode 100644 index 0000000..ffdf3f7 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Mccn.Modules.Hello.ArchitectureTests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/DependencyInjection.cs b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..60833ce --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/DependencyInjection.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Mccn.Modules.Hello.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddHelloModule( + this IServiceCollection services, + IConfiguration configuration) + { + // Hello module has no database/infrastructure dependencies. + // This is where you'd add them when the module grows. + return services; + } +} \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj new file mode 100644 index 0000000..8edd226 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj @@ -0,0 +1,14 @@ + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Abstractions/BaseIntegrationTest.cs b/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Abstractions/BaseIntegrationTest.cs new file mode 100644 index 0000000..655c8ae --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Abstractions/BaseIntegrationTest.cs @@ -0,0 +1,27 @@ +using Bogus; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +namespace Mccn.Modules.Hello.IntegrationTests.Abstractions; + +[Collection(nameof(IntegrationTestCollection))] +public abstract class BaseIntegrationTest : IDisposable +{ + protected static readonly Faker Faker = new(); + + private readonly IServiceScope _scope; + protected readonly HttpClient HttpClient; + protected readonly ISender Sender; + + protected BaseIntegrationTest(IntegrationTestWebAppFactory factory) + { + _scope = factory.Services.CreateScope(); + HttpClient = factory.CreateClient(); + Sender = _scope.ServiceProvider.GetRequiredService(); + } + + public void Dispose() + { + _scope.Dispose(); + } +} \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Abstractions/IntegrationTestCollection.cs b/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Abstractions/IntegrationTestCollection.cs new file mode 100644 index 0000000..1a98043 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Abstractions/IntegrationTestCollection.cs @@ -0,0 +1,4 @@ +namespace Mccn.Modules.Hello.IntegrationTests.Abstractions; + +[CollectionDefinition(nameof(IntegrationTestCollection))] +public sealed class IntegrationTestCollection : ICollectionFixture; \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Abstractions/IntegrationTestWebAppFactory.cs b/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Abstractions/IntegrationTestWebAppFactory.cs new file mode 100644 index 0000000..dfe3bc8 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Abstractions/IntegrationTestWebAppFactory.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Testcontainers.PostgreSql; +using Testcontainers.Redis; + +namespace Mccn.Modules.Hello.IntegrationTests.Abstractions; + +#pragma warning disable CS0618 +public class IntegrationTestWebAppFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder() + .WithImage("postgres:17") + .WithDatabase("mccn") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + private readonly RedisContainer _redisContainer = new RedisBuilder() + .WithImage("redis:latest") + .Build(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + Environment.SetEnvironmentVariable("ConnectionStrings:Database", _dbContainer.GetConnectionString()); + Environment.SetEnvironmentVariable("ConnectionStrings:Cache", _redisContainer.GetConnectionString()); + Environment.SetEnvironmentVariable("Authentication:RequireHttpsMetadata", "false"); + } + + public async Task InitializeAsync() + { + await _dbContainer.StartAsync(); + await _redisContainer.StartAsync(); + } + + public new async Task DisposeAsync() + { + await _dbContainer.StopAsync(); + await _redisContainer.StopAsync(); + } +} +#pragma warning restore CS0618 diff --git a/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Hello/HelloTests.cs b/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Hello/HelloTests.cs new file mode 100644 index 0000000..7f80c51 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Hello/HelloTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Mccn.Common.Domain.Abstractions; +using Mccn.Modules.Hello.Application.Hello.SayHello; +using Mccn.Modules.Hello.IntegrationTests.Abstractions; + +namespace Mccn.Modules.Hello.IntegrationTests.Hello; + +public class HelloTests : BaseIntegrationTest +{ + public HelloTests(IntegrationTestWebAppFactory factory) + : base(factory) + { + } + + [Fact] + public async Task GetHello_Should_ReturnOk_WithDefaultMessage() + { + // Act + HttpResponseMessage response = await HttpClient.GetAsync("hello"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + HelloResponse? body = await response.Content.ReadFromJsonAsync(); + body!.Message.Should().Be("Hello, World!"); + } + + [Fact] + public async Task GetHello_Should_ReturnOk_WithNamedMessage() + { + // Arrange + string name = Faker.Name.FirstName(); + + // Act + HttpResponseMessage response = await HttpClient.GetAsync($"hello?name={name}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + HelloResponse? body = await response.Content.ReadFromJsonAsync(); + body!.Message.Should().Be($"Hello, {name}!"); + } + + [Fact] + public async Task SayHello_Should_ReturnHelloWorld_WhenNoName() + { + // Act + Result result = await Sender.Send(new SayHelloQuery(null)); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Message.Should().Be("Hello, World!"); + } + + [Fact] + public async Task SayHello_Should_ReturnGreeting_WhenNameProvided() + { + // Arrange + string name = Faker.Name.FirstName(); + + // Act + Result result = await Sender.Send(new SayHelloQuery(name)); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Message.Should().Be($"Hello, {name}!"); + } + + [Fact] + public async Task GetHelloMe_Should_ReturnUnauthorized_WhenNotAuthenticated() + { + // Act + HttpResponseMessage response = await HttpClient.GetAsync("hello/me"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Mccn.Modules.Hello.IntegrationTests.csproj b/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Mccn.Modules.Hello.IntegrationTests.csproj new file mode 100644 index 0000000..aab0297 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Mccn.Modules.Hello.IntegrationTests.csproj @@ -0,0 +1,38 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/mccn-realm-export.json b/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/mccn-realm-export.json new file mode 100644 index 0000000..9f9ecc7 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/mccn-realm-export.json @@ -0,0 +1,85 @@ +{ + "realm": "mccn", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "accessTokenLifespan": 1800, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "offlineSessionIdleTimeout": 2592000, + "clients": [ + { + "clientId": "mccn-api", + "name": "Mccn API", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "change-this-secret", + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": false, + "publicClient": false, + "protocol": "openid-connect", + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false + }, + { + "clientId": "mccn-swagger", + "name": "Mccn Swagger UI", + "enabled": true, + "publicClient": true, + "protocol": "openid-connect", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "redirectUris": [ + "http://localhost:5000/*", + "http://localhost:5001/*" + ], + "webOrigins": [ + "http://localhost:5000", + "http://localhost:5001" + ] + } + ], + "roles": { + "realm": [ + { + "name": "user", + "description": "Regular user" + }, + { + "name": "admin", + "description": "Administrator" + } + ] + }, + "users": [ + { + "username": "service-account-mccn-api", + "enabled": true, + "serviceAccountClientId": "mccn-api", + "clientRoleMappings": { + "realm-management": [ + { + "name": "manage-users" + }, + { + "name": "view-users" + } + ] + } + } + ] +} diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Presentation/AssemblyReference.cs b/src/Modules/Hello/Mccn.Modules.Hello.Presentation/AssemblyReference.cs new file mode 100644 index 0000000..7d4d57f --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Presentation/AssemblyReference.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace Mccn.Modules.Hello.Presentation; + +public static class AssemblyReference +{ + public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly; +} \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Presentation/DependencyInjection.cs b/src/Modules/Hello/Mccn.Modules.Hello.Presentation/DependencyInjection.cs new file mode 100644 index 0000000..681d434 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Presentation/DependencyInjection.cs @@ -0,0 +1,13 @@ +using Mccn.Common.Presentation.Endpoints; +using Microsoft.Extensions.DependencyInjection; + +namespace Mccn.Modules.Hello.Presentation; + +public static class DependencyInjection +{ + public static IServiceCollection AddHelloPresentationServices(this IServiceCollection services) + { + services.AddEndpoints(typeof(DependencyInjection).Assembly); + return services; + } +} \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Presentation/Hello/GetHello.cs b/src/Modules/Hello/Mccn.Modules.Hello.Presentation/Hello/GetHello.cs new file mode 100644 index 0000000..37273c7 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Presentation/Hello/GetHello.cs @@ -0,0 +1,43 @@ +using Mccn.Common.Domain.Abstractions; +using Mccn.Common.Presentation.Endpoints; +using Mccn.Modules.Hello.Application.Hello.SayHello; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Mccn.Modules.Hello.Presentation.Hello; + +internal sealed class GetHello : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("hello", async (string? name, ISender sender) => + { + SayHelloQuery query = new(name); + Result result = await sender.Send(query); + + return Results.Ok(result.Value); + }) + .AllowAnonymous() + .WithTags("Hello") + .WithName("GetHello") + .WithSummary("Say hello"); + + app.MapGet("hello/me", async (HttpContext context, ISender sender) => + { + string name = context.User.FindFirst("given_name")?.Value + ?? context.User.FindFirst("name")?.Value + ?? "Authenticated User"; + + SayHelloQuery query = new(name); + Result result = await sender.Send(query); + + return Results.Ok(result.Value); + }) + .RequireAuthorization() + .WithTags("Hello") + .WithName("GetHelloAuthenticated") + .WithSummary("Say hello to the authenticated user"); + } +} \ No newline at end of file diff --git a/src/Modules/Hello/Mccn.Modules.Hello.Presentation/Mccn.Modules.Hello.Presentation.csproj b/src/Modules/Hello/Mccn.Modules.Hello.Presentation/Mccn.Modules.Hello.Presentation.csproj new file mode 100644 index 0000000..fe05006 --- /dev/null +++ b/src/Modules/Hello/Mccn.Modules.Hello.Presentation/Mccn.Modules.Hello.Presentation.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/Modules/Users/Mccn.Modules.Users.Application/Abstractions/IIdentityProviderService.cs b/src/Modules/Users/Mccn.Modules.Users.Application/Abstractions/IIdentityProviderService.cs new file mode 100644 index 0000000..131d2a8 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Application/Abstractions/IIdentityProviderService.cs @@ -0,0 +1,16 @@ +using Mccn.Common.Domain.Abstractions; + +namespace Mccn.Modules.Users.Application.Abstractions; + +public interface IIdentityProviderService +{ + Task> RegisterUserAsync( + UserModel user, + CancellationToken cancellationToken = default); +} + +public sealed record UserModel( + string Email, + string FirstName, + string LastName, + string Password); \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Application/Abstractions/IUnitOfWork.cs b/src/Modules/Users/Mccn.Modules.Users.Application/Abstractions/IUnitOfWork.cs new file mode 100644 index 0000000..fe1c98f --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Application/Abstractions/IUnitOfWork.cs @@ -0,0 +1,6 @@ +namespace Mccn.Modules.Users.Application.Abstractions; + +public interface IUnitOfWork +{ + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Application/AssemblyReference.cs b/src/Modules/Users/Mccn.Modules.Users.Application/AssemblyReference.cs new file mode 100644 index 0000000..12ee859 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Application/AssemblyReference.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace Mccn.Modules.Users.Application; + +public static class AssemblyReference +{ + public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly; +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj b/src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj new file mode 100644 index 0000000..6cefc33 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj @@ -0,0 +1,14 @@ + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/Modules/Users/Mccn.Modules.Users.Application/Users/GetProfile/GetUserProfileQuery.cs b/src/Modules/Users/Mccn.Modules.Users.Application/Users/GetProfile/GetUserProfileQuery.cs new file mode 100644 index 0000000..a3874dd --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Application/Users/GetProfile/GetUserProfileQuery.cs @@ -0,0 +1,5 @@ +using Mccn.Common.Application.Abstractions; + +namespace Mccn.Modules.Users.Application.Users.GetProfile; + +public sealed record GetUserProfileQuery(Guid UserId) : IQuery; \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Application/Users/GetProfile/GetUserProfileQueryHandler.cs b/src/Modules/Users/Mccn.Modules.Users.Application/Users/GetProfile/GetUserProfileQueryHandler.cs new file mode 100644 index 0000000..9b97edd --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Application/Users/GetProfile/GetUserProfileQueryHandler.cs @@ -0,0 +1,30 @@ +using Mccn.Common.Application.Abstractions; +using Mccn.Common.Domain.Abstractions; +using Mccn.Modules.Users.Domain.Users; + +namespace Mccn.Modules.Users.Application.Users.GetProfile; + +internal sealed class GetUserProfileQueryHandler : IQueryHandler +{ + private readonly IUserRepository _userRepository; + + public GetUserProfileQueryHandler(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task> Handle( + GetUserProfileQuery request, + CancellationToken cancellationToken) + { + User? user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken); + + if (user is null) return Result.Failure(UserErrors.NotFound(request.UserId)); + + return new UserProfileResponse( + user.Id, + user.Email, + user.FirstName, + user.LastName); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Application/Users/GetProfile/UserProfileResponse.cs b/src/Modules/Users/Mccn.Modules.Users.Application/Users/GetProfile/UserProfileResponse.cs new file mode 100644 index 0000000..a14fbe6 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Application/Users/GetProfile/UserProfileResponse.cs @@ -0,0 +1,7 @@ +namespace Mccn.Modules.Users.Application.Users.GetProfile; + +public sealed record UserProfileResponse( + Guid Id, + string Email, + string FirstName, + string LastName); \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/RegisterUserCommand.cs b/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/RegisterUserCommand.cs new file mode 100644 index 0000000..7359308 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/RegisterUserCommand.cs @@ -0,0 +1,9 @@ +using Mccn.Common.Application.Abstractions; + +namespace Mccn.Modules.Users.Application.Users.Register; + +public sealed record RegisterUserCommand( + string Email, + string FirstName, + string LastName, + string Password) : ICommand; \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/RegisterUserCommandHandler.cs b/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/RegisterUserCommandHandler.cs new file mode 100644 index 0000000..1a01823 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/RegisterUserCommandHandler.cs @@ -0,0 +1,46 @@ +using Mccn.Common.Application.Abstractions; +using Mccn.Common.Domain.Abstractions; +using Mccn.Modules.Users.Application.Abstractions; +using Mccn.Modules.Users.Domain.Users; + +namespace Mccn.Modules.Users.Application.Users.Register; + +internal sealed class RegisterUserCommandHandler( + IUserRepository userRepository, + IIdentityProviderService identityProviderService, + IUnitOfWork unitOfWork) + : ICommandHandler +{ + public async Task> Handle( + RegisterUserCommand request, + CancellationToken cancellationToken) + { + if (await userRepository.ExistsByEmailAsync(request.Email, cancellationToken)) + return Result.Failure(UserErrors.EmailAlreadyExists(request.Email)); + + UserModel userModel = new( + request.Email, + request.FirstName, + request.LastName, + request.Password); + + Result identityResult = await identityProviderService.RegisterUserAsync( + userModel, + cancellationToken); + + if (identityResult.IsFailure) return Result.Failure(identityResult.Error); + + User user = User.Create( + Guid.NewGuid(), + request.Email, + request.FirstName, + request.LastName, + identityResult.Value); + + userRepository.Add(user); + + await unitOfWork.SaveChangesAsync(cancellationToken); + + return user.Id; + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/RegisterUserCommandValidator.cs b/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/RegisterUserCommandValidator.cs new file mode 100644 index 0000000..67412aa --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Application/Users/Register/RegisterUserCommandValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace Mccn.Modules.Users.Application.Users.Register; + +internal sealed class RegisterUserCommandValidator : AbstractValidator +{ + public RegisterUserCommandValidator() + { + RuleFor(c => c.Email).NotEmpty().EmailAddress(); + RuleFor(c => c.FirstName).NotEmpty().MaximumLength(100); + RuleFor(c => c.LastName).NotEmpty().MaximumLength(100); + RuleFor(c => c.Password).NotEmpty().MinimumLength(8); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Abstractions/BaseTest.cs b/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Abstractions/BaseTest.cs new file mode 100644 index 0000000..dd57bf9 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Abstractions/BaseTest.cs @@ -0,0 +1,21 @@ +using System.Reflection; +using Mccn.Modules.Users.Application; +using Mccn.Modules.Users.Domain.Users; +using Mccn.Modules.Users.Infrastructure; + +namespace Mccn.Modules.Users.ArchitectureTests.Abstractions; + +public abstract class BaseTest +{ + protected static readonly Assembly ApplicationAssembly = + AssemblyReference.Assembly; + + protected static readonly Assembly DomainAssembly = + typeof(User).Assembly; + + protected static readonly Assembly InfrastructureAssembly = + typeof(DependencyInjection).Assembly; + + protected static readonly Assembly PresentationAssembly = + Presentation.AssemblyReference.Assembly; +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Abstractions/TestResultExtensions.cs b/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Abstractions/TestResultExtensions.cs new file mode 100644 index 0000000..410f63b --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Abstractions/TestResultExtensions.cs @@ -0,0 +1,12 @@ +using FluentAssertions; +using NetArchTest.Rules; + +namespace Mccn.Modules.Users.ArchitectureTests.Abstractions; + +internal static class TestResultExtensions +{ + internal static void ShouldBeSuccessful(this TestResult testResult) + { + testResult.FailingTypes?.Should().BeEmpty(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Application/ApplicationTests.cs b/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Application/ApplicationTests.cs new file mode 100644 index 0000000..787baa3 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Application/ApplicationTests.cs @@ -0,0 +1,175 @@ +using FluentValidation; +using Mccn.Common.Application.Abstractions; +using Mccn.Modules.Users.ArchitectureTests.Abstractions; +using NetArchTest.Rules; + +namespace Mccn.Modules.Users.ArchitectureTests.Application; + +public class ApplicationTests : BaseTest +{ + [Fact] + public void Command_Should_BeSealed() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(ICommand)) + .Or() + .ImplementInterface(typeof(ICommand<>)) + .Should() + .BeSealed() + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void Command_ShouldHave_NameEndingWith_Command() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(ICommand)) + .Or() + .ImplementInterface(typeof(ICommand<>)) + .Should() + .HaveNameEndingWith("Command") + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void CommandHandler_Should_NotBePublic() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(ICommandHandler<>)) + .Or() + .ImplementInterface(typeof(ICommandHandler<,>)) + .Should() + .NotBePublic() + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void CommandHandler_Should_BeSealed() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(ICommandHandler<>)) + .Or() + .ImplementInterface(typeof(ICommandHandler<,>)) + .Should() + .BeSealed() + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void CommandHandler_ShouldHave_NameEndingWith_CommandHandler() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(ICommandHandler<>)) + .Or() + .ImplementInterface(typeof(ICommandHandler<,>)) + .Should() + .HaveNameEndingWith("CommandHandler") + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void Query_Should_BeSealed() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQuery<>)) + .Should() + .BeSealed() + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void Query_ShouldHave_NameEndingWith_Query() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQuery<>)) + .Should() + .HaveNameEndingWith("Query") + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void QueryHandler_Should_NotBePublic() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQueryHandler<,>)) + .Should() + .NotBePublic() + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void QueryHandler_Should_BeSealed() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQueryHandler<,>)) + .Should() + .BeSealed() + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void QueryHandler_ShouldHave_NameEndingWith_QueryHandler() + { + Types.InAssembly(ApplicationAssembly) + .That() + .ImplementInterface(typeof(IQueryHandler<,>)) + .Should() + .HaveNameEndingWith("QueryHandler") + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void Validator_Should_NotBePublic() + { + Types.InAssembly(ApplicationAssembly) + .That() + .Inherit(typeof(AbstractValidator<>)) + .Should() + .NotBePublic() + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void Validator_Should_BeSealed() + { + Types.InAssembly(ApplicationAssembly) + .That() + .Inherit(typeof(AbstractValidator<>)) + .Should() + .BeSealed() + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void Validator_ShouldHave_NameEndingWith_Validator() + { + Types.InAssembly(ApplicationAssembly) + .That() + .Inherit(typeof(AbstractValidator<>)) + .Should() + .HaveNameEndingWith("Validator") + .GetResult() + .ShouldBeSuccessful(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Domain/DomainTests.cs b/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Domain/DomainTests.cs new file mode 100644 index 0000000..89cbce7 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Domain/DomainTests.cs @@ -0,0 +1,50 @@ +using System.Reflection; +using FluentAssertions; +using Mccn.Common.Domain.Abstractions; +using Mccn.Modules.Users.ArchitectureTests.Abstractions; +using NetArchTest.Rules; + +namespace Mccn.Modules.Users.ArchitectureTests.Domain; + +public class DomainTests : BaseTest +{ + [Fact] + public void Entities_ShouldHave_PrivateParameterlessConstructor() + { + IEnumerable entityTypes = Types.InAssembly(DomainAssembly) + .That() + .Inherit(typeof(Entity)) + .GetTypes(); + + List failingTypes = new(); + foreach (Type entityType in entityTypes) + { + ConstructorInfo[] constructors = entityType.GetConstructors( + BindingFlags.NonPublic | BindingFlags.Instance); + + if (!constructors.Any(c => c.IsPrivate && c.GetParameters().Length == 0)) failingTypes.Add(entityType); + } + + failingTypes.Should().BeEmpty(); + } + + [Fact] + public void Entities_ShouldOnlyHave_PrivateConstructors() + { + IEnumerable entityTypes = Types.InAssembly(DomainAssembly) + .That() + .Inherit(typeof(Entity)) + .GetTypes(); + + List failingTypes = new(); + foreach (Type entityType in entityTypes) + { + ConstructorInfo[] constructors = entityType.GetConstructors( + BindingFlags.Public | BindingFlags.Instance); + + if (constructors.Any()) failingTypes.Add(entityType); + } + + failingTypes.Should().BeEmpty(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Layers/LayerTests.cs b/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Layers/LayerTests.cs new file mode 100644 index 0000000..29bc244 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Layers/LayerTests.cs @@ -0,0 +1,57 @@ +using Mccn.Modules.Users.ArchitectureTests.Abstractions; +using NetArchTest.Rules; + +namespace Mccn.Modules.Users.ArchitectureTests.Layers; + +public class LayerTests : BaseTest +{ + [Fact] + public void DomainLayer_ShouldNotHaveDependencyOn_ApplicationLayer() + { + Types.InAssembly(DomainAssembly) + .Should() + .NotHaveDependencyOn(ApplicationAssembly.GetName().Name) + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void DomainLayer_ShouldNotHaveDependencyOn_InfrastructureLayer() + { + Types.InAssembly(DomainAssembly) + .Should() + .NotHaveDependencyOn(InfrastructureAssembly.GetName().Name) + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void ApplicationLayer_ShouldNotHaveDependencyOn_InfrastructureLayer() + { + Types.InAssembly(ApplicationAssembly) + .Should() + .NotHaveDependencyOn(InfrastructureAssembly.GetName().Name) + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void ApplicationLayer_ShouldNotHaveDependencyOn_PresentationLayer() + { + Types.InAssembly(ApplicationAssembly) + .Should() + .NotHaveDependencyOn(PresentationAssembly.GetName().Name) + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void PresentationLayer_ShouldNotHaveDependencyOn_InfrastructureLayer() + { + Types.InAssembly(PresentationAssembly) + .Should() + .NotHaveDependencyOn(InfrastructureAssembly.GetName().Name) + .GetResult() + .ShouldBeSuccessful(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Mccn.Modules.Users.ArchitectureTests.csproj b/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Mccn.Modules.Users.ArchitectureTests.csproj new file mode 100644 index 0000000..5de6324 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Mccn.Modules.Users.ArchitectureTests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Domain/Mccn.Modules.Users.Domain.csproj b/src/Modules/Users/Mccn.Modules.Users.Domain/Mccn.Modules.Users.Domain.csproj new file mode 100644 index 0000000..24d0213 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Domain/Mccn.Modules.Users.Domain.csproj @@ -0,0 +1,13 @@ + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/Modules/Users/Mccn.Modules.Users.Domain/Users/IUserRepository.cs b/src/Modules/Users/Mccn.Modules.Users.Domain/Users/IUserRepository.cs new file mode 100644 index 0000000..7a203ff --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Domain/Users/IUserRepository.cs @@ -0,0 +1,9 @@ +namespace Mccn.Modules.Users.Domain.Users; + +public interface IUserRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task GetByEmailAsync(string email, CancellationToken cancellationToken = default); + Task ExistsByEmailAsync(string email, CancellationToken cancellationToken = default); + void Add(User user); +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs b/src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs new file mode 100644 index 0000000..2210280 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs @@ -0,0 +1,32 @@ +using Mccn.Common.Domain.Abstractions; + +namespace Mccn.Modules.Users.Domain.Users; + +public sealed class User : Entity +{ + private User() + { + } + + public string Email { get; private set; } = string.Empty; + public string FirstName { get; private set; } = string.Empty; + public string LastName { get; private set; } = string.Empty; + public string IdentityId { get; private set; } = string.Empty; + + public static User Create( + Guid id, + string email, + string firstName, + string lastName, + string identityId) + { + return new User + { + Id = id, + Email = email, + FirstName = firstName, + LastName = lastName, + IdentityId = identityId + }; + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Domain/Users/UserErrors.cs b/src/Modules/Users/Mccn.Modules.Users.Domain/Users/UserErrors.cs new file mode 100644 index 0000000..1947475 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Domain/Users/UserErrors.cs @@ -0,0 +1,16 @@ +using Mccn.Common.Domain.Abstractions; + +namespace Mccn.Modules.Users.Domain.Users; + +public static class UserErrors +{ + public static Error NotFound(Guid userId) + { + return Error.NotFound("Users.NotFound", $"The user with the ID '{userId}' was not found."); + } + + public static Error EmailAlreadyExists(string email) + { + return Error.Conflict("Users.EmailAlreadyExists", $"A user with email '{email}' already exists."); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315075515_InitialCreate.Designer.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315075515_InitialCreate.Designer.cs new file mode 100644 index 0000000..7358546 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315075515_InitialCreate.Designer.cs @@ -0,0 +1,76 @@ +// +using System; +using Mccn.Modules.Users.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Mccn.Modules.Users.Infrastructure.Database.Migrations +{ + [DbContext(typeof(UsersDbContext))] + [Migration("20260315075515_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("users") + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Mccn.Modules.Users.Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("email"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("IdentityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("identity_id"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.HasIndex("IdentityId") + .IsUnique() + .HasDatabaseName("ix_users_identity_id"); + + b.ToTable("users", "users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315075515_InitialCreate.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315075515_InitialCreate.cs new file mode 100644 index 0000000..5c47fd1 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/20260315075515_InitialCreate.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Mccn.Modules.Users.Infrastructure.Database.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "users"); + + migrationBuilder.CreateTable( + name: "users", + schema: "users", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + email = table.Column(type: "character varying(300)", maxLength: 300, nullable: false), + first_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + last_name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + identity_id = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_users", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_users_email", + schema: "users", + table: "users", + column: "email", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_users_identity_id", + schema: "users", + table: "users", + column: "identity_id", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "users", + schema: "users"); + } + } +} diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs new file mode 100644 index 0000000..22ac259 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Migrations/UsersDbContextModelSnapshot.cs @@ -0,0 +1,73 @@ +// +using System; +using Mccn.Modules.Users.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Mccn.Modules.Users.Infrastructure.Database.Migrations +{ + [DbContext(typeof(UsersDbContext))] + partial class UsersDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("users") + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Mccn.Modules.Users.Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("email"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name"); + + b.Property("IdentityId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("identity_id"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.HasIndex("IdentityId") + .IsUnique() + .HasDatabaseName("ix_users_identity_id"); + + b.ToTable("users", "users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Schemas.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Schemas.cs new file mode 100644 index 0000000..ed32cc2 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/Schemas.cs @@ -0,0 +1,6 @@ +namespace Mccn.Modules.Users.Infrastructure.Database; + +internal static class Schemas +{ + internal const string Users = "users"; +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/UsersDbContext.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/UsersDbContext.cs new file mode 100644 index 0000000..a298e89 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Database/UsersDbContext.cs @@ -0,0 +1,20 @@ +using Mccn.Modules.Users.Domain.Users; +using Microsoft.EntityFrameworkCore; + +namespace Mccn.Modules.Users.Infrastructure.Database; + +public sealed class UsersDbContext : DbContext +{ + public UsersDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Users { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(Schemas.Users); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(UsersDbContext).Assembly); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/DependencyInjection.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..ab44d8d --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/DependencyInjection.cs @@ -0,0 +1,45 @@ +using Mccn.Modules.Users.Application.Abstractions; +using Mccn.Modules.Users.Domain.Users; +using Mccn.Modules.Users.Infrastructure.Database; +using Mccn.Modules.Users.Infrastructure.Keycloak; +using Mccn.Modules.Users.Infrastructure.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Mccn.Modules.Users.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddUsersModule( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddDbContext(options => + { + options.UseNpgsql( + configuration.GetConnectionString("Database"), + o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Users)) + .UseSnakeCaseNamingConvention(); + }); + + services.Configure( + configuration.GetSection(KeycloakOptions.SectionName)); + + services.AddTransient(); + + services.AddHttpClient((sp, client) => + { + KeycloakOptions keycloakOptions = configuration + .GetSection(KeycloakOptions.SectionName) + .Get()!; + client.BaseAddress = new Uri(keycloakOptions.AdminUrl); + }) + .AddHttpMessageHandler(); + + services.AddScoped(); + services.AddScoped(); + + return services; + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Keycloak/KeycloakAuthDelegatingHandler.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Keycloak/KeycloakAuthDelegatingHandler.cs new file mode 100644 index 0000000..d17d796 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Keycloak/KeycloakAuthDelegatingHandler.cs @@ -0,0 +1,45 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace Mccn.Modules.Users.Infrastructure.Keycloak; + +internal sealed class KeycloakAuthDelegatingHandler : DelegatingHandler +{ + private readonly KeycloakOptions _options; + + public KeycloakAuthDelegatingHandler(IOptions options) + { + _options = options.Value; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + string token = await GetAccessTokenAsync(cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + return await base.SendAsync(request, cancellationToken); + } + + private async Task GetAccessTokenAsync(CancellationToken cancellationToken) + { + using HttpClient client = new(); + + FormUrlEncodedContent content = new(new Dictionary + { + ["grant_type"] = "client_credentials", + ["client_id"] = _options.ClientId, + ["client_secret"] = _options.ClientSecret + }); + + HttpResponseMessage response = await client.PostAsync(_options.TokenUrl, content, cancellationToken); + response.EnsureSuccessStatusCode(); + + using JsonDocument doc = await JsonDocument.ParseAsync( + await response.Content.ReadAsStreamAsync(cancellationToken), + cancellationToken: cancellationToken); + + return doc.RootElement.GetProperty("access_token").GetString()!; + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Keycloak/KeycloakClient.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Keycloak/KeycloakClient.cs new file mode 100644 index 0000000..2f0052f --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Keycloak/KeycloakClient.cs @@ -0,0 +1,60 @@ +using System.Net.Http.Json; +using Mccn.Common.Domain.Abstractions; +using Mccn.Modules.Users.Application.Abstractions; + +namespace Mccn.Modules.Users.Infrastructure.Keycloak; + +internal sealed class KeycloakClient : IIdentityProviderService +{ + private readonly HttpClient _httpClient; + + public KeycloakClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task> RegisterUserAsync( + UserModel user, + CancellationToken cancellationToken = default) + { + var userRepresentation = new + { + username = user.Email, + email = user.Email, + firstName = user.FirstName, + lastName = user.LastName, + enabled = true, + credentials = new[] + { + new + { + type = "password", + value = user.Password, + temporary = false + } + } + }; + + HttpResponseMessage response = await _httpClient.PostAsJsonAsync( + "users", + userRepresentation, + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + string body = await response.Content.ReadAsStringAsync(cancellationToken); + return Result.Failure( + Error.Failure("Keycloak.Register", + $"Keycloak returned {(int)response.StatusCode} {response.ReasonPhrase}. Body: {body}")); + } + + // Extract user ID from Location header. + string? locationHeader = response.Headers.Location?.ToString(); + if (string.IsNullOrEmpty(locationHeader)) + return Result.Failure( + Error.Failure("Keycloak.Register", "Could not determine new user identity ID.")); + + string identityId = locationHeader.Split('/').Last(); + return identityId; + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Keycloak/KeycloakOptions.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Keycloak/KeycloakOptions.cs new file mode 100644 index 0000000..5a36f0f --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Keycloak/KeycloakOptions.cs @@ -0,0 +1,12 @@ +namespace Mccn.Modules.Users.Infrastructure.Keycloak; + +public sealed class KeycloakOptions +{ + public const string SectionName = "Users:Keycloak"; + + public string AdminUrl { get; init; } = string.Empty; + public string TokenUrl { get; init; } = string.Empty; + public string ClientId { get; init; } = string.Empty; + public string ClientSecret { get; init; } = string.Empty; + public string PublicClientId { get; init; } = "mccn-swagger"; +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Mccn.Modules.Users.Infrastructure.csproj b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Mccn.Modules.Users.Infrastructure.csproj new file mode 100644 index 0000000..d72ae09 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Mccn.Modules.Users.Infrastructure.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Users/UnitOfWork.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Users/UnitOfWork.cs new file mode 100644 index 0000000..85d8bb2 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Users/UnitOfWork.cs @@ -0,0 +1,19 @@ +using Mccn.Modules.Users.Application.Abstractions; +using Mccn.Modules.Users.Infrastructure.Database; + +namespace Mccn.Modules.Users.Infrastructure.Users; + +internal sealed class UnitOfWork : IUnitOfWork +{ + private readonly UsersDbContext _context; + + public UnitOfWork(UsersDbContext context) + { + _context = context; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + return _context.SaveChangesAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Users/UserConfiguration.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Users/UserConfiguration.cs new file mode 100644 index 0000000..b33d5bd --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Users/UserConfiguration.cs @@ -0,0 +1,33 @@ +using Mccn.Modules.Users.Domain.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Mccn.Modules.Users.Infrastructure.Users; + +internal sealed class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(u => u.Id); + + builder.Property(u => u.Email) + .IsRequired() + .HasMaxLength(300); + + builder.HasIndex(u => u.Email).IsUnique(); + + builder.Property(u => u.FirstName) + .IsRequired() + .HasMaxLength(100); + + builder.Property(u => u.LastName) + .IsRequired() + .HasMaxLength(100); + + builder.Property(u => u.IdentityId) + .IsRequired() + .HasMaxLength(100); + + builder.HasIndex(u => u.IdentityId).IsUnique(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Users/UserRepository.cs b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Users/UserRepository.cs new file mode 100644 index 0000000..c9d1f62 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Infrastructure/Users/UserRepository.cs @@ -0,0 +1,38 @@ +using Mccn.Modules.Users.Domain.Users; +using Mccn.Modules.Users.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; + +namespace Mccn.Modules.Users.Infrastructure.Users; + +internal sealed class UserRepository : IUserRepository +{ + private readonly UsersDbContext _context; + + public UserRepository(UsersDbContext context) + { + _context = context; + } + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + { + return await _context.Users + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + } + + public async Task GetByEmailAsync(string email, CancellationToken cancellationToken = default) + { + return await _context.Users + .FirstOrDefaultAsync(u => u.Email == email, cancellationToken); + } + + public async Task ExistsByEmailAsync(string email, CancellationToken cancellationToken = default) + { + return await _context.Users + .AnyAsync(u => u.Email == email, cancellationToken); + } + + public void Add(User user) + { + _context.Users.Add(user); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Abstractions/BaseIntegrationTest.cs b/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Abstractions/BaseIntegrationTest.cs new file mode 100644 index 0000000..96fee97 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Abstractions/BaseIntegrationTest.cs @@ -0,0 +1,72 @@ +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Bogus; +using Mccn.Modules.Users.Infrastructure.Database; +using Mccn.Modules.Users.Infrastructure.Keycloak; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Mccn.Modules.Users.IntegrationTests.Abstractions; + +[Collection(nameof(IntegrationTestCollection))] +public abstract class BaseIntegrationTest : IDisposable +{ + protected static readonly Faker Faker = new(); + private readonly KeycloakOptions _keycloakOptions; + + private readonly IServiceScope _scope; + protected readonly UsersDbContext DbContext; + protected readonly HttpClient HttpClient; + protected readonly ISender Sender; + + protected BaseIntegrationTest(IntegrationTestWebAppFactory factory) + { + _scope = factory.Services.CreateScope(); + HttpClient = factory.CreateClient(); + Sender = _scope.ServiceProvider.GetRequiredService(); + DbContext = _scope.ServiceProvider.GetRequiredService(); + _keycloakOptions = _scope.ServiceProvider + .GetRequiredService>().Value; + } + + public void Dispose() + { + _scope.Dispose(); + } + + protected async Task CleanDatabaseAsync() + { + await DbContext.Database.ExecuteSqlRawAsync("DELETE FROM users.users;"); + } + + protected async Task GetAccessTokenAsync(string email, string password) + { + using HttpClient client = new(); + + KeyValuePair[] parameters = new KeyValuePair[] + { + new("client_id", _keycloakOptions.PublicClientId), + new("grant_type", "password"), + new("scope", "openid"), + new("username", email), + new("password", password) + }; + + using FormUrlEncodedContent content = new(parameters); + using HttpRequestMessage request = new(HttpMethod.Post, new Uri(_keycloakOptions.TokenUrl)); + request.Content = content; + + using HttpResponseMessage response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + + AuthToken? authToken = await response.Content.ReadFromJsonAsync(); + return authToken!.AccessToken; + } + + internal sealed class AuthToken + { + [JsonPropertyName("access_token")] public string AccessToken { get; init; } = string.Empty; + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Abstractions/IntegrationTestCollection.cs b/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Abstractions/IntegrationTestCollection.cs new file mode 100644 index 0000000..f540875 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Abstractions/IntegrationTestCollection.cs @@ -0,0 +1,4 @@ +namespace Mccn.Modules.Users.IntegrationTests.Abstractions; + +[CollectionDefinition(nameof(IntegrationTestCollection))] +public sealed class IntegrationTestCollection : ICollectionFixture; \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Abstractions/IntegrationTestWebAppFactory.cs b/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Abstractions/IntegrationTestWebAppFactory.cs new file mode 100644 index 0000000..e40b353 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Abstractions/IntegrationTestWebAppFactory.cs @@ -0,0 +1,62 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Testcontainers.Keycloak; +using Testcontainers.PostgreSql; +using Testcontainers.Redis; + +namespace Mccn.Modules.Users.IntegrationTests.Abstractions; + +#pragma warning disable CS0618 +public class IntegrationTestWebAppFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder() + .WithImage("postgres:17") + .WithDatabase("mccn") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + private readonly RedisContainer _redisContainer = new RedisBuilder() + .WithImage("redis:latest") + .Build(); + + private readonly KeycloakContainer _keycloakContainer = new KeycloakBuilder() + .WithImage("quay.io/keycloak/keycloak:26.5.1") + .WithResourceMapping( + new FileInfo("mccn-realm-export.json"), + new FileInfo("/opt/keycloak/data/import/realm.json")) + .WithCommand("--import-realm") + .Build(); + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + string keycloakAddress = _keycloakContainer.GetBaseAddress(); + string keycloakRealmUrl = $"{keycloakAddress}realms/mccn"; + + Environment.SetEnvironmentVariable("ConnectionStrings:Database", _dbContainer.GetConnectionString()); + Environment.SetEnvironmentVariable("ConnectionStrings:Cache", _redisContainer.GetConnectionString()); + Environment.SetEnvironmentVariable("Authentication:MetadataAddress", + $"{keycloakRealmUrl}/.well-known/openid-configuration"); + Environment.SetEnvironmentVariable("Users:Keycloak:AdminUrl", $"{keycloakAddress}admin/realms/mccn/"); + Environment.SetEnvironmentVariable("Users:Keycloak:TokenUrl", + $"{keycloakRealmUrl}/protocol/openid-connect/token"); + Environment.SetEnvironmentVariable("Users:Keycloak:ClientId", "mccn-api"); + Environment.SetEnvironmentVariable("Users:Keycloak:ClientSecret", "change-this-secret"); + Environment.SetEnvironmentVariable("Users:Keycloak:PublicClientId", "mccn-swagger"); + } + + public async Task InitializeAsync() + { + await _dbContainer.StartAsync(); + await _redisContainer.StartAsync(); + await _keycloakContainer.StartAsync(); + } + + public new async Task DisposeAsync() + { + await _dbContainer.StopAsync(); + await _redisContainer.StopAsync(); + await _keycloakContainer.StopAsync(); + } +} +#pragma warning restore CS0618 diff --git a/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Mccn.Modules.Users.IntegrationTests.csproj b/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Mccn.Modules.Users.IntegrationTests.csproj new file mode 100644 index 0000000..aab0297 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Mccn.Modules.Users.IntegrationTests.csproj @@ -0,0 +1,38 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Users/GetUserProfileTests.cs b/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Users/GetUserProfileTests.cs new file mode 100644 index 0000000..da2900f --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Users/GetUserProfileTests.cs @@ -0,0 +1,47 @@ +using FluentAssertions; +using Mccn.Common.Domain.Abstractions; +using Mccn.Modules.Users.Application.Users.GetProfile; +using Mccn.Modules.Users.Application.Users.Register; +using Mccn.Modules.Users.Domain.Users; +using Mccn.Modules.Users.IntegrationTests.Abstractions; + +namespace Mccn.Modules.Users.IntegrationTests.Users; + +public class GetUserProfileTests(IntegrationTestWebAppFactory factory) : BaseIntegrationTest(factory) +{ + [Fact] + public async Task Should_ReturnError_WhenUserDoesNotExist() + { + // Arrange + Guid userId = Guid.NewGuid(); + + // Act + Result result = await Sender.Send(new GetUserProfileQuery(userId)); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(UserErrors.NotFound(userId)); + } + + [Fact] + public async Task Should_ReturnProfile_WhenUserExists() + { + // Arrange + RegisterUserCommand command = new( + Faker.Internet.Email(), + Faker.Name.FirstName(), + Faker.Name.LastName(), + "ValidPass1!"); + + Result registerResult = await Sender.Send(command); + Guid userId = registerResult.Value; + + // Act + Result result = await Sender.Send(new GetUserProfileQuery(userId)); + + // Assert + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value.Id.Should().Be(userId); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Users/RegisterUserTests.cs b/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Users/RegisterUserTests.cs new file mode 100644 index 0000000..d8385c6 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Users/RegisterUserTests.cs @@ -0,0 +1,81 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Mccn.Modules.Users.IntegrationTests.Abstractions; + +namespace Mccn.Modules.Users.IntegrationTests.Users; + +public class RegisterUserTests : BaseIntegrationTest +{ + public static readonly TheoryData InvalidRequests = new() + { + { "", Faker.Internet.Password(), Faker.Name.FirstName(), Faker.Name.LastName() }, + { Faker.Internet.Email(), "", Faker.Name.FirstName(), Faker.Name.LastName() }, + { Faker.Internet.Email(), "short", Faker.Name.FirstName(), Faker.Name.LastName() }, + { Faker.Internet.Email(), Faker.Internet.Password(), "", Faker.Name.LastName() }, + { Faker.Internet.Email(), Faker.Internet.Password(), Faker.Name.FirstName(), "" } + }; + + public RegisterUserTests(IntegrationTestWebAppFactory factory) + : base(factory) + { + } + + [Theory] + [MemberData(nameof(InvalidRequests))] + public async Task Should_ReturnBadRequest_WhenRequestIsNotValid( + string email, string password, string firstName, string lastName) + { + // Arrange + var request = new { Email = email, FirstName = firstName, LastName = lastName, Password = password }; + + // Act + HttpResponseMessage response = await HttpClient.PostAsJsonAsync("users/register", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Should_ReturnCreated_WhenRequestIsValid() + { + // Arrange + var request = new + { + Email = "register@test.com", + FirstName = Faker.Name.FirstName(), + LastName = Faker.Name.LastName(), + Password = "ValidPass1!" + }; + + // Act + HttpResponseMessage response = await HttpClient.PostAsJsonAsync("users/register", request); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Created); + } + + [Fact] + public async Task Should_ReturnAccessToken_WhenUserIsRegistered() + { + // Arrange + const string email = "token@test.com"; + const string password = "ValidPass1!"; + + var request = new + { + Email = email, + FirstName = Faker.Name.FirstName(), + LastName = Faker.Name.LastName(), + Password = password + }; + + await HttpClient.PostAsJsonAsync("users/register", request); + + // Act + string accessToken = await GetAccessTokenAsync(email, password); + + // Assert + accessToken.Should().NotBeNullOrEmpty(); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/mccn-realm-export.json b/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/mccn-realm-export.json new file mode 100644 index 0000000..9f9ecc7 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.IntegrationTests/mccn-realm-export.json @@ -0,0 +1,85 @@ +{ + "realm": "mccn", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "editUsernameAllowed": false, + "bruteForceProtected": true, + "permanentLockout": false, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "accessTokenLifespan": 1800, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "offlineSessionIdleTimeout": 2592000, + "clients": [ + { + "clientId": "mccn-api", + "name": "Mccn API", + "enabled": true, + "clientAuthenticatorType": "client-secret", + "secret": "change-this-secret", + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": false, + "publicClient": false, + "protocol": "openid-connect", + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false + }, + { + "clientId": "mccn-swagger", + "name": "Mccn Swagger UI", + "enabled": true, + "publicClient": true, + "protocol": "openid-connect", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "redirectUris": [ + "http://localhost:5000/*", + "http://localhost:5001/*" + ], + "webOrigins": [ + "http://localhost:5000", + "http://localhost:5001" + ] + } + ], + "roles": { + "realm": [ + { + "name": "user", + "description": "Regular user" + }, + { + "name": "admin", + "description": "Administrator" + } + ] + }, + "users": [ + { + "username": "service-account-mccn-api", + "enabled": true, + "serviceAccountClientId": "mccn-api", + "clientRoleMappings": { + "realm-management": [ + { + "name": "manage-users" + }, + { + "name": "view-users" + } + ] + } + } + ] +} diff --git a/src/Modules/Users/Mccn.Modules.Users.Presentation/AssemblyReference.cs b/src/Modules/Users/Mccn.Modules.Users.Presentation/AssemblyReference.cs new file mode 100644 index 0000000..75f632d --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Presentation/AssemblyReference.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +namespace Mccn.Modules.Users.Presentation; + +public static class AssemblyReference +{ + public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly; +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Presentation/DependencyInjection.cs b/src/Modules/Users/Mccn.Modules.Users.Presentation/DependencyInjection.cs new file mode 100644 index 0000000..632086d --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Presentation/DependencyInjection.cs @@ -0,0 +1,14 @@ +using Mccn.Common.Presentation.Endpoints; +using Microsoft.Extensions.DependencyInjection; + +namespace Mccn.Modules.Users.Presentation; + +public static class DependencyInjection +{ + public static IServiceCollection AddUsersPresentationServices( + this IServiceCollection services) + { + services.AddEndpoints(typeof(DependencyInjection).Assembly); + return services; + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Presentation/Mccn.Modules.Users.Presentation.csproj b/src/Modules/Users/Mccn.Modules.Users.Presentation/Mccn.Modules.Users.Presentation.csproj new file mode 100644 index 0000000..0f4ef84 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Presentation/Mccn.Modules.Users.Presentation.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/src/Modules/Users/Mccn.Modules.Users.Presentation/Users/GetUserProfile.cs b/src/Modules/Users/Mccn.Modules.Users.Presentation/Users/GetUserProfile.cs new file mode 100644 index 0000000..db7c2af --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Presentation/Users/GetUserProfile.cs @@ -0,0 +1,33 @@ +using Mccn.Common.Domain.Abstractions; +using Mccn.Common.Presentation.Endpoints; +using Mccn.Modules.Users.Application.Users.GetProfile; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Mccn.Modules.Users.Presentation.Users; + +internal sealed class GetUserProfile : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapGet("users/{userId:guid}", async (Guid userId, ISender sender) => + { + GetUserProfileQuery query = new(userId); + Result result = await sender.Send(query); + + return result.IsSuccess + ? Results.Ok(result.Value) + : result.Error.Type switch + { + ErrorType.NotFound => Results.NotFound(result.Error), + _ => Results.BadRequest(result.Error) + }; + }) + .RequireAuthorization() + .WithTags("Users") + .WithName("GetUserProfile") + .WithSummary("Get user profile"); + } +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.Presentation/Users/RegisterUser.cs b/src/Modules/Users/Mccn.Modules.Users.Presentation/Users/RegisterUser.cs new file mode 100644 index 0000000..4de4241 --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.Presentation/Users/RegisterUser.cs @@ -0,0 +1,45 @@ +using Mccn.Common.Domain.Abstractions; +using Mccn.Common.Presentation.Endpoints; +using Mccn.Modules.Users.Application.Users.Register; +using MediatR; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace Mccn.Modules.Users.Presentation.Users; + +internal sealed class RegisterUser : IEndpoint +{ + public void MapEndpoint(IEndpointRouteBuilder app) + { + app.MapPost("users/register", async ([FromBody] Request request, ISender sender) => + { + RegisterUserCommand command = new( + request.Email, + request.FirstName, + request.LastName, + request.Password); + + Result result = await sender.Send(command); + + return result.IsSuccess + ? Results.Created($"users/{result.Value}", new { result.Value }) + : result.Error.Type switch + { + ErrorType.Conflict => Results.Conflict(result.Error), + _ => Results.BadRequest(result.Error) + }; + }) + .AllowAnonymous() + .WithTags("Users") + .WithName("RegisterUser") + .WithSummary("Register a new user"); + } + + internal sealed record Request( + string Email, + string FirstName, + string LastName, + string Password); +} \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.UnitTests/Mccn.Modules.Users.UnitTests.csproj b/src/Modules/Users/Mccn.Modules.Users.UnitTests/Mccn.Modules.Users.UnitTests.csproj new file mode 100644 index 0000000..7b5210c --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.UnitTests/Mccn.Modules.Users.UnitTests.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Modules/Users/Mccn.Modules.Users.UnitTests/Users/UserTests.cs b/src/Modules/Users/Mccn.Modules.Users.UnitTests/Users/UserTests.cs new file mode 100644 index 0000000..d680b5a --- /dev/null +++ b/src/Modules/Users/Mccn.Modules.Users.UnitTests/Users/UserTests.cs @@ -0,0 +1,46 @@ +using Bogus; +using FluentAssertions; +using Mccn.Modules.Users.Domain.Users; + +namespace Mccn.Modules.Users.UnitTests.Users; + +public class UserTests +{ + private static readonly Faker Faker = new(); + + [Fact] + public void Create_Should_ReturnUser() + { + // Act + User user = User.Create( + Guid.NewGuid(), + Faker.Internet.Email(), + Faker.Name.FirstName(), + Faker.Name.LastName(), + Guid.NewGuid().ToString()); + + // Assert + user.Should().NotBeNull(); + } + + [Fact] + public void Create_Should_SetProperties_Correctly() + { + // Arrange + Guid id = Guid.NewGuid(); + string email = Faker.Internet.Email(); + string firstName = Faker.Name.FirstName(); + string lastName = Faker.Name.LastName(); + string identityId = Guid.NewGuid().ToString(); + + // Act + User user = User.Create(id, email, firstName, lastName, identityId); + + // Assert + user.Id.Should().Be(id); + user.Email.Should().Be(email); + user.FirstName.Should().Be(firstName); + user.LastName.Should().Be(lastName); + user.IdentityId.Should().Be(identityId); + } +} \ No newline at end of file diff --git a/test/Mccn.ArchitectureTests/Abstractions/BaseTest.cs b/test/Mccn.ArchitectureTests/Abstractions/BaseTest.cs new file mode 100644 index 0000000..8997951 --- /dev/null +++ b/test/Mccn.ArchitectureTests/Abstractions/BaseTest.cs @@ -0,0 +1,7 @@ +namespace Mccn.ArchitectureTests.Abstractions; + +public abstract class BaseTest +{ + protected const string UsersNamespace = "Mccn.Modules.Users"; + protected const string HelloNamespace = "Mccn.Modules.Hello"; +} \ No newline at end of file diff --git a/test/Mccn.ArchitectureTests/Abstractions/TestResultExtensions.cs b/test/Mccn.ArchitectureTests/Abstractions/TestResultExtensions.cs new file mode 100644 index 0000000..bfc6df7 --- /dev/null +++ b/test/Mccn.ArchitectureTests/Abstractions/TestResultExtensions.cs @@ -0,0 +1,12 @@ +using FluentAssertions; +using NetArchTest.Rules; + +namespace Mccn.ArchitectureTests.Abstractions; + +internal static class TestResultExtensions +{ + internal static void ShouldBeSuccessful(this TestResult testResult) + { + testResult.FailingTypes?.Should().BeEmpty(); + } +} \ No newline at end of file diff --git a/test/Mccn.ArchitectureTests/Layers/ModuleTests.cs b/test/Mccn.ArchitectureTests/Layers/ModuleTests.cs new file mode 100644 index 0000000..281bb1f --- /dev/null +++ b/test/Mccn.ArchitectureTests/Layers/ModuleTests.cs @@ -0,0 +1,46 @@ +using System.Reflection; +using Mccn.ArchitectureTests.Abstractions; +using Mccn.Modules.Users.Application; +using Mccn.Modules.Users.Domain.Users; +using Mccn.Modules.Users.Infrastructure; +using NetArchTest.Rules; + +namespace Mccn.ArchitectureTests.Layers; + +public class ModuleTests : BaseTest +{ + [Fact] + public void UsersModule_ShouldNotHaveDependencyOn_HelloModule() + { + List usersAssemblies = + [ + typeof(User).Assembly, + AssemblyReference.Assembly, + Modules.Users.Presentation.AssemblyReference.Assembly, + typeof(DependencyInjection).Assembly + ]; + + Types.InAssemblies(usersAssemblies) + .Should() + .NotHaveDependencyOn(HelloNamespace) + .GetResult() + .ShouldBeSuccessful(); + } + + [Fact] + public void HelloModule_ShouldNotHaveDependencyOn_UsersModule() + { + List helloAssemblies = + [ + Modules.Hello.Application.AssemblyReference.Assembly, + Modules.Hello.Presentation.AssemblyReference.Assembly, + typeof(Modules.Hello.Infrastructure.DependencyInjection).Assembly + ]; + + Types.InAssemblies(helloAssemblies) + .Should() + .NotHaveDependencyOn(UsersNamespace) + .GetResult() + .ShouldBeSuccessful(); + } +} \ No newline at end of file diff --git a/test/Mccn.ArchitectureTests/Mccn.ArchitectureTests.csproj b/test/Mccn.ArchitectureTests/Mccn.ArchitectureTests.csproj new file mode 100644 index 0000000..5ed954d --- /dev/null +++ b/test/Mccn.ArchitectureTests/Mccn.ArchitectureTests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file