diff --git a/CleanArchitecture.Api/Program.cs b/CleanArchitecture.Api/Program.cs index da17e91..f940491 100644 --- a/CleanArchitecture.Api/Program.cs +++ b/CleanArchitecture.Api/Program.cs @@ -91,6 +91,7 @@ app.MapHealthChecks("/healthz", new HealthCheckOptions }); app.MapControllers(); app.MapGrpcService(); +app.MapGrpcService(); app.Run(); 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..90763e6 --- /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(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..4ba31ef --- /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( + 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/Queries/Tenants/GetAllTenantsQueryHandlerTests.cs b/CleanArchitecture.Application.Tests/Queries/Tenants/GetAllTenantsQueryHandlerTests.cs new file mode 100644 index 0000000..882eedf --- /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..222d456 --- /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/Queries/Tenants/GetAll/GetAllTenantsQueryHandler.cs b/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQueryHandler.cs index cd21b8a..844a5f4 100644 --- a/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQueryHandler.cs +++ b/CleanArchitecture.Application/Queries/Tenants/GetAll/GetAllTenantsQueryHandler.cs @@ -25,6 +25,7 @@ public sealed class GetAllTenantsQueryHandler : { return await _tenantRepository .GetAllNoTracking() + .Include(x => x.Users) .Where(x => !x.Deleted) .Select(x => TenantViewModel.FromTenant(x)) .ToListAsync(cancellationToken); diff --git a/CleanArchitecture.Application/Queries/Tenants/GetTenantById/GetTenantByIdQueryHandler.cs b/CleanArchitecture.Application/Queries/Tenants/GetTenantById/GetTenantByIdQueryHandler.cs index 751ab59..9157fe4 100644 --- a/CleanArchitecture.Application/Queries/Tenants/GetTenantById/GetTenantByIdQueryHandler.cs +++ b/CleanArchitecture.Application/Queries/Tenants/GetTenantById/GetTenantByIdQueryHandler.cs @@ -7,6 +7,7 @@ using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Domain.Interfaces.Repositories; using CleanArchitecture.Domain.Notifications; using MediatR; +using Microsoft.EntityFrameworkCore; namespace CleanArchitecture.Application.Queries.Tenants.GetTenantById; @@ -26,6 +27,7 @@ public sealed class GetTenantByIdQueryHandler : { var tenant = _tenantRepository .GetAllNoTracking() + .Include(x => x.Users) .FirstOrDefault(x => x.Id == request.TenantId && x.Deleted == request.IsDeleted); diff --git a/CleanArchitecture.Application/Services/UserService.cs b/CleanArchitecture.Application/Services/UserService.cs index 13920c7..f6181f7 100644 --- a/CleanArchitecture.Application/Services/UserService.cs +++ b/CleanArchitecture.Application/Services/UserService.cs @@ -62,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/gRPC/TenantsApiImplementation.cs b/CleanArchitecture.Application/gRPC/TenantsApiImplementation.cs new file mode 100644 index 0000000..d356c9e --- /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/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/CommandHandler/Tenant/CreateTenant/CreateTenantCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/CreateTenant/CreateTenantCommandHandlerTests.cs new file mode 100644 index 0000000..77e2038 --- /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..9c9a48c --- /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( + 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..25aec37 --- /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( + 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..27b5a2d --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/DeleteTenant/DeleteTenantCommandHandlerTests.cs @@ -0,0 +1,45 @@ +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}"); + } +} \ 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..9ed5c2c --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/DeleteTenant/DeleteTenantCommandTestFixture.cs @@ -0,0 +1,39 @@ +using System; +using CleanArchitecture.Domain.Commands.Tenants.DeleteTenant; +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( + 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; + } +} \ 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..48d3877 --- /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(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..8fa4424 --- /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..82879d6 --- /dev/null +++ b/CleanArchitecture.Domain.Tests/CommandHandler/Tenant/UpdateTenant/UpdateTenantCommandTestFixture.cs @@ -0,0 +1,39 @@ +using System; +using CleanArchitecture.Domain.Commands.Tenants.UpdateTenant; +using CleanArchitecture.Domain.Enums; +using CleanArchitecture.Domain.Interfaces; +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( + 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..4aac523 --- /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( + id ?? Guid.NewGuid(), + name ?? "Test Tenant"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandHandlerTests.cs index 14fcb04..5382f23 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandHandlerTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandHandlerTests.cs @@ -13,11 +13,15 @@ public sealed class CreateUserCommandHandlerTests [Fact] public void Should_Create_User() { - _fixture.SetupUser(); + // Todo: Fix tests + _fixture.SetupCurrentUser(); + + var user = _fixture.SetupUser(); + _fixture.SetupTenant(user.TenantId); var command = new CreateUserCommand( Guid.NewGuid(), - Guid.NewGuid(), + user.TenantId, "test@email.com", "Test", "Email", @@ -34,6 +38,8 @@ public sealed class CreateUserCommandHandlerTests [Fact] public void Should_Not_Create_Already_Existing_User() { + _fixture.SetupCurrentUser(); + var user = _fixture.SetupUser(); var command = new CreateUserCommand( @@ -54,4 +60,54 @@ public sealed class CreateUserCommandHandlerTests DomainErrorCodes.User.UserAlreadyExists, $"There is already a user with Id {command.UserId}"); } + + [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 31ea40e..6d6e5dc 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandTestFixture.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/CreateUser/CreateUserCommandTestFixture.cs @@ -1,6 +1,7 @@ using System; using CleanArchitecture.Domain.Commands.Users.CreateUser; using CleanArchitecture.Domain.Enums; +using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Domain.Interfaces.Repositories; using NSubstitute; @@ -11,16 +12,23 @@ public sealed class CreateUserCommandTestFixture : CommandHandlerFixtureBase public CreateUserCommandTestFixture() { UserRepository = Substitute.For(); + TenantRepository = Substitute.For(); + User = Substitute.For(); CommandHandler = new CreateUserCommandHandler( Bus, UnitOfWork, NotificationHandler, - UserRepository); + UserRepository, + TenantRepository, + User); } + // Todo: Properties over ctor public CreateUserCommandHandler CommandHandler { get; } private IUserRepository UserRepository { get; } + private ITenantRepository TenantRepository { get; } + private IUser User { get; } public Entities.User SetupUser() { @@ -39,4 +47,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 3e303ec..015b40d 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; @@ -58,12 +59,12 @@ public sealed class CreateUserCommandValidationTests : [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.User.UserEmailExceedsMaxLength, - "Email may not be longer than 320 characters"); + $"Email may not be longer than {MaxLengths.User.Email} characters"); } [Fact] @@ -80,12 +81,12 @@ public sealed class CreateUserCommandValidationTests : [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.User.UserFirstNameExceedsMaxLength, - "FirstName may not be longer than 100 characters"); + $"FirstName may not be longer than {MaxLengths.User.FirstName} characters"); } [Fact] @@ -102,12 +103,12 @@ public sealed class CreateUserCommandValidationTests : [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.User.UserLastNameExceedsMaxLength, - "LastName may not be longer than 100 characters"); + $"LastName may not be longer than {MaxLengths.User.LastName} characters"); } [Fact] @@ -175,6 +176,14 @@ public sealed class CreateUserCommandValidationTests : 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, diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/LoginUser/LoginUserCommandValidationTests.cs index d875b73..e6b4675 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; @@ -46,12 +47,12 @@ public sealed class LoginUserCommandValidationTests : [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.User.UserEmailExceedsMaxLength, - "Email may not be longer than 320 characters"); + $"Email may not be longer than {MaxLengths.User.Email} characters"); } [Fact] diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandHandlerTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandHandlerTests.cs index 4bcb809..683d677 100644 --- a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandHandlerTests.cs +++ b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandHandlerTests.cs @@ -23,7 +23,8 @@ public sealed class UpdateUserCommandHandlerTests "test@email.com", "Test", "Email", - UserRole.User); + UserRole.User, + Guid.NewGuid()); await _fixture.CommandHandler.Handle(command, default); @@ -43,7 +44,8 @@ public sealed class UpdateUserCommandHandlerTests "test@email.com", "Test", "Email", - UserRole.User); + UserRole.User, + Guid.NewGuid()); await _fixture.CommandHandler.Handle(command, default); @@ -66,7 +68,8 @@ public sealed class UpdateUserCommandHandlerTests "test@email.com", "Test", "Email", - UserRole.User); + UserRole.User, + Guid.NewGuid()); _fixture.UserRepository .GetByEmailAsync(command.Email) diff --git a/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandValidationTests.cs b/CleanArchitecture.Domain.Tests/CommandHandler/User/UpdateUser/UpdateUserCommandValidationTests.cs index 762dcb1..161aabc 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; @@ -57,12 +58,12 @@ public sealed class UpdateUserCommandValidationTests : [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.User.UserEmailExceedsMaxLength, - "Email may not be longer than 320 characters"); + $"Email may not be longer than {MaxLengths.User.Email} characters"); } [Fact] @@ -79,12 +80,12 @@ public sealed class UpdateUserCommandValidationTests : [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.User.UserFirstNameExceedsMaxLength, - "FirstName may not be longer than 100 characters"); + $"FirstName may not be longer than {MaxLengths.User.FirstName} characters"); } [Fact] @@ -101,16 +102,28 @@ public sealed class UpdateUserCommandValidationTests : [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.User.UserLastNameExceedsMaxLength, - "LastName may not be longer than 100 characters"); + $"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, @@ -121,6 +134,7 @@ public sealed class UpdateUserCommandValidationTests : 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/Commands/Tenants/CreateTenant/CreateTenantCommandHandler.cs b/CleanArchitecture.Domain/Commands/Tenants/CreateTenant/CreateTenantCommandHandler.cs index 78c4aa1..c96c8f0 100644 --- a/CleanArchitecture.Domain/Commands/Tenants/CreateTenant/CreateTenantCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Tenants/CreateTenant/CreateTenantCommandHandler.cs @@ -1,6 +1,7 @@ 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; @@ -14,14 +15,17 @@ public sealed class CreateTenantCommandHandler : CommandHandlerBase, IRequestHandler { private readonly ITenantRepository _tenantRepository; - + private readonly IUser _user; + public CreateTenantCommandHandler( IMediatorHandler bus, IUnitOfWork unitOfWork, INotificationHandler notifications, - ITenantRepository tenantRepository) : base(bus, unitOfWork, notifications) + ITenantRepository tenantRepository, + IUser user) : base(bus, unitOfWork, notifications) { _tenantRepository = tenantRepository; + _user = user; } public async Task Handle(CreateTenantCommand request, CancellationToken cancellationToken) @@ -30,6 +34,17 @@ public sealed class CreateTenantCommandHandler : CommandHandlerBase, { 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)) { diff --git a/CleanArchitecture.Domain/Commands/Tenants/DeleteTenant/DeleteTenantCommandHandler.cs b/CleanArchitecture.Domain/Commands/Tenants/DeleteTenant/DeleteTenantCommandHandler.cs index 1a1ab43..6c6b91a 100644 --- a/CleanArchitecture.Domain/Commands/Tenants/DeleteTenant/DeleteTenantCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Tenants/DeleteTenant/DeleteTenantCommandHandler.cs @@ -1,6 +1,7 @@ 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; @@ -15,16 +16,19 @@ public sealed class DeleteTenantCommandHandler : CommandHandlerBase, { private readonly ITenantRepository _tenantRepository; private readonly IUserRepository _userRepository; - + private readonly IUser _user; + public DeleteTenantCommandHandler( IMediatorHandler bus, IUnitOfWork unitOfWork, INotificationHandler notifications, ITenantRepository tenantRepository, - IUserRepository userRepository) : base(bus, unitOfWork, notifications) + IUserRepository userRepository, + IUser user) : base(bus, unitOfWork, notifications) { _tenantRepository = tenantRepository; _userRepository = userRepository; + _user = user; } public async Task Handle(DeleteTenantCommand request, CancellationToken cancellationToken) @@ -33,6 +37,19 @@ public sealed class DeleteTenantCommandHandler : CommandHandlerBase, { return; } + + // Todo: Test following + + 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); diff --git a/CleanArchitecture.Domain/Commands/Tenants/UpdateTenant/UpdateTenantCommandHandler.cs b/CleanArchitecture.Domain/Commands/Tenants/UpdateTenant/UpdateTenantCommandHandler.cs index f1ba406..4ef9929 100644 --- a/CleanArchitecture.Domain/Commands/Tenants/UpdateTenant/UpdateTenantCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Tenants/UpdateTenant/UpdateTenantCommandHandler.cs @@ -1,5 +1,6 @@ using System.Threading; using System.Threading.Tasks; +using CleanArchitecture.Domain.Enums; using CleanArchitecture.Domain.Errors; using CleanArchitecture.Domain.Events.Tenant; using CleanArchitecture.Domain.Interfaces; @@ -13,14 +14,17 @@ public sealed class UpdateTenantCommandHandler : CommandHandlerBase, IRequestHandler { private readonly ITenantRepository _tenantRepository; - + private readonly IUser _user; + public UpdateTenantCommandHandler( IMediatorHandler bus, IUnitOfWork unitOfWork, INotificationHandler notifications, - ITenantRepository tenantRepository) : base(bus, unitOfWork, notifications) + ITenantRepository tenantRepository, + IUser user) : base(bus, unitOfWork, notifications) { _tenantRepository = tenantRepository; + _user = user; } public async Task Handle(UpdateTenantCommand request, CancellationToken cancellationToken) @@ -29,6 +33,17 @@ public sealed class UpdateTenantCommandHandler : CommandHandlerBase, { 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); diff --git a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs index 7e40c4d..4407656 100644 --- a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs +++ b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandHandler.cs @@ -16,14 +16,20 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase, IRequestHandler { private readonly IUserRepository _userRepository; + private readonly ITenantRepository _tenantRepository; + private readonly IUser _user; 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) @@ -32,12 +38,24 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase, { return; } + + var currentUser = await _userRepository.GetByIdAsync(_user.GetUserId()); + + if (currentUser is null || currentUser.Role != UserRole.Admin) + { + await NotifyAsync( + new DomainNotification( + request.MessageType, + "You are not allowed to create users", + ErrorCodes.InsufficientPermissions)); + return; + } var existingUser = await _userRepository.GetByIdAsync(request.UserId); if (existingUser is not null) { - await Bus.RaiseEventAsync( + await NotifyAsync( new DomainNotification( request.MessageType, $"There is already a user with Id {request.UserId}", @@ -49,7 +67,7 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase, if (existingUser is not null) { - await Bus.RaiseEventAsync( + await NotifyAsync( new DomainNotification( request.MessageType, $"There is already a user with email {request.Email}", @@ -57,6 +75,16 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase, return; } + if (!await _tenantRepository.ExistsAsync(request.TenantId)) + { + await NotifyAsync( + new DomainNotification( + request.MessageType, + $"There is no tenant with Id {request.TenantId}", + ErrorCodes.ObjectNotFound)); + return; + } + var passwordHash = BC.HashPassword(request.Password); var user = new User( diff --git a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandValidation.cs b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandValidation.cs index c2d9564..371bf60 100644 --- a/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandValidation.cs +++ b/CleanArchitecture.Domain/Commands/Users/CreateUser/CreateUserCommandValidation.cs @@ -10,6 +10,7 @@ public sealed class CreateUserCommandValidation : AbstractValidator cmd.TenantId) + .NotEmpty() + .WithErrorCode(DomainErrorCodes.Tenant.TenantEmptyId) + .WithMessage("Tenant id may not be empty"); + } + private void AddRuleForEmail() { RuleFor(cmd => cmd.Email) @@ -32,7 +41,7 @@ public sealed class CreateUserCommandValidation : AbstractValidator cmd.TenantId) + .NotEmpty() + .WithErrorCode(DomainErrorCodes.Tenant.TenantEmptyId) + .WithMessage("Tenant id may not be empty"); + } private void AddRuleForEmail() { @@ -31,7 +40,7 @@ public sealed class UpdateUserCommandValidation : AbstractValidator $"{FirstName}, {LastName}"; public Guid TenantId { get; private set; } @@ -58,4 +58,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 d4131c8..76977e4 100644 --- a/CleanArchitecture.Domain/Errors/DomainErrorCodes.cs +++ b/CleanArchitecture.Domain/Errors/DomainErrorCodes.cs @@ -34,7 +34,7 @@ public static class DomainErrorCodes 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"; } diff --git a/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs b/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs index 9137422..4be4db9 100644 --- a/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs +++ b/CleanArchitecture.Infrastructure/Repositories/BaseRepository.cs @@ -55,7 +55,7 @@ public class BaseRepository : IRepository where TEntity : Enti DbSet.Update(entity); } - public async Task ExistsAsync(Guid id) + public virtual async Task ExistsAsync(Guid id) { return await DbSet.AnyAsync(entity => entity.Id == id); } diff --git a/CleanArchitecture.IntegrationTests/Controller/TenantControllerTests.cs b/CleanArchitecture.IntegrationTests/Controller/TenantControllerTests.cs new file mode 100644 index 0000000..a3d60e5 --- /dev/null +++ b/CleanArchitecture.IntegrationTests/Controller/TenantControllerTests.cs @@ -0,0 +1,136 @@ +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() + { + await _fixture.AuthenticateUserAsync(); + + 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() + { + await _fixture.AuthenticateUserAsync(); + + 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() + { + await _fixture.AuthenticateUserAsync(); + + 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() + { + await _fixture.AuthenticateUserAsync(); + + 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() + { + await _fixture.AuthenticateUserAsync(); + + 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 ee176ec..4fd17ef 100644 --- a/CleanArchitecture.IntegrationTests/Controller/UserControllerTests.cs +++ b/CleanArchitecture.IntegrationTests/Controller/UserControllerTests.cs @@ -25,10 +25,14 @@ public sealed class UserControllerTests : IClassFixture _fixture = fixture; } + // Todo: Refactor tests to work alone + [Fact] [Priority(0)] public async Task Should_Create_User() { + await _fixture.AuthenticateUserAsync(); + var user = new CreateUserViewModel( _fixture.CreatedUserEmail, "Test", @@ -116,7 +120,8 @@ public sealed class UserControllerTests : IClassFixture "newtest@email.com", "NewTest", "NewEmail", - UserRole.User); + UserRole.User, + Ids.Seed.TenantId); var response = await _fixture.ServerClient.PutAsJsonAsync("/api/v1/user", user); @@ -232,5 +237,7 @@ public sealed class UserControllerTests : IClassFixture var content = message!.Data; content.Should().Be(_fixture.CreatedUserId); + + // Todo: Check if stuff is done } } \ 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..0ab2498 --- /dev/null +++ b/CleanArchitecture.IntegrationTests/Fixtures/TenantTestFixture.cs @@ -0,0 +1,19 @@ +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) + { + 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..80c2273 100644 --- a/CleanArchitecture.IntegrationTests/Fixtures/TestFixtureBase.cs +++ b/CleanArchitecture.IntegrationTests/Fixtures/TestFixtureBase.cs @@ -1,6 +1,10 @@ using System; +using System.Net; using System.Net.Http; +using System.Threading.Tasks; +using CleanArchitecture.Application.ViewModels.Users; using CleanArchitecture.Infrastructure.Database; +using CleanArchitecture.IntegrationTests.Extensions; using CleanArchitecture.IntegrationTests.Infrastructure; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; @@ -32,4 +36,18 @@ public class TestFixtureBase IServiceProvider scopedServices) { } + + // Todo: Fix auth + public virtual async Task AuthenticateUserAsync() + { + ServerClient.DefaultRequestHeaders.Clear(); + var user = new LoginUserViewModel( + "admin@email.com", + "!Password123#"); + + var response = await ServerClient.PostAsJsonAsync("/api/v1/user/login", user); + + var message = await response.Content.ReadAsJsonAsync(); + ServerClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {message!.Data}"); + } } \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/Fixtures/UserTestFixture.cs b/CleanArchitecture.IntegrationTests/Fixtures/UserTestFixture.cs index 5769927..e2421d2 100644 --- a/CleanArchitecture.IntegrationTests/Fixtures/UserTestFixture.cs +++ b/CleanArchitecture.IntegrationTests/Fixtures/UserTestFixture.cs @@ -11,6 +11,7 @@ public sealed class UserTestFixture : TestFixtureBase public void EnableAuthentication() { + ServerClient.DefaultRequestHeaders.Clear(); 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..5c9c3bd --- /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( + CreatedTenantId, + "Test Tenant"); + } +} \ No newline at end of file diff --git a/CleanArchitecture.IntegrationTests/gRPC/GetTenantsByIdsTests.cs b/CleanArchitecture.IntegrationTests/gRPC/GetTenantsByIdsTests.cs new file mode 100644 index 0000000..5c939a3 --- /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 8464d7d..6576493 100644 --- a/CleanArchitecture.IntegrationTests/gRPC/GetUsersByIdsTests.cs +++ b/CleanArchitecture.IntegrationTests/gRPC/GetUsersByIdsTests.cs @@ -26,7 +26,7 @@ public sealed class GetUsersByIdsTests : IClassFixture 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.gRPC.Tests/Fixtures/TenantTestFixture.cs b/CleanArchitecture.gRPC.Tests/Fixtures/TenantTestFixture.cs new file mode 100644 index 0000000..4fb0177 --- /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 Tenant(Guid.NewGuid(), "Tenant 1"), + new Tenant(Guid.NewGuid(), "Tenant 2"), + new Tenant(Guid.NewGuid(), "Tenant 3"), + }; + + TenantRepository.GetAllNoTracking().Returns(ExistingTenants.BuildMock()); + + TenantsApiImplementation = new(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 95% rename from CleanArchitecture.gRPC.Tests/Fixtures/UserTestsFixture.cs rename to CleanArchitecture.gRPC.Tests/Fixtures/UserTestFixture.cs index d0616f2..3954783 100644 --- a/CleanArchitecture.gRPC.Tests/Fixtures/UserTestsFixture.cs +++ b/CleanArchitecture.gRPC.Tests/Fixtures/UserTestFixture.cs @@ -9,9 +9,9 @@ using NSubstitute; namespace CleanArchitecture.gRPC.Tests.Fixtures; -public sealed class UserTestsFixture +public sealed class UserTestFixture { - public UserTestsFixture() + public UserTestFixture() { ExistingUsers = new List { diff --git a/CleanArchitecture.gRPC.Tests/Tenants/GetTenantsByIdsTests.cs b/CleanArchitecture.gRPC.Tests/Tenants/GetTenantsByIdsTests.cs new file mode 100644 index 0000000..5a60273 --- /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 1c0e333..4744376 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(); @@ -40,9 +40,10 @@ public sealed class GetUsersByIdsTests : IClassFixture ids.Add(nonExistingId); +// Todo: Use default instead of null everywhere var result = await _fixture.UsersApiImplementation.GetByIds( SetupRequest(ids), - null!); + default!); result.Users.Should().HaveCount(2); 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: