0
0
mirror of https://github.com/alex289/CleanArchitecture.git synced 2025-06-30 02:31:08 +00:00

test: Add tenant tests

This commit is contained in:
alex289 2023-08-30 23:31:47 +02:00
parent 816d92fc85
commit a3152580a2
No known key found for this signature in database
GPG Key ID: 573F77CD2D87F863
51 changed files with 1315 additions and 57 deletions

View File

@ -91,6 +91,7 @@ app.MapHealthChecks("/healthz", new HealthCheckOptions
});
app.MapControllers();
app.MapGrpcService<UsersApiImplementation>();
app.MapGrpcService<TenantsApiImplementation>();
app.Run();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<TenantDeletedEvent>(x => x.AggregateId == tenant.Id);
}
[Fact]
public void Should_Not_Delete_Non_Existing_Tenant()
{
_fixture.SetupTenant();
var command = new DeleteTenantCommand(Guid.NewGuid());
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyNoCommit()
.VerifyNoRaisedEvent<TenantDeletedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.ObjectNotFound,
$"There is no tenant with Id {command.AggregateId}");
}
}

View File

@ -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<ITenantRepository>();
UserRepository = Substitute.For<IUserRepository>();
CommandHandler = new(
Bus,
UnitOfWork,
NotificationHandler,
TenantRepository,
UserRepository,
User);
}
public Entities.Tenant SetupTenant()
{
var tenant = new Entities.Tenant(Guid.NewGuid(), "TestTenant");
TenantRepository
.GetByIdAsync(Arg.Is<Guid>(y => y == tenant.Id))
.Returns(tenant);
return tenant;
}
}

View File

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

View File

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

View File

@ -0,0 +1,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<ITenantRepository>();
CommandHandler = new(
Bus,
UnitOfWork,
NotificationHandler,
TenantRepository,
User);
}
public void SetupUser()
{
User.GetUserRole().Returns(UserRole.User);
}
public void SetupExistingTenant(Guid id)
{
TenantRepository
.GetByIdAsync(Arg.Is<Guid>(x => x == id))
.Returns(new Entities.Tenant(id, "Test Tenant"));
}
}

View File

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

View File

@ -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<UserCreatedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.ObjectNotFound,
$"There is no tenant with Id {command.TenantId}");
}
[Fact]
public void Should_Not_Create_User_Insufficient_Permissions()
{
_fixture.SetupUser();
var command = new CreateUserCommand(
Guid.NewGuid(),
Guid.NewGuid(),
"test@email.com",
"Test",
"Email",
"Po=PF]PC6t.?8?ks)A6W");
_fixture.CommandHandler.Handle(command, default).Wait();
_fixture
.VerifyNoCommit()
.VerifyNoRaisedEvent<UserCreatedEvent>()
.VerifyAnyDomainNotification()
.VerifyExistingNotification(
ErrorCodes.InsufficientPermissions,
"You are not allowed to create users");
}
}

View File

@ -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<IUserRepository>();
TenantRepository = Substitute.For<ITenantRepository>();
User = Substitute.For<IUser>();
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<Guid>(y => y == userId))
.Returns(new Entities.User(
userId,
Guid.NewGuid(),
"some email",
"some first name",
"some last name",
"some password",
UserRole.Admin));
}
public void SetupTenant(Guid tenantId)
{
TenantRepository
.ExistsAsync(Arg.Is<Guid>(y => y == tenantId))
.Returns(true);
}
}

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using CleanArchitecture.Domain.Commands.Users.CreateUser;
using CleanArchitecture.Domain.Constants;
using CleanArchitecture.Domain.Errors;
using Xunit;
@ -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,

View File

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using CleanArchitecture.Domain.Commands.Users.LoginUser;
using CleanArchitecture.Domain.Constants;
using CleanArchitecture.Domain.Errors;
using Xunit;
@ -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]

View File

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

View File

@ -1,5 +1,6 @@
using System;
using CleanArchitecture.Domain.Commands.Users.UpdateUser;
using CleanArchitecture.Domain.Constants;
using CleanArchitecture.Domain.Enums;
using CleanArchitecture.Domain.Errors;
using Xunit;
@ -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());
}
}

View File

@ -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<CreateTenantCommand>
{
private readonly ITenantRepository _tenantRepository;
private readonly IUser _user;
public CreateTenantCommandHandler(
IMediatorHandler bus,
IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> 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))
{

View File

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

View File

@ -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<UpdateTenantCommand>
{
private readonly ITenantRepository _tenantRepository;
private readonly IUser _user;
public UpdateTenantCommandHandler(
IMediatorHandler bus,
IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> 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);

View File

@ -16,14 +16,20 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase,
IRequestHandler<CreateUserCommand>
{
private readonly IUserRepository _userRepository;
private readonly ITenantRepository _tenantRepository;
private readonly IUser _user;
public CreateUserCommandHandler(
IMediatorHandler bus,
IUnitOfWork unitOfWork,
INotificationHandler<DomainNotification> notifications,
IUserRepository userRepository) : base(bus, unitOfWork, notifications)
IUserRepository userRepository,
ITenantRepository tenantRepository,
IUser user) : base(bus, unitOfWork, notifications)
{
_userRepository = userRepository;
_tenantRepository = tenantRepository;
_user = user;
}
public async Task Handle(CreateUserCommand request, CancellationToken cancellationToken)
@ -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(

View File

@ -10,6 +10,7 @@ public sealed class CreateUserCommandValidation : AbstractValidator<CreateUserCo
public CreateUserCommandValidation()
{
AddRuleForId();
AddRuleForTenantId();
AddRuleForEmail();
AddRuleForFirstName();
AddRuleForLastName();
@ -24,6 +25,14 @@ public sealed class CreateUserCommandValidation : AbstractValidator<CreateUserCo
.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)
@ -32,7 +41,7 @@ public sealed class CreateUserCommandValidation : AbstractValidator<CreateUserCo
.WithMessage("Email is not a valid email address")
.MaximumLength(MaxLengths.User.Email)
.WithErrorCode(DomainErrorCodes.User.UserEmailExceedsMaxLength)
.WithMessage("Email may not be longer than 320 characters");
.WithMessage($"Email may not be longer than {MaxLengths.User.Email} characters");
}
private void AddRuleForFirstName()
@ -43,7 +52,7 @@ public sealed class CreateUserCommandValidation : AbstractValidator<CreateUserCo
.WithMessage("FirstName may not be empty")
.MaximumLength(MaxLengths.User.FirstName)
.WithErrorCode(DomainErrorCodes.User.UserFirstNameExceedsMaxLength)
.WithMessage("FirstName may not be longer than 100 characters");
.WithMessage($"FirstName may not be longer than {MaxLengths.User.FirstName} characters");
}
private void AddRuleForLastName()
@ -54,7 +63,7 @@ public sealed class CreateUserCommandValidation : AbstractValidator<CreateUserCo
.WithMessage("LastName may not be empty")
.MaximumLength(MaxLengths.User.LastName)
.WithErrorCode(DomainErrorCodes.User.UserLastNameExceedsMaxLength)
.WithMessage("LastName may not be longer than 100 characters");
.WithMessage($"LastName may not be longer than {MaxLengths.User.LastName} characters");
}
private void AddRuleForPassword()

View File

@ -21,7 +21,7 @@ public sealed class LoginUserCommandValidation : AbstractValidator<LoginUserComm
.WithMessage("Email is not a valid email address")
.MaximumLength(MaxLengths.User.Email)
.WithErrorCode(DomainErrorCodes.User.UserEmailExceedsMaxLength)
.WithMessage("Email may not be longer than 320 characters");
.WithMessage($"Email may not be longer than {MaxLengths.User.Email} characters");
}
private void AddRuleForPassword()

View File

@ -12,16 +12,18 @@ public sealed class UpdateUserCommand : CommandBase
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 Guid TenantId { get; }
public string Email { get; }
public string FirstName { get; }
public string LastName { get; }

View File

@ -38,7 +38,7 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase,
if (user is null)
{
await Bus.RaiseEventAsync(
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is no user with Id {request.UserId}",
@ -63,7 +63,7 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase,
if (existingUser is not null)
{
await Bus.RaiseEventAsync(
await NotifyAsync(
new DomainNotification(
request.MessageType,
$"There is already a user with email {request.Email}",
@ -75,6 +75,10 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase,
if (_user.GetUserRole() == UserRole.Admin)
{
user.SetRole(request.Role);
// Todo: Test
// Todo: Check if tenant exists first
user.SetTenant(request.TenantId);
}
user.SetEmail(request.Email);

View File

@ -9,6 +9,7 @@ public sealed class UpdateUserCommandValidation : AbstractValidator<UpdateUserCo
public UpdateUserCommandValidation()
{
AddRuleForId();
AddRuleForTenantId();
AddRuleForEmail();
AddRuleForFirstName();
AddRuleForLastName();
@ -22,6 +23,14 @@ public sealed class UpdateUserCommandValidation : AbstractValidator<UpdateUserCo
.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()
{
@ -31,7 +40,7 @@ public sealed class UpdateUserCommandValidation : AbstractValidator<UpdateUserCo
.WithMessage("Email is not a valid email address")
.MaximumLength(MaxLengths.User.Email)
.WithErrorCode(DomainErrorCodes.User.UserEmailExceedsMaxLength)
.WithMessage("Email may not be longer than 320 characters");
.WithMessage($"Email may not be longer than {MaxLengths.User.Email} characters");
}
private void AddRuleForFirstName()
@ -42,7 +51,7 @@ public sealed class UpdateUserCommandValidation : AbstractValidator<UpdateUserCo
.WithMessage("FirstName may not be empty")
.MaximumLength(MaxLengths.User.FirstName)
.WithErrorCode(DomainErrorCodes.User.UserFirstNameExceedsMaxLength)
.WithMessage("FirstName may not be longer than 100 characters");
.WithMessage($"FirstName may not be longer than {MaxLengths.User.FirstName} characters");
}
private void AddRuleForLastName()
@ -53,7 +62,7 @@ public sealed class UpdateUserCommandValidation : AbstractValidator<UpdateUserCo
.WithMessage("LastName may not be empty")
.MaximumLength(MaxLengths.User.LastName)
.WithErrorCode(DomainErrorCodes.User.UserLastNameExceedsMaxLength)
.WithMessage("LastName may not be longer than 100 characters");
.WithMessage($"LastName may not be longer than {MaxLengths.User.LastName} characters");
}
private void AddRuleForRole()

View File

@ -1,6 +1,6 @@
namespace CleanArchitecture.Domain.Constants;
public sealed class MaxLengths
public static class MaxLengths
{
public static class User
{

View File

@ -11,7 +11,7 @@ public class User : Entity
public string LastName { get; private set; }
public string Password { get; private set; }
public UserRole Role { get; private set; }
public string FullName => $"{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;
}
}

View File

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

View File

@ -55,7 +55,7 @@ public class BaseRepository<TEntity> : IRepository<TEntity> where TEntity : Enti
DbSet.Update(entity);
}
public async Task<bool> ExistsAsync(Guid id)
public virtual async Task<bool> ExistsAsync(Guid id)
{
return await DbSet.AnyAsync(entity => entity.Id == id);
}

View File

@ -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<TenantTestFixture>
{
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<TenantViewModel>();
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<IEnumerable<TenantViewModel>>();
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<Guid>();
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<TenantViewModel>();
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<UpdateTenantViewModel>();
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<TenantViewModel>();
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);
}
}

View File

@ -25,10 +25,14 @@ public sealed class UserControllerTests : IClassFixture<UserTestFixture>
_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<UserTestFixture>
"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<UserTestFixture>
var content = message!.Data;
content.Should().Be(_fixture.CreatedUserId);
// Todo: Check if stuff is done
}
}

View File

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

View File

@ -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<string>();
ServerClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {message!.Data}");
}
}

View File

@ -11,6 +11,7 @@ public sealed class UserTestFixture : TestFixtureBase
public void EnableAuthentication()
{
ServerClient.DefaultRequestHeaders.Clear();
ServerClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {CreatedUserToken}");
}
}

View File

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

View File

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

View File

@ -26,7 +26,7 @@ public sealed class GetUsersByIdsTests : IClassFixture<GetUsersByIdsTestFixture>
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();

View File

@ -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<Tenant> ExistingTenants { get; }
public TenantTestFixture()
{
TenantRepository = Substitute.For<ITenantRepository>();
ExistingTenants = new List<Tenant>
{
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);
}
}

View File

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

View File

@ -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<TenantTestFixture>
{
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<Guid>()),
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<Guid> ids)
{
var request = new GetTenantsByIdsRequest();
request.Ids.AddRange(ids.Select(id => id.ToString()));
request.Ids.Add("Not a guid");
return request;
}
}

View File

@ -9,11 +9,11 @@ using Xunit;
namespace CleanArchitecture.gRPC.Tests.Users;
public sealed class GetUsersByIdsTests : IClassFixture<UserTestsFixture>
public sealed class GetUsersByIdsTests : IClassFixture<UserTestFixture>
{
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<UserTestsFixture>
{
var result = await _fixture.UsersApiImplementation.GetByIds(
SetupRequest(Enumerable.Empty<Guid>()),
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<UserTestsFixture>
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);

View File

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