init
This commit is contained in:
81
.files/mccn-realm-export.json
Normal file
81
.files/mccn-realm-export.json
Normal file
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
obj/
|
||||||
|
bin/
|
||||||
|
.vs/
|
||||||
|
*.user
|
||||||
|
.env
|
||||||
|
.containers/
|
||||||
|
.claude/
|
||||||
|
src/API/Mccn.Api/appsettings.Development.json
|
||||||
|
.idea/
|
||||||
8
Directory.Build.props
Normal file
8
Directory.Build.props
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
36
Mccn.slnx
Normal file
36
Mccn.slnx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<Solution>
|
||||||
|
<Folder Name="/src/"/>
|
||||||
|
<Folder Name="/src/API/">
|
||||||
|
<Project Path="src/API/Mccn.Api/Mccn.Api.csproj"/>
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/src/Common/">
|
||||||
|
<Project Path="src/Common/Mccn.Common.Application/Mccn.Common.Application.csproj"/>
|
||||||
|
<Project Path="src/Common/Mccn.Common.Domain/Mccn.Common.Domain.csproj"/>
|
||||||
|
<Project Path="src/Common/Mccn.Common.Infrastructure/Mccn.Common.Infrastructure.csproj"/>
|
||||||
|
<Project Path="src/Common/Mccn.Common.Presentation/Mccn.Common.Presentation.csproj"/>
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/src/Modules/"/>
|
||||||
|
<Folder Name="/src/Modules/Hello/">
|
||||||
|
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Application/Mccn.Modules.Hello.Application.csproj"/>
|
||||||
|
<Project
|
||||||
|
Path="src/Modules/Hello/Mccn.Modules.Hello.ArchitectureTests/Mccn.Modules.Hello.ArchitectureTests.csproj"/>
|
||||||
|
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Infrastructure/Mccn.Modules.Hello.Infrastructure.csproj"/>
|
||||||
|
<Project
|
||||||
|
Path="src/Modules/Hello/Mccn.Modules.Hello.IntegrationTests/Mccn.Modules.Hello.IntegrationTests.csproj"/>
|
||||||
|
<Project Path="src/Modules/Hello/Mccn.Modules.Hello.Presentation/Mccn.Modules.Hello.Presentation.csproj"/>
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/src/Modules/Users/">
|
||||||
|
<Project Path="src/Modules/Users/Mccn.Modules.Users.Application/Mccn.Modules.Users.Application.csproj"/>
|
||||||
|
<Project
|
||||||
|
Path="src/Modules/Users/Mccn.Modules.Users.ArchitectureTests/Mccn.Modules.Users.ArchitectureTests.csproj"/>
|
||||||
|
<Project Path="src/Modules/Users/Mccn.Modules.Users.Domain/Mccn.Modules.Users.Domain.csproj"/>
|
||||||
|
<Project Path="src/Modules/Users/Mccn.Modules.Users.Infrastructure/Mccn.Modules.Users.Infrastructure.csproj"/>
|
||||||
|
<Project
|
||||||
|
Path="src/Modules/Users/Mccn.Modules.Users.IntegrationTests/Mccn.Modules.Users.IntegrationTests.csproj"/>
|
||||||
|
<Project Path="src/Modules/Users/Mccn.Modules.Users.Presentation/Mccn.Modules.Users.Presentation.csproj"/>
|
||||||
|
<Project Path="src/Modules/Users/Mccn.Modules.Users.UnitTests/Mccn.Modules.Users.UnitTests.csproj"/>
|
||||||
|
</Folder>
|
||||||
|
<Folder Name="/test/">
|
||||||
|
<Project Path="test/Mccn.ArchitectureTests/Mccn.ArchitectureTests.csproj"/>
|
||||||
|
</Folder>
|
||||||
|
</Solution>
|
||||||
51
docker-compose.yml
Normal file
51
docker-compose.yml
Normal file
@@ -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
|
||||||
26
src/API/Mccn.Api/Extensions/MigrationExtensions.cs
Normal file
26
src/API/Mccn.Api/Extensions/MigrationExtensions.cs
Normal file
@@ -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<UsersDbContext>(scope.ServiceProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task MigrateAsync<TContext>(IServiceProvider serviceProvider) where TContext : DbContext
|
||||||
|
{
|
||||||
|
TContext dbContext = serviceProvider.GetRequiredService<TContext>();
|
||||||
|
|
||||||
|
// Use EnsureCreatedAsync when no migrations exist yet.
|
||||||
|
// Once you add migrations via `dotnet ef migrations add`, switch this to MigrateAsync().
|
||||||
|
IEnumerable<string> pendingMigrations = await dbContext.Database.GetPendingMigrationsAsync();
|
||||||
|
if (pendingMigrations.Any())
|
||||||
|
await dbContext.Database.MigrateAsync();
|
||||||
|
else
|
||||||
|
await dbContext.Database.EnsureCreatedAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/API/Mccn.Api/Mccn.Api.csproj
Normal file
27
src/API/Mccn.Api/Mccn.Api.csproj
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.4">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\Common\Mccn.Common.Infrastructure\Mccn.Common.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Common\Mccn.Common.Presentation\Mccn.Common.Presentation.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Modules\Users\Mccn.Modules.Users.Infrastructure\Mccn.Modules.Users.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Modules\Users\Mccn.Modules.Users.Presentation\Mccn.Modules.Users.Presentation.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Modules\Hello\Mccn.Modules.Hello.Infrastructure\Mccn.Modules.Hello.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Modules\Hello\Mccn.Modules.Hello.Presentation\Mccn.Modules.Hello.Presentation.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
70
src/API/Mccn.Api/Mccn.Api.http
Normal file
70
src/API/Mccn.Api/Mccn.Api.http
Normal file
@@ -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}}
|
||||||
61
src/API/Mccn.Api/Program.cs
Normal file
61
src/API/Mccn.Api/Program.cs
Normal file
@@ -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;
|
||||||
23
src/API/Mccn.Api/Properties/launchSettings.json
Normal file
23
src/API/Mccn.Api/Properties/launchSettings.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/API/Mccn.Api/appsettings.json
Normal file
55
src/API/Mccn.Api/appsettings.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/API/Mccn.Api/http-client.env.json
Normal file
5
src/API/Mccn.Api/http-client.env.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dev": {
|
||||||
|
"baseAddress": "https://localhost:8081"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/Common/Mccn.Common.Application/Abstractions/ICommand.cs
Normal file
12
src/Common/Mccn.Common.Application/Abstractions/ICommand.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using Mccn.Common.Domain.Abstractions;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Mccn.Common.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface ICommand : IRequest<Result>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICommand<TResponse> : IRequest<Result<TResponse>>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using Mccn.Common.Domain.Abstractions;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Mccn.Common.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface ICommandHandler<TCommand> : IRequestHandler<TCommand, Result>
|
||||||
|
where TCommand : ICommand
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ICommandHandler<TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
|
||||||
|
where TCommand : ICommand<TResponse>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Mccn.Common.Domain.Abstractions;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Mccn.Common.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IQuery<TResponse> : IRequest<Result<TResponse>>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Mccn.Common.Domain.Abstractions;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Mccn.Common.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IQueryHandler<TQuery, TResponse> : IRequestHandler<TQuery, Result<TResponse>>
|
||||||
|
where TQuery : IQuery<TResponse>
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -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<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||||
|
where TRequest : IBaseRequest
|
||||||
|
where TResponse : Result
|
||||||
|
{
|
||||||
|
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
||||||
|
|
||||||
|
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
|
||||||
|
{
|
||||||
|
_validators = validators;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TResponse> Handle(
|
||||||
|
TRequest request,
|
||||||
|
RequestHandlerDelegate<TResponse> next,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ValidationContext<TRequest> context = new(request);
|
||||||
|
|
||||||
|
ValidationResult[] validationFailures = await Task.WhenAll(
|
||||||
|
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
|
||||||
|
|
||||||
|
List<ValidationError> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/Common/Mccn.Common.Application/DependencyInjection.cs
Normal file
23
src/Common/Mccn.Common.Application/DependencyInjection.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace Mccn.Common.Application.Exceptions;
|
||||||
|
|
||||||
|
public sealed record ValidationError(string PropertyName, string ErrorMessage);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Mccn.Common.Application.Exceptions;
|
||||||
|
|
||||||
|
public sealed class ValidationException : Exception
|
||||||
|
{
|
||||||
|
public ValidationException(IEnumerable<ValidationError> errors)
|
||||||
|
: base("One or more validation failures has occurred.")
|
||||||
|
{
|
||||||
|
Errors = errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IEnumerable<ValidationError> Errors { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Mccn.Common.Domain\Mccn.Common.Domain.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2"/>
|
||||||
|
<PackageReference Include="MediatR" Version="12.4.1"/>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
15
src/Common/Mccn.Common.Domain/Abstractions/Entity.cs
Normal file
15
src/Common/Mccn.Common.Domain/Abstractions/Entity.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
35
src/Common/Mccn.Common.Domain/Abstractions/Error.cs
Normal file
35
src/Common/Mccn.Common.Domain/Abstractions/Error.cs
Normal file
@@ -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
|
||||||
|
}
|
||||||
58
src/Common/Mccn.Common.Domain/Abstractions/Result.cs
Normal file
58
src/Common/Mccn.Common.Domain/Abstractions/Result.cs
Normal file
@@ -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<TValue> Success<TValue>(TValue value)
|
||||||
|
{
|
||||||
|
return new Result<TValue>(value, true, Error.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Result<TValue> Failure<TValue>(Error error)
|
||||||
|
{
|
||||||
|
return new Result<TValue>(default, false, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class Result<TValue> : 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>(TValue? value)
|
||||||
|
{
|
||||||
|
return value is not null ? Success(value) : Failure<TValue>(Error.NullValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/Common/Mccn.Common.Domain/Mccn.Common.Domain.csproj
Normal file
9
src/Common/Mccn.Common.Domain/Mccn.Common.Domain.csproj
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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<JwtBearerConfigureOptions>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<JwtBearerOptions>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs
Normal file
31
src/Common/Mccn.Common.Infrastructure/DependencyInjection.cs
Normal file
@@ -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<GlobalExceptionHandler>();
|
||||||
|
services.AddProblemDetails();
|
||||||
|
|
||||||
|
services.AddHttpContextAccessor();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<GlobalExceptionHandler> _logger;
|
||||||
|
|
||||||
|
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger, IWebHostEnvironment env)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_env = env;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Mccn.Common.Domain\Mccn.Common.Domain.csproj"/>
|
||||||
|
<ProjectReference Include="..\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5"/>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.5"/>
|
||||||
|
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0"/>
|
||||||
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0"/>
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.1"/>
|
||||||
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0"/>
|
||||||
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0"/>
|
||||||
|
<PackageReference Include="Serilog.Sinks.Seq" Version="9.0.0"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Type> 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<IEndpoint> endpoints = app.Services.GetRequiredService<IEnumerable<IEndpoint>>();
|
||||||
|
|
||||||
|
IEndpointRouteBuilder builder = routeGroupBuilder is null ? app : routeGroupBuilder;
|
||||||
|
|
||||||
|
foreach (IEndpoint endpoint in endpoints) endpoint.MapEndpoint(builder);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
|
namespace Mccn.Common.Presentation.Endpoints;
|
||||||
|
|
||||||
|
public interface IEndpoint
|
||||||
|
{
|
||||||
|
void MapEndpoint(IEndpointRouteBuilder app);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Mccn.Modules.Hello.Application;
|
||||||
|
|
||||||
|
public static class AssemblyReference
|
||||||
|
{
|
||||||
|
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace Mccn.Modules.Hello.Application.Hello.SayHello;
|
||||||
|
|
||||||
|
public sealed record HelloResponse(string Message);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using Mccn.Common.Application.Abstractions;
|
||||||
|
|
||||||
|
namespace Mccn.Modules.Hello.Application.Hello.SayHello;
|
||||||
|
|
||||||
|
public sealed record SayHelloQuery(string? Name) : IQuery<HelloResponse>;
|
||||||
@@ -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<SayHelloQuery, HelloResponse>
|
||||||
|
{
|
||||||
|
public Task<Result<HelloResponse>> Handle(SayHelloQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
string greeting = string.IsNullOrWhiteSpace(request.Name)
|
||||||
|
? "Hello, World!"
|
||||||
|
: $"Hello, {request.Name}!";
|
||||||
|
|
||||||
|
return Task.FromResult(Result.Success(new HelloResponse(greeting)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||||
|
<PackageReference Include="NetArchTest.Rules" Version="1.3.2"/>
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Mccn.Modules.Hello.Infrastructure\Mccn.Modules.Hello.Infrastructure.csproj"/>
|
||||||
|
<ProjectReference Include="..\Mccn.Modules.Hello.Presentation\Mccn.Modules.Hello.Presentation.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Mccn.Modules.Hello.Application\Mccn.Modules.Hello.Application.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\..\Common\Mccn.Common.Infrastructure\Mccn.Common.Infrastructure.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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<ISender>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_scope.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace Mccn.Modules.Hello.IntegrationTests.Abstractions;
|
||||||
|
|
||||||
|
[CollectionDefinition(nameof(IntegrationTestCollection))]
|
||||||
|
public sealed class IntegrationTestCollection : ICollectionFixture<IntegrationTestWebAppFactory>;
|
||||||
@@ -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<Program>, 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
|
||||||
@@ -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<HelloResponse>();
|
||||||
|
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<HelloResponse>();
|
||||||
|
body!.Message.Should().Be($"Hello, {name}!");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SayHello_Should_ReturnHelloWorld_WhenNoName()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
Result<HelloResponse> 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<HelloResponse> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Bogus" Version="35.6.5"/>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||||
|
<PackageReference Include="Testcontainers.Keycloak" Version="4.11.0"/>
|
||||||
|
<PackageReference Include="Testcontainers.PostgreSql" Version="4.11.0"/>
|
||||||
|
<PackageReference Include="Testcontainers.Redis" Version="4.11.0"/>
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\API\Mccn.Api\Mccn.Api.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="mccn-realm-export.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Mccn.Modules.Hello.Presentation;
|
||||||
|
|
||||||
|
public static class AssemblyReference
|
||||||
|
{
|
||||||
|
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<HelloResponse> 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<HelloResponse> result = await sender.Send(query);
|
||||||
|
|
||||||
|
return Results.Ok(result.Value);
|
||||||
|
})
|
||||||
|
.RequireAuthorization()
|
||||||
|
.WithTags("Hello")
|
||||||
|
.WithName("GetHelloAuthenticated")
|
||||||
|
.WithSummary("Say hello to the authenticated user");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Mccn.Modules.Hello.Application\Mccn.Modules.Hello.Application.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\..\Common\Mccn.Common.Presentation\Mccn.Common.Presentation.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Mccn.Common.Domain.Abstractions;
|
||||||
|
|
||||||
|
namespace Mccn.Modules.Users.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IIdentityProviderService
|
||||||
|
{
|
||||||
|
Task<Result<string>> RegisterUserAsync(
|
||||||
|
UserModel user,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UserModel(
|
||||||
|
string Email,
|
||||||
|
string FirstName,
|
||||||
|
string LastName,
|
||||||
|
string Password);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Mccn.Modules.Users.Application.Abstractions;
|
||||||
|
|
||||||
|
public interface IUnitOfWork
|
||||||
|
{
|
||||||
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Mccn.Modules.Users.Application;
|
||||||
|
|
||||||
|
public static class AssemblyReference
|
||||||
|
{
|
||||||
|
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Mccn.Modules.Users.Domain\Mccn.Modules.Users.Domain.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\..\Common\Mccn.Common.Application\Mccn.Common.Application.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using Mccn.Common.Application.Abstractions;
|
||||||
|
|
||||||
|
namespace Mccn.Modules.Users.Application.Users.GetProfile;
|
||||||
|
|
||||||
|
public sealed record GetUserProfileQuery(Guid UserId) : IQuery<UserProfileResponse>;
|
||||||
@@ -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<GetUserProfileQuery, UserProfileResponse>
|
||||||
|
{
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
|
||||||
|
public GetUserProfileQueryHandler(IUserRepository userRepository)
|
||||||
|
{
|
||||||
|
_userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<UserProfileResponse>> Handle(
|
||||||
|
GetUserProfileQuery request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
User? user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
|
||||||
|
|
||||||
|
if (user is null) return Result.Failure<UserProfileResponse>(UserErrors.NotFound(request.UserId));
|
||||||
|
|
||||||
|
return new UserProfileResponse(
|
||||||
|
user.Id,
|
||||||
|
user.Email,
|
||||||
|
user.FirstName,
|
||||||
|
user.LastName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Mccn.Modules.Users.Application.Users.GetProfile;
|
||||||
|
|
||||||
|
public sealed record UserProfileResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Email,
|
||||||
|
string FirstName,
|
||||||
|
string LastName);
|
||||||
@@ -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<Guid>;
|
||||||
@@ -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<RegisterUserCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Result<Guid>> Handle(
|
||||||
|
RegisterUserCommand request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (await userRepository.ExistsByEmailAsync(request.Email, cancellationToken))
|
||||||
|
return Result.Failure<Guid>(UserErrors.EmailAlreadyExists(request.Email));
|
||||||
|
|
||||||
|
UserModel userModel = new(
|
||||||
|
request.Email,
|
||||||
|
request.FirstName,
|
||||||
|
request.LastName,
|
||||||
|
request.Password);
|
||||||
|
|
||||||
|
Result<string> identityResult = await identityProviderService.RegisterUserAsync(
|
||||||
|
userModel,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (identityResult.IsFailure) return Result.Failure<Guid>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Mccn.Modules.Users.Application.Users.Register;
|
||||||
|
|
||||||
|
internal sealed class RegisterUserCommandValidator : AbstractValidator<RegisterUserCommand>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Type> entityTypes = Types.InAssembly(DomainAssembly)
|
||||||
|
.That()
|
||||||
|
.Inherit(typeof(Entity))
|
||||||
|
.GetTypes();
|
||||||
|
|
||||||
|
List<Type> 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<Type> entityTypes = Types.InAssembly(DomainAssembly)
|
||||||
|
.That()
|
||||||
|
.Inherit(typeof(Entity))
|
||||||
|
.GetTypes();
|
||||||
|
|
||||||
|
List<Type> failingTypes = new();
|
||||||
|
foreach (Type entityType in entityTypes)
|
||||||
|
{
|
||||||
|
ConstructorInfo[] constructors = entityType.GetConstructors(
|
||||||
|
BindingFlags.Public | BindingFlags.Instance);
|
||||||
|
|
||||||
|
if (constructors.Any()) failingTypes.Add(entityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
failingTypes.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||||
|
<PackageReference Include="NetArchTest.Rules" Version="1.3.2"/>
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Mccn.Modules.Users.Infrastructure\Mccn.Modules.Users.Infrastructure.csproj"/>
|
||||||
|
<ProjectReference Include="..\Mccn.Modules.Users.Presentation\Mccn.Modules.Users.Presentation.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\Common\Mccn.Common.Domain\Mccn.Common.Domain.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Mccn.Modules.Users.Domain.Users;
|
||||||
|
|
||||||
|
public interface IUserRepository
|
||||||
|
{
|
||||||
|
Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||||
|
Task<User?> GetByEmailAsync(string email, CancellationToken cancellationToken = default);
|
||||||
|
Task<bool> ExistsByEmailAsync(string email, CancellationToken cancellationToken = default);
|
||||||
|
void Add(User user);
|
||||||
|
}
|
||||||
32
src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs
Normal file
32
src/Modules/Users/Mccn.Modules.Users.Domain/Users/User.cs
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)")
|
||||||
|
.HasColumnName("email");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("first_name");
|
||||||
|
|
||||||
|
b.Property<string>("IdentityId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("identity_id");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Mccn.Modules.Users.Infrastructure.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialCreate : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.EnsureSchema(
|
||||||
|
name: "users");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "users",
|
||||||
|
schema: "users",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
email = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: false),
|
||||||
|
first_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
last_name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
identity_id = table.Column<string>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "users",
|
||||||
|
schema: "users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)")
|
||||||
|
.HasColumnName("email");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("first_name");
|
||||||
|
|
||||||
|
b.Property<string>("IdentityId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)")
|
||||||
|
.HasColumnName("identity_id");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Mccn.Modules.Users.Infrastructure.Database;
|
||||||
|
|
||||||
|
internal static class Schemas
|
||||||
|
{
|
||||||
|
internal const string Users = "users";
|
||||||
|
}
|
||||||
@@ -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<UsersDbContext> options)
|
||||||
|
: base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public DbSet<User> Users { get; set; }
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasDefaultSchema(Schemas.Users);
|
||||||
|
modelBuilder.ApplyConfigurationsFromAssembly(typeof(UsersDbContext).Assembly);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<UsersDbContext>(options =>
|
||||||
|
{
|
||||||
|
options.UseNpgsql(
|
||||||
|
configuration.GetConnectionString("Database"),
|
||||||
|
o => o.MigrationsHistoryTable("__ef_migrations_history", Schemas.Users))
|
||||||
|
.UseSnakeCaseNamingConvention();
|
||||||
|
});
|
||||||
|
|
||||||
|
services.Configure<KeycloakOptions>(
|
||||||
|
configuration.GetSection(KeycloakOptions.SectionName));
|
||||||
|
|
||||||
|
services.AddTransient<KeycloakAuthDelegatingHandler>();
|
||||||
|
|
||||||
|
services.AddHttpClient<IIdentityProviderService, KeycloakClient>((sp, client) =>
|
||||||
|
{
|
||||||
|
KeycloakOptions keycloakOptions = configuration
|
||||||
|
.GetSection(KeycloakOptions.SectionName)
|
||||||
|
.Get<KeycloakOptions>()!;
|
||||||
|
client.BaseAddress = new Uri(keycloakOptions.AdminUrl);
|
||||||
|
})
|
||||||
|
.AddHttpMessageHandler<KeycloakAuthDelegatingHandler>();
|
||||||
|
|
||||||
|
services.AddScoped<IUserRepository, UserRepository>();
|
||||||
|
services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<KeycloakOptions> options)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> 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<string> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using HttpClient client = new();
|
||||||
|
|
||||||
|
FormUrlEncodedContent content = new(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["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()!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Result<string>> 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<string>(
|
||||||
|
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<string>(
|
||||||
|
Error.Failure("Keycloak.Register", "Could not determine new user identity ID."));
|
||||||
|
|
||||||
|
string identityId = locationHeader.Split('/').Last();
|
||||||
|
return identityId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Mccn.Modules.Users.Domain\Mccn.Modules.Users.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\Mccn.Modules.Users.Application\Mccn.Modules.Users.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\..\..\Common\Mccn.Common.Infrastructure\Mccn.Common.Infrastructure.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.4" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<User>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<User> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.Users
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<User?> GetByEmailAsync(string email, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
return await _context.Users
|
||||||
|
.FirstOrDefaultAsync(u => u.Email == email, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ISender>();
|
||||||
|
DbContext = _scope.ServiceProvider.GetRequiredService<UsersDbContext>();
|
||||||
|
_keycloakOptions = _scope.ServiceProvider
|
||||||
|
.GetRequiredService<IOptions<KeycloakOptions>>().Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_scope.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task CleanDatabaseAsync()
|
||||||
|
{
|
||||||
|
await DbContext.Database.ExecuteSqlRawAsync("DELETE FROM users.users;");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task<string> GetAccessTokenAsync(string email, string password)
|
||||||
|
{
|
||||||
|
using HttpClient client = new();
|
||||||
|
|
||||||
|
KeyValuePair<string, string>[] parameters = new KeyValuePair<string, string>[]
|
||||||
|
{
|
||||||
|
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<AuthToken>();
|
||||||
|
return authToken!.AccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class AuthToken
|
||||||
|
{
|
||||||
|
[JsonPropertyName("access_token")] public string AccessToken { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace Mccn.Modules.Users.IntegrationTests.Abstractions;
|
||||||
|
|
||||||
|
[CollectionDefinition(nameof(IntegrationTestCollection))]
|
||||||
|
public sealed class IntegrationTestCollection : ICollectionFixture<IntegrationTestWebAppFactory>;
|
||||||
@@ -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<Program>, 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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Bogus" Version="35.6.5"/>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5"/>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||||
|
<PackageReference Include="Testcontainers.Keycloak" Version="4.11.0"/>
|
||||||
|
<PackageReference Include="Testcontainers.PostgreSql" Version="4.11.0"/>
|
||||||
|
<PackageReference Include="Testcontainers.Redis" Version="4.11.0"/>
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\..\API\Mccn.Api\Mccn.Api.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="mccn-realm-export.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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<UserProfileResponse> 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<Guid> registerResult = await Sender.Send(command);
|
||||||
|
Guid userId = registerResult.Value;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
Result<UserProfileResponse> result = await Sender.Send(new GetUserProfileQuery(userId));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.IsSuccess.Should().BeTrue();
|
||||||
|
result.Value.Should().NotBeNull();
|
||||||
|
result.Value.Id.Should().Be(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string, string, string> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Mccn.Modules.Users.Presentation;
|
||||||
|
|
||||||
|
public static class AssemblyReference
|
||||||
|
{
|
||||||
|
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user