diff --git a/CleanArchitecture.Api/CleanArchitecture.Api.csproj b/CleanArchitecture.Api/CleanArchitecture.Api.csproj index 46e1257..ca184ec 100644 --- a/CleanArchitecture.Api/CleanArchitecture.Api.csproj +++ b/CleanArchitecture.Api/CleanArchitecture.Api.csproj @@ -7,26 +7,26 @@ - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + - - - - + + + + diff --git a/CleanArchitecture.Api/Controllers/TenantController.cs b/CleanArchitecture.Api/Controllers/TenantController.cs new file mode 100644 index 0000000..0576122 --- /dev/null +++ b/CleanArchitecture.Api/Controllers/TenantController.cs @@ -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 notifications, + ITenantService tenantService) : base(notifications) + { + _tenantService = tenantService; + } + + [HttpGet] + [SwaggerOperation("Get a list of all tenants")] + [SwaggerResponse(200, "Request successful", typeof(ResponseMessage>))] + public async Task GetAllTenantsAsync() + { + var tenants = await _tenantService.GetAllTenantsAsync(); + return Response(tenants); + } + + [HttpGet("{id:guid}")] + [SwaggerOperation("Get a tenant by id")] + [SwaggerResponse(200, "Request successful", typeof(ResponseMessage))] + public async Task 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))] + public async Task 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))] + public async Task 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))] + public async Task DeleteTenantAsync([FromRoute] Guid id) + { + await _tenantService.DeleteTenantAsync(id); + return Response(id); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Api/Controllers/UserController.cs b/CleanArchitecture.Api/Controllers/UserController.cs index b6f7904..3aac047 100644 --- a/CleanArchitecture.Api/Controllers/UserController.cs +++ b/CleanArchitecture.Api/Controllers/UserController.cs @@ -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>))] @@ -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))] @@ -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))] @@ -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))] @@ -76,7 +73,6 @@ public sealed class UserController : ApiController return Response(id); } - [Authorize] [HttpPut] [SwaggerOperation("Update a user")] [SwaggerResponse(200, "Request successful", typeof(ResponseMessage))] @@ -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))] @@ -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))] public async Task LoginUserAsync([FromBody] LoginUserViewModel viewModel) diff --git a/CleanArchitecture.Api/Extensions/ServiceCollectionExtension.cs b/CleanArchitecture.Api/Extensions/ServiceCollectionExtension.cs index a71b3ed..7224bcc 100644 --- a/CleanArchitecture.Api/Extensions/ServiceCollectionExtension.cs +++ b/CleanArchitecture.Api/Extensions/ServiceCollectionExtension.cs @@ -60,9 +60,12 @@ public static class ServiceCollectionExtension services.AddHttpContextAccessor(); services.AddAuthentication( - options => { options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; }) + options => { options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer( - jwtOptions => { jwtOptions.TokenValidationParameters = CreateTokenValidationParameters(configuration); }); + jwtOptions => + { + jwtOptions.TokenValidationParameters = CreateTokenValidationParameters(configuration); + }); services .AddOptions() @@ -90,4 +93,4 @@ public static class ServiceCollectionExtension return result; } -} +} \ No newline at end of file diff --git a/CleanArchitecture.Api/Models/DetailedError.cs b/CleanArchitecture.Api/Models/DetailedError.cs index a4f3bd7..f7a1fed 100644 --- a/CleanArchitecture.Api/Models/DetailedError.cs +++ b/CleanArchitecture.Api/Models/DetailedError.cs @@ -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; } } \ No newline at end of file diff --git a/CleanArchitecture.Api/Models/ResponseMessage.cs b/CleanArchitecture.Api/Models/ResponseMessage.cs index e425f55..2f971bd 100644 --- a/CleanArchitecture.Api/Models/ResponseMessage.cs +++ b/CleanArchitecture.Api/Models/ResponseMessage.cs @@ -6,15 +6,12 @@ namespace CleanArchitecture.Api.Models; public sealed class ResponseMessage { - [JsonPropertyName("success")] - public bool Success { get; init; } + [JsonPropertyName("success")] public bool Success { get; init; } - [JsonPropertyName("errors")] - public IEnumerable? Errors { get; init; } + [JsonPropertyName("errors")] public IEnumerable? Errors { get; init; } [JsonPropertyName("detailedErrors")] public IEnumerable DetailedErrors { get; init; } = Enumerable.Empty(); - [JsonPropertyName("data")] - public T? Data { get; init; } + [JsonPropertyName("data")] public T? Data { get; init; } } \ No newline at end of file diff --git a/CleanArchitecture.Api/Program.cs b/CleanArchitecture.Api/Program.cs index da17e91..b9819e7 100644 --- a/CleanArchitecture.Api/Program.cs +++ b/CleanArchitecture.Api/Program.cs @@ -61,9 +61,9 @@ var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var services = scope.ServiceProvider; - ApplicationDbContext appDbContext = services.GetRequiredService(); - EventStoreDbContext storeDbContext = services.GetRequiredService(); - DomainNotificationStoreDbContext domainStoreDbContext = services.GetRequiredService(); + var appDbContext = services.GetRequiredService(); + var storeDbContext = services.GetRequiredService(); + var domainStoreDbContext = services.GetRequiredService(); appDbContext.EnsureMigrationsApplied(); @@ -91,6 +91,7 @@ app.MapHealthChecks("/healthz", new HealthCheckOptions }); app.MapControllers(); app.MapGrpcService(); +app.MapGrpcService(); app.Run(); diff --git a/CleanArchitecture.Application.Tests/CleanArchitecture.Application.Tests.csproj b/CleanArchitecture.Application.Tests/CleanArchitecture.Application.Tests.csproj index 225be5b..6f2f43b 100644 --- a/CleanArchitecture.Application.Tests/CleanArchitecture.Application.Tests.csproj +++ b/CleanArchitecture.Application.Tests/CleanArchitecture.Application.Tests.csproj @@ -8,11 +8,11 @@ - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -24,8 +24,8 @@ - - + + diff --git a/CleanArchitecture.Application.Tests/Fixtures/Queries/QueryHandlerBaseFixture.cs b/CleanArchitecture.Application.Tests/Fixtures/Queries/QueryHandlerBaseFixture.cs index 63c7502..d04b652 100644 --- a/CleanArchitecture.Application.Tests/Fixtures/Queries/QueryHandlerBaseFixture.cs +++ b/CleanArchitecture.Application.Tests/Fixtures/Queries/QueryHandlerBaseFixture.cs @@ -11,9 +11,9 @@ public class QueryHandlerBaseFixture public QueryHandlerBaseFixture VerifyExistingNotification(string key, string errorCode, string message) { Bus.Received(1).RaiseEventAsync(Arg.Is(notification => - notification.Key == key && - notification.Code == errorCode && - notification.Value == message)); + notification.Key == key && + notification.Code == errorCode && + notification.Value == message)); return this; } diff --git a/CleanArchitecture.Application.Tests/Fixtures/Queries/Tenants/GetAllTenantsTestFixture.cs b/CleanArchitecture.Application.Tests/Fixtures/Queries/Tenants/GetAllTenantsTestFixture.cs new file mode 100644 index 0000000..b13ac69 --- /dev/null +++ b/CleanArchitecture.Application.Tests/Fixtures/Queries/Tenants/GetAllTenantsTestFixture.cs @@ -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(); + + 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 }.BuildMock(); + TenantRepository.GetAllNoTracking().Returns(tenantList); + + return tenant; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application.Tests/Fixtures/Queries/Tenants/GetTenantByIdTestFixture.cs b/CleanArchitecture.Application.Tests/Fixtures/Queries/Tenants/GetTenantByIdTestFixture.cs new file mode 100644 index 0000000..e5d0622 --- /dev/null +++ b/CleanArchitecture.Application.Tests/Fixtures/Queries/Tenants/GetTenantByIdTestFixture.cs @@ -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(); + + 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 }.BuildMock(); + TenantRepository.GetAllNoTracking().Returns(tenantList); + + return tenant; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetAllUsersTestFixture.cs b/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetAllUsersTestFixture.cs index 0e23086..61b70d7 100644 --- a/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetAllUsersTestFixture.cs +++ b/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetAllUsersTestFixture.cs @@ -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(); @@ -17,19 +21,16 @@ 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, - "max@mustermann.com", - "Max", - "Mustermann", - "Password", - UserRole.User); + ExistingUserId, + Guid.NewGuid(), + "max@mustermann.com", + "Max", + "Mustermann", + "Password", + UserRole.User); var query = new[] { user }.BuildMock(); @@ -39,12 +40,13 @@ public sealed class GetAllUsersTestFixture : QueryHandlerBaseFixture public void SetupDeletedUserAsync() { var user = new User( - ExistingUserId, - "max@mustermann.com", - "Max", - "Mustermann", - "Password", - UserRole.User); + ExistingUserId, + Guid.NewGuid(), + "max@mustermann.com", + "Max", + "Mustermann", + "Password", + UserRole.User); user.Delete(); diff --git a/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetUserByIdTestFixture.cs b/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetUserByIdTestFixture.cs index 741faac..9464fed 100644 --- a/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetUserByIdTestFixture.cs +++ b/CleanArchitecture.Application.Tests/Fixtures/Queries/Users/GetUserByIdTestFixture.cs @@ -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(); @@ -18,19 +22,16 @@ 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, - "max@mustermann.com", - "Max", - "Mustermann", - "Password", - UserRole.User); + ExistingUserId, + Guid.NewGuid(), + "max@mustermann.com", + "Max", + "Mustermann", + "Password", + UserRole.User); var query = new[] { user }.BuildMock(); @@ -40,12 +41,13 @@ public sealed class GetUserByIdTestFixture : QueryHandlerBaseFixture public void SetupDeletedUserAsync() { var user = new User( - ExistingUserId, - "max@mustermann.com", - "Max", - "Mustermann", - "Password", - UserRole.User); + ExistingUserId, + Guid.NewGuid(), + "max@mustermann.com", + "Max", + "Mustermann", + "Password", + UserRole.User); user.Delete(); diff --git a/CleanArchitecture.Application.Tests/Queries/Tenants/GetAllTenantsQueryHandlerTests.cs b/CleanArchitecture.Application.Tests/Queries/Tenants/GetAllTenantsQueryHandlerTests.cs new file mode 100644 index 0000000..6940687 --- /dev/null +++ b/CleanArchitecture.Application.Tests/Queries/Tenants/GetAllTenantsQueryHandlerTests.cs @@ -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); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application.Tests/Queries/Tenants/GetTenantByIdQueryHandlerTests.cs b/CleanArchitecture.Application.Tests/Queries/Tenants/GetTenantByIdQueryHandlerTests.cs new file mode 100644 index 0000000..230864b --- /dev/null +++ b/CleanArchitecture.Application.Tests/Queries/Tenants/GetTenantByIdQueryHandlerTests.cs @@ -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(); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application/CleanArchitecture.Application.csproj b/CleanArchitecture.Application/CleanArchitecture.Application.csproj index fd17662..3b320a5 100644 --- a/CleanArchitecture.Application/CleanArchitecture.Application.csproj +++ b/CleanArchitecture.Application/CleanArchitecture.Application.csproj @@ -6,12 +6,13 @@ - + - - + + + diff --git a/CleanArchitecture.Application/Extensions/ServiceCollectionExtension.cs b/CleanArchitecture.Application/Extensions/ServiceCollectionExtension.cs index 784380f..9da4c3b 100644 --- a/CleanArchitecture.Application/Extensions/ServiceCollectionExtension.cs +++ b/CleanArchitecture.Application/Extensions/ServiceCollectionExtension.cs @@ -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(); + services.AddScoped(); return services; } public static IServiceCollection AddQueryHandlers(this IServiceCollection services) { + // User services.AddScoped, GetUserByIdQueryHandler>(); services.AddScoped>, GetAllUsersQueryHandler>(); + // Tenant + services.AddScoped, GetTenantByIdQueryHandler>(); + services + .AddScoped>, GetAllTenantsQueryHandler>(); + return services; } } \ No newline at end of file diff --git a/CleanArchitecture.Application/Interfaces/ITenantService.cs b/CleanArchitecture.Application/Interfaces/ITenantService.cs new file mode 100644 index 0000000..fbf4332 --- /dev/null +++ b/CleanArchitecture.Application/Interfaces/ITenantService.cs @@ -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 CreateTenantAsync(CreateTenantViewModel tenant); + public Task UpdateTenantAsync(UpdateTenantViewModel tenant); + public Task DeleteTenantAsync(Guid tenantId); + public Task GetTenantByIdAsync(Guid tenantId, bool deleted); + public Task> GetAllTenantsAsync(); +} \ No newline at end of file diff --git a/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQuery.cs b/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQuery.cs new file mode 100644 index 0000000..e87ddec --- /dev/null +++ b/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQuery.cs @@ -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>; \ No newline at end of file diff --git a/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQueryHandler.cs b/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQueryHandler.cs new file mode 100644 index 0000000..844a5f4 --- /dev/null +++ b/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQueryHandler.cs @@ -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> +{ + private readonly ITenantRepository _tenantRepository; + + public GetAllTenantsQueryHandler(ITenantRepository tenantRepository) + { + _tenantRepository = tenantRepository; + } + + public async Task> Handle( + GetAllTenantsQuery request, + CancellationToken cancellationToken) + { + return await _tenantRepository + .GetAllNoTracking() + .Include(x => x.Users) + .Where(x => !x.Deleted) + .Select(x => TenantViewModel.FromTenant(x)) + .ToListAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application/Queries/Tenants/GetTenantById/GetTenantByIdQuery.cs b/CleanArchitecture.Application/Queries/Tenants/GetTenantById/GetTenantByIdQuery.cs new file mode 100644 index 0000000..8639dfe --- /dev/null +++ b/CleanArchitecture.Application/Queries/Tenants/GetTenantById/GetTenantByIdQuery.cs @@ -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; \ No newline at end of file diff --git a/CleanArchitecture.Application/Queries/Tenants/GetTenantById/GetTenantByIdQueryHandler.cs b/CleanArchitecture.Application/Queries/Tenants/GetTenantById/GetTenantByIdQueryHandler.cs new file mode 100644 index 0000000..2e22455 --- /dev/null +++ b/CleanArchitecture.Application/Queries/Tenants/GetTenantById/GetTenantByIdQueryHandler.cs @@ -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 +{ + private readonly IMediatorHandler _bus; + private readonly ITenantRepository _tenantRepository; + + public GetTenantByIdQueryHandler(ITenantRepository tenantRepository, IMediatorHandler bus) + { + _tenantRepository = tenantRepository; + _bus = bus; + } + + public async Task 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); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application/Queries/Users/GetUserById/GetUserByIdQueryHandler.cs b/CleanArchitecture.Application/Queries/Users/GetUserById/GetUserByIdQueryHandler.cs index f057731..aa7974f 100644 --- a/CleanArchitecture.Application/Queries/Users/GetUserById/GetUserByIdQueryHandler.cs +++ b/CleanArchitecture.Application/Queries/Users/GetUserById/GetUserByIdQueryHandler.cs @@ -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( diff --git a/CleanArchitecture.Application/Services/TenantService.cs b/CleanArchitecture.Application/Services/TenantService.cs new file mode 100644 index 0000000..c10fe20 --- /dev/null +++ b/CleanArchitecture.Application/Services/TenantService.cs @@ -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 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 GetTenantByIdAsync(Guid tenantId, bool deleted) + { + return await _bus.QueryAsync(new GetTenantByIdQuery(tenantId, deleted)); + } + + public async Task> GetAllTenantsAsync() + { + return await _bus.QueryAsync(new GetAllTenantsQuery()); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application/Services/UserService.cs b/CleanArchitecture.Application/Services/UserService.cs index 8ab4600..f6181f7 100644 --- a/CleanArchitecture.Application/Services/UserService.cs +++ b/CleanArchitecture.Application/Services/UserService.cs @@ -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) diff --git a/CleanArchitecture.Application/ViewModels/Tenants/CreateTenantViewModel.cs b/CleanArchitecture.Application/ViewModels/Tenants/CreateTenantViewModel.cs new file mode 100644 index 0000000..e536004 --- /dev/null +++ b/CleanArchitecture.Application/ViewModels/Tenants/CreateTenantViewModel.cs @@ -0,0 +1,3 @@ +namespace CleanArchitecture.Application.ViewModels.Tenants; + +public sealed record CreateTenantViewModel(string Name); \ No newline at end of file diff --git a/CleanArchitecture.Application/ViewModels/Tenants/TenantViewModel.cs b/CleanArchitecture.Application/ViewModels/Tenants/TenantViewModel.cs new file mode 100644 index 0000000..af6bb43 --- /dev/null +++ b/CleanArchitecture.Application/ViewModels/Tenants/TenantViewModel.cs @@ -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 Users { get; set; } = new List(); + + public static TenantViewModel FromTenant(Tenant tenant) + { + return new TenantViewModel + { + Id = tenant.Id, + Name = tenant.Name, + Users = tenant.Users.Select(UserViewModel.FromUser) + }; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application/ViewModels/Tenants/UpdateTenantViewModel.cs b/CleanArchitecture.Application/ViewModels/Tenants/UpdateTenantViewModel.cs new file mode 100644 index 0000000..72e595e --- /dev/null +++ b/CleanArchitecture.Application/ViewModels/Tenants/UpdateTenantViewModel.cs @@ -0,0 +1,7 @@ +using System; + +namespace CleanArchitecture.Application.ViewModels.Tenants; + +public sealed record UpdateTenantViewModel( + Guid Id, + string Name); \ No newline at end of file diff --git a/CleanArchitecture.Application/gRPC/TenantsApiImplementation.cs b/CleanArchitecture.Application/gRPC/TenantsApiImplementation.cs new file mode 100644 index 0000000..414206c --- /dev/null +++ b/CleanArchitecture.Application/gRPC/TenantsApiImplementation.cs @@ -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 GetByIds( + GetTenantsByIdsRequest request, + ServerCallContext context) + { + var idsAsGuids = new List(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; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Application/gRPC/UsersApiImplementation.cs b/CleanArchitecture.Application/gRPC/UsersApiImplementation.cs index d8f9599..ff872e1 100644 --- a/CleanArchitecture.Application/gRPC/UsersApiImplementation.cs +++ b/CleanArchitecture.Application/gRPC/UsersApiImplementation.cs @@ -18,8 +18,8 @@ public sealed class UsersApiImplementation : UsersApi.UsersApiBase _userRepository = userRepository; } - public override async Task GetByIds( - GetByIdsRequest request, + public override async Task GetByIds( + GetUsersByIdsRequest request, ServerCallContext context) { var idsAsGuids = new List(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); diff --git a/CleanArchitecture.Application/viewmodels/Users/CreateUserViewModel.cs b/CleanArchitecture.Application/viewmodels/Users/CreateUserViewModel.cs index 2197419..2be45ca 100644 --- a/CleanArchitecture.Application/viewmodels/Users/CreateUserViewModel.cs +++ b/CleanArchitecture.Application/viewmodels/Users/CreateUserViewModel.cs @@ -1,7 +1,10 @@ +using System; + namespace CleanArchitecture.Application.ViewModels.Users; public sealed record CreateUserViewModel( string Email, string FirstName, string LastName, - string Password); \ No newline at end of file + string Password, + Guid TenantId); \ No newline at end of file diff --git a/CleanArchitecture.Application/viewmodels/Users/UpdateUserViewModel.cs b/CleanArchitecture.Application/viewmodels/Users/UpdateUserViewModel.cs index 1826704..85f1846 100644 --- a/CleanArchitecture.Application/viewmodels/Users/UpdateUserViewModel.cs +++ b/CleanArchitecture.Application/viewmodels/Users/UpdateUserViewModel.cs @@ -8,4 +8,5 @@ public sealed record UpdateUserViewModel( string Email, string FirstName, string LastName, - UserRole Role); \ No newline at end of file + UserRole Role, + Guid TenantId); \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CleanArchitecture.Domain.Tests.csproj b/CleanArchitecture.Domain.Tests/CleanArchitecture.Domain.Tests.csproj index 9a66f90..ce47c49 100644 --- a/CleanArchitecture.Domain.Tests/CleanArchitecture.Domain.Tests.csproj +++ b/CleanArchitecture.Domain.Tests/CleanArchitecture.Domain.Tests.csproj @@ -8,11 +8,11 @@ - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -24,7 +24,7 @@ - + diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/CreateTenant/CreateTenantCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/CreateTenant/CreateTenantCommandHandlerTests.cs new file mode 100644 index 0000000..2dce689 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/CreateTenant/CreateTenantCommandHandlerTests.cs @@ -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(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() + .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() + .VerifyAnyDomainNotification() + .VerifyExistingNotification( + DomainErrorCodes.Tenant.TenantAlreadyExists, + $"There is already a tenant with Id {command.AggregateId}"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/CreateTenant/CreateTenantCommandTestFixture.cs b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/CreateTenant/CreateTenantCommandTestFixture.cs new file mode 100644 index 0000000..3fc9210 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/CreateTenant/CreateTenantCommandTestFixture.cs @@ -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(); + + 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(x => x == id)) + .Returns(true); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/CreateTenant/CreateTenantCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/CreateTenant/CreateTenantCommandValidationTests.cs new file mode 100644 index 0000000..2156932 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/CreateTenant/CreateTenantCommandValidationTests.cs @@ -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 +{ + 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"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/DeleteTenant/DeleteTenantCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/DeleteTenant/DeleteTenantCommandHandlerTests.cs new file mode 100644 index 0000000..8f0d378 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/DeleteTenant/DeleteTenantCommandHandlerTests.cs @@ -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(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() + .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() + .VerifyAnyDomainNotification() + .VerifyExistingNotification( + ErrorCodes.InsufficientPermissions, + $"No permission to delete tenant {command.AggregateId}"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/DeleteTenant/DeleteTenantCommandTestFixture.cs b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/DeleteTenant/DeleteTenantCommandTestFixture.cs new file mode 100644 index 0000000..0617c3b --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/DeleteTenant/DeleteTenantCommandTestFixture.cs @@ -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(); + UserRepository = Substitute.For(); + + 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(y => y == tenant.Id)) + .Returns(tenant); + + return tenant; + } + + public void SetupUser() + { + User.GetUserRole().Returns(UserRole.User); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/DeleteTenant/DeleteTenantCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/DeleteTenant/DeleteTenantCommandValidationTests.cs new file mode 100644 index 0000000..b36e294 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/DeleteTenant/DeleteTenantCommandValidationTests.cs @@ -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 +{ + 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()); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/UpdateTenant/UpdateTenantCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/UpdateTenant/UpdateTenantCommandHandlerTests.cs new file mode 100644 index 0000000..77a186a --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/UpdateTenant/UpdateTenantCommandHandlerTests.cs @@ -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(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() + .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() + .VerifyAnyDomainNotification() + .VerifyExistingNotification( + ErrorCodes.ObjectNotFound, + $"There is no tenant with Id {command.AggregateId}"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/UpdateTenant/UpdateTenantCommandTestFixture.cs b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/UpdateTenant/UpdateTenantCommandTestFixture.cs new file mode 100644 index 0000000..ddf762a --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/UpdateTenant/UpdateTenantCommandTestFixture.cs @@ -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(); + + 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(x => x == id)) + .Returns(new Entities.Tenant(id, "Test Tenant")); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/UpdateTenant/UpdateTenantCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/UpdateTenant/UpdateTenantCommandValidationTests.cs new file mode 100644 index 0000000..c7c4f11 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/UpdateTenant/UpdateTenantCommandValidationTests.cs @@ -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 +{ + 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"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandHandlerTests.cs index 08d16ca..293c603 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandHandlerTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandHandlerTests.cs @@ -22,7 +22,7 @@ public sealed class ChangePasswordCommandHandlerTests _fixture .VerifyNoDomainNotification() .VerifyCommit() - .VerifyRaisedEvent(x => x.UserId == user.Id); + .VerifyRaisedEvent(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() .VerifyAnyDomainNotification() .VerifyExistingNotification( - DomainErrorCodes.UserPasswordIncorrect, + DomainErrorCodes.User.UserPasswordIncorrect, "The password is incorrect"); } } \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandTestFixture.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandTestFixture.cs index 20e2189..5da9561 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandTestFixture.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandTestFixture.cs @@ -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(); @@ -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", diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandValidationTests.cs index 39c8186..b75d5f4 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandValidationTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/ChangePassword/ChangePasswordCommandValidationTests.cs @@ -28,12 +28,12 @@ public sealed class ChangePasswordCommandValidationTests : var errors = new List { - 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"); } diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandHandlerTests.cs index 9319274..0f0e7d6 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandHandlerTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandHandlerTests.cs @@ -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(x => x.UserId == command.UserId); + .VerifyRaisedEvent(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() .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(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() + .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() + .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() + .VerifyAnyDomainNotification() + .VerifyExistingNotification( + ErrorCodes.InsufficientPermissions, + "You are not allowed to create users"); } } \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandTestFixture.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandTestFixture.cs index 3662b65..3cf3a09 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandTestFixture.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandTestFixture.cs @@ -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(); + TenantRepository = Substitute.For(); 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(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(y => y == tenantId)) + .Returns(true); + } } \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandValidationTests.cs index f4635df..05e3d7c 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandValidationTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandValidationTests.cs @@ -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 { - 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", diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandHandlerTests.cs index fabfd29..95d2569 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandHandlerTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandHandlerTests.cs @@ -22,7 +22,7 @@ public sealed class DeleteUserCommandHandlerTests _fixture .VerifyNoDomainNotification() .VerifyCommit() - .VerifyRaisedEvent(x => x.UserId == user.Id); + .VerifyRaisedEvent(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() + .VerifyAnyDomainNotification() + .VerifyExistingNotification( + ErrorCodes.InsufficientPermissions, + $"No permission to delete user {command.UserId}"); } } \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandTestFixture.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandTestFixture.cs index 92af9a4..bdd73f9 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandTestFixture.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandTestFixture.cs @@ -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(); @@ -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); + } } \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandValidationTests.cs index a2e6e45..8ea63cf 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandValidationTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/DeleteUser/DeleteUserCommandValidationTests.cs @@ -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()); } } \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandHandlerTests.cs index ba4deaa..1c26051 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandHandlerTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandHandlerTests.cs @@ -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(); diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandTestFixture.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandTestFixture.cs index c8f91cd..155b6b7 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandTestFixture.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandTestFixture.cs @@ -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 { get; set; } + public LoginUserCommandTestFixture() { UserRepository = Substitute.For(); @@ -30,13 +34,10 @@ public sealed class LoginUserCommandTestFixture : CommandHandlerFixtureBase TokenSettings); } - public LoginUserCommandHandler CommandHandler { get; set; } - public IUserRepository UserRepository { get; set; } - public IOptions TokenSettings { get; set; } - public Entities.User SetupUser() { var user = new Entities.User( + Guid.NewGuid(), Guid.NewGuid(), "max@mustermann.com", "Max", diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandValidationTests.cs index fd85ce8..7b1ca35 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandValidationTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandValidationTests.cs @@ -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 { - 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"); } diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandHandlerTests.cs index 841c796..c1229b7 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandHandlerTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandHandlerTests.cs @@ -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(x => x.UserId == command.UserId); + .VerifyRaisedEvent(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() .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() + .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(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(x => x.AggregateId == command.UserId); } } \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandTestFixture.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandTestFixture.cs index cd21e9f..4e572d4 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandTestFixture.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandTestFixture.cs @@ -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(); + TenantRepository = Substitute.For(); 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(y => y == tenant.Id)) + .Returns(true); + + return tenant; + } + + public void SetupCurrentUser(Guid userId) + { + User.GetUserId().Returns(userId); + User.GetUserRole().Returns(UserRole.User); + } } \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandValidationTests.cs index 7a919f4..42de98f 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandValidationTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandValidationTests.cs @@ -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()); } } \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandlerFixtureBase.cs b/CleanArchitecture.Domain.Tests/CommandHandlerFixtureBase.cs index 27b46ef..1d5087b 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandlerFixtureBase.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandlerFixtureBase.cs @@ -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(); @@ -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( diff --git a/CleanArchitecture.Domain/ApiUser.cs b/CleanArchitecture.Domain/ApiUser.cs index 708cec4..3e0ea81 100644 --- a/CleanArchitecture.Domain/ApiUser.cs +++ b/CleanArchitecture.Domain/ApiUser.cs @@ -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; diff --git a/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj b/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj index 0d9cbb5..756c41c 100644 --- a/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj +++ b/CleanArchitecture.Domain/CleanArchitecture.Domain.csproj @@ -6,14 +6,14 @@ - - - - - + + + + + - - - + + + diff --git a/CleanArchitecture.Domain/Commands/CommandBase.cs b/CleanArchitecture.Domain/Commands/CommandBase.cs index 02d78d1..d659231 100644 --- a/CleanArchitecture.Domain/Commands/CommandBase.cs +++ b/CleanArchitecture.Domain/Commands/CommandBase.cs @@ -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(); } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/CommandHandlerBase.cs b/CleanArchitecture.Domain/Commands/CommandHandlerBase.cs index 54a557b..e1f7ba0 100644 --- a/CleanArchitecture.Domain/Commands/CommandHandlerBase.cs +++ b/CleanArchitecture.Domain/Commands/CommandHandlerBase.cs @@ -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"); } diff --git a/CleanArchitecture.Domain/Commands/Tenants/CreateTenant/CreateTenantCommand.cs b/CleanArchitecture.Domain/Commands/Tenants/CreateTenant/CreateTenantCommand.cs new file mode 100644 index 0000000..b9bc519 --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Tenants/CreateTenant/CreateTenantCommand.cs @@ -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; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Tenants/CreateTenant/CreateTenantCommandHandler.cs b/CleanArchitecture.Domain/Commands/Tenants/CreateTenant/CreateTenantCommandHandler.cs new file mode 100644 index 0000000..16d0821 --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Tenants/CreateTenant/CreateTenantCommandHandler.cs @@ -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 +{ + private readonly ITenantRepository _tenantRepository; + private readonly IUser _user; + + public CreateTenantCommandHandler( + IMediatorHandler bus, + IUnitOfWork unitOfWork, + INotificationHandler 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)); + } + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Tenants/CreateTenant/CreateTenantCommandValidation.cs b/CleanArchitecture.Domain/Commands/Tenants/CreateTenant/CreateTenantCommandValidation.cs new file mode 100644 index 0000000..9ac7602 --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Tenants/CreateTenant/CreateTenantCommandValidation.cs @@ -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 +{ + 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"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Tenants/DeleteTenant/DeleteTenantCommand.cs b/CleanArchitecture.Domain/Commands/Tenants/DeleteTenant/DeleteTenantCommand.cs new file mode 100644 index 0000000..e83ddc8 --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Tenants/DeleteTenant/DeleteTenantCommand.cs @@ -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; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Tenants/DeleteTenant/DeleteTenantCommandHandler.cs b/CleanArchitecture.Domain/Commands/Tenants/DeleteTenant/DeleteTenantCommandHandler.cs new file mode 100644 index 0000000..1303d7b --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Tenants/DeleteTenant/DeleteTenantCommandHandler.cs @@ -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 +{ + private readonly ITenantRepository _tenantRepository; + private readonly IUser _user; + private readonly IUserRepository _userRepository; + + public DeleteTenantCommandHandler( + IMediatorHandler bus, + IUnitOfWork unitOfWork, + INotificationHandler 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)); + } + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Tenants/DeleteTenant/DeleteTenantCommandValidation.cs b/CleanArchitecture.Domain/Commands/Tenants/DeleteTenant/DeleteTenantCommandValidation.cs new file mode 100644 index 0000000..7b80034 --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Tenants/DeleteTenant/DeleteTenantCommandValidation.cs @@ -0,0 +1,20 @@ +using CleanArchitecture.Domain.Errors; +using FluentValidation; + +namespace CleanArchitecture.Domain.Commands.Tenants.DeleteTenant; + +public sealed class DeleteTenantCommandValidation : AbstractValidator +{ + public DeleteTenantCommandValidation() + { + AddRuleForId(); + } + + private void AddRuleForId() + { + RuleFor(cmd => cmd.AggregateId) + .NotEmpty() + .WithErrorCode(DomainErrorCodes.Tenant.TenantEmptyId) + .WithMessage("Tenant id may not be empty"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Tenants/UpdateTenant/UpdateTenantCommand.cs b/CleanArchitecture.Domain/Commands/Tenants/UpdateTenant/UpdateTenantCommand.cs new file mode 100644 index 0000000..fd2356d --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Tenants/UpdateTenant/UpdateTenantCommand.cs @@ -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; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Tenants/UpdateTenant/UpdateTenantCommandHandler.cs b/CleanArchitecture.Domain/Commands/Tenants/UpdateTenant/UpdateTenantCommandHandler.cs new file mode 100644 index 0000000..dc1e076 --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Tenants/UpdateTenant/UpdateTenantCommandHandler.cs @@ -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 +{ + private readonly ITenantRepository _tenantRepository; + private readonly IUser _user; + + public UpdateTenantCommandHandler( + IMediatorHandler bus, + IUnitOfWork unitOfWork, + INotificationHandler 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)); + } + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Tenants/UpdateTenant/UpdateTenantCommandValidation.cs b/CleanArchitecture.Domain/Commands/Tenants/UpdateTenant/UpdateTenantCommandValidation.cs new file mode 100644 index 0000000..c9abd29 --- /dev/null +++ b/CleanArchitecture.Domain/Commands/Tenants/UpdateTenant/UpdateTenantCommandValidation.cs @@ -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 +{ + 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"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommand.cs b/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommand.cs index 4a27534..18ba9a2 100644 --- a/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommand.cs +++ b/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommand.cs @@ -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; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommandHandler.cs index 38a85dd..713d43e 100644 --- a/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Users/ChangePassword/ChangePasswordCommandHandler.cs @@ -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; } diff --git a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommand.cs b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommand.cs index c9e9e79..3929d0a 100644 --- a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommand.cs +++ b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommand.cs @@ -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; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs index 98e1b2b..8eb5388 100644 --- a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs @@ -15,15 +15,21 @@ namespace CleanArchitecture.Domain.Commands.Users.CreateUser; public sealed class CreateUserCommandHandler : CommandHandlerBase, IRequestHandler { + private readonly ITenantRepository _tenantRepository; + private readonly IUser _user; private readonly IUserRepository _userRepository; public CreateUserCommandHandler( IMediatorHandler bus, IUnitOfWork unitOfWork, INotificationHandler 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, diff --git a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandValidation.cs b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandValidation.cs index 196db14..371bf60 100644 --- a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandValidation.cs +++ b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandValidation.cs @@ -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 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() diff --git a/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommand.cs b/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommand.cs index 48c42f5..eda35ae 100644 --- a/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommand.cs +++ b/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommand.cs @@ -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; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs index 1fc4970..e7d69b5 100644 --- a/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandHandler.cs @@ -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; diff --git a/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandValidation.cs b/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandValidation.cs index 99dc44a..7ca78ad 100644 --- a/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandValidation.cs +++ b/CleanArchitecture.Domain/Commands/Users/DeleteUser/DeleteUserCommandValidation.cs @@ -14,7 +14,7 @@ public sealed class DeleteUserCommandValidation : AbstractValidator cmd.UserId) .NotEmpty() - .WithErrorCode(DomainErrorCodes.UserEmptyId) + .WithErrorCode(DomainErrorCodes.User.UserEmptyId) .WithMessage("User id may not be empty"); } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommand.cs b/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommand.cs index 2fcdc00..838d3a9 100644 --- a/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommand.cs +++ b/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommand.cs @@ -6,7 +6,10 @@ namespace CleanArchitecture.Domain.Commands.Users.LoginUser; public sealed class LoginUserCommand : CommandBase, IRequest { - 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; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandHandler.cs index dee2b53..aee5fe6 100644 --- a/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandHandler.cs @@ -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 ""; } diff --git a/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandValidation.cs b/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandValidation.cs index 9f668cd..488fca0 100644 --- a/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandValidation.cs +++ b/CleanArchitecture.Domain/Commands/Users/LoginUser/LoginUserCommandValidation.cs @@ -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 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() diff --git a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommand.cs b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommand.cs index 13bc3d9..9f7f58c 100644 --- a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommand.cs +++ b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommand.cs @@ -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; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs index cf0227e..09ef27e 100644 --- a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandHandler.cs @@ -13,6 +13,7 @@ namespace CleanArchitecture.Domain.Commands.Users.UpdateUser; public sealed class UpdateUserCommandHandler : CommandHandlerBase, IRequestHandler { + private readonly ITenantRepository _tenantRepository; private readonly IUser _user; private readonly IUserRepository _userRepository; @@ -21,10 +22,12 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase, IUnitOfWork unitOfWork, INotificationHandler 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); diff --git a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandValidation.cs b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandValidation.cs index 2b29a94..9380fb2 100644 --- a/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandValidation.cs +++ b/CleanArchitecture.Domain/Commands/Users/UpdateUser/UpdateUserCommandValidation.cs @@ -1,3 +1,4 @@ +using CleanArchitecture.Domain.Constants; using CleanArchitecture.Domain.Errors; using FluentValidation; @@ -8,6 +9,7 @@ public sealed class UpdateUserCommandValidation : AbstractValidator 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"); } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Constants/Ids.cs b/CleanArchitecture.Domain/Constants/Ids.cs new file mode 100644 index 0000000..3e7e340 --- /dev/null +++ b/CleanArchitecture.Domain/Constants/Ids.cs @@ -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"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Constants/MaxLengths.cs b/CleanArchitecture.Domain/Constants/MaxLengths.cs new file mode 100644 index 0000000..ab8c6de --- /dev/null +++ b/CleanArchitecture.Domain/Constants/MaxLengths.cs @@ -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; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/DomainEvents/DomainEvent.cs b/CleanArchitecture.Domain/DomainEvents/DomainEvent.cs index e5c25a9..37d8eaf 100644 --- a/CleanArchitecture.Domain/DomainEvents/DomainEvent.cs +++ b/CleanArchitecture.Domain/DomainEvents/DomainEvent.cs @@ -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; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/DomainEvents/Message.cs b/CleanArchitecture.Domain/DomainEvents/Message.cs index 606c259..a99929e 100644 --- a/CleanArchitecture.Domain/DomainEvents/Message.cs +++ b/CleanArchitecture.Domain/DomainEvents/Message.cs @@ -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; } -} +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/DomainEvents/StoredDomainEvent.cs b/CleanArchitecture.Domain/DomainEvents/StoredDomainEvent.cs index 1074410..5220441 100644 --- a/CleanArchitecture.Domain/DomainEvents/StoredDomainEvent.cs +++ b/CleanArchitecture.Domain/DomainEvents/StoredDomainEvent.cs @@ -24,5 +24,6 @@ public class StoredDomainEvent : DomainEvent // EF Constructor protected StoredDomainEvent() : base(Guid.NewGuid()) - { } -} + { + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/DomainNotifications/StoredDomainNotification.cs b/CleanArchitecture.Domain/DomainNotifications/StoredDomainNotification.cs index 3a6199c..a4800a4 100644 --- a/CleanArchitecture.Domain/DomainNotifications/StoredDomainNotification.cs +++ b/CleanArchitecture.Domain/DomainNotifications/StoredDomainNotification.cs @@ -15,11 +15,11 @@ public class StoredDomainNotification : DomainNotification string data, string user, string correlationId) : base( - domainNotification.Key, - domainNotification.Value, - domainNotification.Code, - null, - domainNotification.AggregateId) + domainNotification.Key, + domainNotification.Value, + domainNotification.Code, + null, + domainNotification.AggregateId) { Id = Guid.NewGuid(); User = user; @@ -31,5 +31,6 @@ public class StoredDomainNotification : DomainNotification // EF Constructor protected StoredDomainNotification() : base(string.Empty, string.Empty, string.Empty) - { } -} + { + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Entities/Entity.cs b/CleanArchitecture.Domain/Entities/Entity.cs index 99a272a..41c7b54 100644 --- a/CleanArchitecture.Domain/Entities/Entity.cs +++ b/CleanArchitecture.Domain/Entities/Entity.cs @@ -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) diff --git a/CleanArchitecture.Domain/Entities/Tenant.cs b/CleanArchitecture.Domain/Entities/Tenant.cs new file mode 100644 index 0000000..9b5f2c5 --- /dev/null +++ b/CleanArchitecture.Domain/Entities/Tenant.cs @@ -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 Users { get; private set; } = new HashSet(); + + public Tenant( + Guid id, + string name) : base(id) + { + Name = name; + } + + public void SetName(string name) + { + Name = name; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Entities/User.cs b/CleanArchitecture.Domain/Entities/User.cs index 186913e..f33ea9d 100644 --- a/CleanArchitecture.Domain/Entities/User.cs +++ b/CleanArchitecture.Domain/Entities/User.cs @@ -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; + } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Errors/DomainErrorCodes.cs b/CleanArchitecture.Domain/Errors/DomainErrorCodes.cs index a6091de..76977e4 100644 --- a/CleanArchitecture.Domain/Errors/DomainErrorCodes.cs +++ b/CleanArchitecture.Domain/Errors/DomainErrorCodes.cs @@ -2,26 +2,40 @@ namespace CleanArchitecture.Domain.Errors; public static class DomainErrorCodes { - // User Validation - public const string UserEmptyId = "USER_EMPTY_ID"; - public const string UserEmptyFirstName = "USER_EMPTY_FIRST_NAME"; - public const string UserEmptyLastName = "USER_EMPTY_LAST_NAME"; - public const string UserEmailExceedsMaxLength = "USER_EMAIL_EXCEEDS_MAX_LENGTH"; - public const string UserFirstNameExceedsMaxLength = "USER_FIRST_NAME_EXCEEDS_MAX_LENGTH"; - public const string UserLastNameExceedsMaxLength = "USER_LAST_NAME_EXCEEDS_MAX_LENGTH"; - public const string UserInvalidEmail = "USER_INVALID_EMAIL"; - public const string UserInvalidRole = "USER_INVALID_ROLE"; + public static class User + { + // User Validation + public const string UserEmptyId = "USER_EMPTY_ID"; + public const string UserEmptyFirstName = "USER_EMPTY_FIRST_NAME"; + public const string UserEmptyLastName = "USER_EMPTY_LAST_NAME"; + public const string UserEmailExceedsMaxLength = "USER_EMAIL_EXCEEDS_MAX_LENGTH"; + public const string UserFirstNameExceedsMaxLength = "USER_FIRST_NAME_EXCEEDS_MAX_LENGTH"; + public const string UserLastNameExceedsMaxLength = "USER_LAST_NAME_EXCEEDS_MAX_LENGTH"; + public const string UserInvalidEmail = "USER_INVALID_EMAIL"; + public const string UserInvalidRole = "USER_INVALID_ROLE"; - // User Password Validation - public const string UserEmptyPassword = "USER_PASSWORD_MAY_NOT_BE_EMPTY"; - public const string UserShortPassword = "USER_PASSWORD_MAY_NOT_BE_SHORTER_THAN_6_CHARACTERS"; - public const string UserLongPassword = "USER_PASSWORD_MAY_NOT_BE_LONGER_THAN_50_CHARACTERS"; - public const string UserUppercaseLetterPassword = "USER_PASSWORD_MUST_CONTAIN_A_UPPERCASE_LETTER"; - public const string UserLowercaseLetterPassword = "USER_PASSWORD_MUST_CONTAIN_A_LOWERCASE_LETTER"; - public const string UserNumberPassword = "USER_PASSWORD_MUST_CONTAIN_A_NUMBER"; - public const string UserSpecialCharPassword = "USER_PASSWORD_MUST_CONTAIN_A_SPECIAL_CHARACTER"; + // User Password Validation + public const string UserEmptyPassword = "USER_PASSWORD_MAY_NOT_BE_EMPTY"; + public const string UserShortPassword = "USER_PASSWORD_MAY_NOT_BE_SHORTER_THAN_6_CHARACTERS"; + public const string UserLongPassword = "USER_PASSWORD_MAY_NOT_BE_LONGER_THAN_50_CHARACTERS"; + public const string UserUppercaseLetterPassword = "USER_PASSWORD_MUST_CONTAIN_A_UPPERCASE_LETTER"; + public const string UserLowercaseLetterPassword = "USER_PASSWORD_MUST_CONTAIN_A_LOWERCASE_LETTER"; + public const string UserNumberPassword = "USER_PASSWORD_MUST_CONTAIN_A_NUMBER"; + public const string UserSpecialCharPassword = "USER_PASSWORD_MUST_CONTAIN_A_SPECIAL_CHARACTER"; - // User - public const string UserAlreadyExists = "USER_ALREADY_EXISTS"; - public const string UserPasswordIncorrect = "USER_PASSWORD_INCORRECT"; + // 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"; + } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/EventHandler/TenantEventHandler.cs b/CleanArchitecture.Domain/EventHandler/TenantEventHandler.cs new file mode 100644 index 0000000..ed46c1a --- /dev/null +++ b/CleanArchitecture.Domain/EventHandler/TenantEventHandler.cs @@ -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, + INotificationHandler, + INotificationHandler +{ + 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; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Events/Tenant/TenantCreatedEvent.cs b/CleanArchitecture.Domain/Events/Tenant/TenantCreatedEvent.cs new file mode 100644 index 0000000..dc558b6 --- /dev/null +++ b/CleanArchitecture.Domain/Events/Tenant/TenantCreatedEvent.cs @@ -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; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Events/Tenant/TenantDeletedEvent.cs b/CleanArchitecture.Domain/Events/Tenant/TenantDeletedEvent.cs new file mode 100644 index 0000000..e96bdfd --- /dev/null +++ b/CleanArchitecture.Domain/Events/Tenant/TenantDeletedEvent.cs @@ -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) + { + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Events/Tenant/TenantUpdatedEvent.cs b/CleanArchitecture.Domain/Events/Tenant/TenantUpdatedEvent.cs new file mode 100644 index 0000000..20675d1 --- /dev/null +++ b/CleanArchitecture.Domain/Events/Tenant/TenantUpdatedEvent.cs @@ -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; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Events/User/PasswordChangedEvent.cs b/CleanArchitecture.Domain/Events/User/PasswordChangedEvent.cs index e3bf3f1..c5b1b25 100644 --- a/CleanArchitecture.Domain/Events/User/PasswordChangedEvent.cs +++ b/CleanArchitecture.Domain/Events/User/PasswordChangedEvent.cs @@ -7,8 +7,5 @@ public sealed class PasswordChangedEvent : DomainEvent { public PasswordChangedEvent(Guid userId) : base(userId) { - UserId = userId; } - - public Guid UserId { get; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Events/User/UserCreatedEvent.cs b/CleanArchitecture.Domain/Events/User/UserCreatedEvent.cs index f21e681..95d11bf 100644 --- a/CleanArchitecture.Domain/Events/User/UserCreatedEvent.cs +++ b/CleanArchitecture.Domain/Events/User/UserCreatedEvent.cs @@ -7,8 +7,5 @@ public sealed class UserCreatedEvent : DomainEvent { public UserCreatedEvent(Guid userId) : base(userId) { - UserId = userId; } - - public Guid UserId { get; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Events/User/UserDeletedEvent.cs b/CleanArchitecture.Domain/Events/User/UserDeletedEvent.cs index 5245879..8b485f5 100644 --- a/CleanArchitecture.Domain/Events/User/UserDeletedEvent.cs +++ b/CleanArchitecture.Domain/Events/User/UserDeletedEvent.cs @@ -7,8 +7,5 @@ public sealed class UserDeletedEvent : DomainEvent { public UserDeletedEvent(Guid userId) : base(userId) { - UserId = userId; } - - public Guid UserId { get; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Events/User/UserUpdatedEvent.cs b/CleanArchitecture.Domain/Events/User/UserUpdatedEvent.cs index d78cd72..7056b95 100644 --- a/CleanArchitecture.Domain/Events/User/UserUpdatedEvent.cs +++ b/CleanArchitecture.Domain/Events/User/UserUpdatedEvent.cs @@ -7,8 +7,5 @@ public sealed class UserUpdatedEvent : DomainEvent { public UserUpdatedEvent(Guid userId) : base(userId) { - UserId = userId; } - - public Guid UserId { get; } } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Extensions/ServiceCollectionExtension.cs b/CleanArchitecture.Domain/Extensions/ServiceCollectionExtension.cs index 4c3b2b9..eddd6e7 100644 --- a/CleanArchitecture.Domain/Extensions/ServiceCollectionExtension.cs +++ b/CleanArchitecture.Domain/Extensions/ServiceCollectionExtension.cs @@ -1,9 +1,13 @@ +using CleanArchitecture.Domain.Commands.Tenants.CreateTenant; +using CleanArchitecture.Domain.Commands.Tenants.DeleteTenant; +using CleanArchitecture.Domain.Commands.Tenants.UpdateTenant; using CleanArchitecture.Domain.Commands.Users.ChangePassword; using CleanArchitecture.Domain.Commands.Users.CreateUser; using CleanArchitecture.Domain.Commands.Users.DeleteUser; using CleanArchitecture.Domain.Commands.Users.LoginUser; using CleanArchitecture.Domain.Commands.Users.UpdateUser; using CleanArchitecture.Domain.EventHandler; +using CleanArchitecture.Domain.Events.Tenant; using CleanArchitecture.Domain.Events.User; using CleanArchitecture.Domain.Interfaces; using MediatR; @@ -22,6 +26,10 @@ public static class ServiceCollectionExtension services.AddScoped, ChangePasswordCommandHandler>(); services.AddScoped, LoginUserCommandHandler>(); + // Tenant + services.AddScoped, CreateTenantCommandHandler>(); + services.AddScoped, UpdateTenantCommandHandler>(); + services.AddScoped, DeleteTenantCommandHandler>(); return services; } @@ -34,6 +42,11 @@ public static class ServiceCollectionExtension services.AddScoped, UserEventHandler>(); services.AddScoped, UserEventHandler>(); + // Tenant + services.AddScoped, TenantEventHandler>(); + services.AddScoped, TenantEventHandler>(); + services.AddScoped, TenantEventHandler>(); + return services; } diff --git a/CleanArchitecture.Domain/Extensions/Validation/CustomValidator.cs b/CleanArchitecture.Domain/Extensions/Validation/CustomValidator.cs index 5157f16..dea14c7 100644 --- a/CleanArchitecture.Domain/Extensions/Validation/CustomValidator.cs +++ b/CleanArchitecture.Domain/Extensions/Validation/CustomValidator.cs @@ -23,13 +23,13 @@ public static partial class CustomValidator int maxLength = 50) { var options = ruleBuilder - .NotEmpty().WithErrorCode(DomainErrorCodes.UserEmptyPassword) - .MinimumLength(minLength).WithErrorCode(DomainErrorCodes.UserShortPassword) - .MaximumLength(maxLength).WithErrorCode(DomainErrorCodes.UserLongPassword) - .Matches("[A-Z]").WithErrorCode(DomainErrorCodes.UserUppercaseLetterPassword) - .Matches("[a-z]").WithErrorCode(DomainErrorCodes.UserLowercaseLetterPassword) - .Matches("[0-9]").WithErrorCode(DomainErrorCodes.UserNumberPassword) - .Matches("[^a-zA-Z0-9]").WithErrorCode(DomainErrorCodes.UserSpecialCharPassword); + .NotEmpty().WithErrorCode(DomainErrorCodes.User.UserEmptyPassword) + .MinimumLength(minLength).WithErrorCode(DomainErrorCodes.User.UserShortPassword) + .MaximumLength(maxLength).WithErrorCode(DomainErrorCodes.User.UserLongPassword) + .Matches("[A-Z]").WithErrorCode(DomainErrorCodes.User.UserUppercaseLetterPassword) + .Matches("[a-z]").WithErrorCode(DomainErrorCodes.User.UserLowercaseLetterPassword) + .Matches("[0-9]").WithErrorCode(DomainErrorCodes.User.UserNumberPassword) + .Matches("[^a-zA-Z0-9]").WithErrorCode(DomainErrorCodes.User.UserSpecialCharPassword); return options; } diff --git a/CleanArchitecture.Domain/Interfaces/Repositories/IRepository.cs b/CleanArchitecture.Domain/Interfaces/Repositories/IRepository.cs index 7c162bd..5d90b49 100644 --- a/CleanArchitecture.Domain/Interfaces/Repositories/IRepository.cs +++ b/CleanArchitecture.Domain/Interfaces/Repositories/IRepository.cs @@ -22,4 +22,5 @@ public interface IRepository : IDisposable where TEntity : Entity Task ExistsAsync(Guid id); public void Remove(TEntity entity, bool hardDelete = false); + void RemoveRange(IEnumerable entities, bool hardDelete = false); } \ No newline at end of file diff --git a/CleanArchitecture.Domain/Interfaces/Repositories/ITenantRepository.cs b/CleanArchitecture.Domain/Interfaces/Repositories/ITenantRepository.cs new file mode 100644 index 0000000..d2e0464 --- /dev/null +++ b/CleanArchitecture.Domain/Interfaces/Repositories/ITenantRepository.cs @@ -0,0 +1,7 @@ +using CleanArchitecture.Domain.Entities; + +namespace CleanArchitecture.Domain.Interfaces.Repositories; + +public interface ITenantRepository : IRepository +{ +} \ No newline at end of file diff --git a/CleanArchitecture.Domain/Notifications/DomainNotification.cs b/CleanArchitecture.Domain/Notifications/DomainNotification.cs index f3da784..c228ab4 100644 --- a/CleanArchitecture.Domain/Notifications/DomainNotification.cs +++ b/CleanArchitecture.Domain/Notifications/DomainNotification.cs @@ -5,6 +5,12 @@ namespace CleanArchitecture.Domain.Notifications; public class DomainNotification : DomainEvent { + public string Key { get; } + public string Value { get; } + public string Code { get; } + public object? Data { get; set; } + public int Version { get; private set; } = 1; + public DomainNotification( string key, string value, @@ -19,10 +25,4 @@ public class DomainNotification : DomainEvent Data = data; } - - public string Key { get; } - public string Value { get; } - public string Code { get; } - public object? Data { get; set; } - public int Version { get; private set; } = 1; } \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure.Tests/CleanArchitecture.Infrastructure.Tests.csproj b/CleanArchitecture.Infrastructure.Tests/CleanArchitecture.Infrastructure.Tests.csproj index 41e3ba7..940558b 100644 --- a/CleanArchitecture.Infrastructure.Tests/CleanArchitecture.Infrastructure.Tests.csproj +++ b/CleanArchitecture.Infrastructure.Tests/CleanArchitecture.Infrastructure.Tests.csproj @@ -8,10 +8,10 @@ - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -23,7 +23,7 @@ - + diff --git a/CleanArchitecture.Infrastructure/CleanArchitecture.Infrastructure.csproj b/CleanArchitecture.Infrastructure/CleanArchitecture.Infrastructure.csproj index 1df52f3..4aab3e5 100644 --- a/CleanArchitecture.Infrastructure/CleanArchitecture.Infrastructure.csproj +++ b/CleanArchitecture.Infrastructure/CleanArchitecture.Infrastructure.csproj @@ -6,19 +6,19 @@ - + - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/CleanArchitecture.Infrastructure/Configurations/EventSourcing/StoredDomainEventConfiguration.cs b/CleanArchitecture.Infrastructure/Configurations/EventSourcing/StoredDomainEventConfiguration.cs index 05091d5..4347992 100644 --- a/CleanArchitecture.Infrastructure/Configurations/EventSourcing/StoredDomainEventConfiguration.cs +++ b/CleanArchitecture.Infrastructure/Configurations/EventSourcing/StoredDomainEventConfiguration.cs @@ -22,4 +22,4 @@ public sealed class StoredDomainEventConfiguration : IEntityTypeConfiguration c.SerializedData) .HasColumnName("Data"); } -} +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/Configurations/TenantConfiguration.cs b/CleanArchitecture.Infrastructure/Configurations/TenantConfiguration.cs new file mode 100644 index 0000000..783c4a7 --- /dev/null +++ b/CleanArchitecture.Infrastructure/Configurations/TenantConfiguration.cs @@ -0,0 +1,21 @@ +using CleanArchitecture.Domain.Constants; +using CleanArchitecture.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CleanArchitecture.Infrastructure.Configurations; + +public sealed class TenantConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder + .Property(user => user.Name) + .IsRequired() + .HasMaxLength(MaxLengths.Tenant.Name); + + builder.HasData(new Tenant( + Ids.Seed.TenantId, + "Admin Tenant")); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/Configurations/UserConfiguration.cs b/CleanArchitecture.Infrastructure/Configurations/UserConfiguration.cs index ecd3505..08642b0 100644 --- a/CleanArchitecture.Infrastructure/Configurations/UserConfiguration.cs +++ b/CleanArchitecture.Infrastructure/Configurations/UserConfiguration.cs @@ -1,4 +1,4 @@ -using System; +using CleanArchitecture.Domain.Constants; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Enums; using Microsoft.EntityFrameworkCore; @@ -13,25 +13,26 @@ public sealed class UserConfiguration : IEntityTypeConfiguration builder .Property(user => user.Email) .IsRequired() - .HasMaxLength(320); + .HasMaxLength(MaxLengths.User.Email); builder .Property(user => user.FirstName) .IsRequired() - .HasMaxLength(100); + .HasMaxLength(MaxLengths.User.FirstName); builder .Property(user => user.LastName) .IsRequired() - .HasMaxLength(100); + .HasMaxLength(MaxLengths.User.LastName); builder .Property(user => user.Password) .IsRequired() - .HasMaxLength(128); + .HasMaxLength(MaxLengths.User.Password); builder.HasData(new User( - Guid.NewGuid(), + Ids.Seed.UserId, + Ids.Seed.TenantId, "admin@email.com", "Admin", "User", diff --git a/CleanArchitecture.Infrastructure/Database/ApplicationDbContext.cs b/CleanArchitecture.Infrastructure/Database/ApplicationDbContext.cs index 4280c4d..6a5517f 100644 --- a/CleanArchitecture.Infrastructure/Database/ApplicationDbContext.cs +++ b/CleanArchitecture.Infrastructure/Database/ApplicationDbContext.cs @@ -6,14 +6,16 @@ namespace CleanArchitecture.Infrastructure.Database; public class ApplicationDbContext : DbContext { + public DbSet Users { get; set; } = null!; + public DbSet Tenants { get; set; } = null!; + public ApplicationDbContext(DbContextOptions options) : base(options) { } - public DbSet Users { get; set; } = null!; - protected override void OnModelCreating(ModelBuilder builder) { builder.ApplyConfiguration(new UserConfiguration()); + builder.ApplyConfiguration(new TenantConfiguration()); } } \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/Database/DomainNotificationStoreDbContext.cs b/CleanArchitecture.Infrastructure/Database/DomainNotificationStoreDbContext.cs index 7002617..9b8c603 100644 --- a/CleanArchitecture.Infrastructure/Database/DomainNotificationStoreDbContext.cs +++ b/CleanArchitecture.Infrastructure/Database/DomainNotificationStoreDbContext.cs @@ -6,12 +6,12 @@ namespace CleanArchitecture.Infrastructure.Database; public class DomainNotificationStoreDbContext : DbContext { + public virtual DbSet StoredDomainNotifications { get; set; } = null!; + public DomainNotificationStoreDbContext(DbContextOptions options) : base(options) { } - public virtual DbSet StoredDomainNotifications { get; set; } = null!; - protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/CleanArchitecture.Infrastructure/Database/EventStoreDbContext.cs b/CleanArchitecture.Infrastructure/Database/EventStoreDbContext.cs index fb6a4b3..fa6e2d0 100644 --- a/CleanArchitecture.Infrastructure/Database/EventStoreDbContext.cs +++ b/CleanArchitecture.Infrastructure/Database/EventStoreDbContext.cs @@ -6,16 +6,16 @@ namespace CleanArchitecture.Infrastructure.Database; public class EventStoreDbContext : DbContext { + public virtual DbSet StoredDomainEvents { get; set; } = null!; + public EventStoreDbContext(DbContextOptions options) : base(options) { } - public virtual DbSet StoredDomainEvents { get; set; } = null!; - protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfiguration(new StoredDomainEventConfiguration()); base.OnModelCreating(modelBuilder); } -} +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/EventSourcing/EventStore.cs b/CleanArchitecture.Infrastructure/EventSourcing/EventStore.cs index 9231524..361156d 100644 --- a/CleanArchitecture.Infrastructure/EventSourcing/EventStore.cs +++ b/CleanArchitecture.Infrastructure/EventSourcing/EventStore.cs @@ -9,9 +9,9 @@ namespace CleanArchitecture.Infrastructure.EventSourcing; public sealed class DomainEventStore : IDomainEventStore { - private readonly EventStoreDbContext _eventStoreDbContext; - private readonly DomainNotificationStoreDbContext _domainNotificationStoreDbContext; private readonly IEventStoreContext _context; + private readonly DomainNotificationStoreDbContext _domainNotificationStoreDbContext; + private readonly EventStoreDbContext _eventStoreDbContext; public DomainEventStore( EventStoreDbContext eventStoreDbContext, @@ -53,4 +53,4 @@ public sealed class DomainEventStore : IDomainEventStore break; } } -} +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/EventSourcing/EventStoreContext.cs b/CleanArchitecture.Infrastructure/EventSourcing/EventStoreContext.cs index 0edaffa..f4610b4 100644 --- a/CleanArchitecture.Infrastructure/EventSourcing/EventStoreContext.cs +++ b/CleanArchitecture.Infrastructure/EventSourcing/EventStoreContext.cs @@ -13,8 +13,9 @@ public sealed class EventStoreContext : IEventStoreContext { _user = user; - if (httpContextAccessor?.HttpContext == null || - !httpContextAccessor.HttpContext.Request.Headers.TryGetValue("X-CLEAN-ARCHITECTURE-CORRELATION-ID", out var id)) + if (httpContextAccessor?.HttpContext is null || + !httpContextAccessor.HttpContext.Request.Headers.TryGetValue("X-CLEAN-ARCHITECTURE-CORRELATION-ID", + out var id)) { _correlationId = $"internal - {Guid.NewGuid()}"; } @@ -24,7 +25,13 @@ public sealed class EventStoreContext : IEventStoreContext } } - public string GetCorrelationId() => _correlationId; + public string GetCorrelationId() + { + return _correlationId; + } - public string GetUserEmail() => _user?.GetUserEmail() ?? string.Empty; -} + public string GetUserEmail() + { + return _user?.GetUserEmail() ?? string.Empty; + } +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/EventSourcing/IEventStoreContext.cs b/CleanArchitecture.Infrastructure/EventSourcing/IEventStoreContext.cs index ab493f9..9bd64ca 100644 --- a/CleanArchitecture.Infrastructure/EventSourcing/IEventStoreContext.cs +++ b/CleanArchitecture.Infrastructure/EventSourcing/IEventStoreContext.cs @@ -4,4 +4,4 @@ public interface IEventStoreContext { public string GetUserEmail(); public string GetCorrelationId(); -} +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/Extensions/ServiceCollectionExtensions.cs b/CleanArchitecture.Infrastructure/Extensions/ServiceCollectionExtensions.cs index 02d063f..ad8ebac 100644 --- a/CleanArchitecture.Infrastructure/Extensions/ServiceCollectionExtensions.cs +++ b/CleanArchitecture.Infrastructure/Extensions/ServiceCollectionExtensions.cs @@ -16,18 +16,18 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddInfrastructure( this IServiceCollection services, - IConfiguration configuration, - string migrationsAssemblyName, - string connectionStringName = "DefaultConnection") + IConfiguration configuration, + string migrationsAssemblyName, + string connectionStringName = "DefaultConnection") { // Add event store db context services.AddDbContext( - options => - { - options.UseSqlServer( - configuration.GetConnectionString(connectionStringName), - b => b.MigrationsAssembly(migrationsAssemblyName)); - }); + options => + { + options.UseSqlServer( + configuration.GetConnectionString(connectionStringName), + b => b.MigrationsAssembly(migrationsAssemblyName)); + }); services.AddDbContext( options => @@ -46,6 +46,7 @@ public static class ServiceCollectionExtensions // Repositories services.AddScoped(); + services.AddScoped(); return services; } diff --git a/CleanArchitecture.Infrastructure/GlobalSuppressions.cs b/CleanArchitecture.Infrastructure/GlobalSuppressions.cs index e510236..d7915ca 100644 --- a/CleanArchitecture.Infrastructure/GlobalSuppressions.cs +++ b/CleanArchitecture.Infrastructure/GlobalSuppressions.cs @@ -5,6 +5,12 @@ using System.Diagnostics.CodeAnalysis; -[assembly: SuppressMessage("Style", "IDE0161:Convert to file-scoped namespace", Justification = "", Scope = "namespace", Target = "~N:CleanArchitecture.Infrastructure.Migrations")] -[assembly: SuppressMessage("Style", "IDE0161:Convert to file-scoped namespace", Justification = "", Scope = "namespace", Target = "~N:CleanArchitecture.Infrastructure.Migrations.EventStoreDb")] -[assembly: SuppressMessage("Style", "IDE0161:Convert to file-scoped namespace", Justification = "", Scope = "namespace", Target = "~N:CleanArchitecture.Infrastructure.Migrations.DomainNotificationStoreDb")] +[assembly: + SuppressMessage("Style", "IDE0161:Convert to file-scoped namespace", Justification = "", + Scope = "namespace", Target = "~N:CleanArchitecture.Infrastructure.Migrations")] +[assembly: + SuppressMessage("Style", "IDE0161:Convert to file-scoped namespace", Justification = "", + Scope = "namespace", Target = "~N:CleanArchitecture.Infrastructure.Migrations.EventStoreDb")] +[assembly: + SuppressMessage("Style", "IDE0161:Convert to file-scoped namespace", Justification = "", + Scope = "namespace", Target = "~N:CleanArchitecture.Infrastructure.Migrations.DomainNotificationStoreDb")] \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/InMemoryBus.cs b/CleanArchitecture.Infrastructure/InMemoryBus.cs index 0da1dae..703cbb2 100644 --- a/CleanArchitecture.Infrastructure/InMemoryBus.cs +++ b/CleanArchitecture.Infrastructure/InMemoryBus.cs @@ -8,8 +8,8 @@ namespace CleanArchitecture.Infrastructure; public sealed class InMemoryBus : IMediatorHandler { - private readonly IMediator _mediator; private readonly IDomainEventStore _domainEventStore; + private readonly IMediator _mediator; public InMemoryBus( IMediator mediator, diff --git a/CleanArchitecture.Infrastructure/Migrations/20230827171448_AddTenants.Designer.cs b/CleanArchitecture.Infrastructure/Migrations/20230827171448_AddTenants.Designer.cs new file mode 100644 index 0000000..dfb2c12 --- /dev/null +++ b/CleanArchitecture.Infrastructure/Migrations/20230827171448_AddTenants.Designer.cs @@ -0,0 +1,131 @@ +// +using System; +using CleanArchitecture.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CleanArchitecture.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20230827171448_AddTenants")] + partial class AddTenants + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.10") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true) + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CleanArchitecture.Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Deleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.HasKey("Id"); + + b.ToTable("Tenants"); + + b.HasData( + new + { + Id = new Guid("b542bf25-134c-47a2-a0df-84ed14d03c4a"), + Deleted = false, + Name = "Admin Tenant" + }); + }); + + modelBuilder.Entity("CleanArchitecture.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Deleted") + .HasColumnType("bit"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Role") + .HasColumnType("int"); + + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Users"); + + b.HasData( + new + { + Id = new Guid("7e3892c0-9374-49fa-a3fd-53db637a40ae"), + Deleted = false, + Email = "admin@email.com", + FirstName = "Admin", + LastName = "User", + Password = "$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2", + Role = 0, + TenantId = new Guid("b542bf25-134c-47a2-a0df-84ed14d03c4a") + }); + }); + + modelBuilder.Entity("CleanArchitecture.Domain.Entities.User", b => + { + b.HasOne("CleanArchitecture.Domain.Entities.Tenant", "Tenant") + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("CleanArchitecture.Domain.Entities.Tenant", b => + { + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/CleanArchitecture.Infrastructure/Migrations/20230827171448_AddTenants.cs b/CleanArchitecture.Infrastructure/Migrations/20230827171448_AddTenants.cs new file mode 100644 index 0000000..7af855e --- /dev/null +++ b/CleanArchitecture.Infrastructure/Migrations/20230827171448_AddTenants.cs @@ -0,0 +1,92 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CleanArchitecture.Infrastructure.Migrations +{ + /// + public partial class AddTenants : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("28fc3d91-6a15-448e-b0b5-0c91a3948961")); + + migrationBuilder.AddColumn( + name: "TenantId", + table: "Users", + type: "uniqueidentifier", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateTable( + name: "Tenants", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Name = table.Column(type: "nvarchar(255)", maxLength: 255, nullable: false), + Deleted = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tenants", x => x.Id); + }); + + migrationBuilder.InsertData( + table: "Tenants", + columns: new[] { "Id", "Deleted", "Name" }, + values: new object[] { new Guid("b542bf25-134c-47a2-a0df-84ed14d03c4a"), false, "Admin Tenant" }); + + migrationBuilder.InsertData( + table: "Users", + columns: new[] { "Id", "Deleted", "Email", "FirstName", "LastName", "Password", "Role", "TenantId" }, + values: new object[] { new Guid("7e3892c0-9374-49fa-a3fd-53db637a40ae"), false, "admin@email.com", "Admin", "User", "$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2", 0, new Guid("b542bf25-134c-47a2-a0df-84ed14d03c4a") }); + + migrationBuilder.CreateIndex( + name: "IX_Users_TenantId", + table: "Users", + column: "TenantId"); + + migrationBuilder.AddForeignKey( + name: "FK_Users_Tenants_TenantId", + table: "Users", + column: "TenantId", + principalTable: "Tenants", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Users_Tenants_TenantId", + table: "Users"); + + migrationBuilder.DropTable( + name: "Tenants"); + + migrationBuilder.DropIndex( + name: "IX_Users_TenantId", + table: "Users"); + + migrationBuilder.DeleteData( + table: "Users", + keyColumn: "Id", + keyValue: new Guid("7e3892c0-9374-49fa-a3fd-53db637a40ae")); + + migrationBuilder.DropColumn( + name: "TenantId", + table: "Users"); + + migrationBuilder.InsertData( + table: "Users", + columns: new[] { "Id", "Deleted", "Email", "FirstName", "LastName", "Password", "Role" }, + values: new object[] { new Guid("28fc3d91-6a15-448e-b0b5-0c91a3948961"), false, "admin@email.com", "Admin", "User", "$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2", 0 }); + } + } +} diff --git a/CleanArchitecture.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/CleanArchitecture.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 6a2eca6..2c15819 100644 --- a/CleanArchitecture.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/CleanArchitecture.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace CleanArchitecture.Infrastructure.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "7.0.5") + .HasAnnotation("ProductVersion", "7.0.10") .HasAnnotation("Proxies:ChangeTracking", false) .HasAnnotation("Proxies:CheckEquality", false) .HasAnnotation("Proxies:LazyLoading", true) @@ -25,6 +25,33 @@ namespace CleanArchitecture.Infrastructure.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("CleanArchitecture.Domain.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Deleted") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.HasKey("Id"); + + b.ToTable("Tenants"); + + b.HasData( + new + { + Id = new Guid("b542bf25-134c-47a2-a0df-84ed14d03c4a"), + Deleted = false, + Name = "Admin Tenant" + }); + }); + modelBuilder.Entity("CleanArchitecture.Domain.Entities.User", b => { b.Property("Id") @@ -57,22 +84,44 @@ namespace CleanArchitecture.Infrastructure.Migrations b.Property("Role") .HasColumnType("int"); + b.Property("TenantId") + .HasColumnType("uniqueidentifier"); + b.HasKey("Id"); + b.HasIndex("TenantId"); + b.ToTable("Users"); b.HasData( new { - Id = new Guid("28fc3d91-6a15-448e-b0b5-0c91a3948961"), + Id = new Guid("7e3892c0-9374-49fa-a3fd-53db637a40ae"), Deleted = false, Email = "admin@email.com", FirstName = "Admin", LastName = "User", Password = "$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2", - Role = 0 + Role = 0, + TenantId = new Guid("b542bf25-134c-47a2-a0df-84ed14d03c4a") }); }); + + modelBuilder.Entity("CleanArchitecture.Domain.Entities.User", b => + { + b.HasOne("CleanArchitecture.Domain.Entities.Tenant", "Tenant") + .WithMany("Users") + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Tenant"); + }); + + modelBuilder.Entity("CleanArchitecture.Domain.Entities.Tenant", b => + { + b.Navigation("Users"); + }); #pragma warning restore 612, 618 } } diff --git a/CleanArchitecture.Infrastructure/Migrations/DomainNotificationStoreDb/20230701135523_AddDomainNotificationStore.cs b/CleanArchitecture.Infrastructure/Migrations/DomainNotificationStoreDb/20230701135523_AddDomainNotificationStore.cs index bb09c31..f582c38 100644 --- a/CleanArchitecture.Infrastructure/Migrations/DomainNotificationStoreDb/20230701135523_AddDomainNotificationStore.cs +++ b/CleanArchitecture.Infrastructure/Migrations/DomainNotificationStoreDb/20230701135523_AddDomainNotificationStore.cs @@ -1,43 +1,39 @@ -using System; +#nullable disable + +using System; using Microsoft.EntityFrameworkCore.Migrations; -#nullable disable +namespace CleanArchitecture.Infrastructure.Migrations.DomainNotificationStoreDb; -namespace CleanArchitecture.Infrastructure.Migrations.DomainNotificationStoreDb +/// +public partial class AddDomainNotificationStore : Migration { /// - public partial class AddDomainNotificationStore : Migration + protected override void Up(MigrationBuilder migrationBuilder) { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "StoredDomainNotifications", - columns: table => new - { - Id = table.Column(type: "uniqueidentifier", nullable: false), - Data = table.Column(type: "nvarchar(max)", nullable: false), - User = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - CorrelationId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - AggregateId = table.Column(type: "uniqueidentifier", nullable: false), - MessageType = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - Timestamp = table.Column(type: "datetime2", nullable: false), - Key = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - Value = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: false), - Code = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - Version = table.Column(type: "int", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_StoredDomainNotifications", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "StoredDomainNotifications"); - } + migrationBuilder.CreateTable( + "StoredDomainNotifications", + table => new + { + Id = table.Column("uniqueidentifier", nullable: false), + Data = table.Column("nvarchar(max)", nullable: false), + User = table.Column("nvarchar(100)", maxLength: 100, nullable: false), + CorrelationId = table.Column("nvarchar(100)", maxLength: 100, nullable: false), + AggregateId = table.Column("uniqueidentifier", nullable: false), + MessageType = table.Column("nvarchar(100)", maxLength: 100, nullable: false), + Timestamp = table.Column("datetime2", nullable: false), + Key = table.Column("nvarchar(100)", maxLength: 100, nullable: false), + Value = table.Column("nvarchar(1024)", maxLength: 1024, nullable: false), + Code = table.Column("nvarchar(100)", maxLength: 100, nullable: false), + Version = table.Column("int", nullable: false) + }, + constraints: table => { table.PrimaryKey("PK_StoredDomainNotifications", x => x.Id); }); } -} + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + "StoredDomainNotifications"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/Migrations/EventStoreDb/20230701135441_AddEventStore.cs b/CleanArchitecture.Infrastructure/Migrations/EventStoreDb/20230701135441_AddEventStore.cs index b30585f..8a4c9c8 100644 --- a/CleanArchitecture.Infrastructure/Migrations/EventStoreDb/20230701135441_AddEventStore.cs +++ b/CleanArchitecture.Infrastructure/Migrations/EventStoreDb/20230701135441_AddEventStore.cs @@ -1,39 +1,35 @@ -using System; +#nullable disable + +using System; using Microsoft.EntityFrameworkCore.Migrations; -#nullable disable +namespace CleanArchitecture.Infrastructure.Migrations.EventStoreDb; -namespace CleanArchitecture.Infrastructure.Migrations.EventStoreDb +/// +public partial class AddEventStore : Migration { /// - public partial class AddEventStore : Migration + protected override void Up(MigrationBuilder migrationBuilder) { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "StoredDomainEvents", - columns: table => new - { - Id = table.Column(type: "uniqueidentifier", nullable: false), - Data = table.Column(type: "nvarchar(max)", nullable: false), - User = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - CorrelationId = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), - AggregateId = table.Column(type: "uniqueidentifier", nullable: false), - Action = table.Column(type: "varchar(100)", nullable: false), - CreationDate = table.Column(type: "datetime2", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_StoredDomainEvents", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "StoredDomainEvents"); - } + migrationBuilder.CreateTable( + "StoredDomainEvents", + table => new + { + Id = table.Column("uniqueidentifier", nullable: false), + Data = table.Column("nvarchar(max)", nullable: false), + User = table.Column("nvarchar(100)", maxLength: 100, nullable: false), + CorrelationId = table.Column("nvarchar(100)", maxLength: 100, nullable: false), + AggregateId = table.Column("uniqueidentifier", nullable: false), + Action = table.Column("varchar(100)", nullable: false), + CreationDate = table.Column("datetime2", nullable: false) + }, + constraints: table => { table.PrimaryKey("PK_StoredDomainEvents", x => x.Id); }); } -} + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + "StoredDomainEvents"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs b/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs index db13d69..88b7349 100644 --- a/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs +++ b/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs @@ -55,9 +55,9 @@ public class BaseRepository : IRepository where TEntity : Enti DbSet.Update(entity); } - public Task ExistsAsync(Guid id) + public virtual async Task ExistsAsync(Guid id) { - return DbSet.AnyAsync(entity => entity.Id == id); + return await DbSet.AnyAsync(entity => entity.Id == id); } public void Remove(TEntity entity, bool hardDelete = false) @@ -73,6 +73,20 @@ public class BaseRepository : IRepository where TEntity : Enti } } + public void RemoveRange(IEnumerable entities, bool hardDelete = false) + { + if (hardDelete) + { + DbSet.RemoveRange(entities); + return; + } + + foreach (var entity in entities) + { + entity.Delete(); + } + } + public int SaveChanges() { return _dbContext.SaveChanges(); diff --git a/CleanArchitecture.Infrastructure/Repositories/TenantRepository.cs b/CleanArchitecture.Infrastructure/Repositories/TenantRepository.cs new file mode 100644 index 0000000..f86518b --- /dev/null +++ b/CleanArchitecture.Infrastructure/Repositories/TenantRepository.cs @@ -0,0 +1,12 @@ +using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Interfaces.Repositories; +using CleanArchitecture.Infrastructure.Database; + +namespace CleanArchitecture.Infrastructure.Repositories; + +public sealed class TenantRepository : BaseRepository, ITenantRepository +{ + public TenantRepository(ApplicationDbContext context) : base(context) + { + } +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj b/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj index d008535..56723e6 100644 --- a/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj +++ b/CleanArchitecture.IntegrationTests/CleanArchitecture.IntegrationTests.csproj @@ -8,14 +8,14 @@ - - - - - - - - + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -27,8 +27,9 @@ - - + + + diff --git a/CleanArchitecture.IntegrationTests/Controller/TenantControllerTests.cs b/CleanArchitecture.IntegrationTests/Controller/TenantControllerTests.cs new file mode 100644 index 0000000..9e7f49f --- /dev/null +++ b/CleanArchitecture.IntegrationTests/Controller/TenantControllerTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using CleanArchitecture.Application.ViewModels.Tenants; +using CleanArchitecture.IntegrationTests.Extensions; +using CleanArchitecture.IntegrationTests.Fixtures; +using FluentAssertions; +using Xunit; +using Xunit.Priority; + +namespace CleanArchitecture.IntegrationTests.Controller; + +[Collection("IntegrationTests")] +[TestCaseOrderer(PriorityOrderer.Name, PriorityOrderer.Assembly)] +public sealed class TenantControllerTests : IClassFixture +{ + private readonly TenantTestFixture _fixture; + + public TenantControllerTests(TenantTestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + [Priority(0)] + public async Task Should_Get_Tenant_By_Id() + { + var response = await _fixture.ServerClient.GetAsync($"/api/v1/Tenant/{_fixture.CreatedTenantId}"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var message = await response.Content.ReadAsJsonAsync(); + + message?.Data.Should().NotBeNull(); + + message!.Data!.Id.Should().Be(_fixture.CreatedTenantId); + message.Data.Name.Should().Be("Test Tenant"); + } + + [Fact] + [Priority(5)] + public async Task Should_Get_All_Tenants() + { + var response = await _fixture.ServerClient.GetAsync("api/v1/Tenant"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var message = await response.Content.ReadAsJsonAsync>(); + + message?.Data.Should().NotBeEmpty(); + message!.Data.Should().HaveCountGreaterOrEqualTo(2); + message.Data! + .FirstOrDefault(x => x.Id == _fixture.CreatedTenantId) + .Should().NotBeNull(); + } + + [Fact] + [Priority(10)] + public async Task Should_Create_Tenant() + { + var request = new CreateTenantViewModel("Test Tenant 2"); + + var response = await _fixture.ServerClient.PostAsJsonAsync("/api/v1/Tenant", request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var message = await response.Content.ReadAsJsonAsync(); + var tenantId = message?.Data; + + // Check if tenant exists + var tenantResponse = await _fixture.ServerClient.GetAsync($"/api/v1/Tenant/{tenantId}"); + + tenantResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var tenantMessage = await tenantResponse.Content.ReadAsJsonAsync(); + + tenantMessage?.Data.Should().NotBeNull(); + + tenantMessage!.Data!.Id.Should().Be(tenantId!.Value); + tenantMessage.Data.Name.Should().Be(request.Name); + } + + [Fact] + [Priority(15)] + public async Task Should_Update_Tenant() + { + var request = new UpdateTenantViewModel(_fixture.CreatedTenantId, "Test Tenant 3"); + + var response = await _fixture.ServerClient.PutAsJsonAsync("/api/v1/Tenant", request); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var message = await response.Content.ReadAsJsonAsync(); + + message?.Data.Should().NotBeNull(); + message!.Data.Should().BeEquivalentTo(request); + + // Check if tenant is updated + var tenantResponse = await _fixture.ServerClient.GetAsync($"/api/v1/Tenant/{_fixture.CreatedTenantId}"); + + tenantResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var tenantMessage = await response.Content.ReadAsJsonAsync(); + + tenantMessage?.Data.Should().NotBeNull(); + + tenantMessage!.Data!.Id.Should().Be(_fixture.CreatedTenantId); + tenantMessage.Data.Name.Should().Be(request.Name); + } + + [Fact] + [Priority(20)] + public async Task Should_Delete_Tenant() + { + var response = await _fixture.ServerClient.DeleteAsync($"/api/v1/Tenant/{_fixture.CreatedTenantId}"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // Check if tenant is deleted + var tenantResponse = await _fixture.ServerClient.GetAsync($"/api/v1/Tenant/{_fixture.CreatedTenantId}"); + + tenantResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Controller/UserControllerTests.cs b/CleanArchitecture.IntegrationTests/Controller/UserControllerTests.cs index ad2cb37..45dd1f4 100644 --- a/CleanArchitecture.IntegrationTests/Controller/UserControllerTests.cs +++ b/CleanArchitecture.IntegrationTests/Controller/UserControllerTests.cs @@ -4,9 +4,11 @@ using System.Linq; using System.Net; using System.Threading.Tasks; using CleanArchitecture.Application.ViewModels.Users; +using CleanArchitecture.Domain.Constants; using CleanArchitecture.Domain.Enums; using CleanArchitecture.IntegrationTests.Extensions; using CleanArchitecture.IntegrationTests.Fixtures; +using CleanArchitecture.IntegrationTests.Infrastructure.Auth; using FluentAssertions; using Xunit; using Xunit.Priority; @@ -26,50 +28,33 @@ public sealed class UserControllerTests : IClassFixture [Fact] [Priority(0)] - public async Task Should_Create_User() + public async Task Should_Get_All_User() { - var user = new CreateUserViewModel( - _fixture.CreatedUserEmail, - "Test", - "Email", - _fixture.CreatedUserPassword); - - var response = await _fixture.ServerClient.PostAsJsonAsync("/api/v1/user", user); + var response = await _fixture.ServerClient.GetAsync("/api/v1/user"); response.StatusCode.Should().Be(HttpStatusCode.OK); - var message = await response.Content.ReadAsJsonAsync(); + var message = await response.Content.ReadAsJsonAsync>(); - message?.Data.Should().NotBeEmpty(); + message?.Data.Should().NotBeNull(); - _fixture.CreatedUserId = message!.Data; + var content = message!.Data!.ToList(); + + content.Count.Should().Be(2); + + var currentUser = content.First(x => x.Id == TestAuthenticationOptions.TestUserId); + + currentUser.Role.Should().Be(UserRole.Admin); + currentUser.Email.Should().Be(TestAuthenticationOptions.Email); + currentUser.FirstName.Should().Be(TestAuthenticationOptions.FirstName); + currentUser.LastName.Should().Be(TestAuthenticationOptions.LastName); } [Fact] [Priority(5)] - public async Task Should_Login_User() + public async Task Should_Get_User_By_Id() { - var user = new LoginUserViewModel( - _fixture.CreatedUserEmail, - _fixture.CreatedUserPassword); - - var response = await _fixture.ServerClient.PostAsJsonAsync("/api/v1/user/login", user); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var message = await response.Content.ReadAsJsonAsync(); - - message?.Data.Should().NotBeEmpty(); - - _fixture.CreatedUserToken = message!.Data!; - _fixture.EnableAuthentication(); - } - - [Fact] - [Priority(10)] - public async Task Should_Get_Created_Users() - { - var response = await _fixture.ServerClient.GetAsync("/api/v1/user/" + _fixture.CreatedUserId); + var response = await _fixture.ServerClient.GetAsync("/api/v1/user/" + TestAuthenticationOptions.TestUserId); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -79,14 +64,49 @@ public sealed class UserControllerTests : IClassFixture var content = message!.Data!; - content.Id.Should().Be(_fixture.CreatedUserId); - content.Email.Should().Be("test@email.com"); - content.FirstName.Should().Be("Test"); - content.LastName.Should().Be("Email"); + content.Id.Should().Be(TestAuthenticationOptions.TestUserId); + content.Email.Should().Be(TestAuthenticationOptions.Email); + content.FirstName.Should().Be(TestAuthenticationOptions.FirstName); + content.LastName.Should().Be(TestAuthenticationOptions.LastName); } [Fact] [Priority(10)] + public async Task Should_Create_User() + { + var user = new CreateUserViewModel( + "some@user.com", + "Test", + "Email", + "1234#KSAD23s", + Ids.Seed.TenantId); + + var response = await _fixture.ServerClient.PostAsJsonAsync("/api/v1/user", user); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var message = await response.Content.ReadAsJsonAsync(); + message?.Data.Should().NotBeEmpty(); + } + + [Fact] + [Priority(15)] + public async Task Should_Login_User() + { + var user = new LoginUserViewModel( + "admin@email.com", + "!Password123#"); + + var response = await _fixture.ServerClient.PostAsJsonAsync("/api/v1/user/login", user); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var message = await response.Content.ReadAsJsonAsync(); + message?.Data.Should().NotBeEmpty(); + } + + [Fact] + [Priority(20)] public async Task Should_Get_The_Current_Active_Users() { var response = await _fixture.ServerClient.GetAsync("/api/v1/user/me"); @@ -99,22 +119,23 @@ public sealed class UserControllerTests : IClassFixture var content = message!.Data!; - content.Id.Should().Be(_fixture.CreatedUserId); - content.Email.Should().Be("test@email.com"); - content.FirstName.Should().Be("Test"); - content.LastName.Should().Be("Email"); + content.Id.Should().Be(TestAuthenticationOptions.TestUserId); + content.Email.Should().Be(TestAuthenticationOptions.Email); + content.FirstName.Should().Be(TestAuthenticationOptions.FirstName); + content.LastName.Should().Be(TestAuthenticationOptions.LastName); } [Fact] - [Priority(15)] + [Priority(25)] public async Task Should_Update_User() { var user = new UpdateUserViewModel( - _fixture.CreatedUserId, + Ids.Seed.UserId, "newtest@email.com", "NewTest", "NewEmail", - UserRole.User); + UserRole.User, + Ids.Seed.TenantId); var response = await _fixture.ServerClient.PutAsJsonAsync("/api/v1/user", user); @@ -127,37 +148,32 @@ public sealed class UserControllerTests : IClassFixture var content = message!.Data; content.Should().BeEquivalentTo(user); + + // Check if user is really updated + var userResponse = await _fixture.ServerClient.GetAsync("/api/v1/user/" + user.Id); + + userResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var userMessage = await userResponse.Content.ReadAsJsonAsync(); + + userMessage?.Data.Should().NotBeNull(); + + var userContent = userMessage!.Data!; + + userContent.Id.Should().Be(user.Id); + userContent.Email.Should().Be(user.Email); + userContent.FirstName.Should().Be(user.FirstName); + userContent.LastName.Should().Be(user.LastName); + userContent.Role.Should().Be(user.Role); } [Fact] - [Priority(20)] - public async Task Should_Get_Updated_Users() - { - var response = await _fixture.ServerClient.GetAsync("/api/v1/user/" + _fixture.CreatedUserId); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var message = await response.Content.ReadAsJsonAsync(); - - message?.Data.Should().NotBeNull(); - - var content = message!.Data!; - - content.Id.Should().Be(_fixture.CreatedUserId); - content.Email.Should().Be("newtest@email.com"); - content.FirstName.Should().Be("NewTest"); - content.LastName.Should().Be("NewEmail"); - - _fixture.CreatedUserEmail = content.Email; - } - - [Fact] - [Priority(25)] + [Priority(30)] public async Task Should_Change_User_Password() { var user = new ChangePasswordViewModel( - _fixture.CreatedUserPassword, - _fixture.CreatedUserPassword + "1"); + "!Password123#", + "!Password123#1"); var response = await _fixture.ServerClient.PostAsJsonAsync("/api/v1/user/changePassword", user); @@ -173,8 +189,8 @@ public sealed class UserControllerTests : IClassFixture // Verify the user can login with the new password var login = new LoginUserViewModel( - _fixture.CreatedUserEmail, - _fixture.CreatedUserPassword + "1"); + TestAuthenticationOptions.Email, + user.NewPassword); var loginResponse = await _fixture.ServerClient.PostAsJsonAsync("/api/v1/user/login", login); @@ -185,42 +201,11 @@ public sealed class UserControllerTests : IClassFixture loginMessage?.Data.Should().NotBeEmpty(); } - [Fact] - [Priority(30)] - public async Task Should_Get_All_User() - { - var response = await _fixture.ServerClient.GetAsync("/api/v1/user"); - - response.StatusCode.Should().Be(HttpStatusCode.OK); - - var message = await response.Content.ReadAsJsonAsync>(); - - message?.Data.Should().NotBeNull(); - - var content = message!.Data!.ToList(); - - content.Count.Should().Be(2); - - var currentUser = content.First(x => x.Id == _fixture.CreatedUserId); - - currentUser.Id.Should().Be(_fixture.CreatedUserId); - currentUser.Role.Should().Be(UserRole.User); - currentUser.Email.Should().Be("newtest@email.com"); - currentUser.FirstName.Should().Be("NewTest"); - currentUser.LastName.Should().Be("NewEmail"); - - var adminUser = content.First(x => x.Role == UserRole.Admin); - - adminUser.Email.Should().Be("admin@email.com"); - adminUser.FirstName.Should().Be("Admin"); - adminUser.LastName.Should().Be("User"); - } - [Fact] [Priority(35)] public async Task Should_Delete_User() { - var response = await _fixture.ServerClient.DeleteAsync("/api/v1/user/" + _fixture.CreatedUserId); + var response = await _fixture.ServerClient.DeleteAsync("/api/v1/user/" + TestAuthenticationOptions.TestUserId); response.StatusCode.Should().Be(HttpStatusCode.OK); @@ -229,6 +214,10 @@ public sealed class UserControllerTests : IClassFixture message?.Data.Should().NotBeEmpty(); var content = message!.Data; - content.Should().Be(_fixture.CreatedUserId); + content.Should().Be(TestAuthenticationOptions.TestUserId); + + var userResponse = await _fixture.ServerClient.GetAsync("/api/v1/user/" + TestAuthenticationOptions.TestUserId); + + userResponse.StatusCode.Should().Be(HttpStatusCode.NotFound); } } \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Extensions/FunctionalTestsServiceCollectionExtensions.cs b/CleanArchitecture.IntegrationTests/Extensions/FunctionalTestsServiceCollectionExtensions.cs index d81e2c0..f3c9b08 100644 --- a/CleanArchitecture.IntegrationTests/Extensions/FunctionalTestsServiceCollectionExtensions.cs +++ b/CleanArchitecture.IntegrationTests/Extensions/FunctionalTestsServiceCollectionExtensions.cs @@ -15,7 +15,7 @@ public static class FunctionalTestsServiceCollectionExtensions DbConnection connection) where TContext : DbContext { var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions)); - if (descriptor != null) + if (descriptor is not null) services.Remove(descriptor); services.AddScoped(p => diff --git a/CleanArchitecture.IntegrationTests/Fixtures/AuthTestFixure.cs b/CleanArchitecture.IntegrationTests/Fixtures/AuthTestFixure.cs new file mode 100644 index 0000000..f86ede5 --- /dev/null +++ b/CleanArchitecture.IntegrationTests/Fixtures/AuthTestFixure.cs @@ -0,0 +1,8 @@ +namespace CleanArchitecture.IntegrationTests.Fixtures; + +public sealed class AuthTestFixure : TestFixtureBase +{ + public AuthTestFixure() : base(false) + { + } +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Fixtures/TenantTestFixture.cs b/CleanArchitecture.IntegrationTests/Fixtures/TenantTestFixture.cs new file mode 100644 index 0000000..3d66346 --- /dev/null +++ b/CleanArchitecture.IntegrationTests/Fixtures/TenantTestFixture.cs @@ -0,0 +1,21 @@ +using System; +using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Infrastructure.Database; + +namespace CleanArchitecture.IntegrationTests.Fixtures; + +public sealed class TenantTestFixture : TestFixtureBase +{ + public Guid CreatedTenantId { get; } = Guid.NewGuid(); + + protected override void SeedTestData(ApplicationDbContext context) + { + base.SeedTestData(context); + + context.Tenants.Add(new Tenant( + CreatedTenantId, + "Test Tenant")); + + context.SaveChanges(); + } +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Fixtures/TestFixtureBase.cs b/CleanArchitecture.IntegrationTests/Fixtures/TestFixtureBase.cs index 99a38ed..6a8d633 100644 --- a/CleanArchitecture.IntegrationTests/Fixtures/TestFixtureBase.cs +++ b/CleanArchitecture.IntegrationTests/Fixtures/TestFixtureBase.cs @@ -1,7 +1,11 @@ using System; using System.Net.Http; +using CleanArchitecture.Domain.Constants; +using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Infrastructure.Database; using CleanArchitecture.IntegrationTests.Infrastructure; +using CleanArchitecture.IntegrationTests.Infrastructure.Auth; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; @@ -9,21 +13,33 @@ namespace CleanArchitecture.IntegrationTests.Fixtures; public class TestFixtureBase { - public TestFixtureBase() + public HttpClient ServerClient { get; } + protected WebApplicationFactory Factory { get; } + + public TestFixtureBase(bool useTestAuthentication = true) { Factory = new CleanArchitectureWebApplicationFactory( SeedTestData, - RegisterCustomServicesHandler); + RegisterCustomServicesHandler, + useTestAuthentication); ServerClient = Factory.CreateClient(); ServerClient.Timeout = TimeSpan.FromMinutes(5); } - public HttpClient ServerClient { get; } - protected WebApplicationFactory Factory { get; } - protected virtual void SeedTestData(ApplicationDbContext context) { + context.Users.Add(new User( + TestAuthenticationOptions.TestUserId, + Ids.Seed.TenantId, + TestAuthenticationOptions.Email, + TestAuthenticationOptions.FirstName, + TestAuthenticationOptions.LastName, + // !Password123# + "$2a$12$Blal/uiFIJdYsCLTMUik/egLbfg3XhbnxBC6Sb5IKz2ZYhiU/MzL2", + UserRole.Admin)); + + context.SaveChanges(); } protected virtual void RegisterCustomServicesHandler( diff --git a/CleanArchitecture.IntegrationTests/Fixtures/UserTestFixture.cs b/CleanArchitecture.IntegrationTests/Fixtures/UserTestFixture.cs index 5769927..67baaa6 100644 --- a/CleanArchitecture.IntegrationTests/Fixtures/UserTestFixture.cs +++ b/CleanArchitecture.IntegrationTests/Fixtures/UserTestFixture.cs @@ -1,16 +1,5 @@ -using System; - -namespace CleanArchitecture.IntegrationTests.Fixtures; +namespace CleanArchitecture.IntegrationTests.Fixtures; public sealed class UserTestFixture : TestFixtureBase { - public Guid CreatedUserId { get; set; } - public string CreatedUserEmail { get; set; } = "test@email.com"; - public string CreatedUserPassword { get; set; } = "z8]tnayvd5FNLU9:]AQm"; - public string CreatedUserToken { get; set; } = string.Empty; - - public void EnableAuthentication() - { - ServerClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {CreatedUserToken}"); - } } \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Fixtures/gRPC/GetTenantsByIdsTestFixture.cs b/CleanArchitecture.IntegrationTests/Fixtures/gRPC/GetTenantsByIdsTestFixture.cs new file mode 100644 index 0000000..fdfe5a6 --- /dev/null +++ b/CleanArchitecture.IntegrationTests/Fixtures/gRPC/GetTenantsByIdsTestFixture.cs @@ -0,0 +1,37 @@ +using System; +using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Infrastructure.Database; +using Grpc.Net.Client; + +namespace CleanArchitecture.IntegrationTests.Fixtures.gRPC; + +public sealed class GetTenantsByIdsTestFixture : TestFixtureBase +{ + public GrpcChannel GrpcChannel { get; } + public Guid CreatedTenantId { get; } = Guid.NewGuid(); + + public GetTenantsByIdsTestFixture() + { + GrpcChannel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions + { + HttpHandler = Factory.Server.CreateHandler() + }); + } + + protected override void SeedTestData(ApplicationDbContext context) + { + base.SeedTestData(context); + + var tenant = CreateTenant(); + + context.Tenants.Add(tenant); + context.SaveChanges(); + } + + public Tenant CreateTenant() + { + return new Tenant( + CreatedTenantId, + "Test Tenant"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Fixtures/gRPC/GetUsersByIdsTestFixture.cs b/CleanArchitecture.IntegrationTests/Fixtures/gRPC/GetUsersByIdsTestFixture.cs index df677db..22ce59b 100644 --- a/CleanArchitecture.IntegrationTests/Fixtures/gRPC/GetUsersByIdsTestFixture.cs +++ b/CleanArchitecture.IntegrationTests/Fixtures/gRPC/GetUsersByIdsTestFixture.cs @@ -1,4 +1,5 @@ using System; +using CleanArchitecture.Domain.Constants; using CleanArchitecture.Domain.Entities; using CleanArchitecture.Domain.Enums; using CleanArchitecture.Infrastructure.Database; @@ -33,6 +34,7 @@ public sealed class GetUsersByIdsTestFixture : TestFixtureBase { return new User( CreatedUserId, + Ids.Seed.TenantId, "user@user.de", "User First Name", "User Last Name", diff --git a/CleanArchitecture.IntegrationTests/Infrastructure/Auth/TestAuthenticationExtensions.cs b/CleanArchitecture.IntegrationTests/Infrastructure/Auth/TestAuthenticationExtensions.cs new file mode 100644 index 0000000..64fb224 --- /dev/null +++ b/CleanArchitecture.IntegrationTests/Infrastructure/Auth/TestAuthenticationExtensions.cs @@ -0,0 +1,17 @@ +using System; +using Microsoft.AspNetCore.Authentication; + +namespace CleanArchitecture.IntegrationTests.Infrastructure.Auth; + +public static class TestAuthenticationExtensions +{ + public static AuthenticationBuilder AddTestAuthentication( + this AuthenticationBuilder builder, + Action configureOptions) + { + return builder.AddScheme( + "Testing", + "Test Authentication", + configureOptions); + } +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Infrastructure/Auth/TestAuthenticationHandler.cs b/CleanArchitecture.IntegrationTests/Infrastructure/Auth/TestAuthenticationHandler.cs new file mode 100644 index 0000000..db98295 --- /dev/null +++ b/CleanArchitecture.IntegrationTests/Infrastructure/Auth/TestAuthenticationHandler.cs @@ -0,0 +1,29 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CleanArchitecture.IntegrationTests.Infrastructure.Auth; + +public sealed class TestAuthenticationHandler : AuthenticationHandler +{ + public TestAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock) : base(options, logger, encoder, clock) + { + } + + protected override Task HandleAuthenticateAsync() + { + var authenticationTicket = new AuthenticationTicket( + new ClaimsPrincipal(Options.Identity), + new AuthenticationProperties(), + "Testing"); + + return Task.FromResult(AuthenticateResult.Success(authenticationTicket)); + } +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Infrastructure/Auth/TestAuthenticationOptions.cs b/CleanArchitecture.IntegrationTests/Infrastructure/Auth/TestAuthenticationOptions.cs new file mode 100644 index 0000000..1eb58b4 --- /dev/null +++ b/CleanArchitecture.IntegrationTests/Infrastructure/Auth/TestAuthenticationOptions.cs @@ -0,0 +1,24 @@ +using System; +using System.Security.Claims; +using CleanArchitecture.Domain.Enums; +using Microsoft.AspNetCore.Authentication; + +namespace CleanArchitecture.IntegrationTests.Infrastructure.Auth; + +public sealed class TestAuthenticationOptions : AuthenticationSchemeOptions +{ + public const string Email = "integration@tests.com"; + public const string FirstName = "Integration"; + public const string LastName = "Tests"; + public static Guid TestUserId = new("561e4300-94d6-4c3f-adf5-31c1bdbc64df"); + + public ClaimsIdentity Identity { get; } = new( + new[] + { + new Claim(ClaimTypes.Email, Email), + new Claim(ClaimTypes.Role, UserRole.Admin.ToString()), + new Claim(ClaimTypes.NameIdentifier, TestUserId.ToString()), + new Claim(ClaimTypes.Name, $"{FirstName} {LastName}") + }, + "test"); +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Infrastructure/CleanArchitectureWebApplicationFactory.cs b/CleanArchitecture.IntegrationTests/Infrastructure/CleanArchitectureWebApplicationFactory.cs index 63578e2..da21af7 100644 --- a/CleanArchitecture.IntegrationTests/Infrastructure/CleanArchitectureWebApplicationFactory.cs +++ b/CleanArchitecture.IntegrationTests/Infrastructure/CleanArchitectureWebApplicationFactory.cs @@ -2,6 +2,7 @@ using CleanArchitecture.Infrastructure.Database; using CleanArchitecture.Infrastructure.Extensions; using CleanArchitecture.IntegrationTests.Extensions; +using CleanArchitecture.IntegrationTests.Infrastructure.Auth; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Data.Sqlite; @@ -21,16 +22,19 @@ public sealed class CleanArchitectureWebApplicationFactory : WebApplicationFacto IServiceProvider scopedServices); private readonly AddCustomSeedDataHandler? _addCustomSeedDataHandler; + private readonly bool _addTestAuthentication; private readonly SqliteConnection _connection = new("DataSource=:memory:"); private readonly RegisterCustomServicesHandler? _registerCustomServicesHandler; public CleanArchitectureWebApplicationFactory( AddCustomSeedDataHandler? addCustomSeedDataHandler, - RegisterCustomServicesHandler? registerCustomServicesHandler) + RegisterCustomServicesHandler? registerCustomServicesHandler, + bool addTestAuthentication) { _addCustomSeedDataHandler = addCustomSeedDataHandler; _registerCustomServicesHandler = registerCustomServicesHandler; + _addTestAuthentication = addTestAuthentication; } protected override void ConfigureWebHost(IWebHostBuilder builder) @@ -47,19 +51,28 @@ public sealed class CleanArchitectureWebApplicationFactory : WebApplicationFacto services.SetupTestDatabase(_connection); services.SetupTestDatabase(_connection); - ServiceProvider sp = services.BuildServiceProvider(); + if (_addTestAuthentication) + { + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = "Testing"; + options.DefaultChallengeScheme = "Testing"; + }).AddTestAuthentication(_ => { }); + } - using IServiceScope scope = sp.CreateScope(); - IServiceProvider scopedServices = scope.ServiceProvider; + var sp = services.BuildServiceProvider(); - ApplicationDbContext applicationDbContext = scopedServices.GetRequiredService(); - EventStoreDbContext storeDbContext = scopedServices.GetRequiredService(); - DomainNotificationStoreDbContext domainStoreDbContext = scopedServices.GetRequiredService(); + using var scope = sp.CreateScope(); + var scopedServices = scope.ServiceProvider; + + var applicationDbContext = scopedServices.GetRequiredService(); + var storeDbContext = scopedServices.GetRequiredService(); + var domainStoreDbContext = scopedServices.GetRequiredService(); applicationDbContext.EnsureMigrationsApplied(); var creator2 = (RelationalDatabaseCreator)storeDbContext.Database - .GetService(); + .GetService(); creator2.CreateTables(); var creator3 = (RelationalDatabaseCreator)domainStoreDbContext diff --git a/CleanArchitecture.IntegrationTests/UtilityTests/AuthTests.cs b/CleanArchitecture.IntegrationTests/UtilityTests/AuthTests.cs index a0e7773..7f3f26b 100644 --- a/CleanArchitecture.IntegrationTests/UtilityTests/AuthTests.cs +++ b/CleanArchitecture.IntegrationTests/UtilityTests/AuthTests.cs @@ -9,11 +9,11 @@ namespace CleanArchitecture.IntegrationTests.UtilityTests; [Collection("IntegrationTests")] [TestCaseOrderer(PriorityOrderer.Name, PriorityOrderer.Assembly)] -public sealed class AuthTests : IClassFixture +public sealed class AuthTests : IClassFixture { - private readonly TestFixtureBase _fixture; + private readonly AuthTestFixure _fixture; - public AuthTests(TestFixtureBase fixture) + public AuthTests(AuthTestFixure fixture) { _fixture = fixture; } @@ -22,6 +22,8 @@ public sealed class AuthTests : IClassFixture [InlineData("/api/v1/user")] [InlineData("/api/v1/user/me")] [InlineData("/api/v1/user/d74b112a-ece0-443d-9b4f-85bc418822ca")] + [InlineData("/api/v1/tenant")] + [InlineData("/api/v1/tenant/d74b112a-ece0-443d-9b4f-85bc418822ca")] public async Task Should_Get_Unauthorized_If_Trying_To_Call_Endpoint_Without_Token( string url) { diff --git a/CleanArchitecture.IntegrationTests/UtilityTests/HealthChecksTests.cs b/CleanArchitecture.IntegrationTests/UtilityTests/HealthChecksTests.cs index c13a61b..779f085 100644 --- a/CleanArchitecture.IntegrationTests/UtilityTests/HealthChecksTests.cs +++ b/CleanArchitecture.IntegrationTests/UtilityTests/HealthChecksTests.cs @@ -11,11 +11,11 @@ namespace CleanArchitecture.IntegrationTests.UtilityTests; [Collection("IntegrationTests")] [TestCaseOrderer(PriorityOrderer.Name, PriorityOrderer.Assembly)] -public sealed class HealthChecksTests : IClassFixture +public sealed class HealthChecksTests : IClassFixture { - private readonly TestFixtureBase _fixture; + private readonly AuthTestFixure _fixture; - public HealthChecksTests(TestFixtureBase fixture) + public HealthChecksTests(AuthTestFixure fixture) { _fixture = fixture; } diff --git a/CleanArchitecture.IntegrationTests/gRPC/GetTenantsByIdsTests.cs b/CleanArchitecture.IntegrationTests/gRPC/GetTenantsByIdsTests.cs new file mode 100644 index 0000000..d6c2e8b --- /dev/null +++ b/CleanArchitecture.IntegrationTests/gRPC/GetTenantsByIdsTests.cs @@ -0,0 +1,38 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using CleanArchitecture.IntegrationTests.Fixtures.gRPC; +using CleanArchitecture.Proto.Tenants; +using FluentAssertions; +using Xunit; + +namespace CleanArchitecture.IntegrationTests.gRPC; + +public sealed class GetTenantsByIdsTests : IClassFixture +{ + private readonly GetTenantsByIdsTestFixture _fixture; + + public GetTenantsByIdsTests(GetTenantsByIdsTestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Should_Get_Tenants_By_Ids() + { + var client = new TenantsApi.TenantsApiClient(_fixture.GrpcChannel); + + var request = new GetTenantsByIdsRequest(); + request.Ids.Add(_fixture.CreatedTenantId.ToString()); + + var response = await client.GetByIdsAsync(request); + + response.Tenants.Should().HaveCount(1); + + var tenant = response.Tenants.First(); + var createdTenant = _fixture.CreateTenant(); + + new Guid(tenant.Id).Should().Be(createdTenant.Id); + tenant.Name.Should().Be(createdTenant.Name); + } +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/gRPC/GetUsersByIdsTests.cs b/CleanArchitecture.IntegrationTests/gRPC/GetUsersByIdsTests.cs index 0ad9f75..6576493 100644 --- a/CleanArchitecture.IntegrationTests/gRPC/GetUsersByIdsTests.cs +++ b/CleanArchitecture.IntegrationTests/gRPC/GetUsersByIdsTests.cs @@ -21,12 +21,12 @@ public sealed class GetUsersByIdsTests : IClassFixture { var client = new UsersApi.UsersApiClient(_fixture.GrpcChannel); - var request = new GetByIdsRequest(); + var request = new GetUsersByIdsRequest(); request.Ids.Add(_fixture.CreatedUserId.ToString()); var response = await client.GetByIdsAsync(request); - response.Users.Count.Should().Be(1); + response.Users.Should().HaveCount(1); var user = response.Users.First(); var createdUser = _fixture.CreateUser(); diff --git a/CleanArchitecture.Proto/CleanArchitecture.Proto.csproj b/CleanArchitecture.Proto/CleanArchitecture.Proto.csproj index df3c40c..f3114e8 100644 --- a/CleanArchitecture.Proto/CleanArchitecture.Proto.csproj +++ b/CleanArchitecture.Proto/CleanArchitecture.Proto.csproj @@ -6,19 +6,16 @@ - - + + + + - - - - - - - - + + + diff --git a/CleanArchitecture.Proto/Tenants/Models.proto b/CleanArchitecture.Proto/Tenants/Models.proto new file mode 100644 index 0000000..c98c121 --- /dev/null +++ b/CleanArchitecture.Proto/Tenants/Models.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +option csharp_namespace = "CleanArchitecture.Proto.Tenants"; + +message Tenant { + string id = 1; + string name = 2; + bool isDeleted = 3; +} + +message GetTenantsByIdsResult { + repeated Tenant tenants = 1; +} + +message GetTenantsByIdsRequest { + repeated string ids = 1; +} \ No newline at end of file diff --git a/CleanArchitecture.Proto/Tenants/TenantsApi.proto b/CleanArchitecture.Proto/Tenants/TenantsApi.proto new file mode 100644 index 0000000..776d2d7 --- /dev/null +++ b/CleanArchitecture.Proto/Tenants/TenantsApi.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +option csharp_namespace = "CleanArchitecture.Proto.Tenants"; + +import "Tenants/Models.proto"; + +service TenantsApi { + rpc GetByIds(GetTenantsByIdsRequest) returns (GetTenantsByIdsResult); +} diff --git a/CleanArchitecture.Proto/Users/Models.proto b/CleanArchitecture.Proto/Users/Models.proto index 03e59a7..c372420 100644 --- a/CleanArchitecture.Proto/Users/Models.proto +++ b/CleanArchitecture.Proto/Users/Models.proto @@ -10,10 +10,10 @@ message GrpcUser { bool isDeleted = 6; } -message GetByIdsResult { +message GetUsersByIdsResult { repeated GrpcUser users = 1; } -message GetByIdsRequest { +message GetUsersByIdsRequest { repeated string ids = 1; } \ No newline at end of file diff --git a/CleanArchitecture.Proto/Users/UsersApi.proto b/CleanArchitecture.Proto/Users/UsersApi.proto index e718218..069393e 100644 --- a/CleanArchitecture.Proto/Users/UsersApi.proto +++ b/CleanArchitecture.Proto/Users/UsersApi.proto @@ -5,5 +5,5 @@ option csharp_namespace = "CleanArchitecture.Proto.Users"; import "Users/Models.proto"; service UsersApi { - rpc GetByIds(GetByIdsRequest) returns (GetByIdsResult); + rpc GetByIds(GetUsersByIdsRequest) returns (GetUsersByIdsResult); } diff --git a/CleanArchitecture.Shared/CleanArchitecture.Shared.csproj b/CleanArchitecture.Shared/CleanArchitecture.Shared.csproj index de3c61c..7547394 100644 --- a/CleanArchitecture.Shared/CleanArchitecture.Shared.csproj +++ b/CleanArchitecture.Shared/CleanArchitecture.Shared.csproj @@ -1,8 +1,9 @@ - - net7.0 - enable - + + net7.0 + enable + + diff --git a/CleanArchitecture.Shared/Tenants/TenantViewModel.cs b/CleanArchitecture.Shared/Tenants/TenantViewModel.cs new file mode 100644 index 0000000..97cd79f --- /dev/null +++ b/CleanArchitecture.Shared/Tenants/TenantViewModel.cs @@ -0,0 +1,7 @@ +using System; + +namespace CleanArchitecture.Shared.Tenants; + +public sealed record TenantViewModel( + Guid Id, + string Name); \ No newline at end of file diff --git a/CleanArchitecture.Shared/Users/UserViewModel.cs b/CleanArchitecture.Shared/Users/UserViewModel.cs index c183ded..d08d316 100644 --- a/CleanArchitecture.Shared/Users/UserViewModel.cs +++ b/CleanArchitecture.Shared/Users/UserViewModel.cs @@ -7,4 +7,4 @@ public sealed record UserViewModel( string Email, string FirstName, string LastName, - bool IsDeleted); + bool IsDeleted); \ No newline at end of file diff --git a/CleanArchitecture.gRPC.Tests/CleanArchitecture.gRPC.Tests.csproj b/CleanArchitecture.gRPC.Tests/CleanArchitecture.gRPC.Tests.csproj index e4730d8..dfc5b4f 100644 --- a/CleanArchitecture.gRPC.Tests/CleanArchitecture.gRPC.Tests.csproj +++ b/CleanArchitecture.gRPC.Tests/CleanArchitecture.gRPC.Tests.csproj @@ -8,11 +8,11 @@ - - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -24,9 +24,9 @@ - - - + + + diff --git a/CleanArchitecture.gRPC.Tests/Fixtures/TenantTestFixture.cs b/CleanArchitecture.gRPC.Tests/Fixtures/TenantTestFixture.cs new file mode 100644 index 0000000..565b589 --- /dev/null +++ b/CleanArchitecture.gRPC.Tests/Fixtures/TenantTestFixture.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using CleanArchitecture.Application.gRPC; +using CleanArchitecture.Domain.Entities; +using CleanArchitecture.Domain.Interfaces.Repositories; +using MockQueryable.NSubstitute; +using NSubstitute; + +namespace CleanArchitecture.gRPC.Tests.Fixtures; + +public sealed class TenantTestFixture +{ + public TenantsApiImplementation TenantsApiImplementation { get; } + private ITenantRepository TenantRepository { get; } + + public IEnumerable ExistingTenants { get; } + + public TenantTestFixture() + { + TenantRepository = Substitute.For(); + + ExistingTenants = new List + { + new(Guid.NewGuid(), "Tenant 1"), + new(Guid.NewGuid(), "Tenant 2"), + new(Guid.NewGuid(), "Tenant 3") + }; + + TenantRepository.GetAllNoTracking().Returns(ExistingTenants.BuildMock()); + + TenantsApiImplementation = new TenantsApiImplementation(TenantRepository); + } +} \ No newline at end of file diff --git a/CleanArchitecture.gRPC.Tests/Fixtures/UserTestsFixture.cs b/CleanArchitecture.gRPC.Tests/Fixtures/UserTestFixture.cs similarity index 90% rename from CleanArchitecture.gRPC.Tests/Fixtures/UserTestsFixture.cs rename to CleanArchitecture.gRPC.Tests/Fixtures/UserTestFixture.cs index 6ecba91..f350d7c 100644 --- a/CleanArchitecture.gRPC.Tests/Fixtures/UserTestsFixture.cs +++ b/CleanArchitecture.gRPC.Tests/Fixtures/UserTestFixture.cs @@ -9,13 +9,20 @@ using NSubstitute; namespace CleanArchitecture.gRPC.Tests.Fixtures; -public sealed class UserTestsFixture +public sealed class UserTestFixture { - public UserTestsFixture() + private IUserRepository UserRepository { get; } = Substitute.For(); + + public UsersApiImplementation UsersApiImplementation { get; } + + public IEnumerable ExistingUsers { get; } + + public UserTestFixture() { ExistingUsers = new List { new( + Guid.NewGuid(), Guid.NewGuid(), "test@test.de", "Test First Name", @@ -23,6 +30,7 @@ public sealed class UserTestsFixture "Test Password", UserRole.User), new( + Guid.NewGuid(), Guid.NewGuid(), "email@Email.de", "Email First Name", @@ -30,6 +38,7 @@ public sealed class UserTestsFixture "Email Password", UserRole.Admin), new( + Guid.NewGuid(), Guid.NewGuid(), "user@user.de", "User First Name", @@ -44,10 +53,4 @@ public sealed class UserTestsFixture UsersApiImplementation = new UsersApiImplementation(UserRepository); } - - private IUserRepository UserRepository { get; } = Substitute.For(); - - public UsersApiImplementation UsersApiImplementation { get; } - - public IEnumerable ExistingUsers { get; } } \ No newline at end of file diff --git a/CleanArchitecture.gRPC.Tests/Tenants/GetTenantsByIdsTests.cs b/CleanArchitecture.gRPC.Tests/Tenants/GetTenantsByIdsTests.cs new file mode 100644 index 0000000..61e51f5 --- /dev/null +++ b/CleanArchitecture.gRPC.Tests/Tenants/GetTenantsByIdsTests.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CleanArchitecture.gRPC.Tests.Fixtures; +using CleanArchitecture.Proto.Tenants; +using FluentAssertions; +using Xunit; + +namespace CleanArchitecture.gRPC.Tests.Tenants; + +public sealed class GetTenantsByIdsTests : IClassFixture +{ + private readonly TenantTestFixture _fixture; + + public GetTenantsByIdsTests(TenantTestFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task Should_Get_Empty_List_If_No_Ids_Are_Given() + { + var result = await _fixture.TenantsApiImplementation.GetByIds( + SetupRequest(Enumerable.Empty()), + default!); + + result.Tenants.Should().HaveCount(0); + } + + [Fact] + public async Task? Should_Get_Requested_Tenants() + { + var nonExistingId = Guid.NewGuid(); + + var ids = _fixture.ExistingTenants + .Take(2) + .Select(tenant => tenant.Id) + .ToList(); + + ids.Add(nonExistingId); + + var result = await _fixture.TenantsApiImplementation.GetByIds( + SetupRequest(ids), + default!); + + result.Tenants.Should().HaveCount(2); + + foreach (var tenant in result.Tenants) + { + var tenantId = Guid.Parse(tenant.Id); + + tenantId.Should().NotBe(nonExistingId); + + var mockTenant = _fixture.ExistingTenants.First(t => t.Id == tenantId); + + mockTenant.Should().NotBeNull(); + + tenant.Name.Should().Be(mockTenant.Name); + } + } + + private static GetTenantsByIdsRequest SetupRequest(IEnumerable ids) + { + var request = new GetTenantsByIdsRequest(); + + request.Ids.AddRange(ids.Select(id => id.ToString())); + request.Ids.Add("Not a guid"); + + return request; + } +} \ No newline at end of file diff --git a/CleanArchitecture.gRPC.Tests/Users/GetUsersByIdsTests.cs b/CleanArchitecture.gRPC.Tests/Users/GetUsersByIdsTests.cs index 7022833..dbac281 100644 --- a/CleanArchitecture.gRPC.Tests/Users/GetUsersByIdsTests.cs +++ b/CleanArchitecture.gRPC.Tests/Users/GetUsersByIdsTests.cs @@ -9,11 +9,11 @@ using Xunit; namespace CleanArchitecture.gRPC.Tests.Users; -public sealed class GetUsersByIdsTests : IClassFixture +public sealed class GetUsersByIdsTests : IClassFixture { - private readonly UserTestsFixture _fixture; + private readonly UserTestFixture _fixture; - public GetUsersByIdsTests(UserTestsFixture fixture) + public GetUsersByIdsTests(UserTestFixture fixture) { _fixture = fixture; } @@ -23,13 +23,13 @@ public sealed class GetUsersByIdsTests : IClassFixture { var result = await _fixture.UsersApiImplementation.GetByIds( SetupRequest(Enumerable.Empty()), - null!); + default!); result.Users.Should().HaveCount(0); } [Fact] - public async Task Should_Get_Requested_Asked_Ids() + public async Task Should_Get_Requested_Users() { var nonExistingId = Guid.NewGuid(); @@ -42,7 +42,7 @@ public sealed class GetUsersByIdsTests : IClassFixture var result = await _fixture.UsersApiImplementation.GetByIds( SetupRequest(ids), - null!); + default!); result.Users.Should().HaveCount(2); @@ -62,9 +62,9 @@ public sealed class GetUsersByIdsTests : IClassFixture } } - private static GetByIdsRequest SetupRequest(IEnumerable ids) + private static GetUsersByIdsRequest SetupRequest(IEnumerable ids) { - var request = new GetByIdsRequest(); + var request = new GetUsersByIdsRequest(); request.Ids.AddRange(ids.Select(id => id.ToString())); request.Ids.Add("Not a guid"); diff --git a/CleanArchitecture.gRPC/CleanArchitecture.cs b/CleanArchitecture.gRPC/CleanArchitecture.cs index 649b2fc..3a54670 100644 --- a/CleanArchitecture.gRPC/CleanArchitecture.cs +++ b/CleanArchitecture.gRPC/CleanArchitecture.cs @@ -4,12 +4,15 @@ namespace CleanArchitecture.gRPC; public sealed class CleanArchitecture : ICleanArchitecture { - private readonly IUsersContext _users; - - public IUsersContext Users => _users; - - public CleanArchitecture(IUsersContext users) + public CleanArchitecture( + IUsersContext users, + ITenantsContext tenants) { - _users = users; + Users = users; + Tenants = tenants; } -} + + public IUsersContext Users { get; } + + public ITenantsContext Tenants { get; } +} \ No newline at end of file diff --git a/CleanArchitecture.gRPC/CleanArchitecture.gRPC.csproj b/CleanArchitecture.gRPC/CleanArchitecture.gRPC.csproj index 1bec38c..068e549 100644 --- a/CleanArchitecture.gRPC/CleanArchitecture.gRPC.csproj +++ b/CleanArchitecture.gRPC/CleanArchitecture.gRPC.csproj @@ -6,9 +6,9 @@ - - - + + + diff --git a/CleanArchitecture.gRPC/Contexts/TenantsContext.cs b/CleanArchitecture.gRPC/Contexts/TenantsContext.cs new file mode 100644 index 0000000..f6038e4 --- /dev/null +++ b/CleanArchitecture.gRPC/Contexts/TenantsContext.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CleanArchitecture.gRPC.Interfaces; +using CleanArchitecture.Proto.Tenants; +using CleanArchitecture.Shared.Tenants; + +namespace CleanArchitecture.gRPC.Contexts; + +public sealed class TenantsContext : ITenantsContext +{ + private readonly TenantsApi.TenantsApiClient _client; + + public TenantsContext(TenantsApi.TenantsApiClient client) + { + _client = client; + } + + public async Task> GetTenantsByIds(IEnumerable ids) + { + var request = new GetTenantsByIdsRequest(); + + request.Ids.AddRange(ids.Select(id => id.ToString())); + + var result = await _client.GetByIdsAsync(request); + + return result.Tenants.Select(tenant => new TenantViewModel( + Guid.Parse(tenant.Id), + tenant.Name)); + } +} \ No newline at end of file diff --git a/CleanArchitecture.gRPC/Contexts/UsersContext.cs b/CleanArchitecture.gRPC/Contexts/UsersContext.cs index 4822231..a6b02d2 100644 --- a/CleanArchitecture.gRPC/Contexts/UsersContext.cs +++ b/CleanArchitecture.gRPC/Contexts/UsersContext.cs @@ -19,7 +19,7 @@ public sealed class UsersContext : IUsersContext public async Task> GetUsersByIds(IEnumerable ids) { - var request = new GetByIdsRequest(); + var request = new GetUsersByIdsRequest(); request.Ids.AddRange(ids.Select(id => id.ToString())); @@ -32,4 +32,4 @@ public sealed class UsersContext : IUsersContext user.LastName, user.IsDeleted)); } -} +} \ No newline at end of file diff --git a/CleanArchitecture.gRPC/Extensions/ServiceCollectionExtensions.cs b/CleanArchitecture.gRPC/Extensions/ServiceCollectionExtensions.cs index 72d5c3c..114c410 100644 --- a/CleanArchitecture.gRPC/Extensions/ServiceCollectionExtensions.cs +++ b/CleanArchitecture.gRPC/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using CleanArchitecture.gRPC.Contexts; using CleanArchitecture.gRPC.Interfaces; using CleanArchitecture.gRPC.Models; +using CleanArchitecture.Proto.Tenants; using CleanArchitecture.Proto.Users; using Grpc.Net.Client; using Microsoft.Extensions.Configuration; @@ -35,20 +36,24 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddCleanArchitectureGrpcClient( this IServiceCollection services, - string tetraQueryApiUrl) + string gRPCUrl) { - if (string.IsNullOrWhiteSpace(tetraQueryApiUrl)) + if (string.IsNullOrWhiteSpace(gRPCUrl)) { return services; } - var channel = GrpcChannel.ForAddress(tetraQueryApiUrl); + var channel = GrpcChannel.ForAddress(gRPCUrl); var usersClient = new UsersApi.UsersApiClient(channel); services.AddSingleton(usersClient); + var tenantsClient = new TenantsApi.TenantsApiClient(channel); + services.AddSingleton(tenantsClient); + services.AddSingleton(); + services.AddSingleton(); return services; } -} +} \ No newline at end of file diff --git a/CleanArchitecture.gRPC/ICleanArchitecture.cs b/CleanArchitecture.gRPC/ICleanArchitecture.cs index 168df0d..9c92a1c 100644 --- a/CleanArchitecture.gRPC/ICleanArchitecture.cs +++ b/CleanArchitecture.gRPC/ICleanArchitecture.cs @@ -5,4 +5,5 @@ namespace CleanArchitecture.gRPC; public interface ICleanArchitecture { IUsersContext Users { get; } -} + ITenantsContext Tenants { get; } +} \ No newline at end of file diff --git a/CleanArchitecture.gRPC/Interfaces/ITenantsContext.cs b/CleanArchitecture.gRPC/Interfaces/ITenantsContext.cs new file mode 100644 index 0000000..db07813 --- /dev/null +++ b/CleanArchitecture.gRPC/Interfaces/ITenantsContext.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using CleanArchitecture.Shared.Tenants; + +namespace CleanArchitecture.gRPC.Interfaces; + +public interface ITenantsContext +{ + Task> GetTenantsByIds(IEnumerable ids); +} \ No newline at end of file diff --git a/CleanArchitecture.gRPC/Interfaces/IUsersContext.cs b/CleanArchitecture.gRPC/Interfaces/IUsersContext.cs index f1f2f04..c8b01f3 100644 --- a/CleanArchitecture.gRPC/Interfaces/IUsersContext.cs +++ b/CleanArchitecture.gRPC/Interfaces/IUsersContext.cs @@ -8,4 +8,4 @@ namespace CleanArchitecture.gRPC.Interfaces; public interface IUsersContext { Task> GetUsersByIds(IEnumerable ids); -} +} \ No newline at end of file diff --git a/CleanArchitecture.gRPC/Models/GRPCSettings.cs b/CleanArchitecture.gRPC/Models/GRPCSettings.cs index fd7a02c..e784168 100644 --- a/CleanArchitecture.gRPC/Models/GRPCSettings.cs +++ b/CleanArchitecture.gRPC/Models/GRPCSettings.cs @@ -3,4 +3,4 @@ public sealed class GRPCSettings { public string CleanArchitectureUrl { get; set; } = string.Empty; -} +} \ No newline at end of file diff --git a/Readme.md b/Readme.md index e4f2ffa..f73b60d 100644 --- a/Readme.md +++ b/Readme.md @@ -2,7 +2,9 @@ ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/alex289/CleanArchitecture/dotnet.yml) -This repository contains a sample API project built using the Clean Architecture principles, Onion Architecture, MediatR, and Entity Framework. The project also includes unit tests for all layers and integration tests using xUnit. +This repository contains a sample API project built using the Clean Architecture principles, Onion Architecture, MediatR, and Entity Framework. The project also includes unit tests for all layers and integration tests using xUnit and Nsubstitute. + +The purpose of this project is to create a clean boilerplate for an API and to show how to implement specific features. ## Project Structure The project follows the Onion Architecture, which means that the codebase is organized into layers, with the domain model at the center and the outer layers dependent on the inner layers. @@ -20,6 +22,7 @@ The project uses the following dependencies: - **MediatR**: A lightweight library that provides a mediator pattern implementation for .NET. - **Entity Framework Core**: A modern object-relational mapper for .NET that provides data access to the application. - **FluentValidation**: A validation library that provides a fluent API for validating objects. +- **gRPC**: gRPC is an open-source remote procedure call framework that enables efficient communication between distributed systems using a variety of programming languages and protocols. ## Running the Project To run the project, follow these steps: