init
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user