using System.Reflection; using System.Text.Json; using Api.SeaHavenIndustries.Controllers; using Api.SeaHavenIndustries.Filters; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging.Abstractions; using FluentAssertions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using System.Security.Claims; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class TeamMemberInviteControllerTests { [Fact] public void RegistrationRequests_CarryNoUserIdentifier() { var requestTypes = new[] { typeof(TeamMemberInviteTokenRequestDTO), typeof(VerifyTeamMemberInviteCodeRequestDTO), typeof(CompleteTeamMemberRegistrationRequestDTO) }; var properties = requestTypes .SelectMany(type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) .Select(property => property.Name) .Distinct() .OrderBy(name => name); properties.Should().Equal("Code", "Password", "Phone", "Token"); } [Fact] public void RegistrationEndpoints_AreAnonymousAndNeverCached() { var type = typeof(TeamMemberInviteController); type.GetCustomAttribute().Should().NotBeNull(); var cache = type.GetCustomAttribute(); cache.Should().NotBeNull(); cache!.NoStore.Should().BeTrue(); } [Fact] public void RegistrationEndpoints_UseTheControllerScopedExceptionFilter() { var filter = typeof(TeamMemberInviteController).GetCustomAttribute(); filter.Should().NotBeNull(); filter!.ImplementationType.Should().Be(typeof(InviteRegistrationExceptionFilter)); } [Fact] public void ExceptionFilter_ReturnsAFixedBodyWithoutExceptionDetail() { var context = ExceptionContextFor(new ArgumentException( "SELECT [Id] FROM [TeamMemberInvites] WHERE [TokenHash] = 'leak-me'")); new InviteRegistrationExceptionFilter(NullLogger.Instance) .OnException(context); context.ExceptionHandled.Should().BeTrue(); var result = context.Result.Should().BeOfType().Subject; result.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); var body = JsonSerializer.Serialize(result.Value); body.Should().Be( "{\"code\":\"server_error\",\"message\":\"Something went wrong. Try again in a minute.\",\"retryAfterSeconds\":null}"); body.Should().NotContain("SELECT").And.NotContain("leak-me"); } [Fact] public void ExceptionFilter_LeavesCancellationToTheHost() { var context = ExceptionContextFor(new OperationCanceledException()); new InviteRegistrationExceptionFilter(NullLogger.Instance) .OnException(context); context.ExceptionHandled.Should().BeFalse(); context.Result.Should().BeNull(); } private static ExceptionContext ExceptionContextFor(Exception exception) { var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); return new ExceptionContext(actionContext, new List()) { Exception = exception }; } [Theory] [InlineData(TeamMemberRegistrationStatus.InvalidInvite)] [InlineData(TeamMemberRegistrationStatus.Ok)] public async Task Complete_WithoutASession_ReturnsTheGenericInviteError(TeamMemberRegistrationStatus status) { var service = new Mock(); service.Setup(x => x.CompleteAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new TeamMemberRegistrationOutcomeDTO { Status = status }); var controller = new TeamMemberInviteController(service.Object) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; var result = await controller.Complete(new CompleteTeamMemberRegistrationRequestDTO(), CancellationToken.None); var failure = result.Should().BeOfType().Subject; failure.StatusCode.Should().Be(StatusCodes.Status400BadRequest); failure.Value.Should().BeEquivalentTo(new { code = "invalid_invite", message = TeamMemberInviteController.InvalidInviteMessage }); } [Fact] public void ResendInvite_RequiresAuthenticatedCaller() { typeof(TeamMemberController).GetCustomAttribute().Should().NotBeNull(); typeof(TeamMemberController).GetMethod(nameof(TeamMemberController.ResendInvite))! .GetCustomAttribute().Should().BeNull(); } [Fact] public async Task ResendInvite_Success_ReturnsInviteSent() { var invites = new Mock(); invites.Setup(x => x.ResendAsync("user-1", It.IsAny(), It.IsAny())) .ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = true }); var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None); result.Should().BeOfType().Which.Value.Should().BeEquivalentTo(new { message = "Invite sent" }); } [Theory] [InlineData("Forbidden", typeof(ForbidResult))] [InlineData("Team member not found.", typeof(NotFoundObjectResult))] [InlineData("Only pending team members can be re-invited.", typeof(BadRequestObjectResult))] [InlineData("The invite could not be emailed. Try again.", typeof(BadRequestObjectResult))] public async Task ResendInvite_Failure_MapsToHttpResult(string error, Type expected) { var invites = new Mock(); invites.Setup(x => x.ResendAsync("user-1", It.IsAny(), It.IsAny())) .ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = false, Error = error }); var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None); result.Should().BeOfType(expected); } private static TeamMemberController NewTeamMemberController(Mock invites) { return new TeamMemberController(Mock.Of(), Mock.Of(), invites.Object) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test")) } } }; } }