0
0
mirror of https://github.com/alex289/CleanArchitecture.git synced 2025-08-23 19:58:34 +00:00

Merge pull request #20 from alex289/feature/tenants

Add tenants
This commit is contained in:
Alex 2023-08-31 18:56:47 +02:00 committed by GitHub
commit 0601af8e42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
171 changed files with 3398 additions and 775 deletions

View File

@ -7,26 +7,26 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.ApplicationStatus" Version="7.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="7.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="7.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.10" />
<PackageReference Include="AspNetCore.HealthChecks.ApplicationStatus" Version="7.0.0"/>
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="7.0.0"/>
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="7.1.0"/>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.10"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.10"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="7.0.10" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.10" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="7.0.10"/>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.10"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0"/>
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CleanArchitecture.Application\CleanArchitecture.Application.csproj" />
<ProjectReference Include="..\CleanArchitecture.Domain\CleanArchitecture.Domain.csproj" />
<ProjectReference Include="..\CleanArchitecture.gRPC\CleanArchitecture.gRPC.csproj" />
<ProjectReference Include="..\CleanArchitecture.Infrastructure\CleanArchitecture.Infrastructure.csproj" />
<ProjectReference Include="..\CleanArchitecture.Application\CleanArchitecture.Application.csproj"/>
<ProjectReference Include="..\CleanArchitecture.Domain\CleanArchitecture.Domain.csproj"/>
<ProjectReference Include="..\CleanArchitecture.gRPC\CleanArchitecture.gRPC.csproj"/>
<ProjectReference Include="..\CleanArchitecture.Infrastructure\CleanArchitecture.Infrastructure.csproj"/>
</ItemGroup>
</Project>

View File

@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CleanArchitecture.Api.Models;
using CleanArchitecture.Application.Interfaces;
using CleanArchitecture.Application.ViewModels.Tenants;
using CleanArchitecture.Domain.Notifications;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;
namespace CleanArchitecture.Api.Controllers;
[ApiController]
[Authorize]
[Route("/api/v1/[controller]")]
public sealed class TenantController : ApiController
{
private readonly ITenantService _tenantService;
public TenantController(
INotificationHandler<DomainNotification> notifications,
ITenantService tenantService) : base(notifications)
{
_tenantService = tenantService;
}
[HttpGet]
[SwaggerOperation("Get a list of all tenants")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<IEnumerable<TenantViewModel>>))]
public async Task<IActionResult> GetAllTenantsAsync()
{
var tenants = await _tenantService.GetAllTenantsAsync();
return Response(tenants);
}
[HttpGet("{id:guid}")]
[SwaggerOperation("Get a tenant by id")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<TenantViewModel>))]
public async Task<IActionResult> GetTenantByIdAsync(
[FromRoute] Guid id,
[FromQuery] bool isDeleted = false)
{
var tenant = await _tenantService.GetTenantByIdAsync(id, isDeleted);
return Response(tenant);
}
[HttpPost]
[SwaggerOperation("Create a new tenant")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<Guid>))]
public async Task<IActionResult> CreateTenantAsync([FromBody] CreateTenantViewModel tenant)
{
var tenantId = await _tenantService.CreateTenantAsync(tenant);
return Response(tenantId);
}
[HttpPut]
[SwaggerOperation("Update an existing tenant")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<UpdateTenantViewModel>))]
public async Task<IActionResult> UpdateTenantAsync([FromBody] UpdateTenantViewModel tenant)
{
await _tenantService.UpdateTenantAsync(tenant);
return Response(tenant);
}
[HttpDelete("{id:guid}")]
[SwaggerOperation("Delete an existing tenant")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<Guid>))]
public async Task<IActionResult> DeleteTenantAsync([FromRoute] Guid id)
{
await _tenantService.DeleteTenantAsync(id);
return Response(id);
}
}

View File

@ -13,6 +13,7 @@ using Swashbuckle.AspNetCore.Annotations;
namespace CleanArchitecture.Api.Controllers;
[ApiController]
[Authorize]
[Route("/api/v1/[controller]")]
public sealed class UserController : ApiController
{
@ -25,7 +26,6 @@ public sealed class UserController : ApiController
_userService = userService;
}
[Authorize]
[HttpGet]
[SwaggerOperation("Get a list of all users")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<IEnumerable<UserViewModel>>))]
@ -35,7 +35,6 @@ public sealed class UserController : ApiController
return Response(users);
}
[Authorize]
[HttpGet("{id:guid}")]
[SwaggerOperation("Get a user by id")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<UserViewModel>))]
@ -47,7 +46,6 @@ public sealed class UserController : ApiController
return Response(user);
}
[Authorize]
[HttpGet("me")]
[SwaggerOperation("Get the current active user")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<UserViewModel>))]
@ -66,7 +64,6 @@ public sealed class UserController : ApiController
return Response(userId);
}
[Authorize]
[HttpDelete("{id:guid}")]
[SwaggerOperation("Delete a user")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<Guid>))]
@ -76,7 +73,6 @@ public sealed class UserController : ApiController
return Response(id);
}
[Authorize]
[HttpPut]
[SwaggerOperation("Update a user")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<UpdateUserViewModel>))]
@ -86,7 +82,6 @@ public sealed class UserController : ApiController
return Response(viewModel);
}
[Authorize]
[HttpPost("changePassword")]
[SwaggerOperation("Change a password for the current active user")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<ChangePasswordViewModel>))]
@ -97,6 +92,7 @@ public sealed class UserController : ApiController
}
[HttpPost("login")]
[AllowAnonymous]
[SwaggerOperation("Get a signed token for a user")]
[SwaggerResponse(200, "Request successful", typeof(ResponseMessage<string>))]
public async Task<IActionResult> LoginUserAsync([FromBody] LoginUserViewModel viewModel)

View File

@ -62,7 +62,10 @@ public static class ServiceCollectionExtension
services.AddAuthentication(
options => { options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; })
.AddJwtBearer(
jwtOptions => { jwtOptions.TokenValidationParameters = CreateTokenValidationParameters(configuration); });
jwtOptions =>
{
jwtOptions.TokenValidationParameters = CreateTokenValidationParameters(configuration);
});
services
.AddOptions<TokenSettings>()

View File

@ -4,9 +4,7 @@ namespace CleanArchitecture.Api.Models;
public sealed class DetailedError
{
[JsonPropertyName("code")]
public string Code { get; init; } = string.Empty;
[JsonPropertyName("code")] public string Code { get; init; } = string.Empty;
[JsonPropertyName("data")]
public object? Data { get; init; }
[JsonPropertyName("data")] public object? Data { get; init; }
}

View File

@ -6,15 +6,12 @@ namespace CleanArchitecture.Api.Models;
public sealed class ResponseMessage<T>
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("success")] public bool Success { get; init; }
[JsonPropertyName("errors")]
public IEnumerable<string>? Errors { get; init; }
[JsonPropertyName("errors")] public IEnumerable<string>? Errors { get; init; }
[JsonPropertyName("detailedErrors")]
public IEnumerable<DetailedError> DetailedErrors { get; init; } = Enumerable.Empty<DetailedError>();
[JsonPropertyName("data")]
public T? Data { get; init; }
[JsonPropertyName("data")] public T? Data { get; init; }
}

View File

@ -61,9 +61,9 @@ var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var services = scope.ServiceProvider;
ApplicationDbContext appDbContext = services.GetRequiredService<ApplicationDbContext>();
EventStoreDbContext storeDbContext = services.GetRequiredService<EventStoreDbContext>();
DomainNotificationStoreDbContext domainStoreDbContext = services.GetRequiredService<DomainNotificationStoreDbContext>();
var appDbContext = services.GetRequiredService<ApplicationDbContext>();
var storeDbContext = services.GetRequiredService<EventStoreDbContext>();
var domainStoreDbContext = services.GetRequiredService<DomainNotificationStoreDbContext>();
appDbContext.EnsureMigrationsApplied();
@ -91,6 +91,7 @@ app.MapHealthChecks("/healthz", new HealthCheckOptions
});
app.MapControllers();
app.MapGrpcService<UsersApiImplementation>();
app.MapGrpcService<TenantsApiImplementation>();
app.Run();

View File

@ -8,11 +8,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.11.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.0" />
<PackageReference Include="MockQueryable.NSubstitute" Version="7.0.0" />
<PackageReference Include="NSubstitute" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.5.0" />
<PackageReference Include="FluentAssertions" Version="6.11.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.0"/>
<PackageReference Include="MockQueryable.NSubstitute" Version="7.0.0"/>
<PackageReference Include="NSubstitute" Version="5.0.0"/>
<PackageReference Include="xunit" Version="2.5.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
@ -24,8 +24,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CleanArchitecture.Application\CleanArchitecture.Application.csproj" />
<ProjectReference Include="..\CleanArchitecture.Domain\CleanArchitecture.Domain.csproj" />
<ProjectReference Include="..\CleanArchitecture.Application\CleanArchitecture.Application.csproj"/>
<ProjectReference Include="..\CleanArchitecture.Domain\CleanArchitecture.Domain.csproj"/>
</ItemGroup>
</Project>

View File

@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using CleanArchitecture.Application.Queries.Tenants.GetAll;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces.Repositories;
using MockQueryable.NSubstitute;
using NSubstitute;
namespace CleanArchitecture.Application.Tests.Fixtures.Queries.Tenants;
public sealed class GetAllTenantsTestFixture : QueryHandlerBaseFixture
{
public GetAllTenantsQueryHandler QueryHandler { get; }
private ITenantRepository TenantRepository { get; }
public GetAllTenantsTestFixture()
{
TenantRepository = Substitute.For<ITenantRepository>();
QueryHandler = new GetAllTenantsQueryHandler(TenantRepository);
}
public Tenant SetupTenant(bool deleted = false)
{
var tenant = new Tenant(Guid.NewGuid(), "Tenant 1");
if (deleted)
{
tenant.Delete();
}
var tenantList = new List<Tenant> { tenant }.BuildMock();
TenantRepository.GetAllNoTracking().Returns(tenantList);
return tenant;
}
}

View File

@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using CleanArchitecture.Application.Queries.Tenants.GetTenantById;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Interfaces.Repositories;
using MockQueryable.NSubstitute;
using NSubstitute;
namespace CleanArchitecture.Application.Tests.Fixtures.Queries.Tenants;
public sealed class GetTenantByIdTestFixture : QueryHandlerBaseFixture
{
public GetTenantByIdQueryHandler QueryHandler { get; }
private ITenantRepository TenantRepository { get; }
public GetTenantByIdTestFixture()
{
TenantRepository = Substitute.For<ITenantRepository>();
QueryHandler = new GetTenantByIdQueryHandler(
TenantRepository,
Bus);
}
public Tenant SetupTenant(bool deleted = false)
{
var tenant = new Tenant(Guid.NewGuid(), "Tenant 1");
if (deleted)
{
tenant.Delete();
}
var tenantList = new List<Tenant> { tenant }.BuildMock();
TenantRepository.GetAllNoTracking().Returns(tenantList);
return tenant;
}
}

View File

@ -10,6 +10,10 @@ namespace CleanArchitecture.Application.Tests.Fixtures.Queries.Users;
public sealed class GetAllUsersTestFixture : QueryHandlerBaseFixture
{
private IUserRepository UserRepository { get; }
public GetAllUsersQueryHandler Handler { get; }
public Guid ExistingUserId { get; } = Guid.NewGuid();
public GetAllUsersTestFixture()
{
UserRepository = Substitute.For<IUserRepository>();
@ -17,14 +21,11 @@ public sealed class GetAllUsersTestFixture : QueryHandlerBaseFixture
Handler = new GetAllUsersQueryHandler(UserRepository);
}
private IUserRepository UserRepository { get; }
public GetAllUsersQueryHandler Handler { get; }
public Guid ExistingUserId { get; } = Guid.NewGuid();
public void SetupUserAsync()
{
var user = new User(
ExistingUserId,
Guid.NewGuid(),
"max@mustermann.com",
"Max",
"Mustermann",
@ -40,6 +41,7 @@ public sealed class GetAllUsersTestFixture : QueryHandlerBaseFixture
{
var user = new User(
ExistingUserId,
Guid.NewGuid(),
"max@mustermann.com",
"Max",
"Mustermann",

View File

@ -11,6 +11,10 @@ namespace CleanArchitecture.Application.Tests.Fixtures.Queries.Users;
public sealed class GetUserByIdTestFixture : QueryHandlerBaseFixture
{
private IUserRepository UserRepository { get; }
public GetUserByIdQueryHandler Handler { get; }
public Guid ExistingUserId { get; } = Guid.NewGuid();
public GetUserByIdTestFixture()
{
UserRepository = Substitute.For<IUserRepository>();
@ -18,14 +22,11 @@ public sealed class GetUserByIdTestFixture : QueryHandlerBaseFixture
Handler = new GetUserByIdQueryHandler(UserRepository, Bus);
}
private IUserRepository UserRepository { get; }
public GetUserByIdQueryHandler Handler { get; }
public Guid ExistingUserId { get; } = Guid.NewGuid();
public void SetupUserAsync()
{
var user = new User(
ExistingUserId,
Guid.NewGuid(),
"max@mustermann.com",
"Max",
"Mustermann",
@ -41,6 +42,7 @@ public sealed class GetUserByIdTestFixture : QueryHandlerBaseFixture
{
var user = new User(
ExistingUserId,
Guid.NewGuid(),
"max@mustermann.com",
"Max",
"Mustermann",

View File

@ -0,0 +1,39 @@
using System.Linq;
using System.Threading.Tasks;
using CleanArchitecture.Application.Queries.Tenants.GetAll;
using CleanArchitecture.Application.Tests.Fixtures.Queries.Tenants;
using FluentAssertions;
using Xunit;
namespace CleanArchitecture.Application.Tests.Queries.Tenants;
public sealed class GetAllTenantsQueryHandlerTests
{
private readonly GetAllTenantsTestFixture _fixture = new();
[Fact]
public async Task Should_Get_Existing_Tenant()
{
var tenant = _fixture.SetupTenant();
var result = await _fixture.QueryHandler.Handle(
new GetAllTenantsQuery(),
default);
_fixture.VerifyNoDomainNotification();
tenant.Should().BeEquivalentTo(result.First());
}
[Fact]
public async Task Should_Not_Get_Deleted_Tenant()
{
_fixture.SetupTenant(true);
var result = await _fixture.QueryHandler.Handle(
new GetAllTenantsQuery(),
default);
result.Should().HaveCount(0);
}
}

View File

@ -0,0 +1,57 @@
using System.Threading.Tasks;
using CleanArchitecture.Application.Queries.Tenants.GetTenantById;
using CleanArchitecture.Application.Tests.Fixtures.Queries.Tenants;
using CleanArchitecture.Domain.Errors;
using FluentAssertions;
using Xunit;
namespace CleanArchitecture.Application.Tests.Queries.Tenants;
public sealed class GetTenantByIdQueryHandlerTests
{
private readonly GetTenantByIdTestFixture _fixture = new();
[Fact]
public async Task Should_Get_Existing_Tenant()
{
var tenant = _fixture.SetupTenant();
var result = await _fixture.QueryHandler.Handle(
new GetTenantByIdQuery(tenant.Id, false),
default);
_fixture.VerifyNoDomainNotification();
tenant.Should().BeEquivalentTo(result);
}
[Fact]
public async Task Should_Get_Deleted_Tenant()
{
var tenant = _fixture.SetupTenant(true);
var result = await _fixture.QueryHandler.Handle(
new GetTenantByIdQuery(tenant.Id, true),
default);
_fixture.VerifyNoDomainNotification();
tenant.Should().BeEquivalentTo(result);
}
[Fact]
public async Task Should_Not_Get_Deleted_Tenant()
{
var tenant = _fixture.SetupTenant(true);
var result = await _fixture.QueryHandler.Handle(
new GetTenantByIdQuery(tenant.Id, false),
default);
_fixture.VerifyExistingNotification(
nameof(GetTenantByIdQuery),
ErrorCodes.ObjectNotFound,
$"Tenant with id {tenant.Id} could not be found");
result.Should().BeNull();
}
}

View File

@ -6,12 +6,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.10"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CleanArchitecture.Domain\CleanArchitecture.Domain.csproj" />
<ProjectReference Include="..\CleanArchitecture.Proto\CleanArchitecture.Proto.csproj" />
<ProjectReference Include="..\CleanArchitecture.Domain\CleanArchitecture.Domain.csproj"/>
<ProjectReference Include="..\CleanArchitecture.Proto\CleanArchitecture.Proto.csproj"/>
</ItemGroup>
</Project>

View File

@ -1,8 +1,11 @@
using System.Collections.Generic;
using CleanArchitecture.Application.Interfaces;
using CleanArchitecture.Application.Queries.Tenants.GetAll;
using CleanArchitecture.Application.Queries.Tenants.GetTenantById;
using CleanArchitecture.Application.Queries.Users.GetAll;
using CleanArchitecture.Application.Queries.Users.GetUserById;
using CleanArchitecture.Application.Services;
using CleanArchitecture.Application.ViewModels.Tenants;
using CleanArchitecture.Application.ViewModels.Users;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
@ -14,15 +17,22 @@ public static class ServiceCollectionExtension
public static IServiceCollection AddServices(this IServiceCollection services)
{
services.AddScoped<IUserService, UserService>();
services.AddScoped<ITenantService, TenantService>();
return services;
}
public static IServiceCollection AddQueryHandlers(this IServiceCollection services)
{
// User
services.AddScoped<IRequestHandler<GetUserByIdQuery, UserViewModel?>, GetUserByIdQueryHandler>();
services.AddScoped<IRequestHandler<GetAllUsersQuery, IEnumerable<UserViewModel>>, GetAllUsersQueryHandler>();
// Tenant
services.AddScoped<IRequestHandler<GetTenantByIdQuery, TenantViewModel?>, GetTenantByIdQueryHandler>();
services
.AddScoped<IRequestHandler<GetAllTenantsQuery, IEnumerable<TenantViewModel>>, GetAllTenantsQueryHandler>();
return services;
}
}

View File

@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CleanArchitecture.Application.ViewModels.Tenants;
namespace CleanArchitecture.Application.Interfaces;
public interface ITenantService
{
public Task<Guid> CreateTenantAsync(CreateTenantViewModel tenant);
public Task UpdateTenantAsync(UpdateTenantViewModel tenant);
public Task DeleteTenantAsync(Guid tenantId);
public Task<TenantViewModel?> GetTenantByIdAsync(Guid tenantId, bool deleted);
public Task<IEnumerable<TenantViewModel>> GetAllTenantsAsync();
}

View File

@ -0,0 +1,7 @@
using System.Collections.Generic;
using CleanArchitecture.Application.ViewModels.Tenants;
using MediatR;
namespace CleanArchitecture.Application.Queries.Tenants.GetAll;
public sealed record GetAllTenantsQuery : IRequest<IEnumerable<TenantViewModel>>;

View File

@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CleanArchitecture.Application.ViewModels.Tenants;
using CleanArchitecture.Domain.Interfaces.Repositories;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CleanArchitecture.Application.Queries.Tenants.GetAll;
public sealed class GetAllTenantsQueryHandler :
IRequestHandler<GetAllTenantsQuery, IEnumerable<TenantViewModel>>
{
private readonly ITenantRepository _tenantRepository;
public GetAllTenantsQueryHandler(ITenantRepository tenantRepository)
{
_tenantRepository = tenantRepository;
}
public async Task<IEnumerable<TenantViewModel>> Handle(
GetAllTenantsQuery request,
CancellationToken cancellationToken)
{
return await _tenantRepository
.GetAllNoTracking()
.Include(x => x.Users)
.Where(x => !x.Deleted)
.Select(x => TenantViewModel.FromTenant(x))
.ToListAsync(cancellationToken);
}
}

View File

@ -0,0 +1,7 @@
using System;
using CleanArchitecture.Application.ViewModels.Tenants;
using MediatR;
namespace CleanArchitecture.Application.Queries.Tenants.GetTenantById;
public sealed record GetTenantByIdQuery(Guid TenantId, bool IsDeleted) : IRequest<TenantViewModel?>;

View File

@ -0,0 +1,47 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CleanArchitecture.Application.ViewModels.Tenants;
using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Domain.Interfaces.Repositories;
using CleanArchitecture.Domain.Notifications;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace CleanArchitecture.Application.Queries.Tenants.GetTenantById;
public sealed class GetTenantByIdQueryHandler :
IRequestHandler<GetTenantByIdQuery, TenantViewModel?>
{
private readonly IMediatorHandler _bus;
private readonly ITenantRepository _tenantRepository;
public GetTenantByIdQueryHandler(ITenantRepository tenantRepository, IMediatorHandler bus)
{
_tenantRepository = tenantRepository;
_bus = bus;
}
public async Task<TenantViewModel?> Handle(GetTenantByIdQuery request, CancellationToken cancellationToken)
{
var tenant = _tenantRepository
.GetAllNoTracking()
.Include(x => x.Users)
.FirstOrDefault(x =>
x.Id == request.TenantId &&
x.Deleted == request.IsDeleted);
if (tenant is null)
{
await _bus.RaiseEventAsync(
new DomainNotification(
nameof(GetTenantByIdQuery),
$"Tenant with id {request.TenantId} could not be found",
ErrorCodes.ObjectNotFound));
return null;
}
return TenantViewModel.FromTenant(tenant);
}
}

View File

@ -30,7 +30,7 @@ public sealed class GetUserByIdQueryHandler :
x.Id == request.UserId &&
x.Deleted == request.IsDeleted);
if (user == null)
if (user is null)
{
await _bus.RaiseEventAsync(
new DomainNotification(

View File

@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using CleanArchitecture.Application.Interfaces;
using CleanArchitecture.Application.Queries.Tenants.GetAll;
using CleanArchitecture.Application.Queries.Tenants.GetTenantById;
using CleanArchitecture.Application.ViewModels.Tenants;
using CleanArchitecture.Domain.Commands.Tenants.CreateTenant;
using CleanArchitecture.Domain.Commands.Tenants.DeleteTenant;
using CleanArchitecture.Domain.Commands.Tenants.UpdateTenant;
using CleanArchitecture.Domain.Interfaces;
namespace CleanArchitecture.Application.Services;
public sealed class TenantService : ITenantService
{
private readonly IMediatorHandler _bus;
public TenantService(IMediatorHandler bus)
{
_bus = bus;
}
public async Task<Guid> CreateTenantAsync(CreateTenantViewModel tenant)
{
var tenantId = Guid.NewGuid();
await _bus.SendCommandAsync(new CreateTenantCommand(
tenantId,
tenant.Name));
return tenantId;
}
public async Task UpdateTenantAsync(UpdateTenantViewModel tenant)
{
await _bus.SendCommandAsync(new UpdateTenantCommand(
tenant.Id,
tenant.Name));
}
public async Task DeleteTenantAsync(Guid tenantId)
{
await _bus.SendCommandAsync(new DeleteTenantCommand(tenantId));
}
public async Task<TenantViewModel?> GetTenantByIdAsync(Guid tenantId, bool deleted)
{
return await _bus.QueryAsync(new GetTenantByIdQuery(tenantId, deleted));
}
public async Task<IEnumerable<TenantViewModel>> GetAllTenantsAsync()
{
return await _bus.QueryAsync(new GetAllTenantsQuery());
}
}

View File

@ -46,6 +46,7 @@ public sealed class UserService : IUserService
await _bus.SendCommandAsync(new CreateUserCommand(
userId,
user.TenantId,
user.Email,
user.FirstName,
user.LastName,
@ -61,7 +62,8 @@ public sealed class UserService : IUserService
user.Email,
user.FirstName,
user.LastName,
user.Role));
user.Role,
user.TenantId));
}
public async Task DeleteUserAsync(Guid userId)

View File

@ -0,0 +1,3 @@
namespace CleanArchitecture.Application.ViewModels.Tenants;
public sealed record CreateTenantViewModel(string Name);

View File

@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CleanArchitecture.Application.ViewModels.Users;
using CleanArchitecture.Domain.Entities;
namespace CleanArchitecture.Application.ViewModels.Tenants;
public sealed class TenantViewModel
{
public Guid Id { get; set; }
public string Name { get; set; } = string.Empty;
public IEnumerable<UserViewModel> Users { get; set; } = new List<UserViewModel>();
public static TenantViewModel FromTenant(Tenant tenant)
{
return new TenantViewModel
{
Id = tenant.Id,
Name = tenant.Name,
Users = tenant.Users.Select(UserViewModel.FromUser)
};
}
}

View File

@ -0,0 +1,7 @@
using System;
namespace CleanArchitecture.Application.ViewModels.Tenants;
public sealed record UpdateTenantViewModel(
Guid Id,
string Name);

View File

@ -0,0 +1,52 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CleanArchitecture.Domain.Interfaces.Repositories;
using CleanArchitecture.Proto.Tenants;
using Grpc.Core;
using Microsoft.EntityFrameworkCore;
namespace CleanArchitecture.Application.gRPC;
public sealed class TenantsApiImplementation : TenantsApi.TenantsApiBase
{
private readonly ITenantRepository _tenantRepository;
public TenantsApiImplementation(ITenantRepository tenantRepository)
{
_tenantRepository = tenantRepository;
}
public override async Task<GetTenantsByIdsResult> GetByIds(
GetTenantsByIdsRequest request,
ServerCallContext context)
{
var idsAsGuids = new List<Guid>(request.Ids.Count);
foreach (var id in request.Ids)
{
if (Guid.TryParse(id, out var parsed))
{
idsAsGuids.Add(parsed);
}
}
var tenants = await _tenantRepository
.GetAllNoTracking()
.Where(tenant => idsAsGuids.Contains(tenant.Id))
.Select(tenant => new Tenant
{
Id = tenant.Id.ToString(),
Name = tenant.Name,
IsDeleted = tenant.Deleted
})
.ToListAsync();
var result = new GetTenantsByIdsResult();
result.Tenants.AddRange(tenants);
return result;
}
}

View File

@ -18,8 +18,8 @@ public sealed class UsersApiImplementation : UsersApi.UsersApiBase
_userRepository = userRepository;
}
public override async Task<GetByIdsResult> GetByIds(
GetByIdsRequest request,
public override async Task<GetUsersByIdsResult> GetByIds(
GetUsersByIdsRequest request,
ServerCallContext context)
{
var idsAsGuids = new List<Guid>(request.Ids.Count);
@ -45,7 +45,7 @@ public sealed class UsersApiImplementation : UsersApi.UsersApiBase
})
.ToListAsync();
var result = new GetByIdsResult();
var result = new GetUsersByIdsResult();
result.Users.AddRange(users);

View File

@ -1,7 +1,10 @@
using System;
namespace CleanArchitecture.Application.ViewModels.Users;
public sealed record CreateUserViewModel(
string Email,
string FirstName,
string LastName,
string Password);
string Password,
Guid TenantId);

View File

@ -8,4 +8,5 @@ public sealed record UpdateUserViewModel(
string Email,
string FirstName,
string LastName,
UserRole Role);
UserRole Role,
Guid TenantId);

View File

@ -8,11 +8,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FluentAssertions" Version="6.11.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.0" />
<PackageReference Include="NSubstitute" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.5.0" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3"/>
<PackageReference Include="FluentAssertions" Version="6.11.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.0"/>
<PackageReference Include="NSubstitute" Version="5.0.0"/>
<PackageReference Include="xunit" Version="2.5.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
@ -24,7 +24,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CleanArchitecture.Domain\CleanArchitecture.Domain.csproj" />
<ProjectReference Include="..\CleanArchitecture.Domain\CleanArchitecture.Domain.csproj"/>
</ItemGroup>
</Project>

View File

@ -0,0 +1,69 @@
using System;
using CleanArchitecture.Domain.Commands.Tenants.CreateTenant;
using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Events.Tenant;
using Xunit;
namespace CleanArchitecture.Domain.Tests.CommandHandler.Tenant.CreateTenant;
public sealed class CreateTenantCommandHandlerTests
{
private readonly CreateTenantCommandTestFixture _fixture = new();
[Fact]
public void Should_Create_Tenant()
{
var command = new CreateTenantCommand(
Guid.NewGuid(),
"Test Tenant");
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyNoDomainNotification()
.VerifyCommit()
.VerifyRaisedEvent<TenantCreatedEvent>(x =>
x.AggregateId == command.AggregateId &&
x.Name == command.Name);
}
[Fact]
public void Should_Not_Create_Tenant_Insufficient_Permissions()
{
_fixture.SetupUser();
var command = new CreateTenantCommand(
Guid.NewGuid(),
"Test Tenant");
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyNoCommit()
.VerifyNoRaisedEvent<TenantCreatedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.InsufficientPermissions,
$"No permission to create tenant {command.AggregateId}");
}
[Fact]
public void Should_Not_Create_Tenant_Already_Exists()
{
var command = new CreateTenantCommand(
Guid.NewGuid(),
"Test Tenant");
_fixture.SetupExistingTenant(command.AggregateId);
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyNoCommit()
.VerifyNoRaisedEvent<TenantCreatedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
DomainErrorCodes.Tenant.TenantAlreadyExists,
$"There is already a tenant with Id {command.AggregateId}");
}
}

View File

@ -0,0 +1,38 @@
using System;
using CleanArchitecture.Domain.Commands.Tenants.CreateTenant;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Interfaces.Repositories;
using NSubstitute;
namespace CleanArchitecture.Domain.Tests.CommandHandler.Tenant.CreateTenant;
public sealed class CreateTenantCommandTestFixture : CommandHandlerFixtureBase
{
public CreateTenantCommandHandler CommandHandler { get; }
private ITenantRepository TenantRepository { get; }
public CreateTenantCommandTestFixture()
{
TenantRepository = Substitute.For<ITenantRepository>();
CommandHandler = new CreateTenantCommandHandler(
Bus,
UnitOfWork,
NotificationHandler,
TenantRepository,
User);
}
public void SetupUser()
{
User.GetUserRole().Returns(UserRole.User);
}
public void SetupExistingTenant(Guid id)
{
TenantRepository
.ExistsAsync(Arg.Is<Guid>(x => x == id))
.Returns(true);
}
}

View File

@ -0,0 +1,53 @@
using System;
using CleanArchitecture.Domain.Commands.Tenants.CreateTenant;
using CleanArchitecture.Domain.Errors;
using Xunit;
namespace CleanArchitecture.Domain.Tests.CommandHandler.Tenant.CreateTenant;
public sealed class CreateTenantCommandValidationTests :
ValidationTestBase<CreateTenantCommand, CreateTenantCommandValidation>
{
public CreateTenantCommandValidationTests() : base(new CreateTenantCommandValidation())
{
}
[Fact]
public void Should_Be_Valid()
{
var command = CreateTestCommand();
ShouldBeValid(command);
}
[Fact]
public void Should_Be_Invalid_For_Empty_Tenant_Id()
{
var command = CreateTestCommand(Guid.Empty);
ShouldHaveSingleError(
command,
DomainErrorCodes.Tenant.TenantEmptyId,
"Tenant id may not be empty");
}
[Fact]
public void Should_Be_Invalid_For_Empty_Tenant_Name()
{
var command = CreateTestCommand(name: "");
ShouldHaveSingleError(
command,
DomainErrorCodes.Tenant.TenantEmptyName,
"Name may not be empty");
}
private static CreateTenantCommand CreateTestCommand(
Guid? id = null,
string? name = null)
{
return new CreateTenantCommand(
id ?? Guid.NewGuid(),
name ?? "Test Tenant");
}
}

View File

@ -0,0 +1,64 @@
using System;
using CleanArchitecture.Domain.Commands.Tenants.DeleteTenant;
using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Events.Tenant;
using Xunit;
namespace CleanArchitecture.Domain.Tests.CommandHandler.Tenant.DeleteTenant;
public sealed class DeleteTenantCommandHandlerTests
{
private readonly DeleteTenantCommandTestFixture _fixture = new();
[Fact]
public void Should_Delete_Tenant()
{
var tenant = _fixture.SetupTenant();
var command = new DeleteTenantCommand(tenant.Id);
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyNoDomainNotification()
.VerifyCommit()
.VerifyRaisedEvent<TenantDeletedEvent>(x => x.AggregateId == tenant.Id);
}
[Fact]
public void Should_Not_Delete_Non_Existing_Tenant()
{
_fixture.SetupTenant();
var command = new DeleteTenantCommand(Guid.NewGuid());
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyNoCommit()
.VerifyNoRaisedEvent<TenantDeletedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.ObjectNotFound,
$"There is no tenant with Id {command.AggregateId}");
}
[Fact]
public void Should_Not_Delete_Tenant_Insufficient_Permissions()
{
var tenant = _fixture.SetupTenant();
_fixture.SetupUser();
var command = new DeleteTenantCommand(tenant.Id);
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyNoCommit()
.VerifyNoRaisedEvent<TenantDeletedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.InsufficientPermissions,
$"No permission to delete tenant {command.AggregateId}");
}
}

View File

@ -0,0 +1,45 @@
using System;
using CleanArchitecture.Domain.Commands.Tenants.DeleteTenant;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Interfaces.Repositories;
using NSubstitute;
namespace CleanArchitecture.Domain.Tests.CommandHandler.Tenant.DeleteTenant;
public sealed class DeleteTenantCommandTestFixture : CommandHandlerFixtureBase
{
public DeleteTenantCommandHandler CommandHandler { get; }
private ITenantRepository TenantRepository { get; }
private IUserRepository UserRepository { get; }
public DeleteTenantCommandTestFixture()
{
TenantRepository = Substitute.For<ITenantRepository>();
UserRepository = Substitute.For<IUserRepository>();
CommandHandler = new DeleteTenantCommandHandler(
Bus,
UnitOfWork,
NotificationHandler,
TenantRepository,
UserRepository,
User);
}
public Entities.Tenant SetupTenant()
{
var tenant = new Entities.Tenant(Guid.NewGuid(), "TestTenant");
TenantRepository
.GetByIdAsync(Arg.Is<Guid>(y => y == tenant.Id))
.Returns(tenant);
return tenant;
}
public void SetupUser()
{
User.GetUserRole().Returns(UserRole.User);
}
}

View File

@ -0,0 +1,38 @@
using System;
using CleanArchitecture.Domain.Commands.Tenants.DeleteTenant;
using CleanArchitecture.Domain.Errors;
using Xunit;
namespace CleanArchitecture.Domain.Tests.CommandHandler.Tenant.DeleteTenant;
public sealed class DeleteTenantCommandValidationTests :
ValidationTestBase<DeleteTenantCommand, DeleteTenantCommandValidation>
{
public DeleteTenantCommandValidationTests() : base(new DeleteTenantCommandValidation())
{
}
[Fact]
public void Should_Be_Valid()
{
var command = CreateTestCommand();
ShouldBeValid(command);
}
[Fact]
public void Should_Be_Invalid_For_Empty_Tenant_Id()
{
var command = CreateTestCommand(Guid.Empty);
ShouldHaveSingleError(
command,
DomainErrorCodes.Tenant.TenantEmptyId,
"Tenant id may not be empty");
}
private static DeleteTenantCommand CreateTestCommand(Guid? tenantId = null)
{
return new DeleteTenantCommand(tenantId ?? Guid.NewGuid());
}
}

View File

@ -0,0 +1,69 @@
using System;
using CleanArchitecture.Domain.Commands.Tenants.UpdateTenant;
using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Events.Tenant;
using Xunit;
namespace CleanArchitecture.Domain.Tests.CommandHandler.Tenant.UpdateTenant;
public sealed class UpdateTenantCommandHandlerTests
{
private readonly UpdateTenantCommandTestFixture _fixture = new();
[Fact]
public void Should_Update_Tenant()
{
var command = new UpdateTenantCommand(
Guid.NewGuid(),
"Tenant Name");
_fixture.SetupExistingTenant(command.AggregateId);
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyCommit()
.VerifyNoDomainNotification()
.VerifyRaisedEvent<TenantUpdatedEvent>(x =>
x.AggregateId == command.AggregateId &&
x.Name == command.Name);
}
[Fact]
public void Should_Not_Update_Tenant_Insufficient_Permissions()
{
var command = new UpdateTenantCommand(
Guid.NewGuid(),
"Tenant Name");
_fixture.SetupUser();
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyNoCommit()
.VerifyNoRaisedEvent<TenantUpdatedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.InsufficientPermissions,
$"No permission to update tenant {command.AggregateId}");
}
[Fact]
public void Should_Not_Update_Tenant_Not_Existing()
{
var command = new UpdateTenantCommand(
Guid.NewGuid(),
"Tenant Name");
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyNoCommit()
.VerifyNoRaisedEvent<TenantUpdatedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.ObjectNotFound,
$"There is no tenant with Id {command.AggregateId}");
}
}

View File

@ -0,0 +1,38 @@
using System;
using CleanArchitecture.Domain.Commands.Tenants.UpdateTenant;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Interfaces.Repositories;
using NSubstitute;
namespace CleanArchitecture.Domain.Tests.CommandHandler.Tenant.UpdateTenant;
public sealed class UpdateTenantCommandTestFixture : CommandHandlerFixtureBase
{
public UpdateTenantCommandHandler CommandHandler { get; }
private ITenantRepository TenantRepository { get; }
public UpdateTenantCommandTestFixture()
{
TenantRepository = Substitute.For<ITenantRepository>();
CommandHandler = new UpdateTenantCommandHandler(
Bus,
UnitOfWork,
NotificationHandler,
TenantRepository,
User);
}
public void SetupUser()
{
User.GetUserRole().Returns(UserRole.User);
}
public void SetupExistingTenant(Guid id)
{
TenantRepository
.GetByIdAsync(Arg.Is<Guid>(x => x == id))
.Returns(new Entities.Tenant(id, "Test Tenant"));
}
}

View File

@ -0,0 +1,53 @@
using System;
using CleanArchitecture.Domain.Commands.Tenants.UpdateTenant;
using CleanArchitecture.Domain.Errors;
using Xunit;
namespace CleanArchitecture.Domain.Tests.CommandHandler.Tenant.UpdateTenant;
public sealed class UpdateTenantCommandValidationTests :
ValidationTestBase<UpdateTenantCommand, UpdateTenantCommandValidation>
{
public UpdateTenantCommandValidationTests() : base(new UpdateTenantCommandValidation())
{
}
[Fact]
public void Should_Be_Valid()
{
var command = CreateTestCommand();
ShouldBeValid(command);
}
[Fact]
public void Should_Be_Invalid_For_Empty_Tenant_Id()
{
var command = CreateTestCommand(Guid.Empty);
ShouldHaveSingleError(
command,
DomainErrorCodes.Tenant.TenantEmptyId,
"Tenant id may not be empty");
}
[Fact]
public void Should_Be_Invalid_For_Empty_Tenant_Name()
{
var command = CreateTestCommand(name: "");
ShouldHaveSingleError(
command,
DomainErrorCodes.Tenant.TenantEmptyName,
"Name may not be empty");
}
private static UpdateTenantCommand CreateTestCommand(
Guid? id = null,
string? name = null)
{
return new UpdateTenantCommand(
id ?? Guid.NewGuid(),
name ?? "Test Tenant");
}
}

View File

@ -22,7 +22,7 @@ public sealed class ChangePasswordCommandHandlerTests
_fixture
.VerifyNoDomainNotification()
.VerifyCommit()
.VerifyRaisedEvent<PasswordChangedEvent>(x => x.UserId == user.Id);
.VerifyRaisedEvent<PasswordChangedEvent>(x => x.AggregateId == user.Id);
}
[Fact]
@ -40,7 +40,7 @@ public sealed class ChangePasswordCommandHandlerTests
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.ObjectNotFound,
$"There is no User with Id {userId}");
$"There is no user with Id {userId}");
}
[Fact]
@ -57,7 +57,7 @@ public sealed class ChangePasswordCommandHandlerTests
.VerifyNoRaisedEvent<UserUpdatedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
DomainErrorCodes.UserPasswordIncorrect,
DomainErrorCodes.User.UserPasswordIncorrect,
"The password is incorrect");
}
}

View File

@ -9,6 +9,9 @@ namespace CleanArchitecture.Domain.Tests.CommandHandler.User.ChangePassword;
public sealed class ChangePasswordCommandTestFixture : CommandHandlerFixtureBase
{
public ChangePasswordCommandHandler CommandHandler { get; }
private IUserRepository UserRepository { get; }
public ChangePasswordCommandTestFixture()
{
UserRepository = Substitute.For<IUserRepository>();
@ -21,12 +24,10 @@ public sealed class ChangePasswordCommandTestFixture : CommandHandlerFixtureBase
User);
}
public ChangePasswordCommandHandler CommandHandler { get; }
private IUserRepository UserRepository { get; }
public Entities.User SetupUser()
{
var user = new Entities.User(
Guid.NewGuid(),
Guid.NewGuid(),
"max@mustermann.com",
"Max",

View File

@ -28,12 +28,12 @@ public sealed class ChangePasswordCommandValidationTests :
var errors = new List<string>
{
DomainErrorCodes.UserEmptyPassword,
DomainErrorCodes.UserSpecialCharPassword,
DomainErrorCodes.UserNumberPassword,
DomainErrorCodes.UserLowercaseLetterPassword,
DomainErrorCodes.UserUppercaseLetterPassword,
DomainErrorCodes.UserShortPassword
DomainErrorCodes.User.UserEmptyPassword,
DomainErrorCodes.User.UserSpecialCharPassword,
DomainErrorCodes.User.UserNumberPassword,
DomainErrorCodes.User.UserLowercaseLetterPassword,
DomainErrorCodes.User.UserUppercaseLetterPassword,
DomainErrorCodes.User.UserShortPassword
};
ShouldHaveExpectedErrors(command, errors.ToArray());
@ -44,7 +44,7 @@ public sealed class ChangePasswordCommandValidationTests :
{
var command = CreateTestCommand("z8tnayvd5FNLU9AQm");
ShouldHaveSingleError(command, DomainErrorCodes.UserSpecialCharPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserSpecialCharPassword);
}
[Fact]
@ -52,7 +52,7 @@ public sealed class ChangePasswordCommandValidationTests :
{
var command = CreateTestCommand("z]tnayvdFNLU:]AQm");
ShouldHaveSingleError(command, DomainErrorCodes.UserNumberPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserNumberPassword);
}
[Fact]
@ -60,7 +60,7 @@ public sealed class ChangePasswordCommandValidationTests :
{
var command = CreateTestCommand("Z8]TNAYVDFNLU:]AQM");
ShouldHaveSingleError(command, DomainErrorCodes.UserLowercaseLetterPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserLowercaseLetterPassword);
}
[Fact]
@ -68,7 +68,7 @@ public sealed class ChangePasswordCommandValidationTests :
{
var command = CreateTestCommand("z8]tnayvd5fnlu9:]aqm");
ShouldHaveSingleError(command, DomainErrorCodes.UserUppercaseLetterPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserUppercaseLetterPassword);
}
[Fact]
@ -76,7 +76,7 @@ public sealed class ChangePasswordCommandValidationTests :
{
var command = CreateTestCommand("zA6{");
ShouldHaveSingleError(command, DomainErrorCodes.UserShortPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserShortPassword);
}
[Fact]
@ -84,13 +84,13 @@ public sealed class ChangePasswordCommandValidationTests :
{
var command = CreateTestCommand(string.Concat(Enumerable.Repeat("zA6{", 12), 12));
ShouldHaveSingleError(command, DomainErrorCodes.UserLongPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserLongPassword);
}
private static ChangePasswordCommand CreateTestCommand(
string? password = null, string? newPassword = null)
{
return new(
return new ChangePasswordCommand(
password ?? "z8]tnayvd5FNLU9:]AQm",
newPassword ?? "z8]tnayvd5FNLU9:]AQw");
}

View File

@ -1,7 +1,9 @@
using System;
using CleanArchitecture.Domain.Commands.Users.CreateUser;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Events.User;
using NSubstitute;
using Xunit;
namespace CleanArchitecture.Domain.Tests.CommandHandler.User.CreateUser;
@ -13,10 +15,14 @@ public sealed class CreateUserCommandHandlerTests
[Fact]
public void Should_Create_User()
{
_fixture.SetupUser();
_fixture.SetupCurrentUser();
var user = _fixture.SetupUser();
_fixture.SetupTenant(user.TenantId);
var command = new CreateUserCommand(
Guid.NewGuid(),
user.TenantId,
"test@email.com",
"Test",
"Email",
@ -27,16 +33,19 @@ public sealed class CreateUserCommandHandlerTests
_fixture
.VerifyNoDomainNotification()
.VerifyCommit()
.VerifyRaisedEvent<UserCreatedEvent>(x => x.UserId == command.UserId);
.VerifyRaisedEvent<UserCreatedEvent>(x => x.AggregateId == command.UserId);
}
[Fact]
public void Should_Not_Create_Already_Existing_User()
{
_fixture.SetupCurrentUser();
var user = _fixture.SetupUser();
var command = new CreateUserCommand(
user.Id,
Guid.NewGuid(),
"test@email.com",
"Test",
"Email",
@ -49,7 +58,92 @@ public sealed class CreateUserCommandHandlerTests
.VerifyNoRaisedEvent<UserCreatedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
DomainErrorCodes.UserAlreadyExists,
$"There is already a User with Id {command.UserId}");
DomainErrorCodes.User.UserAlreadyExists,
$"There is already a user with Id {command.UserId}");
}
[Fact]
public void Should_Not_Create_Already_Existing_Email()
{
_fixture.SetupCurrentUser();
_fixture.UserRepository
.GetByEmailAsync(Arg.Is<string>(y => y == "test@email.com"))
.Returns(new Entities.User(
Guid.NewGuid(),
Guid.NewGuid(),
"max@mustermann.com",
"Max",
"Mustermann",
"Password",
UserRole.User));
var command = new CreateUserCommand(
Guid.NewGuid(),
Guid.NewGuid(),
"test@email.com",
"Test",
"Email",
"Po=PF]PC6t.?8?ks)A6W");
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyNoCommit()
.VerifyNoRaisedEvent<UserCreatedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
DomainErrorCodes.User.UserAlreadyExists,
$"There is already a user with email {command.Email}");
}
[Fact]
public void Should_Not_Create_User_Tenant_Does_Not_Exist()
{
_fixture.SetupCurrentUser();
_fixture.SetupUser();
var command = new CreateUserCommand(
Guid.NewGuid(),
Guid.NewGuid(),
"test@email.com",
"Test",
"Email",
"Po=PF]PC6t.?8?ks)A6W");
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyNoCommit()
.VerifyNoRaisedEvent<UserCreatedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.ObjectNotFound,
$"There is no tenant with Id {command.TenantId}");
}
[Fact]
public void Should_Not_Create_User_Insufficient_Permissions()
{
_fixture.SetupUser();
var command = new CreateUserCommand(
Guid.NewGuid(),
Guid.NewGuid(),
"test@email.com",
"Test",
"Email",
"Po=PF]PC6t.?8?ks)A6W");
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyNoCommit()
.VerifyNoRaisedEvent<UserCreatedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.InsufficientPermissions,
"You are not allowed to create users");
}
}

View File

@ -8,23 +8,28 @@ namespace CleanArchitecture.Domain.Tests.CommandHandler.User.CreateUser;
public sealed class CreateUserCommandTestFixture : CommandHandlerFixtureBase
{
public CreateUserCommandHandler CommandHandler { get; }
public IUserRepository UserRepository { get; }
private ITenantRepository TenantRepository { get; }
public CreateUserCommandTestFixture()
{
UserRepository = Substitute.For<IUserRepository>();
TenantRepository = Substitute.For<ITenantRepository>();
CommandHandler = new CreateUserCommandHandler(
Bus,
UnitOfWork,
NotificationHandler,
UserRepository);
UserRepository,
TenantRepository,
User);
}
public CreateUserCommandHandler CommandHandler { get; }
private IUserRepository UserRepository { get; }
public Entities.User SetupUser()
{
var user = new Entities.User(
Guid.NewGuid(),
Guid.NewGuid(),
"max@mustermann.com",
"Max",
@ -38,4 +43,29 @@ public sealed class CreateUserCommandTestFixture : CommandHandlerFixtureBase
return user;
}
public void SetupCurrentUser()
{
var userId = Guid.NewGuid();
User.GetUserId().Returns(userId);
UserRepository
.GetByIdAsync(Arg.Is<Guid>(y => y == userId))
.Returns(new Entities.User(
userId,
Guid.NewGuid(),
"some email",
"some first name",
"some last name",
"some password",
UserRole.Admin));
}
public void SetupTenant(Guid tenantId)
{
TenantRepository
.ExistsAsync(Arg.Is<Guid>(y => y == tenantId))
.Returns(true);
}
}

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using CleanArchitecture.Domain.Commands.Users.CreateUser;
using CleanArchitecture.Domain.Constants;
using CleanArchitecture.Domain.Errors;
using Xunit;
@ -29,7 +30,7 @@ public sealed class CreateUserCommandValidationTests :
ShouldHaveSingleError(
command,
DomainErrorCodes.UserEmptyId,
DomainErrorCodes.User.UserEmptyId,
"User id may not be empty");
}
@ -40,7 +41,7 @@ public sealed class CreateUserCommandValidationTests :
ShouldHaveSingleError(
command,
DomainErrorCodes.UserInvalidEmail,
DomainErrorCodes.User.UserInvalidEmail,
"Email is not a valid email address");
}
@ -51,19 +52,19 @@ public sealed class CreateUserCommandValidationTests :
ShouldHaveSingleError(
command,
DomainErrorCodes.UserInvalidEmail,
DomainErrorCodes.User.UserInvalidEmail,
"Email is not a valid email address");
}
[Fact]
public void Should_Be_Invalid_For_Email_Exceeds_Max_Length()
{
var command = CreateTestCommand(email: new string('a', 320) + "@test.com");
var command = CreateTestCommand(email: new string('a', MaxLengths.User.Email) + "@test.com");
ShouldHaveSingleError(
command,
DomainErrorCodes.UserEmailExceedsMaxLength,
"Email may not be longer than 320 characters");
DomainErrorCodes.User.UserEmailExceedsMaxLength,
$"Email may not be longer than {MaxLengths.User.Email} characters");
}
[Fact]
@ -73,19 +74,19 @@ public sealed class CreateUserCommandValidationTests :
ShouldHaveSingleError(
command,
DomainErrorCodes.UserEmptyFirstName,
DomainErrorCodes.User.UserEmptyFirstName,
"FirstName may not be empty");
}
[Fact]
public void Should_Be_Invalid_For_First_Name_Exceeds_Max_Length()
{
var command = CreateTestCommand(firstName: new string('a', 101));
var command = CreateTestCommand(firstName: new string('a', MaxLengths.User.FirstName + 1));
ShouldHaveSingleError(
command,
DomainErrorCodes.UserFirstNameExceedsMaxLength,
"FirstName may not be longer than 100 characters");
DomainErrorCodes.User.UserFirstNameExceedsMaxLength,
$"FirstName may not be longer than {MaxLengths.User.FirstName} characters");
}
[Fact]
@ -95,19 +96,19 @@ public sealed class CreateUserCommandValidationTests :
ShouldHaveSingleError(
command,
DomainErrorCodes.UserEmptyLastName,
DomainErrorCodes.User.UserEmptyLastName,
"LastName may not be empty");
}
[Fact]
public void Should_Be_Invalid_For_Last_Name_Exceeds_Max_Length()
{
var command = CreateTestCommand(lastName: new string('a', 101));
var command = CreateTestCommand(lastName: new string('a', MaxLengths.User.LastName + 1));
ShouldHaveSingleError(
command,
DomainErrorCodes.UserLastNameExceedsMaxLength,
"LastName may not be longer than 100 characters");
DomainErrorCodes.User.UserLastNameExceedsMaxLength,
$"LastName may not be longer than {MaxLengths.User.LastName} characters");
}
[Fact]
@ -117,12 +118,12 @@ public sealed class CreateUserCommandValidationTests :
var errors = new List<string>
{
DomainErrorCodes.UserEmptyPassword,
DomainErrorCodes.UserSpecialCharPassword,
DomainErrorCodes.UserNumberPassword,
DomainErrorCodes.UserLowercaseLetterPassword,
DomainErrorCodes.UserUppercaseLetterPassword,
DomainErrorCodes.UserShortPassword
DomainErrorCodes.User.UserEmptyPassword,
DomainErrorCodes.User.UserSpecialCharPassword,
DomainErrorCodes.User.UserNumberPassword,
DomainErrorCodes.User.UserLowercaseLetterPassword,
DomainErrorCodes.User.UserUppercaseLetterPassword,
DomainErrorCodes.User.UserShortPassword
};
ShouldHaveExpectedErrors(command, errors.ToArray());
@ -133,7 +134,7 @@ public sealed class CreateUserCommandValidationTests :
{
var command = CreateTestCommand(password: "z8tnayvd5FNLU9AQm");
ShouldHaveSingleError(command, DomainErrorCodes.UserSpecialCharPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserSpecialCharPassword);
}
[Fact]
@ -141,7 +142,7 @@ public sealed class CreateUserCommandValidationTests :
{
var command = CreateTestCommand(password: "z]tnayvdFNLU:]AQm");
ShouldHaveSingleError(command, DomainErrorCodes.UserNumberPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserNumberPassword);
}
[Fact]
@ -149,7 +150,7 @@ public sealed class CreateUserCommandValidationTests :
{
var command = CreateTestCommand(password: "Z8]TNAYVDFNLU:]AQM");
ShouldHaveSingleError(command, DomainErrorCodes.UserLowercaseLetterPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserLowercaseLetterPassword);
}
[Fact]
@ -157,7 +158,7 @@ public sealed class CreateUserCommandValidationTests :
{
var command = CreateTestCommand(password: "z8]tnayvd5fnlu9:]aqm");
ShouldHaveSingleError(command, DomainErrorCodes.UserUppercaseLetterPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserUppercaseLetterPassword);
}
[Fact]
@ -165,7 +166,7 @@ public sealed class CreateUserCommandValidationTests :
{
var command = CreateTestCommand(password: "zA6{");
ShouldHaveSingleError(command, DomainErrorCodes.UserShortPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserShortPassword);
}
[Fact]
@ -173,18 +174,28 @@ public sealed class CreateUserCommandValidationTests :
{
var command = CreateTestCommand(password: string.Concat(Enumerable.Repeat("zA6{", 12), 12));
ShouldHaveSingleError(command, DomainErrorCodes.UserLongPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserLongPassword);
}
[Fact]
public void Should_Be_Invalid_For_Empty_Tenant_Id()
{
var command = CreateTestCommand(tenantId: Guid.Empty);
ShouldHaveSingleError(command, DomainErrorCodes.Tenant.TenantEmptyId);
}
private static CreateUserCommand CreateTestCommand(
Guid? userId = null,
Guid? tenantId = null,
string? email = null,
string? firstName = null,
string? lastName = null,
string? password = null)
{
return new(
return new CreateUserCommand(
userId ?? Guid.NewGuid(),
tenantId ?? Guid.NewGuid(),
email ?? "test@email.com",
firstName ?? "test",
lastName ?? "email",

View File

@ -22,7 +22,7 @@ public sealed class DeleteUserCommandHandlerTests
_fixture
.VerifyNoDomainNotification()
.VerifyCommit()
.VerifyRaisedEvent<UserDeletedEvent>(x => x.UserId == user.Id);
.VerifyRaisedEvent<UserDeletedEvent>(x => x.AggregateId == user.Id);
}
[Fact]
@ -40,6 +40,26 @@ public sealed class DeleteUserCommandHandlerTests
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.ObjectNotFound,
$"There is no User with Id {command.UserId}");
$"There is no user with Id {command.UserId}");
}
[Fact]
public void Should_Not_Delete_User_Insufficient_Permissions()
{
var user = _fixture.SetupUser();
_fixture.SetupCurrentUser();
var command = new DeleteUserCommand(user.Id);
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyNoCommit()
.VerifyNoRaisedEvent<UserDeletedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.InsufficientPermissions,
$"No permission to delete user {command.UserId}");
}
}

View File

@ -8,6 +8,9 @@ namespace CleanArchitecture.Domain.Tests.CommandHandler.User.DeleteUser;
public sealed class DeleteUserCommandTestFixture : CommandHandlerFixtureBase
{
public DeleteUserCommandHandler CommandHandler { get; }
private IUserRepository UserRepository { get; }
public DeleteUserCommandTestFixture()
{
UserRepository = Substitute.For<IUserRepository>();
@ -20,12 +23,10 @@ public sealed class DeleteUserCommandTestFixture : CommandHandlerFixtureBase
User);
}
public DeleteUserCommandHandler CommandHandler { get; }
private IUserRepository UserRepository { get; }
public Entities.User SetupUser()
{
var user = new Entities.User(
Guid.NewGuid(),
Guid.NewGuid(),
"max@mustermann.com",
"Max",
@ -39,4 +40,9 @@ public sealed class DeleteUserCommandTestFixture : CommandHandlerFixtureBase
return user;
}
public void SetupCurrentUser()
{
User.GetUserRole().Returns(UserRole.User);
}
}

View File

@ -27,12 +27,12 @@ public sealed class DeleteUserCommandValidationTests :
ShouldHaveSingleError(
command,
DomainErrorCodes.UserEmptyId,
DomainErrorCodes.User.UserEmptyId,
"User id may not be empty");
}
private static DeleteUserCommand CreateTestCommand(Guid? userId = null)
{
return new(userId ?? Guid.NewGuid());
return new DeleteUserCommand(userId ?? Guid.NewGuid());
}
}

View File

@ -57,7 +57,7 @@ public sealed class LoginUserCommandHandlerTests
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.ObjectNotFound,
$"There is no User with Email {command.Email}");
$"There is no user with email {command.Email}");
token.Should().BeEmpty();
}
@ -74,7 +74,7 @@ public sealed class LoginUserCommandHandlerTests
_fixture
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
DomainErrorCodes.UserPasswordIncorrect,
DomainErrorCodes.User.UserPasswordIncorrect,
"The password is incorrect");
token.Should().BeEmpty();

View File

@ -11,6 +11,10 @@ namespace CleanArchitecture.Domain.Tests.CommandHandler.User.LoginUser;
public sealed class LoginUserCommandTestFixture : CommandHandlerFixtureBase
{
public LoginUserCommandHandler CommandHandler { get; set; }
public IUserRepository UserRepository { get; set; }
public IOptions<TokenSettings> TokenSettings { get; set; }
public LoginUserCommandTestFixture()
{
UserRepository = Substitute.For<IUserRepository>();
@ -30,13 +34,10 @@ public sealed class LoginUserCommandTestFixture : CommandHandlerFixtureBase
TokenSettings);
}
public LoginUserCommandHandler CommandHandler { get; set; }
public IUserRepository UserRepository { get; set; }
public IOptions<TokenSettings> TokenSettings { get; set; }
public Entities.User SetupUser()
{
var user = new Entities.User(
Guid.NewGuid(),
Guid.NewGuid(),
"max@mustermann.com",
"Max",

View File

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using CleanArchitecture.Domain.Commands.Users.LoginUser;
using CleanArchitecture.Domain.Constants;
using CleanArchitecture.Domain.Errors;
using Xunit;
@ -28,7 +29,7 @@ public sealed class LoginUserCommandValidationTests :
ShouldHaveSingleError(
command,
DomainErrorCodes.UserInvalidEmail,
DomainErrorCodes.User.UserInvalidEmail,
"Email is not a valid email address");
}
@ -39,19 +40,19 @@ public sealed class LoginUserCommandValidationTests :
ShouldHaveSingleError(
command,
DomainErrorCodes.UserInvalidEmail,
DomainErrorCodes.User.UserInvalidEmail,
"Email is not a valid email address");
}
[Fact]
public void Should_Be_Invalid_For_Email_Exceeds_Max_Length()
{
var command = CreateTestCommand(new string('a', 320) + "@test.com");
var command = CreateTestCommand(new string('a', MaxLengths.User.Email) + "@test.com");
ShouldHaveSingleError(
command,
DomainErrorCodes.UserEmailExceedsMaxLength,
"Email may not be longer than 320 characters");
DomainErrorCodes.User.UserEmailExceedsMaxLength,
$"Email may not be longer than {MaxLengths.User.Email} characters");
}
[Fact]
@ -61,12 +62,12 @@ public sealed class LoginUserCommandValidationTests :
var errors = new List<string>
{
DomainErrorCodes.UserEmptyPassword,
DomainErrorCodes.UserSpecialCharPassword,
DomainErrorCodes.UserNumberPassword,
DomainErrorCodes.UserLowercaseLetterPassword,
DomainErrorCodes.UserUppercaseLetterPassword,
DomainErrorCodes.UserShortPassword
DomainErrorCodes.User.UserEmptyPassword,
DomainErrorCodes.User.UserSpecialCharPassword,
DomainErrorCodes.User.UserNumberPassword,
DomainErrorCodes.User.UserLowercaseLetterPassword,
DomainErrorCodes.User.UserUppercaseLetterPassword,
DomainErrorCodes.User.UserShortPassword
};
ShouldHaveExpectedErrors(command, errors.ToArray());
@ -77,7 +78,7 @@ public sealed class LoginUserCommandValidationTests :
{
var command = CreateTestCommand(password: "z8tnayvd5FNLU9AQm");
ShouldHaveSingleError(command, DomainErrorCodes.UserSpecialCharPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserSpecialCharPassword);
}
[Fact]
@ -85,7 +86,7 @@ public sealed class LoginUserCommandValidationTests :
{
var command = CreateTestCommand(password: "z]tnayvdFNLU:]AQm");
ShouldHaveSingleError(command, DomainErrorCodes.UserNumberPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserNumberPassword);
}
[Fact]
@ -93,7 +94,7 @@ public sealed class LoginUserCommandValidationTests :
{
var command = CreateTestCommand(password: "Z8]TNAYVDFNLU:]AQM");
ShouldHaveSingleError(command, DomainErrorCodes.UserLowercaseLetterPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserLowercaseLetterPassword);
}
[Fact]
@ -101,7 +102,7 @@ public sealed class LoginUserCommandValidationTests :
{
var command = CreateTestCommand(password: "z8]tnayvd5fnlu9:]aqm");
ShouldHaveSingleError(command, DomainErrorCodes.UserUppercaseLetterPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserUppercaseLetterPassword);
}
[Fact]
@ -109,7 +110,7 @@ public sealed class LoginUserCommandValidationTests :
{
var command = CreateTestCommand(password: "zA6{");
ShouldHaveSingleError(command, DomainErrorCodes.UserShortPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserShortPassword);
}
[Fact]
@ -117,14 +118,14 @@ public sealed class LoginUserCommandValidationTests :
{
var command = CreateTestCommand(password: string.Concat(Enumerable.Repeat("zA6{", 12), 12));
ShouldHaveSingleError(command, DomainErrorCodes.UserLongPassword);
ShouldHaveSingleError(command, DomainErrorCodes.User.UserLongPassword);
}
private static LoginUserCommand CreateTestCommand(
string? email = null,
string? password = null)
{
return new(
return new LoginUserCommand(
email ?? "test@email.com",
password ?? "Po=PF]PC6t.?8?ks)A6W");
}

View File

@ -23,14 +23,17 @@ public sealed class UpdateUserCommandHandlerTests
"test@email.com",
"Test",
"Email",
UserRole.User);
UserRole.User,
Guid.NewGuid());
_fixture.SetupTenant(command.TenantId);
await _fixture.CommandHandler.Handle(command, default);
_fixture
.VerifyNoDomainNotification()
.VerifyCommit()
.VerifyRaisedEvent<UserUpdatedEvent>(x => x.UserId == command.UserId);
.VerifyRaisedEvent<UserUpdatedEvent>(x => x.AggregateId == command.UserId);
}
[Fact]
@ -43,7 +46,10 @@ public sealed class UpdateUserCommandHandlerTests
"test@email.com",
"Test",
"Email",
UserRole.User);
UserRole.User,
Guid.NewGuid());
_fixture.SetupTenant(command.TenantId);
await _fixture.CommandHandler.Handle(command, default);
@ -53,7 +59,7 @@ public sealed class UpdateUserCommandHandlerTests
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.ObjectNotFound,
$"There is no User with Id {command.UserId}");
$"There is no user with Id {command.UserId}");
}
[Fact]
@ -66,11 +72,15 @@ public sealed class UpdateUserCommandHandlerTests
"test@email.com",
"Test",
"Email",
UserRole.User);
UserRole.User,
Guid.NewGuid());
_fixture.SetupTenant(command.TenantId);
_fixture.UserRepository
.GetByEmailAsync(command.Email)
.Returns(new Entities.User(
Guid.NewGuid(),
Guid.NewGuid(),
command.Email,
"Some",
@ -85,7 +95,62 @@ public sealed class UpdateUserCommandHandlerTests
.VerifyNoRaisedEvent<UserUpdatedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
DomainErrorCodes.UserAlreadyExists,
$"There is already a User with Email {command.Email}");
DomainErrorCodes.User.UserAlreadyExists,
$"There is already a user with email {command.Email}");
}
[Fact]
public async Task Should_Not_Update_Non_Existing_Tenant()
{
var user = _fixture.SetupUser();
var command = new UpdateUserCommand(
user.Id,
"test@email.com",
"Test",
"Email",
UserRole.User,
Guid.NewGuid());
await _fixture.CommandHandler.Handle(command, default);
_fixture
.VerifyNoCommit()
.VerifyNoRaisedEvent<UserUpdatedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.ObjectNotFound,
$"There is no tenant with Id {command.TenantId}");
}
[Fact]
public async Task Should_Not_Update_Admin_Properties()
{
var user = _fixture.SetupUser();
_fixture.SetupCurrentUser(user.Id);
var command = new UpdateUserCommand(
user.Id,
"test@email.com",
"Test",
"Email",
UserRole.Admin,
Guid.NewGuid());
_fixture.SetupTenant(command.TenantId);
await _fixture.CommandHandler.Handle(command, default);
_fixture.UserRepository.Received(1).Update(Arg.Is<Entities.User>(u =>
u.TenantId == user.TenantId &&
u.Role == user.Role &&
u.Id == command.UserId &&
u.Email == command.Email &&
u.FirstName == command.FirstName));
_fixture
.VerifyNoDomainNotification()
.VerifyCommit()
.VerifyRaisedEvent<UserUpdatedEvent>(x => x.AggregateId == command.UserId);
}
}

View File

@ -8,24 +8,28 @@ namespace CleanArchitecture.Domain.Tests.CommandHandler.User.UpdateUser;
public sealed class UpdateUserCommandTestFixture : CommandHandlerFixtureBase
{
public UpdateUserCommandHandler CommandHandler { get; }
public IUserRepository UserRepository { get; }
private ITenantRepository TenantRepository { get; }
public UpdateUserCommandTestFixture()
{
UserRepository = Substitute.For<IUserRepository>();
TenantRepository = Substitute.For<ITenantRepository>();
CommandHandler = new UpdateUserCommandHandler(
Bus,
UnitOfWork,
NotificationHandler,
UserRepository,
User);
User,
TenantRepository);
}
public UpdateUserCommandHandler CommandHandler { get; }
public IUserRepository UserRepository { get; }
public Entities.User SetupUser()
{
var user = new Entities.User(
Guid.NewGuid(),
Guid.NewGuid(),
"max@mustermann.com",
"Max",
@ -39,4 +43,21 @@ public sealed class UpdateUserCommandTestFixture : CommandHandlerFixtureBase
return user;
}
public Entities.Tenant SetupTenant(Guid tenantId)
{
var tenant = new Entities.Tenant(tenantId, "Name");
TenantRepository
.ExistsAsync(Arg.Is<Guid>(y => y == tenant.Id))
.Returns(true);
return tenant;
}
public void SetupCurrentUser(Guid userId)
{
User.GetUserId().Returns(userId);
User.GetUserRole().Returns(UserRole.User);
}
}

View File

@ -1,5 +1,6 @@
using System;
using CleanArchitecture.Domain.Commands.Users.UpdateUser;
using CleanArchitecture.Domain.Constants;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Errors;
using Xunit;
@ -28,7 +29,7 @@ public sealed class UpdateUserCommandValidationTests :
ShouldHaveSingleError(
command,
DomainErrorCodes.UserEmptyId,
DomainErrorCodes.User.UserEmptyId,
"User id may not be empty");
}
@ -39,7 +40,7 @@ public sealed class UpdateUserCommandValidationTests :
ShouldHaveSingleError(
command,
DomainErrorCodes.UserInvalidEmail,
DomainErrorCodes.User.UserInvalidEmail,
"Email is not a valid email address");
}
@ -50,19 +51,19 @@ public sealed class UpdateUserCommandValidationTests :
ShouldHaveSingleError(
command,
DomainErrorCodes.UserInvalidEmail,
DomainErrorCodes.User.UserInvalidEmail,
"Email is not a valid email address");
}
[Fact]
public void Should_Be_Invalid_For_Email_Exceeds_Max_Length()
{
var command = CreateTestCommand(email: new string('a', 320) + "@test.com");
var command = CreateTestCommand(email: new string('a', MaxLengths.User.Email) + "@test.com");
ShouldHaveSingleError(
command,
DomainErrorCodes.UserEmailExceedsMaxLength,
"Email may not be longer than 320 characters");
DomainErrorCodes.User.UserEmailExceedsMaxLength,
$"Email may not be longer than {MaxLengths.User.Email} characters");
}
[Fact]
@ -72,19 +73,19 @@ public sealed class UpdateUserCommandValidationTests :
ShouldHaveSingleError(
command,
DomainErrorCodes.UserEmptyFirstName,
DomainErrorCodes.User.UserEmptyFirstName,
"FirstName may not be empty");
}
[Fact]
public void Should_Be_Invalid_For_First_Name_Exceeds_Max_Length()
{
var command = CreateTestCommand(firstName: new string('a', 101));
var command = CreateTestCommand(firstName: new string('a', MaxLengths.User.FirstName + 1));
ShouldHaveSingleError(
command,
DomainErrorCodes.UserFirstNameExceedsMaxLength,
"FirstName may not be longer than 100 characters");
DomainErrorCodes.User.UserFirstNameExceedsMaxLength,
$"FirstName may not be longer than {MaxLengths.User.FirstName} characters");
}
[Fact]
@ -94,33 +95,46 @@ public sealed class UpdateUserCommandValidationTests :
ShouldHaveSingleError(
command,
DomainErrorCodes.UserEmptyLastName,
DomainErrorCodes.User.UserEmptyLastName,
"LastName may not be empty");
}
[Fact]
public void Should_Be_Invalid_For_Last_Name_Exceeds_Max_Length()
{
var command = CreateTestCommand(lastName: new string('a', 101));
var command = CreateTestCommand(lastName: new string('a', MaxLengths.User.LastName + 1));
ShouldHaveSingleError(
command,
DomainErrorCodes.UserLastNameExceedsMaxLength,
"LastName may not be longer than 100 characters");
DomainErrorCodes.User.UserLastNameExceedsMaxLength,
$"LastName may not be longer than {MaxLengths.User.LastName} characters");
}
[Fact]
public void Should_Be_Invalid_For_Empty_Tenant_Id()
{
var command = CreateTestCommand(tenantId: Guid.Empty);
ShouldHaveSingleError(
command,
DomainErrorCodes.Tenant.TenantEmptyId,
"Tenant id may not be empty");
}
private static UpdateUserCommand CreateTestCommand(
Guid? userId = null,
Guid? tenantId = null,
string? email = null,
string? firstName = null,
string? lastName = null,
UserRole? role = null)
{
return new(
return new UpdateUserCommand(
userId ?? Guid.NewGuid(),
email ?? "test@email.com",
firstName ?? "test",
lastName ?? "email",
role ?? UserRole.User);
role ?? UserRole.User,
tenantId ?? Guid.NewGuid());
}
}

View File

@ -10,6 +10,11 @@ namespace CleanArchitecture.Domain.Tests;
public class CommandHandlerFixtureBase
{
protected IMediatorHandler Bus { get; }
protected IUnitOfWork UnitOfWork { get; }
protected DomainNotificationHandler NotificationHandler { get; }
protected IUser User { get; }
protected CommandHandlerFixtureBase()
{
Bus = Substitute.For<IMediatorHandler>();
@ -23,11 +28,6 @@ public class CommandHandlerFixtureBase
UnitOfWork.CommitAsync().Returns(true);
}
protected IMediatorHandler Bus { get; }
protected IUnitOfWork UnitOfWork { get; }
protected DomainNotificationHandler NotificationHandler { get; }
protected IUser User { get; }
public CommandHandlerFixtureBase VerifyExistingNotification(string errorCode, string message)
{
Bus.Received(1).RaiseEventAsync(

View File

@ -55,21 +55,24 @@ public sealed class ApiUser : IUser
{
get
{
if (_name != null)
if (_name is not null)
{
return _name;
}
var identity = _httpContextAccessor.HttpContext?.User.Identity;
if (identity == null)
if (identity is null)
{
_name = string.Empty;
return string.Empty;
}
if (!string.IsNullOrWhiteSpace(identity.Name))
{
_name = identity.Name;
return identity.Name;
}
var claim = _httpContextAccessor.HttpContext!.User.Claims
.FirstOrDefault(c => string.Equals(c.Type, ClaimTypes.Name, StringComparison.OrdinalIgnoreCase))?
.Value;

View File

@ -6,14 +6,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FluentValidation" Version="11.7.1" />
<PackageReference Include="MediatR" Version="12.1.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="7.0.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.32.1" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3"/>
<PackageReference Include="FluentValidation" Version="11.7.1"/>
<PackageReference Include="MediatR" Version="12.1.1"/>
<PackageReference Include="Microsoft.Extensions.Options" Version="7.0.1"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.32.1"/>
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
</ItemGroup>
</Project>

View File

@ -6,6 +6,11 @@ namespace CleanArchitecture.Domain.Commands;
public abstract class CommandBase : IRequest
{
public Guid AggregateId { get; }
public string MessageType { get; }
public DateTime Timestamp { get; }
public ValidationResult? ValidationResult { get; protected set; }
protected CommandBase(Guid aggregateId)
{
MessageType = GetType().Name;
@ -13,10 +18,5 @@ public abstract class CommandBase : IRequest
AggregateId = aggregateId;
}
public Guid AggregateId { get; }
public string MessageType { get; }
public DateTime Timestamp { get; }
public ValidationResult? ValidationResult { get; protected set; }
public abstract bool IsValid();
}

View File

@ -9,9 +9,9 @@ namespace CleanArchitecture.Domain.Commands;
public abstract class CommandHandlerBase
{
protected readonly IMediatorHandler Bus;
private readonly DomainNotificationHandler _notifications;
private readonly IUnitOfWork _unitOfWork;
protected readonly IMediatorHandler Bus;
protected CommandHandlerBase(
IMediatorHandler bus,
@ -62,7 +62,7 @@ public abstract class CommandHandlerBase
return true;
}
if (command.ValidationResult == null)
if (command.ValidationResult is null)
{
throw new InvalidOperationException("Command is invalid and should therefore have a validation result");
}

View File

@ -0,0 +1,21 @@
using System;
namespace CleanArchitecture.Domain.Commands.Tenants.CreateTenant;
public sealed class CreateTenantCommand : CommandBase
{
private static readonly CreateTenantCommandValidation s_validation = new();
public string Name { get; }
public CreateTenantCommand(Guid tenantId, string name) : base(tenantId)
{
Name = name;
}
public override bool IsValid()
{
ValidationResult = s_validation.Validate(this);
return ValidationResult.IsValid;
}
}

View File

@ -0,0 +1,73 @@
using System.Threading;
using System.Threading.Tasks;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Events.Tenant;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Domain.Interfaces.Repositories;
using CleanArchitecture.Domain.Notifications;
using MediatR;
namespace CleanArchitecture.Domain.Commands.Tenants.CreateTenant;
public sealed class CreateTenantCommandHandler : CommandHandlerBase,
IRequestHandler<CreateTenantCommand>
{
private readonly ITenantRepository _tenantRepository;
private readonly IUser _user;
public CreateTenantCommandHandler(
IMediatorHandler bus,
IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> notifications,
ITenantRepository tenantRepository,
IUser user) : base(bus, unitOfWork, notifications)
{
_tenantRepository = tenantRepository;
_user = user;
}
public async Task Handle(CreateTenantCommand request, CancellationToken cancellationToken)
{
if (!await TestValidityAsync(request))
{
return;
}
if (_user.GetUserRole() != UserRole.Admin)
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"No permission to create tenant {request.AggregateId}",
ErrorCodes.InsufficientPermissions));
return;
}
if (await _tenantRepository.ExistsAsync(request.AggregateId))
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is already a tenant with Id {request.AggregateId}",
DomainErrorCodes.Tenant.TenantAlreadyExists));
return;
}
var tenant = new Tenant(
request.AggregateId,
request.Name);
_tenantRepository.Add(tenant);
if (await CommitAsync())
{
await Bus.RaiseEventAsync(new TenantCreatedEvent(
tenant.Id,
tenant.Name));
}
}
}

View File

@ -0,0 +1,33 @@
using CleanArchitecture.Domain.Constants;
using CleanArchitecture.Domain.Errors;
using FluentValidation;
namespace CleanArchitecture.Domain.Commands.Tenants.CreateTenant;
public sealed class CreateTenantCommandValidation : AbstractValidator<CreateTenantCommand>
{
public CreateTenantCommandValidation()
{
AddRuleForId();
AddRuleForName();
}
private void AddRuleForId()
{
RuleFor(cmd => cmd.AggregateId)
.NotEmpty()
.WithErrorCode(DomainErrorCodes.Tenant.TenantEmptyId)
.WithMessage("Tenant id may not be empty");
}
private void AddRuleForName()
{
RuleFor(cmd => cmd.Name)
.NotEmpty()
.WithErrorCode(DomainErrorCodes.Tenant.TenantEmptyName)
.WithMessage("Name may not be empty")
.MaximumLength(MaxLengths.Tenant.Name)
.WithErrorCode(DomainErrorCodes.Tenant.TenantNameExceedsMaxLength)
.WithMessage($"Name may not be longer than {MaxLengths.Tenant.Name} characters");
}
}

View File

@ -0,0 +1,18 @@
using System;
namespace CleanArchitecture.Domain.Commands.Tenants.DeleteTenant;
public sealed class DeleteTenantCommand : CommandBase
{
private static readonly DeleteTenantCommandValidation s_validation = new();
public DeleteTenantCommand(Guid tenantId) : base(tenantId)
{
}
public override bool IsValid()
{
ValidationResult = s_validation.Validate(this);
return ValidationResult.IsValid;
}
}

View File

@ -0,0 +1,78 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Events.Tenant;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Domain.Interfaces.Repositories;
using CleanArchitecture.Domain.Notifications;
using MediatR;
namespace CleanArchitecture.Domain.Commands.Tenants.DeleteTenant;
public sealed class DeleteTenantCommandHandler : CommandHandlerBase,
IRequestHandler<DeleteTenantCommand>
{
private readonly ITenantRepository _tenantRepository;
private readonly IUser _user;
private readonly IUserRepository _userRepository;
public DeleteTenantCommandHandler(
IMediatorHandler bus,
IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> notifications,
ITenantRepository tenantRepository,
IUserRepository userRepository,
IUser user) : base(bus, unitOfWork, notifications)
{
_tenantRepository = tenantRepository;
_userRepository = userRepository;
_user = user;
}
public async Task Handle(DeleteTenantCommand request, CancellationToken cancellationToken)
{
if (!await TestValidityAsync(request))
{
return;
}
if (_user.GetUserRole() != UserRole.Admin)
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"No permission to delete tenant {request.AggregateId}",
ErrorCodes.InsufficientPermissions));
return;
}
var tenant = await _tenantRepository.GetByIdAsync(request.AggregateId);
if (tenant is null)
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is no tenant with Id {request.AggregateId}",
ErrorCodes.ObjectNotFound));
return;
}
var tenantUsers = _userRepository
.GetAll()
.Where(x => x.TenantId == request.AggregateId);
_userRepository.RemoveRange(tenantUsers);
_tenantRepository.Remove(tenant);
if (await CommitAsync())
{
await Bus.RaiseEventAsync(new TenantDeletedEvent(tenant.Id));
}
}
}

View File

@ -0,0 +1,20 @@
using CleanArchitecture.Domain.Errors;
using FluentValidation;
namespace CleanArchitecture.Domain.Commands.Tenants.DeleteTenant;
public sealed class DeleteTenantCommandValidation : AbstractValidator<DeleteTenantCommand>
{
public DeleteTenantCommandValidation()
{
AddRuleForId();
}
private void AddRuleForId()
{
RuleFor(cmd => cmd.AggregateId)
.NotEmpty()
.WithErrorCode(DomainErrorCodes.Tenant.TenantEmptyId)
.WithMessage("Tenant id may not be empty");
}
}

View File

@ -0,0 +1,21 @@
using System;
namespace CleanArchitecture.Domain.Commands.Tenants.UpdateTenant;
public sealed class UpdateTenantCommand : CommandBase
{
private static readonly UpdateTenantCommandValidation s_validation = new();
public string Name { get; }
public UpdateTenantCommand(Guid tenantId, string name) : base(tenantId)
{
Name = name;
}
public override bool IsValid()
{
ValidationResult = s_validation.Validate(this);
return ValidationResult.IsValid;
}
}

View File

@ -0,0 +1,70 @@
using System.Threading;
using System.Threading.Tasks;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Events.Tenant;
using CleanArchitecture.Domain.Interfaces;
using CleanArchitecture.Domain.Interfaces.Repositories;
using CleanArchitecture.Domain.Notifications;
using MediatR;
namespace CleanArchitecture.Domain.Commands.Tenants.UpdateTenant;
public sealed class UpdateTenantCommandHandler : CommandHandlerBase,
IRequestHandler<UpdateTenantCommand>
{
private readonly ITenantRepository _tenantRepository;
private readonly IUser _user;
public UpdateTenantCommandHandler(
IMediatorHandler bus,
IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> notifications,
ITenantRepository tenantRepository,
IUser user) : base(bus, unitOfWork, notifications)
{
_tenantRepository = tenantRepository;
_user = user;
}
public async Task Handle(UpdateTenantCommand request, CancellationToken cancellationToken)
{
if (!await TestValidityAsync(request))
{
return;
}
if (_user.GetUserRole() != UserRole.Admin)
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"No permission to update tenant {request.AggregateId}",
ErrorCodes.InsufficientPermissions));
return;
}
var tenant = await _tenantRepository.GetByIdAsync(request.AggregateId);
if (tenant is null)
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is no tenant with Id {request.AggregateId}",
ErrorCodes.ObjectNotFound));
return;
}
tenant.SetName(request.Name);
if (await CommitAsync())
{
await Bus.RaiseEventAsync(new TenantUpdatedEvent(
tenant.Id,
tenant.Name));
}
}
}

View File

@ -0,0 +1,33 @@
using CleanArchitecture.Domain.Constants;
using CleanArchitecture.Domain.Errors;
using FluentValidation;
namespace CleanArchitecture.Domain.Commands.Tenants.UpdateTenant;
public sealed class UpdateTenantCommandValidation : AbstractValidator<UpdateTenantCommand>
{
public UpdateTenantCommandValidation()
{
AddRuleForId();
AddRuleForName();
}
private void AddRuleForId()
{
RuleFor(cmd => cmd.AggregateId)
.NotEmpty()
.WithErrorCode(DomainErrorCodes.Tenant.TenantEmptyId)
.WithMessage("Tenant id may not be empty");
}
private void AddRuleForName()
{
RuleFor(cmd => cmd.Name)
.NotEmpty()
.WithErrorCode(DomainErrorCodes.Tenant.TenantEmptyName)
.WithMessage("Name may not be empty")
.MaximumLength(MaxLengths.Tenant.Name)
.WithErrorCode(DomainErrorCodes.Tenant.TenantNameExceedsMaxLength)
.WithMessage($"Name may not be longer than {MaxLengths.Tenant.Name} characters");
}
}

View File

@ -4,7 +4,10 @@ namespace CleanArchitecture.Domain.Commands.Users.ChangePassword;
public sealed class ChangePasswordCommand : CommandBase
{
private readonly ChangePasswordCommandValidation _validation = new();
private static readonly ChangePasswordCommandValidation s_validation = new();
public string Password { get; }
public string NewPassword { get; }
public ChangePasswordCommand(string password, string newPassword) : base(Guid.NewGuid())
{
@ -12,12 +15,9 @@ public sealed class ChangePasswordCommand : CommandBase
NewPassword = newPassword;
}
public string Password { get; }
public string NewPassword { get; }
public override bool IsValid()
{
ValidationResult = _validation.Validate(this);
ValidationResult = s_validation.Validate(this);
return ValidationResult.IsValid;
}
}

View File

@ -36,12 +36,12 @@ public sealed class ChangePasswordCommandHandler : CommandHandlerBase,
var user = await _userRepository.GetByIdAsync(_user.GetUserId());
if (user == null)
if (user is null)
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is no User with Id {_user.GetUserId()}",
$"There is no user with Id {_user.GetUserId()}",
ErrorCodes.ObjectNotFound));
return;
@ -53,7 +53,7 @@ public sealed class ChangePasswordCommandHandler : CommandHandlerBase,
new DomainNotification(
request.MessageType,
"The password is incorrect",
DomainErrorCodes.UserPasswordIncorrect));
DomainErrorCodes.User.UserPasswordIncorrect));
return;
}

View File

@ -4,31 +4,34 @@ namespace CleanArchitecture.Domain.Commands.Users.CreateUser;
public sealed class CreateUserCommand : CommandBase
{
private readonly CreateUserCommandValidation _validation = new();
private static readonly CreateUserCommandValidation s_validation = new();
public Guid UserId { get; }
public Guid TenantId { get; }
public string Email { get; }
public string FirstName { get; }
public string LastName { get; }
public string Password { get; }
public CreateUserCommand(
Guid userId,
Guid tenantId,
string email,
string firstName,
string lastName,
string password) : base(userId)
{
UserId = userId;
TenantId = tenantId;
Email = email;
FirstName = firstName;
LastName = lastName;
Password = password;
}
public Guid UserId { get; }
public string Email { get; }
public string FirstName { get; }
public string LastName { get; }
public string Password { get; }
public override bool IsValid()
{
ValidationResult = _validation.Validate(this);
ValidationResult = s_validation.Validate(this);
return ValidationResult.IsValid;
}
}

View File

@ -15,15 +15,21 @@ namespace CleanArchitecture.Domain.Commands.Users.CreateUser;
public sealed class CreateUserCommandHandler : CommandHandlerBase,
IRequestHandler<CreateUserCommand>
{
private readonly ITenantRepository _tenantRepository;
private readonly IUser _user;
private readonly IUserRepository _userRepository;
public CreateUserCommandHandler(
IMediatorHandler bus,
IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> notifications,
IUserRepository userRepository) : base(bus, unitOfWork, notifications)
IUserRepository userRepository,
ITenantRepository tenantRepository,
IUser user) : base(bus, unitOfWork, notifications)
{
_userRepository = userRepository;
_tenantRepository = tenantRepository;
_user = user;
}
public async Task Handle(CreateUserCommand request, CancellationToken cancellationToken)
@ -33,27 +39,49 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase,
return;
}
var existingUser = await _userRepository.GetByIdAsync(request.UserId);
var currentUser = await _userRepository.GetByIdAsync(_user.GetUserId());
if (existingUser != null)
if (currentUser is null || currentUser.Role != UserRole.Admin)
{
await Bus.RaiseEventAsync(
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is already a User with Id {request.UserId}",
DomainErrorCodes.UserAlreadyExists));
"You are not allowed to create users",
ErrorCodes.InsufficientPermissions));
return;
}
var existingUser = await _userRepository.GetByIdAsync(request.UserId);
if (existingUser is not null)
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is already a user with Id {request.UserId}",
DomainErrorCodes.User.UserAlreadyExists));
return;
}
existingUser = await _userRepository.GetByEmailAsync(request.Email);
if (existingUser != null)
if (existingUser is not null)
{
await Bus.RaiseEventAsync(
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is already a User with Email {request.Email}",
DomainErrorCodes.UserAlreadyExists));
$"There is already a user with email {request.Email}",
DomainErrorCodes.User.UserAlreadyExists));
return;
}
if (!await _tenantRepository.ExistsAsync(request.TenantId))
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is no tenant with Id {request.TenantId}",
ErrorCodes.ObjectNotFound));
return;
}
@ -61,6 +89,7 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase,
var user = new User(
request.UserId,
request.TenantId,
request.Email,
request.FirstName,
request.LastName,

View File

@ -1,3 +1,4 @@
using CleanArchitecture.Domain.Constants;
using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Extensions.Validation;
using FluentValidation;
@ -9,6 +10,7 @@ public sealed class CreateUserCommandValidation : AbstractValidator<CreateUserCo
public CreateUserCommandValidation()
{
AddRuleForId();
AddRuleForTenantId();
AddRuleForEmail();
AddRuleForFirstName();
AddRuleForLastName();
@ -19,41 +21,49 @@ public sealed class CreateUserCommandValidation : AbstractValidator<CreateUserCo
{
RuleFor(cmd => cmd.UserId)
.NotEmpty()
.WithErrorCode(DomainErrorCodes.UserEmptyId)
.WithErrorCode(DomainErrorCodes.User.UserEmptyId)
.WithMessage("User id may not be empty");
}
private void AddRuleForTenantId()
{
RuleFor(cmd => cmd.TenantId)
.NotEmpty()
.WithErrorCode(DomainErrorCodes.Tenant.TenantEmptyId)
.WithMessage("Tenant id may not be empty");
}
private void AddRuleForEmail()
{
RuleFor(cmd => cmd.Email)
.EmailAddress()
.WithErrorCode(DomainErrorCodes.UserInvalidEmail)
.WithErrorCode(DomainErrorCodes.User.UserInvalidEmail)
.WithMessage("Email is not a valid email address")
.MaximumLength(320)
.WithErrorCode(DomainErrorCodes.UserEmailExceedsMaxLength)
.WithMessage("Email may not be longer than 320 characters");
.MaximumLength(MaxLengths.User.Email)
.WithErrorCode(DomainErrorCodes.User.UserEmailExceedsMaxLength)
.WithMessage($"Email may not be longer than {MaxLengths.User.Email} characters");
}
private void AddRuleForFirstName()
{
RuleFor(cmd => cmd.FirstName)
.NotEmpty()
.WithErrorCode(DomainErrorCodes.UserEmptyFirstName)
.WithErrorCode(DomainErrorCodes.User.UserEmptyFirstName)
.WithMessage("FirstName may not be empty")
.MaximumLength(100)
.WithErrorCode(DomainErrorCodes.UserFirstNameExceedsMaxLength)
.WithMessage("FirstName may not be longer than 100 characters");
.MaximumLength(MaxLengths.User.FirstName)
.WithErrorCode(DomainErrorCodes.User.UserFirstNameExceedsMaxLength)
.WithMessage($"FirstName may not be longer than {MaxLengths.User.FirstName} characters");
}
private void AddRuleForLastName()
{
RuleFor(cmd => cmd.LastName)
.NotEmpty()
.WithErrorCode(DomainErrorCodes.UserEmptyLastName)
.WithErrorCode(DomainErrorCodes.User.UserEmptyLastName)
.WithMessage("LastName may not be empty")
.MaximumLength(100)
.WithErrorCode(DomainErrorCodes.UserLastNameExceedsMaxLength)
.WithMessage("LastName may not be longer than 100 characters");
.MaximumLength(MaxLengths.User.LastName)
.WithErrorCode(DomainErrorCodes.User.UserLastNameExceedsMaxLength)
.WithMessage($"LastName may not be longer than {MaxLengths.User.LastName} characters");
}
private void AddRuleForPassword()

View File

@ -4,18 +4,18 @@ namespace CleanArchitecture.Domain.Commands.Users.DeleteUser;
public sealed class DeleteUserCommand : CommandBase
{
private readonly DeleteUserCommandValidation _validation = new();
private static readonly DeleteUserCommandValidation s_validation = new();
public Guid UserId { get; }
public DeleteUserCommand(Guid userId) : base(userId)
{
UserId = userId;
}
public Guid UserId { get; }
public override bool IsValid()
{
ValidationResult = _validation.Validate(this);
ValidationResult = s_validation.Validate(this);
return ValidationResult.IsValid;
}
}

View File

@ -36,12 +36,12 @@ public sealed class DeleteUserCommandHandler : CommandHandlerBase,
var user = await _userRepository.GetByIdAsync(request.UserId);
if (user == null)
if (user is null)
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is no User with Id {request.UserId}",
$"There is no user with Id {request.UserId}",
ErrorCodes.ObjectNotFound));
return;

View File

@ -14,7 +14,7 @@ public sealed class DeleteUserCommandValidation : AbstractValidator<DeleteUserCo
{
RuleFor(cmd => cmd.UserId)
.NotEmpty()
.WithErrorCode(DomainErrorCodes.UserEmptyId)
.WithErrorCode(DomainErrorCodes.User.UserEmptyId)
.WithMessage("User id may not be empty");
}
}

View File

@ -6,7 +6,10 @@ namespace CleanArchitecture.Domain.Commands.Users.LoginUser;
public sealed class LoginUserCommand : CommandBase,
IRequest<string>
{
private readonly LoginUserCommandValidation _validation = new();
private static readonly LoginUserCommandValidation s_validation = new();
public string Email { get; set; }
public string Password { get; set; }
public LoginUserCommand(
@ -17,12 +20,9 @@ public sealed class LoginUserCommand : CommandBase,
Password = password;
}
public string Email { get; set; }
public string Password { get; set; }
public override bool IsValid()
{
ValidationResult = _validation.Validate(this);
ValidationResult = s_validation.Validate(this);
return ValidationResult.IsValid;
}
}

View File

@ -45,12 +45,12 @@ public sealed class LoginUserCommandHandler : CommandHandlerBase,
var user = await _userRepository.GetByEmailAsync(request.Email);
if (user == null)
if (user is null)
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is no User with Email {request.Email}",
$"There is no user with email {request.Email}",
ErrorCodes.ObjectNotFound));
return "";
@ -64,7 +64,7 @@ public sealed class LoginUserCommandHandler : CommandHandlerBase,
new DomainNotification(
request.MessageType,
"The password is incorrect",
DomainErrorCodes.UserPasswordIncorrect));
DomainErrorCodes.User.UserPasswordIncorrect));
return "";
}

View File

@ -1,4 +1,5 @@
using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Constants;
using CleanArchitecture.Domain.Errors;
using CleanArchitecture.Domain.Extensions.Validation;
using FluentValidation;
@ -16,11 +17,11 @@ public sealed class LoginUserCommandValidation : AbstractValidator<LoginUserComm
{
RuleFor(cmd => cmd.Email)
.EmailAddress()
.WithErrorCode(DomainErrorCodes.UserInvalidEmail)
.WithErrorCode(DomainErrorCodes.User.UserInvalidEmail)
.WithMessage("Email is not a valid email address")
.MaximumLength(320)
.WithErrorCode(DomainErrorCodes.UserEmailExceedsMaxLength)
.WithMessage("Email may not be longer than 320 characters");
.MaximumLength(MaxLengths.User.Email)
.WithErrorCode(DomainErrorCodes.User.UserEmailExceedsMaxLength)
.WithMessage($"Email may not be longer than {MaxLengths.User.Email} characters");
}
private void AddRuleForPassword()

View File

@ -5,31 +5,33 @@ namespace CleanArchitecture.Domain.Commands.Users.UpdateUser;
public sealed class UpdateUserCommand : CommandBase
{
private readonly UpdateUserCommandValidation _validation = new();
private static readonly UpdateUserCommandValidation s_validation = new();
public Guid UserId { get; }
public Guid TenantId { get; }
public string Email { get; }
public string FirstName { get; }
public string LastName { get; }
public UserRole Role { get; }
public UpdateUserCommand(
Guid userId,
string email,
string firstName,
string lastName,
UserRole role) : base(userId)
UserRole role, Guid tenantId) : base(userId)
{
UserId = userId;
Email = email;
FirstName = firstName;
LastName = lastName;
Role = role;
TenantId = tenantId;
}
public Guid UserId { get; }
public string Email { get; }
public string FirstName { get; }
public string LastName { get; }
public UserRole Role { get; }
public override bool IsValid()
{
ValidationResult = _validation.Validate(this);
ValidationResult = s_validation.Validate(this);
return ValidationResult.IsValid;
}
}

View File

@ -13,6 +13,7 @@ namespace CleanArchitecture.Domain.Commands.Users.UpdateUser;
public sealed class UpdateUserCommandHandler : CommandHandlerBase,
IRequestHandler<UpdateUserCommand>
{
private readonly ITenantRepository _tenantRepository;
private readonly IUser _user;
private readonly IUserRepository _userRepository;
@ -21,10 +22,12 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase,
IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> notifications,
IUserRepository userRepository,
IUser user) : base(bus, unitOfWork, notifications)
IUser user,
ITenantRepository tenantRepository) : base(bus, unitOfWork, notifications)
{
_userRepository = userRepository;
_user = user;
_tenantRepository = tenantRepository;
}
public async Task Handle(UpdateUserCommand request, CancellationToken cancellationToken)
@ -36,12 +39,12 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase,
var user = await _userRepository.GetByIdAsync(request.UserId);
if (user == null)
if (user is null)
{
await Bus.RaiseEventAsync(
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is no User with Id {request.UserId}",
$"There is no user with Id {request.UserId}",
ErrorCodes.ObjectNotFound));
return;
}
@ -61,13 +64,13 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase,
{
var existingUser = await _userRepository.GetByEmailAsync(request.Email);
if (existingUser != null)
if (existingUser is not null)
{
await Bus.RaiseEventAsync(
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is already a User with Email {request.Email}",
DomainErrorCodes.UserAlreadyExists));
$"There is already a user with email {request.Email}",
DomainErrorCodes.User.UserAlreadyExists));
return;
}
}
@ -75,6 +78,18 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase,
if (_user.GetUserRole() == UserRole.Admin)
{
user.SetRole(request.Role);
if (!await _tenantRepository.ExistsAsync(request.TenantId))
{
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is no tenant with Id {request.TenantId}",
ErrorCodes.ObjectNotFound));
return;
}
user.SetTenant(request.TenantId);
}
user.SetEmail(request.Email);

View File

@ -1,3 +1,4 @@
using CleanArchitecture.Domain.Constants;
using CleanArchitecture.Domain.Errors;
using FluentValidation;
@ -8,6 +9,7 @@ public sealed class UpdateUserCommandValidation : AbstractValidator<UpdateUserCo
public UpdateUserCommandValidation()
{
AddRuleForId();
AddRuleForTenantId();
AddRuleForEmail();
AddRuleForFirstName();
AddRuleForLastName();
@ -18,48 +20,56 @@ public sealed class UpdateUserCommandValidation : AbstractValidator<UpdateUserCo
{
RuleFor(cmd => cmd.UserId)
.NotEmpty()
.WithErrorCode(DomainErrorCodes.UserEmptyId)
.WithErrorCode(DomainErrorCodes.User.UserEmptyId)
.WithMessage("User id may not be empty");
}
private void AddRuleForTenantId()
{
RuleFor(cmd => cmd.TenantId)
.NotEmpty()
.WithErrorCode(DomainErrorCodes.Tenant.TenantEmptyId)
.WithMessage("Tenant id may not be empty");
}
private void AddRuleForEmail()
{
RuleFor(cmd => cmd.Email)
.EmailAddress()
.WithErrorCode(DomainErrorCodes.UserInvalidEmail)
.WithErrorCode(DomainErrorCodes.User.UserInvalidEmail)
.WithMessage("Email is not a valid email address")
.MaximumLength(320)
.WithErrorCode(DomainErrorCodes.UserEmailExceedsMaxLength)
.WithMessage("Email may not be longer than 320 characters");
.MaximumLength(MaxLengths.User.Email)
.WithErrorCode(DomainErrorCodes.User.UserEmailExceedsMaxLength)
.WithMessage($"Email may not be longer than {MaxLengths.User.Email} characters");
}
private void AddRuleForFirstName()
{
RuleFor(cmd => cmd.FirstName)
.NotEmpty()
.WithErrorCode(DomainErrorCodes.UserEmptyFirstName)
.WithErrorCode(DomainErrorCodes.User.UserEmptyFirstName)
.WithMessage("FirstName may not be empty")
.MaximumLength(100)
.WithErrorCode(DomainErrorCodes.UserFirstNameExceedsMaxLength)
.WithMessage("FirstName may not be longer than 100 characters");
.MaximumLength(MaxLengths.User.FirstName)
.WithErrorCode(DomainErrorCodes.User.UserFirstNameExceedsMaxLength)
.WithMessage($"FirstName may not be longer than {MaxLengths.User.FirstName} characters");
}
private void AddRuleForLastName()
{
RuleFor(cmd => cmd.LastName)
.NotEmpty()
.WithErrorCode(DomainErrorCodes.UserEmptyLastName)
.WithErrorCode(DomainErrorCodes.User.UserEmptyLastName)
.WithMessage("LastName may not be empty")
.MaximumLength(100)
.WithErrorCode(DomainErrorCodes.UserLastNameExceedsMaxLength)
.WithMessage("LastName may not be longer than 100 characters");
.MaximumLength(MaxLengths.User.LastName)
.WithErrorCode(DomainErrorCodes.User.UserLastNameExceedsMaxLength)
.WithMessage($"LastName may not be longer than {MaxLengths.User.LastName} characters");
}
private void AddRuleForRole()
{
RuleFor(cmd => cmd.Role)
.IsInEnum()
.WithErrorCode(DomainErrorCodes.UserInvalidRole)
.WithErrorCode(DomainErrorCodes.User.UserInvalidRole)
.WithMessage("Role is not a valid role");
}
}

View File

@ -0,0 +1,12 @@
using System;
namespace CleanArchitecture.Domain.Constants;
public static class Ids
{
public static class Seed
{
public static readonly Guid UserId = new("7e3892c0-9374-49fa-a3fd-53db637a40ae");
public static readonly Guid TenantId = new("b542bf25-134c-47a2-a0df-84ed14d03c4a");
}
}

View File

@ -0,0 +1,17 @@
namespace CleanArchitecture.Domain.Constants;
public static class MaxLengths
{
public static class User
{
public const int Email = 320;
public const int FirstName = 100;
public const int LastName = 100;
public const int Password = 128;
}
public static class Tenant
{
public const int Name = 255;
}
}

View File

@ -5,6 +5,8 @@ namespace CleanArchitecture.Domain.DomainEvents;
public abstract class DomainEvent : Message, INotification
{
public DateTime Timestamp { get; private set; }
protected DomainEvent(Guid aggregateId) : base(aggregateId)
{
Timestamp = DateTime.Now;
@ -14,6 +16,4 @@ public abstract class DomainEvent : Message, INotification
{
Timestamp = DateTime.Now;
}
public DateTime Timestamp { get; private set; }
}

View File

@ -5,6 +5,9 @@ namespace CleanArchitecture.Domain.DomainEvents;
public abstract class Message : IRequest
{
public Guid AggregateId { get; private set; }
public string MessageType { get; protected set; }
protected Message(Guid aggregateId)
{
AggregateId = aggregateId;
@ -16,7 +19,4 @@ public abstract class Message : IRequest
AggregateId = aggregateId;
MessageType = messageType ?? string.Empty;
}
public Guid AggregateId { get; private set; }
public string MessageType { get; protected set; }
}

View File

@ -24,5 +24,6 @@ public class StoredDomainEvent : DomainEvent
// EF Constructor
protected StoredDomainEvent() : base(Guid.NewGuid())
{ }
{
}
}

View File

@ -31,5 +31,6 @@ public class StoredDomainNotification : DomainNotification
// EF Constructor
protected StoredDomainNotification() : base(string.Empty, string.Empty, string.Empty)
{ }
{
}
}

View File

@ -4,14 +4,14 @@ namespace CleanArchitecture.Domain.Entities;
public abstract class Entity
{
public Guid Id { get; private set; }
public bool Deleted { get; private set; }
protected Entity(Guid id)
{
Id = id;
}
public Guid Id { get; private set; }
public bool Deleted { get; private set; }
public void SetId(Guid id)
{
if (id == Guid.Empty)

View File

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
namespace CleanArchitecture.Domain.Entities;
public class Tenant : Entity
{
public string Name { get; private set; }
public virtual ICollection<User> Users { get; private set; } = new HashSet<User>();
public Tenant(
Guid id,
string name) : base(id)
{
Name = name;
}
public void SetName(string name)
{
Name = name;
}
}

View File

@ -1,26 +1,10 @@
using System;
using System.Diagnostics.CodeAnalysis;
using CleanArchitecture.Domain.Enums;
namespace CleanArchitecture.Domain.Entities;
public class User : Entity
{
public User(
Guid id,
string email,
string firstName,
string lastName,
string password,
UserRole role) : base(id)
{
Email = email;
FirstName = firstName;
LastName = lastName;
Password = password;
Role = role;
}
public string Email { get; private set; }
public string FirstName { get; private set; }
public string LastName { get; private set; }
@ -29,71 +13,43 @@ public class User : Entity
public string FullName => $"{FirstName}, {LastName}";
[MemberNotNull(nameof(Email))]
public Guid TenantId { get; private set; }
public virtual Tenant Tenant { get; private set; } = null!;
public User(
Guid id,
Guid tenantId,
string email,
string firstName,
string lastName,
string password,
UserRole role) : base(id)
{
Email = email;
TenantId = tenantId;
FirstName = firstName;
LastName = lastName;
Password = password;
Role = role;
}
public void SetEmail(string email)
{
if (email == null)
{
throw new ArgumentNullException(nameof(email));
}
if (email.Length > 320)
{
throw new ArgumentException(
"Email may not be longer than 320 characters.");
}
Email = email;
}
[MemberNotNull(nameof(FirstName))]
public void SetFirstName(string firstName)
{
if (firstName == null)
{
throw new ArgumentNullException(nameof(firstName));
}
if (firstName.Length > 100)
{
throw new ArgumentException(
"First name may not be longer than 100 characters");
}
FirstName = firstName;
}
[MemberNotNull(nameof(LastName))]
public void SetLastName(string lastName)
{
if (lastName == null)
{
throw new ArgumentNullException(nameof(lastName));
}
if (lastName.Length > 100)
{
throw new ArgumentException(
"Last name may not be longer than 100 characters");
}
LastName = lastName;
}
[MemberNotNull(nameof(Password))]
public void SetPassword(string password)
{
if (password == null)
{
throw new ArgumentNullException(nameof(password));
}
if (password.Length > 100)
{
throw new ArgumentException(
"Password may not be longer than 100 characters");
}
Password = password;
}
@ -101,4 +57,9 @@ public class User : Entity
{
Role = role;
}
public void SetTenant(Guid tenantId)
{
TenantId = tenantId;
}
}

View File

@ -2,6 +2,8 @@ namespace CleanArchitecture.Domain.Errors;
public static class DomainErrorCodes
{
public static class User
{
// User Validation
public const string UserEmptyId = "USER_EMPTY_ID";
public const string UserEmptyFirstName = "USER_EMPTY_FIRST_NAME";
@ -21,7 +23,19 @@ public static class DomainErrorCodes
public const string UserNumberPassword = "USER_PASSWORD_MUST_CONTAIN_A_NUMBER";
public const string UserSpecialCharPassword = "USER_PASSWORD_MUST_CONTAIN_A_SPECIAL_CHARACTER";
// User
// General
public const string UserAlreadyExists = "USER_ALREADY_EXISTS";
public const string UserPasswordIncorrect = "USER_PASSWORD_INCORRECT";
}
public static class Tenant
{
// Tenant Validation
public const string TenantEmptyId = "TENANT_EMPTY_ID";
public const string TenantEmptyName = "TENANT_EMPTY_NAME";
public const string TenantNameExceedsMaxLength = "TENANT_NAME_EXCEEDS_MAX_LENGTH";
// General
public const string TenantAlreadyExists = "TENANT_ALREADY_EXISTS";
}
}

View File

@ -0,0 +1,27 @@
using System.Threading;
using System.Threading.Tasks;
using CleanArchitecture.Domain.Events.Tenant;
using MediatR;
namespace CleanArchitecture.Domain.EventHandler;
public sealed class TenantEventHandler :
INotificationHandler<TenantCreatedEvent>,
INotificationHandler<TenantDeletedEvent>,
INotificationHandler<TenantUpdatedEvent>
{
public Task Handle(TenantCreatedEvent notification, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task Handle(TenantDeletedEvent notification, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
public Task Handle(TenantUpdatedEvent notification, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,14 @@
using System;
using CleanArchitecture.Domain.DomainEvents;
namespace CleanArchitecture.Domain.Events.Tenant;
public sealed class TenantCreatedEvent : DomainEvent
{
public string Name { get; set; }
public TenantCreatedEvent(Guid tenantId, string name) : base(tenantId)
{
Name = name;
}
}

View File

@ -0,0 +1,11 @@
using System;
using CleanArchitecture.Domain.DomainEvents;
namespace CleanArchitecture.Domain.Events.Tenant;
public sealed class TenantDeletedEvent : DomainEvent
{
public TenantDeletedEvent(Guid tenantId) : base(tenantId)
{
}
}

View File

@ -0,0 +1,14 @@
using System;
using CleanArchitecture.Domain.DomainEvents;
namespace CleanArchitecture.Domain.Events.Tenant;
public sealed class TenantUpdatedEvent : DomainEvent
{
public string Name { get; set; }
public TenantUpdatedEvent(Guid tenantId, string name) : base(tenantId)
{
Name = name;
}
}

View File

@ -7,8 +7,5 @@ public sealed class PasswordChangedEvent : DomainEvent
{
public PasswordChangedEvent(Guid userId) : base(userId)
{
UserId = userId;
}
public Guid UserId { get; }
}

View File

@ -7,8 +7,5 @@ public sealed class UserCreatedEvent : DomainEvent
{
public UserCreatedEvent(Guid userId) : base(userId)
{
UserId = userId;
}
public Guid UserId { get; }
}

Some files were not shown because too many files have changed in this diff Show More