This commit is contained in:
2026-03-15 11:22:01 +01:00
commit 599ecd66a5
109 changed files with 3348 additions and 0 deletions

View File

@@ -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);

View File

@@ -0,0 +1,6 @@
namespace Mccn.Modules.Users.Application.Abstractions;
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,8 @@
using System.Reflection;
namespace Mccn.Modules.Users.Application;
public static class AssemblyReference
{
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
}

View File

@@ -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>

View File

@@ -0,0 +1,5 @@
using Mccn.Common.Application.Abstractions;
namespace Mccn.Modules.Users.Application.Users.GetProfile;
public sealed record GetUserProfileQuery(Guid UserId) : IQuery<UserProfileResponse>;

View File

@@ -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);
}
}

View File

@@ -0,0 +1,7 @@
namespace Mccn.Modules.Users.Application.Users.GetProfile;
public sealed record UserProfileResponse(
Guid Id,
string Email,
string FirstName,
string LastName);

View File

@@ -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>;

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
}

View 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
};
}
}

View File

@@ -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.");
}
}

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,6 @@
namespace Mccn.Modules.Users.Infrastructure.Database;
internal static class Schemas
{
internal const string Users = "users";
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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()!;
}
}

View File

@@ -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;
}
}

View File

@@ -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";
}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,4 @@
namespace Mccn.Modules.Users.IntegrationTests.Abstractions;
[CollectionDefinition(nameof(IntegrationTestCollection))]
public sealed class IntegrationTestCollection : ICollectionFixture<IntegrationTestWebAppFactory>;

View File

@@ -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

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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"
}
]
}
}
]
}

View File

@@ -0,0 +1,8 @@
using System.Reflection;
namespace Mccn.Modules.Users.Presentation;
public static class AssemblyReference
{
public static readonly Assembly Assembly = typeof(AssemblyReference).Assembly;
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Mccn.Modules.Users.Application\Mccn.Modules.Users.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>

View File

@@ -0,0 +1,33 @@
using Mccn.Common.Domain.Abstractions;
using Mccn.Common.Presentation.Endpoints;
using Mccn.Modules.Users.Application.Users.GetProfile;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace Mccn.Modules.Users.Presentation.Users;
internal sealed class GetUserProfile : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapGet("users/{userId:guid}", async (Guid userId, ISender sender) =>
{
GetUserProfileQuery query = new(userId);
Result<UserProfileResponse> result = await sender.Send(query);
return result.IsSuccess
? Results.Ok(result.Value)
: result.Error.Type switch
{
ErrorType.NotFound => Results.NotFound(result.Error),
_ => Results.BadRequest(result.Error)
};
})
.RequireAuthorization()
.WithTags("Users")
.WithName("GetUserProfile")
.WithSummary("Get user profile");
}
}

View File

@@ -0,0 +1,45 @@
using Mccn.Common.Domain.Abstractions;
using Mccn.Common.Presentation.Endpoints;
using Mccn.Modules.Users.Application.Users.Register;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
namespace Mccn.Modules.Users.Presentation.Users;
internal sealed class RegisterUser : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapPost("users/register", async ([FromBody] Request request, ISender sender) =>
{
RegisterUserCommand command = new(
request.Email,
request.FirstName,
request.LastName,
request.Password);
Result<Guid> result = await sender.Send(command);
return result.IsSuccess
? Results.Created($"users/{result.Value}", new { result.Value })
: result.Error.Type switch
{
ErrorType.Conflict => Results.Conflict(result.Error),
_ => Results.BadRequest(result.Error)
};
})
.AllowAnonymous()
.WithTags("Users")
.WithName("RegisterUser")
.WithSummary("Register a new user");
}
internal sealed record Request(
string Email,
string FirstName,
string LastName,
string Password);
}

View File

@@ -0,0 +1,27 @@
<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.NET.Test.Sdk" Version="17.14.1"/>
<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.Domain\Mccn.Modules.Users.Domain.csproj"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,46 @@
using Bogus;
using FluentAssertions;
using Mccn.Modules.Users.Domain.Users;
namespace Mccn.Modules.Users.UnitTests.Users;
public class UserTests
{
private static readonly Faker Faker = new();
[Fact]
public void Create_Should_ReturnUser()
{
// Act
User user = User.Create(
Guid.NewGuid(),
Faker.Internet.Email(),
Faker.Name.FirstName(),
Faker.Name.LastName(),
Guid.NewGuid().ToString());
// Assert
user.Should().NotBeNull();
}
[Fact]
public void Create_Should_SetProperties_Correctly()
{
// Arrange
Guid id = Guid.NewGuid();
string email = Faker.Internet.Email();
string firstName = Faker.Name.FirstName();
string lastName = Faker.Name.LastName();
string identityId = Guid.NewGuid().ToString();
// Act
User user = User.Create(id, email, firstName, lastName, identityId);
// Assert
user.Id.Should().Be(id);
user.Email.Should().Be(email);
user.FirstName.Should().Be(firstName);
user.LastName.Should().Be(lastName);
user.IdentityId.Should().Be(identityId);
}
}