From dd4c97600665d7fcb6d3d05527154c7643e981fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20=C5=A0kr=C3=A1=C5=A1ek?= Date: Thu, 25 Apr 2024 00:54:41 +0200 Subject: [PATCH] Team Management - Invitations (#7) * add domain logic for invitation management * add necessary logic to user access module (generating users) * add endpoint tests for invitation management * fix endpoint authorization requirement * refactoring (naming, namespaces, ...) --- .../Errors/EventualConsistencyError.cs | 5 + .../EndpointGroupBuilder.cs | 5 +- .../TeamUp.Notifications.Contracts.csproj | 2 + .../AcceptInvitationCommandHandler.cs | 27 + ...ateInvitationRequestCreatedEventHandler.cs | 70 +++ .../GetMyInvitationsQueryHandler.cs | 35 ++ .../GetTeamInvitationsQueryHandler.cs | 55 ++ .../Invitations/InviteUserCommandHandler.cs | 27 + .../RemoveInvitationCommandHandler.cs | 27 + .../TeamUp.TeamManagement.Application.csproj | 5 +- .../AcceptInvitationCommand.cs | 21 + .../GetMyInvitations/GetMyInvitationsQuery.cs | 9 + .../GetTeamInvitationsQuery.cs | 11 + .../Invitations/InvitationResponse.cs | 8 + .../InviteUser/InviteUserCommand.cs | 24 + .../InviteUser/InviteUserRequest.cs | 13 + .../RemoveInvitationCommand.cs | 21 + .../Invitations/TeamInvitationResponse.cs | 8 + .../Invitations/IInvitationDomainService.cs | 14 + ...nvitationRequestCreatedIntegrationEvent.cs | 11 + .../Invitations/InvitationDomainService.cs | 142 ++++++ .../Invitations/InvitationFactory.cs | 11 +- .../TeamUp.TeamManagement.Domain.csproj | 2 +- ...dpoint.cs => Endpoint.UpsertEventReply.cs} | 0 .../InvitationEndpoints.cs | 13 +- .../Invitations/Endpoint.AcceptInvitaion.cs | 44 ++ .../Invitations/Endpoint.GetMyInvitations.cs | 39 ++ .../Endpoint.GetTeamInvitations.cs | 44 ++ .../Invitations/Endpoint.InviteUser.cs | 48 ++ .../Invitations/Endpoint.RemoveInvitation.cs | 43 ++ .../TeamManagementEndpointGroup.cs | 7 +- .../TeamManagementModule.cs | 7 +- .../GenerateUserCommandHandler.cs | 28 ++ .../RegisterUserCommandHandler.cs | 5 +- .../CreateUser/GenerateUserCommand.cs | 24 + .../CreateUser/RegisterUserCommand.cs | 4 +- .../EventHandlers/UserCreatedEventHandler.cs | 2 +- .../TeamUp.UserAccess.Domain/User.cs | 9 + .../TeamUp.UserAccess.Domain/UserFactory.cs | 10 + .../TeamManagement/InvitationGenerators.cs | 39 ++ .../InvitationTests.AcceptInvitation.cs | 330 ++++++++++++ .../InvitationTests.GetMyInvitations.cs | 98 ++++ .../InvitationTests.GetTeamInvitations.cs | 177 +++++++ .../Invitations/InvitationTests.InviteUser.cs | 472 ++++++++++++++++++ .../InvitationTests.RemoveInvitation.cs | 238 +++++++++ .../Invitations/InvitationTests.cs | 3 + .../TeamUp.Tests.EndToEnd.csproj | 4 - 47 files changed, 2213 insertions(+), 28 deletions(-) create mode 100644 src/Common/TeamUp.Common.Contracts/Errors/EventualConsistencyError.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/AcceptInvitationCommandHandler.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/CreateInvitationRequestCreatedEventHandler.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/GetMyInvitationsQueryHandler.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/GetTeamInvitationsQueryHandler.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/InviteUserCommandHandler.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/RemoveInvitationCommandHandler.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/AcceptInvitation/AcceptInvitationCommand.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/GetMyInvitations/GetMyInvitationsQuery.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/GetTeamInvitations/GetTeamInvitationsQuery.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/InvitationResponse.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/InviteUser/InviteUserCommand.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/InviteUser/InviteUserRequest.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/RemoveInvitation/RemoveInvitationCommand.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/TeamInvitationResponse.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/IInvitationDomainService.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/IntegrationEvents/CreateInvitationRequestCreatedIntegrationEvent.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/InvitationDomainService.cs rename src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Events/{UpsertEventReplyEndpoint.cs => Endpoint.UpsertEventReply.cs} (100%) create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.AcceptInvitaion.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.GetMyInvitations.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.GetTeamInvitations.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.InviteUser.cs create mode 100644 src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.RemoveInvitation.cs create mode 100644 src/Modules/UserAccess/TeamUp.UserAccess.Application/GenerateUserCommandHandler.cs create mode 100644 src/Modules/UserAccess/TeamUp.UserAccess.Contracts/CreateUser/GenerateUserCommand.cs create mode 100644 tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.AcceptInvitation.cs create mode 100644 tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.GetMyInvitations.cs create mode 100644 tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.GetTeamInvitations.cs create mode 100644 tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.InviteUser.cs create mode 100644 tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.RemoveInvitation.cs create mode 100644 tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.cs diff --git a/src/Common/TeamUp.Common.Contracts/Errors/EventualConsistencyError.cs b/src/Common/TeamUp.Common.Contracts/Errors/EventualConsistencyError.cs new file mode 100644 index 0000000..3138027 --- /dev/null +++ b/src/Common/TeamUp.Common.Contracts/Errors/EventualConsistencyError.cs @@ -0,0 +1,5 @@ +using RailwayResult; + +namespace TeamUp.Common.Contracts.Errors; + +public sealed record EventualConsistencyError(string Key, string Message) : Error(Key, Message); diff --git a/src/Common/TeamUp.Common.Endpoints/EndpointGroupBuilder.cs b/src/Common/TeamUp.Common.Endpoints/EndpointGroupBuilder.cs index c7e33b0..1c0ec84 100644 --- a/src/Common/TeamUp.Common.Endpoints/EndpointGroupBuilder.cs +++ b/src/Common/TeamUp.Common.Endpoints/EndpointGroupBuilder.cs @@ -36,7 +36,10 @@ public EndpointGroupBuilder WithTags(params string[] tags) public EndpointGroupBuilder Configure(Func configureGroup) { - Group = configureGroup(Group); + foreach (var group in _subGroups) + { + group.Group = configureGroup(group.Group); + } return this; } diff --git a/src/Modules/Notifications/TeamUp.Notifications.Contracts/TeamUp.Notifications.Contracts.csproj b/src/Modules/Notifications/TeamUp.Notifications.Contracts/TeamUp.Notifications.Contracts.csproj index eeb47fc..cf62791 100644 --- a/src/Modules/Notifications/TeamUp.Notifications.Contracts/TeamUp.Notifications.Contracts.csproj +++ b/src/Modules/Notifications/TeamUp.Notifications.Contracts/TeamUp.Notifications.Contracts.csproj @@ -1,5 +1,7 @@  + + diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/AcceptInvitationCommandHandler.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/AcceptInvitationCommandHandler.cs new file mode 100644 index 0000000..42164b5 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/AcceptInvitationCommandHandler.cs @@ -0,0 +1,27 @@ +using RailwayResult; + +using TeamUp.Common.Application; +using TeamUp.TeamManagement.Contracts; +using TeamUp.TeamManagement.Contracts.Invitations.AcceptInvitation; +using TeamUp.TeamManagement.Domain.Aggregates.Invitations; + +namespace TeamUp.TeamManagement.Application.Invitations; + +internal sealed class AcceptInvitationCommandHandler : ICommandHandler +{ + private readonly IInvitationDomainService _invitationDomainService; + private readonly IUnitOfWork _unitOfWork; + + public AcceptInvitationCommandHandler(IInvitationDomainService invitationDomainService, IUnitOfWork unitOfWork) + { + _invitationDomainService = invitationDomainService; + _unitOfWork = unitOfWork; + } + + public async Task Handle(AcceptInvitationCommand command, CancellationToken ct) + { + return await _invitationDomainService + .AcceptInvitationAsync(command.InitiatorId, command.InvitationId, ct) + .TapAsync(() => _unitOfWork.SaveChangesAsync(ct)); + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/CreateInvitationRequestCreatedEventHandler.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/CreateInvitationRequestCreatedEventHandler.cs new file mode 100644 index 0000000..4e71b29 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/CreateInvitationRequestCreatedEventHandler.cs @@ -0,0 +1,70 @@ +using Microsoft.Extensions.Logging; + +using RailwayResult; +using RailwayResult.Errors; +using RailwayResult.FunctionalExtensions; + +using TeamUp.Common.Application; +using TeamUp.Common.Contracts.Errors; +using TeamUp.Common.Domain; +using TeamUp.Notifications.Contracts; +using TeamUp.TeamManagement.Contracts; +using TeamUp.TeamManagement.Domain.Aggregates.Invitations; +using TeamUp.TeamManagement.Domain.Aggregates.Invitations.IntegrationEvents; +using TeamUp.TeamManagement.Domain.Aggregates.Users; + +namespace TeamUp.TeamManagement.Application.Invitations; + +internal sealed class CreateInvitationRequestCreatedEventHandler : IIntegrationEventHandler +{ + private readonly IUserRepository _userRepository; + private readonly ILogger _logger; + private readonly InvitationFactory _invitationFactory; + private readonly IUnitOfWork _unitOfWork; + private readonly IIntegrationEventPublisher _publisher; + + public CreateInvitationRequestCreatedEventHandler(IUserRepository userRepository, ILogger logger, InvitationFactory invitationFactory, IUnitOfWork unitOfWork, IIntegrationEventPublisher publisher) + { + _userRepository = userRepository; + _logger = logger; + _invitationFactory = invitationFactory; + _unitOfWork = unitOfWork; + _publisher = publisher; + } + + public async Task Handle(CreateInvitationRequestCreatedIntegrationEvent integrationEvent, CancellationToken ct) + { + var user = await _userRepository.GetUserByIdAsync(integrationEvent.UserId, ct); + if (user is null) + { + _logger.LogWarning("User {userId} is not yet created for inviting to team {teamId}.", integrationEvent.UserId, integrationEvent.TeamId); + return new EventualConsistencyError("TeamManagement.Users.NotFound", "User not found when inviting."); + } + + var result = await _invitationFactory + .CreateAndAddInvitationAsync(user.Id, integrationEvent.TeamId, ct) + .Tap(invitation => + { + var message = new EmailCreatedIntegrationEvent + { + Email = user.Email, + Subject = "You were invited to team!", + Message = $"Accept invitation link for {invitation.Id}." + }; + + _publisher.Publish(message); + }) + .ThenAsync(_ => _unitOfWork.SaveChangesAsync(ct)); + + if (result.IsFailure) + { + if (result.Error is ConflictError error) + { + _logger.LogWarning("Invitation of user {userId} to team {teamId} probably already exist. {error}", integrationEvent.UserId, integrationEvent.TeamId, error); + return Result.Success; + } + } + + return result; + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/GetMyInvitationsQueryHandler.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/GetMyInvitationsQueryHandler.cs new file mode 100644 index 0000000..86480e1 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/GetMyInvitationsQueryHandler.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore; + +using RailwayResult; + +using TeamUp.Common.Application; +using TeamUp.Common.Contracts; +using TeamUp.TeamManagement.Contracts.Invitations; +using TeamUp.TeamManagement.Contracts.Invitations.GetMyInvitations; + +namespace TeamUp.TeamManagement.Application.Invitations; + +internal sealed class GetMyInvitationsQueryHandler : IQueryHandler> +{ + private readonly ITeamManagementQueryContext _appQueryContext; + + public GetMyInvitationsQueryHandler(ITeamManagementQueryContext appQueryContext) + { + _appQueryContext = appQueryContext; + } + + public async Task>> Handle(GetMyInvitationsQuery query, CancellationToken ct) + { + var invitations = await _appQueryContext.Invitations + .Where(invitation => invitation.RecipientId == query.InitiatorId) + .Select(invitation => new InvitationResponse + { + Id = invitation.Id, + TeamName = _appQueryContext.Teams.First(team => team.Id == invitation.TeamId).Name, + CreatedUtc = invitation.CreatedUtc + }) + .ToListAsync(ct); + + return new Collection(invitations); + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/GetTeamInvitationsQueryHandler.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/GetTeamInvitationsQueryHandler.cs new file mode 100644 index 0000000..5af6cd3 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/GetTeamInvitationsQueryHandler.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; + +using RailwayResult; +using RailwayResult.FunctionalExtensions; + +using TeamUp.Common.Application; +using TeamUp.Common.Contracts; +using TeamUp.TeamManagement.Contracts.Invitations; +using TeamUp.TeamManagement.Contracts.Invitations.GetTeamInvitations; +using TeamUp.TeamManagement.Domain.Aggregates.Teams; + +namespace TeamUp.TeamManagement.Application.Invitations; + +internal sealed class GetTeamInvitationsQueryHandler : IQueryHandler> +{ + private readonly ITeamManagementQueryContext _appQueryContext; + + public GetTeamInvitationsQueryHandler(ITeamManagementQueryContext appQueryContext) + { + _appQueryContext = appQueryContext; + } + + public async Task>> Handle(GetTeamInvitationsQuery query, CancellationToken ct) + { + var teamWithInitiator = await _appQueryContext.Teams + .Select(team => new + { + team.Id, + Initiaotor = team.Members + .Select(member => new { member.UserId, member.Role }) + .FirstOrDefault(member => member.UserId == query.InitiatorId), + }) + .FirstOrDefaultAsync(team => team.Id == query.TeamId, ct); + + return await teamWithInitiator + .EnsureNotNull(TeamErrors.TeamNotFound) + .EnsureNotNull(team => team.Initiaotor, TeamErrors.NotMemberOfTeam) + .Ensure(team => team.Initiaotor!.Role.CanInviteTeamMembers(), TeamErrors.UnauthorizedToReadInvitationList) + .ThenAsync(team => + { + return _appQueryContext.Invitations + .Where(invitation => invitation.TeamId == query.TeamId) + .Select(invitation => new TeamInvitationResponse + { + Id = invitation.Id, + Email = _appQueryContext.Users + .Select(user => new { user.Id, user.Email }) + .First(user => user.Id == invitation.RecipientId).Email, + CreatedUtc = invitation.CreatedUtc + }) + .ToListAsync(ct); + }) + .Then(invitations => new Collection(invitations)); + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/InviteUserCommandHandler.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/InviteUserCommandHandler.cs new file mode 100644 index 0000000..a356175 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/InviteUserCommandHandler.cs @@ -0,0 +1,27 @@ +using RailwayResult; + +using TeamUp.Common.Application; +using TeamUp.TeamManagement.Contracts; +using TeamUp.TeamManagement.Contracts.Invitations.InviteUser; +using TeamUp.TeamManagement.Domain.Aggregates.Invitations; + +namespace TeamUp.TeamManagement.Application.Invitations; + +internal sealed class InviteUserCommandHandler : ICommandHandler +{ + private readonly IInvitationDomainService _invitationDomainService; + private readonly IUnitOfWork _unitOfWork; + + public InviteUserCommandHandler(IInvitationDomainService invitationDomainService, IUnitOfWork unitOfWork) + { + _invitationDomainService = invitationDomainService; + _unitOfWork = unitOfWork; + } + + public Task Handle(InviteUserCommand command, CancellationToken ct) + { + return _invitationDomainService + .InviteUserAsync(command.InitiatorId, command.TeamId, command.Email, ct) + .TapAsync(() => _unitOfWork.SaveChangesAsync(ct)); + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/RemoveInvitationCommandHandler.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/RemoveInvitationCommandHandler.cs new file mode 100644 index 0000000..662c141 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/Invitations/RemoveInvitationCommandHandler.cs @@ -0,0 +1,27 @@ +using RailwayResult; + +using TeamUp.Common.Application; +using TeamUp.TeamManagement.Contracts; +using TeamUp.TeamManagement.Contracts.Invitations.RemoveInvitation; +using TeamUp.TeamManagement.Domain.Aggregates.Invitations; + +namespace TeamUp.TeamManagement.Application.Invitations; + +internal sealed class RemoveInvitationCommandHandler : ICommandHandler +{ + private readonly IInvitationDomainService _invitationDomainService; + private readonly IUnitOfWork _unitOfWork; + + public RemoveInvitationCommandHandler(IInvitationDomainService invitationDomainService, IUnitOfWork unitOfWork) + { + _invitationDomainService = invitationDomainService; + _unitOfWork = unitOfWork; + } + + public Task Handle(RemoveInvitationCommand command, CancellationToken ct) + { + return _invitationDomainService + .RemoveInvitationAsync(command.InitiatorId, command.InvitationId, ct) + .TapAsync(() => _unitOfWork.SaveChangesAsync(ct)); + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/TeamUp.TeamManagement.Application.csproj b/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/TeamUp.TeamManagement.Application.csproj index 2f0df3f..7ae5980 100644 --- a/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/TeamUp.TeamManagement.Application.csproj +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Application/TeamUp.TeamManagement.Application.csproj @@ -1,11 +1,8 @@  - - - - + diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/AcceptInvitation/AcceptInvitationCommand.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/AcceptInvitation/AcceptInvitationCommand.cs new file mode 100644 index 0000000..d6b3198 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/AcceptInvitation/AcceptInvitationCommand.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +using TeamUp.Common.Contracts; +using TeamUp.UserAccess.Contracts; + +namespace TeamUp.TeamManagement.Contracts.Invitations.AcceptInvitation; + +public sealed record AcceptInvitationCommand : ICommand +{ + public required UserId InitiatorId { get; init; } + public required InvitationId InvitationId { get; init; } + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.InitiatorId).NotEmpty(); + RuleFor(x => x.InvitationId).NotEmpty(); + } + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/GetMyInvitations/GetMyInvitationsQuery.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/GetMyInvitations/GetMyInvitationsQuery.cs new file mode 100644 index 0000000..514dbd5 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/GetMyInvitations/GetMyInvitationsQuery.cs @@ -0,0 +1,9 @@ +using TeamUp.Common.Contracts; +using TeamUp.UserAccess.Contracts; + +namespace TeamUp.TeamManagement.Contracts.Invitations.GetMyInvitations; + +public sealed record GetMyInvitationsQuery : IQuery> +{ + public required UserId InitiatorId { get; init; } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/GetTeamInvitations/GetTeamInvitationsQuery.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/GetTeamInvitations/GetTeamInvitationsQuery.cs new file mode 100644 index 0000000..abd9d64 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/GetTeamInvitations/GetTeamInvitationsQuery.cs @@ -0,0 +1,11 @@ +using TeamUp.Common.Contracts; +using TeamUp.TeamManagement.Contracts.Teams; +using TeamUp.UserAccess.Contracts; + +namespace TeamUp.TeamManagement.Contracts.Invitations.GetTeamInvitations; + +public sealed record GetTeamInvitationsQuery : IQuery> +{ + public required UserId InitiatorId { get; init; } + public required TeamId TeamId { get; init; } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/InvitationResponse.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/InvitationResponse.cs new file mode 100644 index 0000000..796900f --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/InvitationResponse.cs @@ -0,0 +1,8 @@ +namespace TeamUp.TeamManagement.Contracts.Invitations; + +public sealed class InvitationResponse +{ + public required InvitationId Id { get; init; } + public required string TeamName { get; init; } + public required DateTime CreatedUtc { get; init; } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/InviteUser/InviteUserCommand.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/InviteUser/InviteUserCommand.cs new file mode 100644 index 0000000..4acff85 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/InviteUser/InviteUserCommand.cs @@ -0,0 +1,24 @@ +using FluentValidation; + +using TeamUp.Common.Contracts; +using TeamUp.TeamManagement.Contracts.Teams; +using TeamUp.UserAccess.Contracts; + +namespace TeamUp.TeamManagement.Contracts.Invitations.InviteUser; + +public sealed record InviteUserCommand : ICommand +{ + public required UserId InitiatorId { get; init; } + public required TeamId TeamId { get; init; } + public required string Email { get; init; } + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.InitiatorId).NotEmpty(); + RuleFor(x => x.TeamId).NotEmpty(); + RuleFor(x => x.Email).EmailAddress(); + } + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/InviteUser/InviteUserRequest.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/InviteUser/InviteUserRequest.cs new file mode 100644 index 0000000..a742af4 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/InviteUser/InviteUserRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +using TeamUp.TeamManagement.Contracts.Teams; + +namespace TeamUp.TeamManagement.Contracts.Invitations.InviteUser; + +public sealed class InviteUserRequest +{ + public required TeamId TeamId { get; init; } + + [DataType(DataType.EmailAddress)] + public required string Email { get; init; } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/RemoveInvitation/RemoveInvitationCommand.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/RemoveInvitation/RemoveInvitationCommand.cs new file mode 100644 index 0000000..180495d --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/RemoveInvitation/RemoveInvitationCommand.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +using TeamUp.Common.Contracts; +using TeamUp.UserAccess.Contracts; + +namespace TeamUp.TeamManagement.Contracts.Invitations.RemoveInvitation; + +public sealed record RemoveInvitationCommand : ICommand +{ + public required UserId InitiatorId { get; init; } + public required InvitationId InvitationId { get; init; } + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.InitiatorId).NotEmpty(); + RuleFor(x => x.InvitationId).NotEmpty(); + } + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/TeamInvitationResponse.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/TeamInvitationResponse.cs new file mode 100644 index 0000000..b824d0d --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Contracts/Invitations/TeamInvitationResponse.cs @@ -0,0 +1,8 @@ +namespace TeamUp.TeamManagement.Contracts.Invitations; + +public sealed class TeamInvitationResponse +{ + public required InvitationId Id { get; init; } + public required string Email { get; init; } + public required DateTime CreatedUtc { get; init; } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/IInvitationDomainService.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/IInvitationDomainService.cs new file mode 100644 index 0000000..28bfbe5 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/IInvitationDomainService.cs @@ -0,0 +1,14 @@ +using RailwayResult; + +using TeamUp.TeamManagement.Contracts.Invitations; +using TeamUp.TeamManagement.Contracts.Teams; +using TeamUp.UserAccess.Contracts; + +namespace TeamUp.TeamManagement.Domain.Aggregates.Invitations; + +public interface IInvitationDomainService +{ + public Task InviteUserAsync(UserId initiatorId, TeamId teamId, string email, CancellationToken ct = default); + public Task RemoveInvitationAsync(UserId initiatorId, InvitationId invitationId, CancellationToken ct = default); + public Task AcceptInvitationAsync(UserId initiatorId, InvitationId invitationId, CancellationToken ct = default); +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/IntegrationEvents/CreateInvitationRequestCreatedIntegrationEvent.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/IntegrationEvents/CreateInvitationRequestCreatedIntegrationEvent.cs new file mode 100644 index 0000000..350cbdf --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/IntegrationEvents/CreateInvitationRequestCreatedIntegrationEvent.cs @@ -0,0 +1,11 @@ +using TeamUp.Common.Contracts; +using TeamUp.TeamManagement.Contracts.Teams; +using TeamUp.UserAccess.Contracts; + +namespace TeamUp.TeamManagement.Domain.Aggregates.Invitations.IntegrationEvents; + +public sealed record CreateInvitationRequestCreatedIntegrationEvent : IIntegrationEvent +{ + public required UserId UserId { get; init; } + public required TeamId TeamId { get; init; } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/InvitationDomainService.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/InvitationDomainService.cs new file mode 100644 index 0000000..9b7c04e --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/InvitationDomainService.cs @@ -0,0 +1,142 @@ +using MassTransit; + +using RailwayResult; +using RailwayResult.FunctionalExtensions; + +using TeamUp.Common.Contracts; +using TeamUp.Common.Domain; +using TeamUp.TeamManagement.Contracts; +using TeamUp.TeamManagement.Contracts.Invitations; +using TeamUp.TeamManagement.Contracts.Teams; +using TeamUp.TeamManagement.Domain.Aggregates.Invitations.IntegrationEvents; +using TeamUp.TeamManagement.Domain.Aggregates.Teams; +using TeamUp.TeamManagement.Domain.Aggregates.Users; +using TeamUp.UserAccess.Contracts; +using TeamUp.UserAccess.Contracts.CreateUser; + +namespace TeamUp.TeamManagement.Domain.Aggregates.Invitations; + +internal sealed class InvitationDomainService : IInvitationDomainService +{ + private readonly IUserRepository _userRepository; + private readonly ITeamRepository _teamRepository; + private readonly IInvitationRepository _invitationRepository; + private readonly IIntegrationEventPublisher _publisher; + private readonly IRequestClient _client; + private readonly IDateTimeProvider _dateTimeProvider; + + public InvitationDomainService( + IUserRepository userRepository, + ITeamRepository teamRepository, + IInvitationRepository invitationRepository, + IIntegrationEventPublisher publisher, + IRequestClient client, + IDateTimeProvider dateTimeProvider) + { + _userRepository = userRepository; + _teamRepository = teamRepository; + _invitationRepository = invitationRepository; + _publisher = publisher; + _client = client; + _dateTimeProvider = dateTimeProvider; + } + + public async Task AcceptInvitationAsync(UserId initiatorId, InvitationId invitationId, CancellationToken ct = default) + { + var invitation = await _invitationRepository.GetInvitationByIdAsync(invitationId, ct); + return await invitation + .EnsureNotNull(InvitationErrors.InvitationNotFound) + .Ensure(invitation => invitation.RecipientId == initiatorId, InvitationErrors.UnauthorizedToAcceptInvitation) + .Ensure(invitation => !invitation.HasExpired(_dateTimeProvider.UtcNow), InvitationErrors.InvitationExpired) + .AndAsync(invitation => _teamRepository.GetTeamByIdAsync(invitation.TeamId, ct)) + .EnsureSecondNotNull(TeamErrors.TeamNotFound) + .ThenAsync(async (invitation, team) => + { + return await team + .Ensure(TeamRules.TeamHasNotReachedCapacity) + .ThenAsync(_ => _userRepository.GetUserByIdAsync(invitation.RecipientId)) + .EnsureNotNull(UserErrors.UserNotFound) + .Tap(user => + { + team.AddTeamMember(user, _dateTimeProvider); + _invitationRepository.RemoveInvitation(invitation); + }); + }) + .ToResultAsync(); + } + + public async Task InviteUserAsync(UserId initiatorId, TeamId teamId, string email, CancellationToken ct = default) + { + var team = await _teamRepository.GetTeamByIdAsync(teamId, ct); + return await team + .EnsureNotNull(TeamErrors.TeamNotFound) + .Ensure(TeamRules.TeamHasNotReachedCapacity) + .And(team => team.GetTeamMemberByUserId(initiatorId)) + .Ensure((_, member) => member.Role.CanInviteTeamMembers(), TeamErrors.UnauthorizedToInviteTeamMembers) + .Then((team, _) => team) + .AndAsync(team => _userRepository.GetUserByEmailAsync(email, ct)) + .Ensure(TeamRules.InvitedUserIsNotTeamMember) + .ThenAsync(async (team, user) => + { + //generate user if user doesn't exist + if (user is null) + { + return await GenerateUserAsync(email, ct) + .Then(userId => (team, userId)); + } + + //check for whether user is already invited to the same team + var conflictingInvitationExists = await _invitationRepository.ExistsInvitationForUserToTeamAsync(user.Id, teamId, ct); + if (conflictingInvitationExists) + { + return InvitationErrors.UserIsAlreadyInvited; + } + + return (team, user.Id); + }) + .Tap((team, userId) => + { + var message = new CreateInvitationRequestCreatedIntegrationEvent + { + TeamId = team.Id, + UserId = userId + }; + + _publisher.Publish(message); + }) + .ToResultAsync(); + } + + private async Task> GenerateUserAsync(string email, CancellationToken ct) + { + var message = new GenerateUserCommand + { + Email = email, + Name = email + }; + + var response = await _client.GetResponse>(message, ct); + return response.Message; + } + + public async Task RemoveInvitationAsync(UserId initiatorId, InvitationId invitationId, CancellationToken ct = default) + { + var invitation = await _invitationRepository.GetInvitationByIdAsync(invitationId, ct); + return await invitation + .EnsureNotNull(InvitationErrors.InvitationNotFound) + .ThenAsync(async invitation => + { + if (invitation.RecipientId == initiatorId) + return invitation; + + var team = await _teamRepository.GetTeamByIdAsync(invitation.TeamId, ct); + return team + .EnsureNotNull(TeamErrors.NotMemberOfTeam) + .Then(team => team.GetTeamMemberByUserId(initiatorId)) + .Ensure(member => member.Role.CanInviteTeamMembers(), TeamErrors.UnauthorizedToCancelInvitations) + .Then(_ => invitation); + }) + .Tap(_invitationRepository.RemoveInvitation) + .ToResultAsync(); + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/InvitationFactory.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/InvitationFactory.cs index e750c1c..0480546 100644 --- a/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/InvitationFactory.cs +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/Aggregates/Invitations/InvitationFactory.cs @@ -7,10 +7,10 @@ namespace TeamUp.TeamManagement.Domain.Aggregates.Invitations; -internal sealed class InvitationFactory +public sealed class InvitationFactory { - private readonly IInvitationRepository _invitationRepository; private readonly IDateTimeProvider _dateTimeProvider; + private readonly IInvitationRepository _invitationRepository; public InvitationFactory(IInvitationRepository invitationRepository, IDateTimeProvider dateTimeProvider) { @@ -18,13 +18,16 @@ public InvitationFactory(IInvitationRepository invitationRepository, IDateTimePr _dateTimeProvider = dateTimeProvider; } - public async Task> CreateInvitationAsync(UserId userId, TeamId teamId, CancellationToken ct) + public async Task> CreateAndAddInvitationAsync(UserId userId, TeamId teamId, CancellationToken ct) { if (await _invitationRepository.ExistsInvitationForUserToTeamAsync(userId, teamId, ct)) { return InvitationErrors.UserIsAlreadyInvited; } - return new Invitation(InvitationId.New(), userId, teamId, _dateTimeProvider.UtcNow); + var invitation = new Invitation(InvitationId.New(), userId, teamId, _dateTimeProvider.UtcNow); + _invitationRepository.AddInvitation(invitation); + + return invitation; } } diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/TeamUp.TeamManagement.Domain.csproj b/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/TeamUp.TeamManagement.Domain.csproj index ef013cd..d6da413 100644 --- a/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/TeamUp.TeamManagement.Domain.csproj +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Domain/TeamUp.TeamManagement.Domain.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Events/UpsertEventReplyEndpoint.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Events/Endpoint.UpsertEventReply.cs similarity index 100% rename from src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Events/UpsertEventReplyEndpoint.cs rename to src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Events/Endpoint.UpsertEventReply.cs diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/InvitationEndpoints.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/InvitationEndpoints.cs index 2579a9c..4f97522 100644 --- a/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/InvitationEndpoints.cs +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/InvitationEndpoints.cs @@ -1,11 +1,20 @@ using TeamUp.Common.Endpoints; +using TeamUp.TeamManagement.Endpoints.Invitations; namespace TeamUp.TeamManagement.Endpoints; -public sealed class InvitationEndpoints : IEndpointGroup +internal sealed class InvitationEndpoints : IEndpointGroup { public EndpointGroupBuilder MapEndpoints(EndpointGroupBuilder group) { - return group; + return group.CreateGroup("invitations", group => + { + group + .AddEndpoint() + .AddEndpoint() + .AddEndpoint() + .AddEndpoint() + .AddEndpoint(); + }); } } diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.AcceptInvitaion.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.AcceptInvitaion.cs new file mode 100644 index 0000000..8e22eba --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.AcceptInvitaion.cs @@ -0,0 +1,44 @@ +using MediatR; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +using TeamUp.Common.Endpoints; +using TeamUp.TeamManagement.Contracts.Invitations; +using TeamUp.TeamManagement.Contracts.Invitations.AcceptInvitation; +using TeamUp.UserAccess.Contracts; + +namespace TeamUp.TeamManagement.Endpoints.Invitations; + +public sealed class AcceptInvitationEndpoint : IEndpoint +{ + public void MapEndpoint(RouteGroupBuilder group) + { + group.MapPost("/{invitationId:guid}/accept", AcceptInvitationAsync) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status400BadRequest) + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithName(nameof(AcceptInvitationEndpoint)) + .MapToApiVersion(1); + } + + private async Task AcceptInvitationAsync( + [FromRoute] Guid invitationId, + [FromServices] ISender sender, + HttpContext httpContext, + CancellationToken ct) + { + var command = new AcceptInvitationCommand + { + InitiatorId = httpContext.GetCurrentUserId(), + InvitationId = InvitationId.FromGuid(invitationId) + }; + + var result = await sender.Send(command, ct); + return result.ToResponse(TypedResults.Ok); + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.GetMyInvitations.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.GetMyInvitations.cs new file mode 100644 index 0000000..3cdf39f --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.GetMyInvitations.cs @@ -0,0 +1,39 @@ +using MediatR; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +using TeamUp.Common.Endpoints; +using TeamUp.TeamManagement.Contracts.Invitations; +using TeamUp.TeamManagement.Contracts.Invitations.GetMyInvitations; +using TeamUp.UserAccess.Contracts; + +namespace TeamUp.TeamManagement.Endpoints.Invitations; + +public sealed class GetMyInvitationsEndpoint : IEndpoint +{ + public void MapEndpoint(RouteGroupBuilder group) + { + group.MapGet("/", GetTeamInvitationsAsync) + .Produces>(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status401Unauthorized) + .WithName(nameof(GetMyInvitationsEndpoint)) + .MapToApiVersion(1); + } + + private async Task GetTeamInvitationsAsync( + [FromServices] ISender sender, + HttpContext httpContext, + CancellationToken ct) + { + var query = new GetMyInvitationsQuery + { + InitiatorId = httpContext.GetCurrentUserId() + }; + + var result = await sender.Send(query, ct); + return result.ToResponse(TypedResults.Ok); + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.GetTeamInvitations.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.GetTeamInvitations.cs new file mode 100644 index 0000000..8e3a6a1 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.GetTeamInvitations.cs @@ -0,0 +1,44 @@ +using MediatR; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +using TeamUp.Common.Endpoints; +using TeamUp.TeamManagement.Contracts.Invitations; +using TeamUp.TeamManagement.Contracts.Invitations.GetTeamInvitations; +using TeamUp.TeamManagement.Contracts.Teams; +using TeamUp.UserAccess.Contracts; + +namespace TeamUp.TeamManagement.Endpoints.Invitations; + +public sealed class GetTeamInvitationsEndpoint : IEndpoint +{ + public void MapEndpoint(RouteGroupBuilder group) + { + group.MapGet("/teams/{teamId:guid}", GetTeamInvitationsAsync) + .Produces>(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithName(nameof(GetTeamInvitationsEndpoint)) + .MapToApiVersion(1); + } + + private async Task GetTeamInvitationsAsync( + [FromRoute] Guid teamId, + [FromServices] ISender sender, + HttpContext httpContext, + CancellationToken ct) + { + var query = new GetTeamInvitationsQuery + { + InitiatorId = httpContext.GetCurrentUserId(), + TeamId = TeamId.FromGuid(teamId) + }; + + var result = await sender.Send(query, ct); + return result.ToResponse(TypedResults.Ok); + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.InviteUser.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.InviteUser.cs new file mode 100644 index 0000000..97a0175 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.InviteUser.cs @@ -0,0 +1,48 @@ +using MediatR; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +using TeamUp.Common.Endpoints; +using TeamUp.TeamManagement.Contracts.Invitations; +using TeamUp.TeamManagement.Contracts.Invitations.InviteUser; +using TeamUp.UserAccess.Contracts; + +namespace TeamUp.TeamManagement.Endpoints.Invitations; + +public sealed class InviteUserEndpoint : IEndpoint +{ + public void MapEndpoint(RouteGroupBuilder group) + { + group.MapPost("/", InviteUserAsync) + .Produces(StatusCodes.Status201Created) + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound) + .ProducesValidationProblem() + .WithName(nameof(InviteUserEndpoint)) + .MapToApiVersion(1); + } + + private async Task InviteUserAsync( + [FromBody] InviteUserRequest request, + [FromServices] ISender sender, + [FromServices] LinkGenerator linkGenerator, + HttpContext httpContext, + CancellationToken ct) + { + var command = new InviteUserCommand + { + InitiatorId = httpContext.GetCurrentUserId(), + TeamId = request.TeamId, + Email = request.Email + }; + + var result = await sender.Send(command, ct); + return result.ToResponse(() => TypedResults.Accepted( + uri: linkGenerator.GetPathByName(httpContext, nameof(GetTeamInvitationsEndpoint), request.TeamId.Value) + )); + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.RemoveInvitation.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.RemoveInvitation.cs new file mode 100644 index 0000000..8d1e125 --- /dev/null +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/Invitations/Endpoint.RemoveInvitation.cs @@ -0,0 +1,43 @@ +using MediatR; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +using TeamUp.Common.Endpoints; +using TeamUp.TeamManagement.Contracts.Invitations; +using TeamUp.TeamManagement.Contracts.Invitations.RemoveInvitation; +using TeamUp.UserAccess.Contracts; + +namespace TeamUp.TeamManagement.Endpoints.Invitations; + +public sealed class RemoveInvitationEndpoint : IEndpoint +{ + public void MapEndpoint(RouteGroupBuilder group) + { + group.MapDelete("/{invitationId:guid}", RemoveInvitationAsync) + .Produces(StatusCodes.Status200OK) + .ProducesProblem(StatusCodes.Status401Unauthorized) + .ProducesProblem(StatusCodes.Status403Forbidden) + .ProducesProblem(StatusCodes.Status404NotFound) + .WithName(nameof(RemoveInvitationEndpoint)) + .MapToApiVersion(1); + } + + private async Task RemoveInvitationAsync( + [FromRoute] Guid invitationId, + [FromServices] ISender sender, + HttpContext httpContext, + CancellationToken ct) + { + var command = new RemoveInvitationCommand + { + InitiatorId = httpContext.GetCurrentUserId(), + InvitationId = InvitationId.FromGuid(invitationId) + }; + + var result = await sender.Send(command, ct); + return result.ToResponse(TypedResults.Ok); + } +} diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/TeamManagementEndpointGroup.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/TeamManagementEndpointGroup.cs index 3dcd3b9..4a9f0a2 100644 --- a/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/TeamManagementEndpointGroup.cs +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Endpoints/TeamManagementEndpointGroup.cs @@ -1,4 +1,6 @@ -using TeamUp.Common.Endpoints; +using Microsoft.AspNetCore.Builder; + +using TeamUp.Common.Endpoints; namespace TeamUp.TeamManagement.Endpoints; @@ -8,6 +10,7 @@ public EndpointGroupBuilder MapEndpoints(EndpointGroupBuilder group) { return group .MapGroup() - .MapGroup(); + .MapGroup() + .Configure(group => group.RequireAuthorization()); } } diff --git a/src/Modules/TeamManagement/TeamUp.TeamManagement.Infrastructure/TeamManagementModule.cs b/src/Modules/TeamManagement/TeamUp.TeamManagement.Infrastructure/TeamManagementModule.cs index df0e82e..09536b7 100644 --- a/src/Modules/TeamManagement/TeamUp.TeamManagement.Infrastructure/TeamManagementModule.cs +++ b/src/Modules/TeamManagement/TeamUp.TeamManagement.Infrastructure/TeamManagementModule.cs @@ -34,12 +34,13 @@ public sealed class TeamManagementModule : ModuleWithEndpoints() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); } } diff --git a/src/Modules/UserAccess/TeamUp.UserAccess.Application/GenerateUserCommandHandler.cs b/src/Modules/UserAccess/TeamUp.UserAccess.Application/GenerateUserCommandHandler.cs new file mode 100644 index 0000000..1b10d4d --- /dev/null +++ b/src/Modules/UserAccess/TeamUp.UserAccess.Application/GenerateUserCommandHandler.cs @@ -0,0 +1,28 @@ +using TeamUp.Common.Application; +using TeamUp.UserAccess.Contracts; +using TeamUp.UserAccess.Contracts.CreateUser; +using TeamUp.UserAccess.Domain; + +namespace TeamUp.UserAccess.Application; + +internal sealed class GenerateUserCommandHandler : ICommandHandler +{ + private readonly UserFactory _userFactory; + private readonly IUnitOfWork _unitOfWork; + + public GenerateUserCommandHandler( + UserFactory userFactory, + IUnitOfWork unitOfWork) + { + _userFactory = userFactory; + _unitOfWork = unitOfWork; + } + + public async Task> Handle(GenerateUserCommand command, CancellationToken ct) + { + return await _userFactory + .GenerateAndAddUserAsync(command.Name, command.Email, ct) + .Then(user => user.Id) + .TapAsync(_ => _unitOfWork.SaveChangesAsync(ct)); + } +} diff --git a/src/Modules/UserAccess/TeamUp.UserAccess.Application/RegisterUserCommandHandler.cs b/src/Modules/UserAccess/TeamUp.UserAccess.Application/RegisterUserCommandHandler.cs index 34b9ade..eed692c 100644 --- a/src/Modules/UserAccess/TeamUp.UserAccess.Application/RegisterUserCommandHandler.cs +++ b/src/Modules/UserAccess/TeamUp.UserAccess.Application/RegisterUserCommandHandler.cs @@ -25,9 +25,8 @@ public RegisterUserCommandHandler( public async Task> Handle(RegisterUserCommand command, CancellationToken ct) { var password = _passwordService.HashPassword(command.Password); - var user = await _userFactory.CreateAndAddUserAsync(command.Name, command.Email, password, ct); - - return await user + return await _userFactory + .CreateAndAddUserAsync(command.Name, command.Email, password, ct) .Then(user => user.Id) .TapAsync(_ => _unitOfWork.SaveChangesAsync(ct)); } diff --git a/src/Modules/UserAccess/TeamUp.UserAccess.Contracts/CreateUser/GenerateUserCommand.cs b/src/Modules/UserAccess/TeamUp.UserAccess.Contracts/CreateUser/GenerateUserCommand.cs new file mode 100644 index 0000000..e40d1de --- /dev/null +++ b/src/Modules/UserAccess/TeamUp.UserAccess.Contracts/CreateUser/GenerateUserCommand.cs @@ -0,0 +1,24 @@ +using FluentValidation; + +using TeamUp.Common.Contracts; + +namespace TeamUp.UserAccess.Contracts.CreateUser; + +public sealed record GenerateUserCommand : ICommand +{ + public required string Name { get; init; } + public required string Email { get; init; } + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Name) + .NotEmpty() + .MinimumLength(UserConstants.USERNAME_MIN_SIZE) + .MaximumLength(UserConstants.USERNAME_MAX_SIZE); + + RuleFor(x => x.Email).EmailAddress(); + } + } +} diff --git a/src/Modules/UserAccess/TeamUp.UserAccess.Contracts/CreateUser/RegisterUserCommand.cs b/src/Modules/UserAccess/TeamUp.UserAccess.Contracts/CreateUser/RegisterUserCommand.cs index a7444dd..3245084 100644 --- a/src/Modules/UserAccess/TeamUp.UserAccess.Contracts/CreateUser/RegisterUserCommand.cs +++ b/src/Modules/UserAccess/TeamUp.UserAccess.Contracts/CreateUser/RegisterUserCommand.cs @@ -19,9 +19,7 @@ public Validator() .MinimumLength(UserConstants.USERNAME_MIN_SIZE) .MaximumLength(UserConstants.USERNAME_MAX_SIZE); - RuleFor(x => x.Email) - .NotEmpty() - .EmailAddress(); + RuleFor(x => x.Email).EmailAddress(); //TODO: configure password requirements RuleFor(x => x.Password) diff --git a/src/Modules/UserAccess/TeamUp.UserAccess.Domain/EventHandlers/UserCreatedEventHandler.cs b/src/Modules/UserAccess/TeamUp.UserAccess.Domain/EventHandlers/UserCreatedEventHandler.cs index 66787f2..c4cc696 100644 --- a/src/Modules/UserAccess/TeamUp.UserAccess.Domain/EventHandlers/UserCreatedEventHandler.cs +++ b/src/Modules/UserAccess/TeamUp.UserAccess.Domain/EventHandlers/UserCreatedEventHandler.cs @@ -21,7 +21,7 @@ public Task Handle(UserCreatedDomainEvent domainEvent, CancellationToken ct) { UserId = domainEvent.User.Id, Email = domainEvent.User.Email, - Name = domainEvent.User.Name + Name = domainEvent.User.Name, }; _publisher.Publish(userCrated); diff --git a/src/Modules/UserAccess/TeamUp.UserAccess.Domain/User.cs b/src/Modules/UserAccess/TeamUp.UserAccess.Domain/User.cs index d6d6a75..6199de8 100644 --- a/src/Modules/UserAccess/TeamUp.UserAccess.Domain/User.cs +++ b/src/Modules/UserAccess/TeamUp.UserAccess.Domain/User.cs @@ -25,6 +25,7 @@ private User(UserId id, string name, string email, Password password, UserState AddDomainEvent(new UserCreatedDomainEvent(this)); } + internal static User Create(string name, string email, Password password) => new( UserId.New(), name, @@ -33,6 +34,14 @@ private User(UserId id, string name, string email, Password password, UserState UserState.NotActivated ); + internal static User Generate(string name, string email) => new( + UserId.New(), + name, + email, + new Password(), + UserState.Generated + ); + public void Delete() { AddDomainEvent(new UserDeletedDomainEvent(this)); diff --git a/src/Modules/UserAccess/TeamUp.UserAccess.Domain/UserFactory.cs b/src/Modules/UserAccess/TeamUp.UserAccess.Domain/UserFactory.cs index dc66a9c..0d15974 100644 --- a/src/Modules/UserAccess/TeamUp.UserAccess.Domain/UserFactory.cs +++ b/src/Modules/UserAccess/TeamUp.UserAccess.Domain/UserFactory.cs @@ -18,4 +18,14 @@ public async Task> CreateAndAddUserAsync(string name, string email, .Then(_ => User.Create(name, email, password)) .Tap(_userRepository.AddUser); } + + public async Task> GenerateAndAddUserAsync(string name, string email, CancellationToken ct = default) + { + return await name + .Ensure(Rules.UserNameMinSize, Rules.UserNameMaxSize) + .ThenAsync(_ => _userRepository.ExistsUserWithConflictingEmailAsync(email, ct)) + .Ensure(conflictingUserExists => conflictingUserExists == false, UserErrors.ConflictingEmail) + .Then(_ => User.Generate(name, email)) + .Tap(_userRepository.AddUser); + } } diff --git a/tests/TeamUp.Tests.Common/DataGenerators/TeamManagement/InvitationGenerators.cs b/tests/TeamUp.Tests.Common/DataGenerators/TeamManagement/InvitationGenerators.cs index f8fd8b3..985c3d1 100644 --- a/tests/TeamUp.Tests.Common/DataGenerators/TeamManagement/InvitationGenerators.cs +++ b/tests/TeamUp.Tests.Common/DataGenerators/TeamManagement/InvitationGenerators.cs @@ -1,6 +1,7 @@ global using InvitationGenerator = Bogus.Faker; using TeamUp.TeamManagement.Contracts.Invitations; +using TeamUp.TeamManagement.Contracts.Invitations.InviteUser; using TeamUp.TeamManagement.Contracts.Teams; using TeamUp.TeamManagement.Domain.Aggregates.Invitations; using TeamUp.TeamManagement.Domain.Aggregates.Teams; @@ -8,6 +9,8 @@ using TeamUp.Tests.Common.Extensions; using TeamUp.UserAccess.Contracts; +using Xunit; + namespace TeamUp.Tests.Common.DataGenerators.TeamManagement; public sealed class InvitationGenerators : BaseGenerator @@ -51,4 +54,40 @@ public static List GenerateUserInvitations(UserId userId, DateTime c .Generate() ).ToList(); } + + public sealed class InvalidInviteUserRequest : TheoryData> + { + public InvalidInviteUserRequest() + { + this.Add(x => x.Email, new InviteUserRequest + { + TeamId = TeamId.New(), + Email = "" + }); + + this.Add(x => x.Email, new InviteUserRequest + { + TeamId = TeamId.New(), + Email = "@@" + }); + + this.Add(x => x.Email, new InviteUserRequest + { + TeamId = TeamId.New(), + Email = "invalid email" + }); + + this.Add(x => x.Email, new InviteUserRequest + { + TeamId = TeamId.New(), + Email = "missing.domain@" + }); + + this.Add(x => x.Email, new InviteUserRequest + { + TeamId = TeamId.New(), + Email = "@missing.username" + }); + } + } } diff --git a/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.AcceptInvitation.cs b/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.AcceptInvitation.cs new file mode 100644 index 0000000..fc43ef2 --- /dev/null +++ b/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.AcceptInvitation.cs @@ -0,0 +1,330 @@ +using Microsoft.EntityFrameworkCore; + +using TeamUp.Common.Infrastructure.Services; +using TeamUp.TeamManagement.Contracts.Invitations; +using TeamUp.TeamManagement.Contracts.Teams; +using TeamUp.TeamManagement.Domain.Aggregates.Invitations; +using TeamUp.TeamManagement.Domain.Aggregates.Teams; +using TeamUp.TeamManagement.Infrastructure; +using TeamUp.Tests.Common.DataGenerators.TeamManagement; +using TeamUp.Tests.Common.DataGenerators.UserAccess; +using TeamUp.UserAccess.Infrastructure.Persistence; + +namespace TeamUp.Tests.EndToEnd.EndpointTests.TeamManagement.Invitations; + +public sealed class AcceptInvitationTests(AppFixture app) : InvitationTests(app) +{ + public static string GetUrl(InvitationId invitationId) => GetUrl(invitationId.Value); + public static string GetUrl(Guid invitationId) => $"/api/v1/invitations/{invitationId}/accept"; + + [Fact] + public async Task AcceptInvitation_ThatIsValid_AsRecipient_Should_RemoveInvitationFromDatabase_And_AddUserAsMemberToTeamInDatabase() + { + //arrange + var ownerUA = UserGenerators.User.Generate(); + var owner = ownerUA.ToUserTM(); + + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(owner, members).Generate(); + var invitation = InvitationGenerators.GenerateInvitation(initiatorUser.Id, team.Id, DateTime.UtcNow); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUserUA, ownerUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUser, owner]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + dbContext.Invitations.Add(invitation); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.PostAsync(GetUrl(invitation.Id), null); + + //assert + response.Should().Be200Ok(); + + await UseDbContextAsync(async dbContext => + { + var teamMembers = await dbContext + .Set() + .Where(member => member.TeamId == team.Id) + .ToListAsync(); + + teamMembers.Should().HaveCount(21); + + var teamMember = teamMembers.SingleOrDefault(member => member.UserId == initiatorUser.Id); + teamMember.ShouldNotBeNull(); + teamMember.Role.Should().Be(TeamRole.Member); + + var acceptedInvitation = await dbContext.Invitations.FindAsync(invitation.Id); + acceptedInvitation.Should().BeNull(); + }); + } + + [Fact] + public async Task AcceptInvitation_ThatExpired_AsRecipient_Should_ResultInBadRequest_DomainError() + { + //arrange + var ownerUA = UserGenerators.User.Generate(); + var owner = ownerUA.ToUserTM(); + + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(owner, members).Generate(); + var invitation = InvitationGenerators.GenerateInvitation(initiatorUser.Id, team.Id, DateTime.UtcNow.AddDays(-5)); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUserUA, ownerUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUser, owner]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + dbContext.Invitations.Add(invitation); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.PostAsync(GetUrl(invitation.Id), null); + + //assert + response.Should().Be400BadRequest(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(InvitationErrors.InvitationExpired); + } + + [Fact] + public async Task AcceptInvitation_ThatDoesNotExist_AsRecipient_Should_ResultInNotFound() + { + //arrange + var ownerUA = UserGenerators.User.Generate(); + var owner = ownerUA.ToUserTM(); + + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(owner, members).Generate(); + var invitationId = Guid.NewGuid(); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUserUA, ownerUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUser, owner]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.PostAsync(GetUrl(invitationId), null); + + //assert + response.Should().Be404NotFound(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(InvitationErrors.InvitationNotFound); + } + + [Fact] + public async Task AcceptInvitation_ForAnotherUser_Should_ResultInForbidden() + { + //arrange + var ownerUA = UserGenerators.User.Generate(); + var owner = ownerUA.ToUserTM(); + + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var targetUserUA = UserGenerators.User.Generate(); + var targetUser = targetUserUA.ToUserTM(); + + var team = TeamGenerators.Team.WithMembers(owner, members).Generate(); + var invitation = InvitationGenerators.GenerateInvitation(targetUser.Id, team.Id, DateTime.UtcNow.AddDays(-5)); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUserUA, ownerUA, targetUserUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUser, owner, targetUser]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + dbContext.Invitations.Add(invitation); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.PostAsync(GetUrl(invitation.Id), null); + + //assert + response.Should().Be403Forbidden(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(InvitationErrors.UnauthorizedToAcceptInvitation); + } + + [Fact] + public async Task AcceptInvitation_ThatIsValid_AsRecipient_WhenTeamIsFull_Should_ResultInBadRequest_DomainError() + { + //arrange + var ownerUA = UserGenerators.User.Generate(); + var owner = ownerUA.ToUserTM(); + + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(TeamConstants.MAX_TEAM_CAPACITY - 1); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(owner, members).Generate(); + var invitation = InvitationGenerators.GenerateInvitation(initiatorUser.Id, team.Id, DateTime.UtcNow); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUserUA, ownerUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUser, owner]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + dbContext.Invitations.Add(invitation); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.PostAsync(GetUrl(invitation.Id), null); + + //assert + response.Should().Be400BadRequest(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(TeamErrors.MaximumCapacityReached); + } + + [Fact] + public async Task AcceptInvitation_ThatIsValid_AsRecipient_ForLastEmptySpot_WhenConcurrentInvitationToSameTeamHasBeenAccepted_Should_ResultInConflict() + { + //arrange + var userAUA = UserGenerators.User.Generate(); + var userA = userAUA.ToUserTM(); + + var userBUA = UserGenerators.User.Generate(); + var userB = userBUA.ToUserTM(); + + var ownerUA = UserGenerators.User.Generate(); + var owner = ownerUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(TeamConstants.MAX_TEAM_CAPACITY - 2); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(owner, members).Generate(); + var invitationA = InvitationGenerators.GenerateInvitation(userA.Id, team.Id, DateTime.UtcNow); + var invitationB = InvitationGenerators.GenerateInvitation(userB.Id, team.Id, DateTime.UtcNow); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([userAUA, userBUA, ownerUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([userA, userB, owner]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + dbContext.Invitations.AddRange([invitationA, invitationB]); + return dbContext.SaveChangesAsync(); + }); + + //act + var (responseA, responseB) = await RunConcurrentRequestsAsync( + () => + { + Authenticate(userAUA); + return Client.PostAsync(GetUrl(invitationA.Id), null); + }, + () => + { + Authenticate(userBUA); + return Client.PostAsync(GetUrl(invitationB.Id), null); + } + ); + + //assert + responseA.Should().Be200Ok(); + responseB.Should().Be409Conflict(); + + await UseDbContextAsync(async dbContext => + { + var teamMembers = await dbContext + .Set() + .Where(member => member.TeamId == team.Id) + .ToListAsync(); + + teamMembers.Should().HaveCount(TeamConstants.MAX_TEAM_CAPACITY); + + var teamMember = teamMembers.SingleOrDefault(member => member.UserId == userA.Id); + teamMember.ShouldNotBeNull(); + teamMember.Role.Should().Be(TeamRole.Member); + + var acceptedInvitation = await dbContext.Invitations.FindAsync(invitationA.Id); + acceptedInvitation.Should().BeNull(); + }); + + var problemDetails = await responseB.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(UnitOfWork.ConcurrencyError); + } +} diff --git a/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.GetMyInvitations.cs b/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.GetMyInvitations.cs new file mode 100644 index 0000000..f1bf569 --- /dev/null +++ b/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.GetMyInvitations.cs @@ -0,0 +1,98 @@ +using TeamUp.TeamManagement.Contracts.Invitations; +using TeamUp.TeamManagement.Infrastructure; +using TeamUp.Tests.Common.DataGenerators.TeamManagement; +using TeamUp.Tests.Common.DataGenerators.UserAccess; +using TeamUp.UserAccess.Infrastructure.Persistence; + +namespace TeamUp.Tests.EndToEnd.EndpointTests.TeamManagement.Invitations; + +public sealed class GetMyInvitationsTests(AppFixture app) : InvitationTests(app) +{ + public const string URL = "/api/v1/invitations"; + + [Fact] + public async Task GetMyInvitations_Should_ReturnListOfInvitations() + { + //arrange + var ownerUA = UserGenerators.User.Generate(); + var owner = ownerUA.ToUserTM(); + + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var teams = TeamGenerators.Team.WithMembers(owner, members).Generate(3); + var invitations = InvitationGenerators.GenerateUserInvitations(initiatorUser.Id, DateTime.UtcNow.DropMicroSeconds(), teams); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUserUA, ownerUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUser, owner]); + dbContext.Users.AddRange(members); + dbContext.Teams.AddRange(teams); + dbContext.Invitations.AddRange(invitations); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.GetAsync(URL); + + //assert + response.Should().Be200Ok(); + + var userInvitations = await response.ReadFromJsonAsync>(); + invitations.Should().BeEquivalentTo(userInvitations, o => o.ExcludingMissingMembers()); + } + + [Fact] + public async Task GetMyInvitations_WhenNotInvited_Should_ReturnEmptyList() + { + //arrange + var ownerUA = UserGenerators.User.Generate(); + var owner = ownerUA.ToUserTM(); + + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var teams = TeamGenerators.Team.WithMembers(owner, members).Generate(3); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUserUA, ownerUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUser, owner]); + dbContext.Users.AddRange(members); + dbContext.Teams.AddRange(teams); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.GetAsync(URL); + + //assert + response.Should().Be200Ok(); + + var invitations = await response.ReadFromJsonAsync>(); + invitations.Should().BeEmpty(); + } +} diff --git a/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.GetTeamInvitations.cs b/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.GetTeamInvitations.cs new file mode 100644 index 0000000..da5d742 --- /dev/null +++ b/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.GetTeamInvitations.cs @@ -0,0 +1,177 @@ +using TeamUp.TeamManagement.Contracts.Invitations; +using TeamUp.TeamManagement.Contracts.Teams; +using TeamUp.TeamManagement.Domain.Aggregates.Teams; +using TeamUp.TeamManagement.Infrastructure; +using TeamUp.Tests.Common.DataGenerators.TeamManagement; +using TeamUp.Tests.Common.DataGenerators.UserAccess; +using TeamUp.UserAccess.Infrastructure.Persistence; + +namespace TeamUp.Tests.EndToEnd.EndpointTests.TeamManagement.Invitations; + +public sealed class GetTeamInvitationsTests(AppFixture app) : InvitationTests(app) +{ + public static string GetUrl(TeamId teamId) => GetUrl(teamId.Value); + public static string GetUrl(Guid teamId) => $"/api/v1/invitations/teams/{teamId}"; + + [Theory] + [InlineData(TeamRole.Coordinator)] + [InlineData(TeamRole.Admin)] + [InlineData(TeamRole.Owner)] + public async Task GetTeamInvitations_AsCoordinatorOrHigher_Should_ReturnListOfInvitations(TeamRole teamRole) + { + //arrange + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(initiatorUser, teamRole, members).Generate(); + var invitations = InvitationGenerators.GenerateTeamInvitations(team.Id, DateTime.UtcNow.DropMicroSeconds(), members); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.Add(initiatorUserUA); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.Add(initiatorUser); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + dbContext.Invitations.AddRange(invitations); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.GetAsync(GetUrl(team.Id)); + + //assert + response.Should().Be200Ok(); + + var teamInvitations = await response.ReadFromJsonAsync>(); + invitations.Should().BeEquivalentTo(teamInvitations, o => o.ExcludingMissingMembers()); + } + + [Fact] + public async Task GetTeamInvitations_AsMember_Should_ResultInForbidden() + { + //arrange + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(initiatorUser, TeamRole.Member, members).Generate(); + var invitations = InvitationGenerators.GenerateTeamInvitations(team.Id, DateTime.UtcNow, members); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.Add(initiatorUserUA); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.Add(initiatorUser); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + dbContext.Invitations.AddRange(invitations); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.GetAsync(GetUrl(team.Id)); + + //assert + response.Should().Be403Forbidden(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(TeamErrors.UnauthorizedToReadInvitationList); + } + + [Fact] + public async Task GetTeamInvitations_WhenNotMemberOfTeam_Should_ResultInForbidden() + { + //arrange + var ownerUA = UserGenerators.User.Generate(); + var owner = ownerUA.ToUserTM(); + + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(owner, members).Generate(); + var invitations = InvitationGenerators.GenerateTeamInvitations(team.Id, DateTime.UtcNow, members); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUserUA, ownerUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUser, owner]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + dbContext.Invitations.AddRange(invitations); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.GetAsync(GetUrl(team.Id)); + + //assert + response.Should().Be403Forbidden(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(TeamErrors.NotMemberOfTeam); + } + + [Fact] + public async Task GetTeamInvitations_OfUnExistingTeam_Should_ResultInNotFound() + { + //arrange + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var teamId = Guid.NewGuid(); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.Add(initiatorUserUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.Add(initiatorUser); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.GetAsync(GetUrl(teamId)); + + //assert + response.Should().Be404NotFound(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(TeamErrors.TeamNotFound); + } +} diff --git a/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.InviteUser.cs b/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.InviteUser.cs new file mode 100644 index 0000000..34fce8c --- /dev/null +++ b/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.InviteUser.cs @@ -0,0 +1,472 @@ +using Microsoft.EntityFrameworkCore; + +using TeamUp.TeamManagement.Contracts.Invitations.InviteUser; +using TeamUp.TeamManagement.Contracts.Teams; +using TeamUp.TeamManagement.Domain.Aggregates.Invitations; +using TeamUp.TeamManagement.Domain.Aggregates.Teams; +using TeamUp.TeamManagement.Infrastructure; +using TeamUp.Tests.Common.DataGenerators.TeamManagement; +using TeamUp.Tests.Common.DataGenerators.UserAccess; +using TeamUp.UserAccess.Infrastructure.Persistence; + +namespace TeamUp.Tests.EndToEnd.EndpointTests.TeamManagement.Invitations; + +public sealed class InviteUserTests(AppFixture app) : InvitationTests(app) +{ + public const string URL = "/api/v1/invitations"; + + [Theory] + [InlineData(TeamRole.Coordinator)] + [InlineData(TeamRole.Admin)] + [InlineData(TeamRole.Owner)] + public async Task InviteUser_ThatIsActivated_AsCoordinatorOrHigher_Should_CreateInvitationInDatabase_And_SendInvitationEmail(TeamRole initiatorRole) + { + //arrange + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var targetUserUA = UserGenerators.User.Generate(); + var targetUser = targetUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(initiatorUser, initiatorRole, members).Generate(); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUserUA, initiatorUserUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUser, initiatorUser]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + var request = new InviteUserRequest + { + Email = targetUser.Email, + TeamId = team.Id + }; + + //act + var response = await Client.PostAsJsonAsync(URL, request); + + //assert + response.Should().Be202Accepted(); + + await WaitForIntegrationEventHandlerAsync(); + + await UseDbContextAsync(async dbContext => + { + var invitation = await dbContext.Invitations.FirstAsync(); + invitation.ShouldNotBeNull(); + invitation.TeamId.Should().Be(team.Id); + invitation.RecipientId.Should().Be(targetUser.Id); + }); + + await WaitForIntegrationEventHandlerAsync(); + + Inbox.Should().Contain(email => email.EmailAddress == targetUser.Email); + } + + [Theory] + [InlineData(TeamRole.Coordinator)] + [InlineData(TeamRole.Admin)] + [InlineData(TeamRole.Owner)] + public async Task InviteUser_ThatIsNotRegistered_AsCoordinatorOrHigher_Should_CreateInvitationInDatabase_And_GenerateNewUser_And_SendInvitationEmail(TeamRole initiatorRole) + { + //arrange + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(initiatorUser, initiatorRole, members).Generate(); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.Add(initiatorUserUA); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.Add(initiatorUser); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + var targetEmail = F.Internet.Email(); + var request = new InviteUserRequest + { + Email = targetEmail, + TeamId = team.Id + }; + + //act + var response = await Client.PostAsJsonAsync(URL, request); + + //assert + response.Should().Be202Accepted(); + + await WaitForIntegrationEventHandlerAsync(); + await WaitForIntegrationEventHandlerAsync(); + + await UseDbContextAsync(async dbContext => + { + var invitation = await dbContext.Invitations.FirstAsync(); + invitation.TeamId.Should().Be(team.Id); + + var user = await dbContext.Users.FindAsync(invitation.RecipientId); + user.ShouldNotBeNull(); + user.Email.Should().Be(targetEmail); + + await UseDbContextAsync(async dbContext => + { + var user = await dbContext.Users.FindAsync(invitation.RecipientId); + user.ShouldNotBeNull(); + user.Email.Should().Be(targetEmail); + user.State.Should().Be(UserState.Generated); + }); + }); + + await WaitForIntegrationEventHandlerAsync(); + + Inbox.Should().Contain(email => email.EmailAddress == targetEmail); + } + + [Theory] + [InlineData(TeamRole.Coordinator)] + [InlineData(TeamRole.Admin)] + [InlineData(TeamRole.Owner)] + public async Task InviteUser_ThatIsAlreadyInTeam_AsCoordinatorOrHigher_Should_ResultInBadRequest_DomainError(TeamRole initiatorRole) + { + //arrange + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(initiatorUser, initiatorRole, members).Generate(); + var targetUser = members.First(); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.Add(initiatorUserUA); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.Add(initiatorUser); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + var request = new InviteUserRequest + { + Email = targetUser.Email, + TeamId = team.Id + }; + + //act + var response = await Client.PostAsJsonAsync(URL, request); + + //assert + response.Should().Be400BadRequest(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(TeamErrors.CannotInviteUserThatIsTeamMember); + } + + [Fact] + public async Task InviteUser_AsTeamMember_Should_ResultInForbidden() + { + //arrange + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var targetUserUA = UserGenerators.User.Generate(); + var targetUser = targetUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(initiatorUser, TeamRole.Member, members).Generate(); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUserUA, initiatorUserUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUser, initiatorUser]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + var request = new InviteUserRequest + { + Email = targetUser.Email, + TeamId = team.Id + }; + + //act + var response = await Client.PostAsJsonAsync(URL, request); + + //assert + response.Should().Be403Forbidden(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(TeamErrors.UnauthorizedToInviteTeamMembers); + } + + [Theory] + [InlineData(TeamRole.Coordinator)] + [InlineData(TeamRole.Admin)] + [InlineData(TeamRole.Owner)] + public async Task InviteUser_ThatIsAlreadyInvited_AsCoordinatorOrHigher_Should_ResultInConflict(TeamRole initiatorRole) + { + //arrange + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var targetUserUA = UserGenerators.User.Generate(); + var targetUser = targetUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(initiatorUser, initiatorRole, members).Generate(); + var invitation = InvitationGenerators.GenerateInvitation(targetUser.Id, team.Id, DateTime.UtcNow); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUserUA, initiatorUserUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUser, initiatorUser]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + dbContext.Invitations.Add(invitation); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + var request = new InviteUserRequest + { + Email = targetUser.Email, + TeamId = team.Id + }; + + //act + var response = await Client.PostAsJsonAsync(URL, request); + + //assert + response.Should().Be409Conflict(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(InvitationErrors.UserIsAlreadyInvited); + } + + [Fact] + public async Task InviteUser_WhenNotMemberOfTeam_Should_ResultInForbidden() + { + //arrange + var ownerUA = UserGenerators.User.Generate(); + var owner = ownerUA.ToUserTM(); + + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var targetUserUA = UserGenerators.User.Generate(); + var targetUser = targetUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(owner, members).Generate(); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([ownerUA, targetUserUA, initiatorUserUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([owner, targetUser, initiatorUser]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + var request = new InviteUserRequest + { + Email = targetUser.Email, + TeamId = team.Id + }; + + //act + var response = await Client.PostAsJsonAsync(URL, request); + + //assert + response.Should().Be403Forbidden(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(TeamErrors.NotMemberOfTeam); + } + + [Fact] + public async Task InviteUser_ToUnExistingTeam_Should_ResultInNotFound() + { + //arrange + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var targetUserUA = UserGenerators.User.Generate(); + var targetUser = targetUserUA.ToUserTM(); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUserUA, initiatorUserUA]); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUser, initiatorUser]); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + var request = new InviteUserRequest + { + Email = targetUser.Email, + TeamId = TeamId.New() + }; + + //act + var response = await Client.PostAsJsonAsync(URL, request); + + //assert + response.Should().Be404NotFound(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(TeamErrors.TeamNotFound); + } + + [Theory] + [ClassData(typeof(InvitationGenerators.InvalidInviteUserRequest))] + public async Task InviteUser_WithInvalidRequest_Should_ResultInBadRequest_ValidationErrors(InvalidRequest request) + { + //arrange + //arrange + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.Add(initiatorUserUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.Add(initiatorUser); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.PostAsJsonAsync(URL, request.Request); + + //assert + response.Should().Be400BadRequest(); + + var problemDetails = await response.ReadValidationProblemDetailsAsync(); + problemDetails.ShouldContainValidationErrorFor(request.InvalidProperty); + } + + [Fact] + public async Task InviteUser_AsOwner_WhenTeamIsFull_Should_ResultInBadRequest_DomainError() + { + //arrange + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var targetUserUA = UserGenerators.User.Generate(); + var targetUser = targetUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(TeamConstants.MAX_TEAM_CAPACITY - 1); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(initiatorUser, members).Generate(); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUserUA, initiatorUserUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUser, initiatorUser]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + var request = new InviteUserRequest + { + Email = targetUser.Email, + TeamId = team.Id + }; + + //act + var response = await Client.PostAsJsonAsync(URL, request); + + //assert + response.Should().Be400BadRequest(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(TeamErrors.MaximumCapacityReached); + } +} diff --git a/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.RemoveInvitation.cs b/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.RemoveInvitation.cs new file mode 100644 index 0000000..29390e5 --- /dev/null +++ b/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.RemoveInvitation.cs @@ -0,0 +1,238 @@ +using TeamUp.TeamManagement.Contracts.Invitations; +using TeamUp.TeamManagement.Contracts.Teams; +using TeamUp.TeamManagement.Domain.Aggregates.Invitations; +using TeamUp.TeamManagement.Domain.Aggregates.Teams; +using TeamUp.TeamManagement.Infrastructure; +using TeamUp.Tests.Common.DataGenerators.TeamManagement; +using TeamUp.Tests.Common.DataGenerators.UserAccess; +using TeamUp.UserAccess.Infrastructure.Persistence; + +namespace TeamUp.Tests.EndToEnd.EndpointTests.TeamManagement.Invitations; + +public sealed class RemoveInvitationTests(AppFixture app) : InvitationTests(app) +{ + public static string GetUrl(InvitationId invitationId) => GetUrl(invitationId.Value); + public static string GetUrl(Guid invitationId) => $"/api/v1/invitations/{invitationId}"; + + [Fact] + public async Task RemoveInvitation_AsRecipient_Should_RemoveInvitationFromDatabase() + { + //arrange + var ownerUA = UserGenerators.User.Generate(); + var owner = ownerUA.ToUserTM(); + + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(owner, members).Generate(); + var invitation = InvitationGenerators.GenerateInvitation(initiatorUser.Id, team.Id, DateTime.UtcNow); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUserUA, ownerUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([initiatorUser, owner]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + dbContext.Invitations.Add(invitation); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.DeleteAsync(GetUrl(invitation.Id)); + + //assert + response.Should().Be200Ok(); + + var removedInvitation = await UseDbContextAsync(dbContext => dbContext.Invitations.FindAsync(invitation.Id)); + removedInvitation.Should().BeNull(); + } + + [Theory] + [InlineData(TeamRole.Coordinator)] + [InlineData(TeamRole.Admin)] + [InlineData(TeamRole.Owner)] + public async Task RemoveInvitation_AsCoordinatorOrHigher_Should_RemoveInvitationFromDatabase(TeamRole initiatorRole) + { + //arrange + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var targetUserUA = UserGenerators.User.Generate(); + var targetUser = targetUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(initiatorUser, initiatorRole, members).Generate(); + var invitation = InvitationGenerators.GenerateInvitation(targetUser.Id, team.Id, DateTime.UtcNow); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUserUA, initiatorUserUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUser, initiatorUser]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + dbContext.Invitations.Add(invitation); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.DeleteAsync(GetUrl(invitation.Id)); + + //assert + response.Should().Be200Ok(); + + var removedInvitation = await UseDbContextAsync(dbContext => dbContext.Invitations.FindAsync(invitation.Id)); + removedInvitation.Should().BeNull(); + } + + [Fact] + public async Task RemoveInvitation_AsMember_Should_ResultInForbidden() + { + //arrange + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var targetUserUA = UserGenerators.User.Generate(); + var targetUser = targetUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(initiatorUser, TeamRole.Member, members).Generate(); + var invitation = InvitationGenerators.GenerateInvitation(targetUser.Id, team.Id, DateTime.UtcNow); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUserUA, initiatorUserUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUser, initiatorUser]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + dbContext.Invitations.Add(invitation); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.DeleteAsync(GetUrl(invitation.Id)); + + //assert + response.Should().Be403Forbidden(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(TeamErrors.UnauthorizedToCancelInvitations); + } + + [Fact] + public async Task RemoveInvitation_WhenNotMemberOfTeam_And_NotRecipient_Should_ResultInForbidden() + { + //arrange + var ownerUA = UserGenerators.User.Generate(); + var owner = ownerUA.ToUserTM(); + + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var targetUserUA = UserGenerators.User.Generate(); + var targetUser = targetUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(owner, members).Generate(); + var invitation = InvitationGenerators.GenerateInvitation(targetUser.Id, team.Id, DateTime.UtcNow); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUserUA, initiatorUserUA, ownerUA]); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.AddRange([targetUser, initiatorUser, owner]); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + dbContext.Invitations.Add(invitation); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.DeleteAsync(GetUrl(invitation.Id)); + + //assert + response.Should().Be403Forbidden(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(TeamErrors.NotMemberOfTeam); + } + + [Fact] + public async Task RemoveInvitation_ThaDoesNotExist_AsOwner_Should_ResultNotFound() + { + //arrange + var initiatorUserUA = UserGenerators.User.Generate(); + var initiatorUser = initiatorUserUA.ToUserTM(); + + var membersUA = UserGenerators.User.Generate(19); + var members = membersUA.ToUsersTM(); + + var team = TeamGenerators.Team.WithMembers(initiatorUser, members).Generate(); + var invitationId = Guid.NewGuid(); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.Add(initiatorUserUA); + dbContext.Users.AddRange(membersUA); + return dbContext.SaveChangesAsync(); + }); + + await UseDbContextAsync(dbContext => + { + dbContext.Users.Add(initiatorUser); + dbContext.Users.AddRange(members); + dbContext.Teams.Add(team); + return dbContext.SaveChangesAsync(); + }); + + Authenticate(initiatorUserUA); + + //act + var response = await Client.DeleteAsync(GetUrl(invitationId)); + + //assert + response.Should().Be404NotFound(); + + var problemDetails = await response.ReadProblemDetailsAsync(); + problemDetails.ShouldContainError(InvitationErrors.InvitationNotFound); + } +} diff --git a/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.cs b/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.cs new file mode 100644 index 0000000..9327b88 --- /dev/null +++ b/tests/TeamUp.Tests.EndToEnd/EndpointTests/TeamManagement/Invitations/InvitationTests.cs @@ -0,0 +1,3 @@ +namespace TeamUp.Tests.EndToEnd.EndpointTests.TeamManagement.Invitations; + +public abstract class InvitationTests(AppFixture app) : BaseEndpointTests(app); diff --git a/tests/TeamUp.Tests.EndToEnd/TeamUp.Tests.EndToEnd.csproj b/tests/TeamUp.Tests.EndToEnd/TeamUp.Tests.EndToEnd.csproj index c97f0f6..9f43219 100644 --- a/tests/TeamUp.Tests.EndToEnd/TeamUp.Tests.EndToEnd.csproj +++ b/tests/TeamUp.Tests.EndToEnd/TeamUp.Tests.EndToEnd.csproj @@ -5,10 +5,6 @@ true - - - - all