using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentAssertions; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using Xunit; namespace Api.SeaHavenIndustries.Tests; public sealed class TeamMemberServiceTests { [Fact] public async Task Create_DispatcherRequiresAtLeastOneServiceArea() { var userManager = UserManager(); var roleManager = RoleManager(); var service = new TeamMemberService( userManager.Object, roleManager.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of()); var result = await service.CreateAsync( ValidRequest() with { ServiceAreas = Array.Empty() }, Admin(), CancellationToken.None); result.Success.Should().BeFalse(); result.Error.Should().Be("At least one service area is required for a Dispatcher."); userManager.Verify(manager => manager.CreateAsync(It.IsAny()), Times.Never); } [Fact] public async Task Create_RejectsUnknownColorAndPermission() { var service = NewService(out var userManager, out _, out _, out _, out _, out _); var badColor = await service.CreateAsync( ValidRequest() with { Color = "#000000" }, Admin(), CancellationToken.None); var badPermission = await service.CreateAsync( ValidRequest() with { PermissionOverrides = new Dictionary { ["not-a-permission"] = UserPermissionState.Allow } }, Admin(), CancellationToken.None); badColor.Error.Should().Be("Color must be selected from the accessible palette."); badPermission.Error.Should().Be("Unknown permission key: not-a-permission."); userManager.Verify(manager => manager.CreateAsync(It.IsAny()), Times.Never); } [Fact] public async Task Create_PersistsPendingUserWithoutPasswordAndStoresAreasAndOverrides() { var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides, out _, out _); ApplicationUser? created = null; userManager .Setup(manager => manager.CreateAsync(It.IsAny())) .Callback(user => { user.Id = "new-user"; created = user; }) .ReturnsAsync(IdentityResult.Success); userManager .Setup(manager => manager.FindByEmailAsync(It.IsAny())) .ReturnsAsync((ApplicationUser?)null); userManager .Setup(manager => manager.AddToRoleAsync(It.IsAny(), "Dispatcher")) .ReturnsAsync(IdentityResult.Success); roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); var result = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None); result.Success.Should().BeTrue(); created.Should().NotBeNull(); created!.PasswordHash.Should().BeNull(); created.EmailConfirmed.Should().BeFalse(); created.PendingRegistration.Should().BeTrue(); created.PhoneNumber.Should().Be("555-0100"); result.Member!.ServiceAreas.Should().Equal("East", "West"); areas.Verify(data => data.ReplaceAsync("new-user", It.Is>(value => value.SequenceEqual(new[] { "East", "West" })), It.IsAny()), Times.Once); overrides.Verify(data => data.SetOverridesAsync("new-user", It.Is>(value => value["deleteSites"] == UserPermissionState.Allow), It.IsAny()), Times.Once); } [Fact] public async Task Update_ProtectsAccountOwnerFromRoleChangeAndDeactivation() { var service = NewService( out var userManager, out _, out _, out _, out var userData, out _); var owner = new ApplicationUser { Id = "owner", FirstName = "Primary", Email = "owner@example.com", IsAccountOwner = true }; userManager.Setup(manager => manager.FindByIdAsync(owner.Id)).ReturnsAsync(owner); userManager.Setup(manager => manager.GetRolesAsync(owner)).ReturnsAsync(new List { "Admin" }); userData.Setup(data => data.IsAccountOwnerAsync(owner.Id, It.IsAny())).ReturnsAsync(true); var result = await service.UpdateAsync( owner.Id, new UpdateTeamMemberRequestDTO { Name = "Primary", Role = "Dispatcher", Color = "#F59E0B", Email = owner.Email, ServiceAreas = new[] { "East" }, IsActive = false }, Admin(), CancellationToken.None); result.Success.Should().BeFalse(); result.Error.Should().Be("Forbidden"); userData.Verify(data => data.UpdateUserAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Update_ReplacesAreasAndOverridesAndReturnsFullDetail() { var service = NewService( out var userManager, out var roleManager, out var areas, out var overrides, out var userData, out var permissions); var user = new ApplicationUser { Id = "dispatcher", FirstName = "Taylor", Email = "old@example.com", Color = "#F59E0B", UniqueName = "Active" }; userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user); userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List { "Dispatcher" }); userManager.Setup(manager => manager.FindByEmailAsync("new@example.com")).ReturnsAsync((ApplicationUser?)null); userManager.Setup(manager => manager.SetEmailAsync(user, "new@example.com")) .Callback((member, email) => member.Email = email) .ReturnsAsync(IdentityResult.Success); userManager.Setup(manager => manager.SetUserNameAsync(user, "new@example.com")) .Callback((member, email) => member.UserName = email) .ReturnsAsync(IdentityResult.Success); userManager.Setup(manager => manager.UpdateSecurityStampAsync(user)) .ReturnsAsync(IdentityResult.Success); roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny())) .ReturnsAsync(new[] { "Central" }); permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny(), It.IsAny())) .ReturnsAsync(TeamPermissionResult.Success(new TeamPermissionProfileDTO { UserId = user.Id, RoleName = "Dispatcher", Permissions = new[] { new TeamPermissionValueDTO { PermissionKey = "createSites", OverrideState = UserPermissionState.Deny, IsGranted = false } } })); var result = await service.UpdateAsync( user.Id, new UpdateTeamMemberRequestDTO { Name = "Taylor Updated", Role = "Dispatcher", Color = "#0D9488", Email = "new@example.com", Phone = "555-0101", ServiceAreas = new[] { "West" }, PermissionOverrides = new Dictionary { ["createSites"] = UserPermissionState.Deny } }, Admin(), CancellationToken.None); result.Success.Should().BeTrue(); result.Member!.Name.Should().Be("Taylor Updated"); result.Member.Email.Should().Be("new@example.com"); result.Member.ServiceAreas.Should().Equal("Central"); result.Member.Permissions.Should().ContainSingle(permission => permission.PermissionKey == "createSites"); userData.Verify(data => data.UpdateUserAsync(user, It.IsAny()), Times.Once); userManager.Verify(manager => manager.UpdateSecurityStampAsync(user), Times.Once); areas.Verify(data => data.ReplaceAsync(user.Id, It.Is>(value => value.SequenceEqual(new[] { "West" })), It.IsAny()), Times.Once); overrides.Verify(data => data.ClearOverridesAsync(user.Id, It.IsAny()), Times.Once); overrides.Verify(data => data.SetOverridesAsync(user.Id, It.Is>(value => value["createSites"] == UserPermissionState.Deny), It.IsAny()), Times.Once); } [Fact] public async Task Update_SavesUnchangedNameWithoutDuplicatingRoleLikeLastName() { var service = NewService( out var userManager, out var roleManager, out var areas, out _, out var userData, out var permissions); var user = new ApplicationUser { Id = "admin", FirstName = "Legacy", LastName = "Manager", Email = "legacy@example.com", Color = "#F59E0B", UniqueName = "Active" }; userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user); userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List { "Admin" }); userManager.Setup(manager => manager.FindByEmailAsync(user.Email!)).ReturnsAsync(user); roleManager.Setup(manager => manager.RoleExistsAsync("Admin")).ReturnsAsync(true); areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny())) .ReturnsAsync(Array.Empty()); permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny(), It.IsAny())) .ReturnsAsync(TeamPermissionResult.Success(new TeamPermissionProfileDTO { UserId = user.Id, Permissions = Array.Empty() })); var result = await service.UpdateAsync( user.Id, new UpdateTeamMemberRequestDTO { Name = "Legacy Manager", Role = "Admin", Color = "#F59E0B", Email = user.Email }, Admin(), CancellationToken.None); result.Success.Should().BeTrue(); user.FirstName.Should().Be("Legacy"); user.LastName.Should().Be("Manager"); result.Member!.Name.Should().Be("Legacy Manager"); userData.Verify(data => data.UpdateUserAsync(user, It.IsAny()), Times.Once); } [Fact] public async Task Update_LeavesOmittedOverridesUnchanged() { var service = NewService( out var userManager, out var roleManager, out var areas, out var overrides, out var userData, out var permissions); var user = new ApplicationUser { Id = "dispatcher", FirstName = "Taylor", Email = "taylor@example.com", Color = "#F59E0B", UniqueName = "Active" }; userManager.Setup(manager => manager.FindByIdAsync(user.Id)).ReturnsAsync(user); userManager.Setup(manager => manager.GetRolesAsync(user)).ReturnsAsync(new List { "Dispatcher" }); roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny())).ReturnsAsync(Array.Empty()); permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny(), It.IsAny())) .ReturnsAsync(TeamPermissionResult.Success(new TeamPermissionProfileDTO { UserId = user.Id, Permissions = Array.Empty() })); var result = await service.UpdateAsync( user.Id, new UpdateTeamMemberRequestDTO { Name = "Taylor", Role = "Dispatcher", Color = "#F59E0B", Email = user.Email, ServiceAreas = new[] { "East" } }, Admin(), CancellationToken.None); result.Success.Should().BeTrue(); overrides.Verify(data => data.ClearOverridesAsync(It.IsAny(), It.IsAny()), Times.Never); overrides.Verify(data => data.SetOverridesAsync(It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); userData.Verify(data => data.UpdateUserAsync(user, It.IsAny()), Times.Once); } [Fact] public async Task Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500() { var service = NewService(out var userManager, out var roleManager, out _, out _, out var userData, out _); userManager .Setup(manager => manager.FindByEmailAsync(It.IsAny())) .ReturnsAsync((ApplicationUser?)null); roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); userManager .Setup(manager => manager.CreateAsync(It.IsAny())) .ThrowsAsync(new DbUpdateException("duplicate key", new Exception())); var result = await service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None); result.Success.Should().BeFalse(); result.Error.Should().Be("Email is already in use."); userData.Verify( data => data.ExecuteTransactionalAsync(It.IsAny>(), It.IsAny()), Times.Once); userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny(), It.IsAny()), Times.Never); userManager.Verify(manager => manager.DeleteAsync(It.IsAny()), Times.Never); } [Fact] public async Task Create_MidSequencePersistenceFailure_PropagatesWithoutCompensation() { var service = NewService(out var userManager, out var roleManager, out var areas, out var overrides, out _, out _); ApplicationUser? created = null; userManager .Setup(manager => manager.FindByEmailAsync(It.IsAny())) .ReturnsAsync((ApplicationUser?)null); userManager .Setup(manager => manager.CreateAsync(It.IsAny())) .Callback(user => { user.Id = "mid-fail-user"; created = user; }) .ReturnsAsync(IdentityResult.Success); userManager .Setup(manager => manager.AddToRoleAsync(It.IsAny(), "Dispatcher")) .ReturnsAsync(IdentityResult.Success); roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true); areas .Setup(data => data.ReplaceAsync( "mid-fail-user", It.Is>(value => value.SequenceEqual(new[] { "East", "West" })), It.IsAny())) .Returns(Task.CompletedTask); overrides .Setup(data => data.SetOverridesAsync( "mid-fail-user", It.IsAny>(), It.IsAny())) .ThrowsAsync(new InvalidOperationException("injected mid-sequence failure")); var failure = await Record.ExceptionAsync(() => service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None)); failure.Should().BeOfType() .Which.Message.Should().Be("injected mid-sequence failure"); created.Should().NotBeNull(); areas.Verify( data => data.ReplaceAsync("mid-fail-user", It.IsAny>(), It.IsAny()), Times.Once); userManager.Verify(manager => manager.DeleteAsync(It.IsAny()), Times.Never); } private static TeamMemberService NewService( out Mock> userManager, out Mock> roleManager, out Mock areas, out Mock overrides, out Mock userData, out Mock permissions) { userManager = UserManager(); roleManager = RoleManager(); areas = new Mock(); overrides = new Mock(); userData = new Mock(); permissions = new Mock(); userData .Setup(data => data.ExecuteTransactionalAsync( It.IsAny>(), It.IsAny())) .Returns, CancellationToken>((callback, token) => callback(token)); return new TeamMemberService( userManager.Object, roleManager.Object, areas.Object, overrides.Object, userData.Object, permissions.Object, Mock.Of(), Mock.Of()); } private static Mock> UserManager() { var store = new Mock>(); return new Mock>( store.Object, Microsoft.Extensions.Options.Options.Create(new IdentityOptions()), Mock.Of>(), Array.Empty>(), Array.Empty>(), Mock.Of(), new IdentityErrorDescriber(), Mock.Of(), Mock.Of>>()); } private static Mock> RoleManager() { var store = new Mock>(); return new Mock>( store.Object, Array.Empty>(), Mock.Of(), new IdentityErrorDescriber(), Mock.Of>>()); } private static CreateTeamMemberRequestDTO ValidRequest() => new() { Name = "Taylor Dispatcher", Role = "dispatcher", Color = "#F59E0B", Email = "taylor@example.com", Phone = "555-0100", ServiceAreas = new[] { "east", "West" }, PermissionOverrides = new Dictionary { ["deleteSites"] = UserPermissionState.Allow } }; private static ClaimsPrincipal Admin() => new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test")); }