using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.AspNetCore.Identity; using Moq; using SeaHaven.DataServices.Dto; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; using System.Security.Claims; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class UserServiceTests { private static ClaimsPrincipal Principal(params string[] roles) { var claims = roles.Select(r => new Claim(ClaimTypes.Role, r)).ToList(); claims.Add(new Claim(ClaimTypes.NameIdentifier, "caller-1")); return new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")); } private static UserService NewService( Mock userData, Mock email, out Mock> store, Mock? accounts = null, Mock? sessions = null) { var (manager, s, _) = IdentityTestHelpers.CreateUserManager(); store = s; return new UserService( manager, userData.Object, (accounts ?? new Mock()).Object, email.Object, (sessions ?? new Mock()).Object); } [Fact] public async Task GetUsers_FormatsCreatedDate_AndHandlesNullDate() { var userData = new Mock(); userData.Setup(u => u.GetAllForListAsync(It.IsAny())) .ReturnsAsync(new List { new() { Id = "1", Name = "A", Email = "a@x.com", RoleName = "Admin", Status = "Active", CreatedDate = new DateTime(2026, 1, 5) }, new() { Id = "2", Name = "B", Email = "b@x.com", RoleName = "Tech", Status = "Active", CreatedDate = null } }); var service = NewService(userData, new Mock(), out _); var users = (await service.GetUsersAsync(CancellationToken.None)).ToList(); users.Should().HaveCount(2); users[0].Date.Should().Be(new DateTime(2026, 1, 5).ToString("MMM dd yyyy")); users[0].RoleName.Should().Be("Admin"); users[0].Status.Should().Be("Active"); users[1].Date.Should().Be(""); users[1].Name.Should().Be("B"); } [Fact] public async Task DeleteUser_NotFound_ReturnsUserNotFoundWithoutCascade() { var userData = new Mock(); userData.Setup(u => u.GetForEditAsync("missing", It.IsAny())).ReturnsAsync((ApplicationUser?)null); var service = NewService(userData, new Mock(), out _); var outcome = await service.DeleteUserAsync("missing", Principal("Admin"), CancellationToken.None); outcome.Success.Should().BeFalse(); outcome.Error.Should().Be(UserMutationErrors.UserNotFound); userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task DeleteUser_Found_PerformsCascade() { var user = IdentityTestHelpers.User(); var userData = new Mock(); userData.Setup(u => u.GetForEditAsync("u1", It.IsAny())).ReturnsAsync(user); var service = NewService(userData, new Mock(), out _); var outcome = await service.DeleteUserAsync("u1", Principal("Admin"), CancellationToken.None); outcome.Success.Should().BeTrue(); userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny()), Times.Once); } [Fact] public async Task DeleteUser_AccountOwner_ReturnsForbiddenWithoutCascade() { var user = IdentityTestHelpers.User("owner"); var userData = new Mock(); userData.Setup(u => u.GetForEditAsync("owner", It.IsAny())).ReturnsAsync(user); userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny())).ReturnsAsync(true); var service = NewService(userData, new Mock(), out _); var outcome = await service.DeleteUserAsync("owner", Principal("Admin"), CancellationToken.None); outcome.Success.Should().BeFalse(); outcome.Error.Should().Be(UserMutationErrors.Forbidden); userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task DeleteUser_NonAdmin_ReturnsForbiddenWithoutCascade() { var user = IdentityTestHelpers.User(); var userData = new Mock(); userData.Setup(u => u.GetForEditAsync("u1", It.IsAny())).ReturnsAsync(user); var service = NewService(userData, new Mock(), out _); var outcome = await service.DeleteUserAsync("u1", Principal("User"), CancellationToken.None); outcome.Success.Should().BeFalse(); outcome.Error.Should().Be(UserMutationErrors.Forbidden); userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task DeleteCurrentUser_SetsIsDeletedFlag() { var user = IdentityTestHelpers.User(); var userData = new Mock(); userData.Setup(u => u.GetForEditAsync("u1", It.IsAny())).ReturnsAsync(user); var service = NewService(userData, new Mock(), out _); await service.DeleteCurrentUserAsync("u1", CancellationToken.None); user.IsDeleted.Should().BeTrue(); userData.Verify(u => u.UpdateUserAsync(user, It.IsAny()), Times.Once); } [Fact] public async Task EditUser_NotFound_ReturnsUserNotFound() { var userData = new Mock(); userData.Setup(u => u.GetForEditAsync("x", It.IsAny())).ReturnsAsync((ApplicationUser?)null); var service = NewService(userData, new Mock(), out _); var outcome = await service.EditUserAsync( new EditUserRequestDTO { Id = "x", Email = "e@x.com", Name = "n", Role = "Admin" }, Principal("Admin"), CancellationToken.None); outcome.Success.Should().BeFalse(); outcome.Error.Should().Be(UserMutationErrors.UserNotFound); } [Fact] public async Task EditUser_AccountOwner_ReturnsForbiddenWithoutMutation() { var user = IdentityTestHelpers.User("owner"); var userData = new Mock(); userData.Setup(u => u.GetForEditAsync("owner", It.IsAny())).ReturnsAsync(user); userData.Setup(u => u.IsAccountOwnerAsync("owner", It.IsAny())).ReturnsAsync(true); var service = NewService(userData, new Mock(), out _); var outcome = await service.EditUserAsync( new EditUserRequestDTO { Id = "owner", Email = "owner@example.com", Name = "Owner", Role = "User" }, Principal("Admin"), CancellationToken.None); outcome.Success.Should().BeFalse(); outcome.Error.Should().Be(UserMutationErrors.Forbidden); userData.Verify(u => u.UpdateUserAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task GetUserProfile_MapsCreatedDateToAddedDate() { var user = new UserProfileData { Id = "u1", FirstName = "Alice", CreatedDate = new DateTime(2026, 3, 4) }; var userData = new Mock(); userData.Setup(u => u.GetProfileAsync("u1", It.IsAny())).ReturnsAsync(user); var service = NewService(userData, new Mock(), out _); var rows = await service.GetUserProfileAsync("u1", CancellationToken.None); rows.Should().HaveCount(1); rows[0].AddedDate.Should().Be(new DateTime(2026, 3, 4)); rows[0].Name.Should().Be("Alice"); } [Fact] public async Task AddUser_CreateSuccess_PersistsRoleAndEmailsLoginInformation() { var userData = new Mock(); userData.Setup(u => u.GetByIdAsync(It.IsAny())).ReturnsAsync((ApplicationUser?)null); var email = new Mock(); var service = NewService(userData, email, out var store); store.As>() .Setup(s => s.SetPasswordHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); store.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(IdentityResult.Success); store.Setup(s => s.AddToRoleAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var outcome = await service.AddUserAsync( new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "Admin" }, Principal("Admin"), CancellationToken.None); outcome.Success.Should().BeTrue(); store.Verify(s => s.CreateAsync(It.IsAny(), It.IsAny()), Times.Once); store.Verify(s => s.AddToRoleAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); email.Verify( e => e.SendEmailAsync( "n@x.com", "Login Information", It.Is(body => ContainsStrongGeneratedPassword(body))), Times.Once); } [Fact] public async Task AddUser_CreateFails_ReturnsFirstError() { var userData = new Mock(); userData.Setup(u => u.GetByIdAsync(It.IsAny())).ReturnsAsync((ApplicationUser?)null); var service = NewService(userData, new Mock(), out var store); store.As>() .Setup(s => s.SetPasswordHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); store.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "duplicate" })); var outcome = await service.AddUserAsync( new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "Admin" }, Principal("Admin"), CancellationToken.None); outcome.Success.Should().BeFalse(); outcome.Error.Should().Be("duplicate"); } [Fact] public async Task AddUser_NonAdmin_CannotSetAccountId_OnSelfOrOthers() { var userData = new Mock(); var accounts = new Mock(); accounts.Setup(a => a.ExistsAsync(99)).ReturnsAsync(true); var service = NewService(userData, new Mock(), out _, accounts); var selfOutcome = await service.AddUserAsync( new AddUserRequestDTO { Name = "Me", Email = "me@x.com", Role = "User", AccountId = 99 }, Principal("User"), CancellationToken.None); var otherOutcome = await service.EditUserAsync( new EditUserRequestDTO { Id = "other-1", Name = "Other", Email = "o@x.com", Role = "Dispatcher", AccountId = 99 }, Principal("Dispatcher"), CancellationToken.None); selfOutcome.Success.Should().BeFalse(); selfOutcome.Error.Should().Be(UserMutationErrors.Forbidden); otherOutcome.Success.Should().BeFalse(); otherOutcome.Error.Should().Be(UserMutationErrors.Forbidden); userData.Verify(u => u.UpdateUserAsync(It.IsAny(), It.IsAny()), Times.Never); accounts.Verify(a => a.ExistsAsync(It.IsAny()), Times.Never); } [Fact] public async Task EditUser_NonAdmin_CannotChangeOwnAccountScope() { var existing = IdentityTestHelpers.User("caller-1"); existing.AccountId = 1; var userData = new Mock(); userData.Setup(u => u.GetForEditAsync("caller-1", It.IsAny())).ReturnsAsync(existing); var accounts = new Mock(); accounts.Setup(a => a.ExistsAsync(2)).ReturnsAsync(true); var service = NewService(userData, new Mock(), out _, accounts); var outcome = await service.EditUserAsync( new EditUserRequestDTO { Id = "caller-1", Name = "Alice", Email = "alice@example.com", Role = "User", AccountId = 2 }, Principal("User"), CancellationToken.None); outcome.Success.Should().BeFalse(); outcome.Error.Should().Be(UserMutationErrors.Forbidden); existing.AccountId.Should().Be(1); userData.Verify(u => u.UpdateUserAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task AddUser_Admin_InvalidAccountId_RejectsWithoutCreate() { var userData = new Mock(); var accounts = new Mock(); accounts.Setup(a => a.ExistsAsync(404)).ReturnsAsync(false); var service = NewService(userData, new Mock(), out var store, accounts); var outcome = await service.AddUserAsync( new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "User", AccountId = 404 }, Principal("Admin"), CancellationToken.None); outcome.Success.Should().BeFalse(); outcome.Error.Should().Be(UserMutationErrors.AccountNotFound); store.Verify(s => s.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); userData.Verify(u => u.GetByIdAsync(It.IsAny()), Times.Never); } [Fact] public async Task EditUser_Admin_InvalidAccountId_RejectsWithoutSave() { var existing = IdentityTestHelpers.User("u9"); existing.AccountId = 1; var userData = new Mock(); userData.Setup(u => u.GetForEditAsync("u9", It.IsAny())).ReturnsAsync(existing); var accounts = new Mock(); accounts.Setup(a => a.ExistsAsync(404)).ReturnsAsync(false); var service = NewService(userData, new Mock(), out _, accounts); var outcome = await service.EditUserAsync( new EditUserRequestDTO { Id = "u9", Name = "N", Email = "n@x.com", Role = "User", AccountId = 404 }, Principal("Admin"), CancellationToken.None); outcome.Success.Should().BeFalse(); outcome.Error.Should().Be(UserMutationErrors.AccountNotFound); existing.AccountId.Should().Be(1); userData.Verify(u => u.UpdateUserAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task AddUser_Admin_ValidAccountId_PersistsOnCreate() { var userData = new Mock(); userData.Setup(u => u.GetByIdAsync(It.IsAny())).ReturnsAsync((ApplicationUser?)null); var accounts = new Mock(); accounts.Setup(a => a.ExistsAsync(10)).ReturnsAsync(true); var service = NewService(userData, new Mock(), out var store, accounts); store.As>() .Setup(s => s.SetPasswordHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); ApplicationUser? created = null; store.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) .Callback((u, _) => created = u) .ReturnsAsync(IdentityResult.Success); store.Setup(s => s.AddToRoleAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var outcome = await service.AddUserAsync( new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "User", AccountId = 10 }, Principal("Admin"), CancellationToken.None); outcome.Success.Should().BeTrue(); created.Should().NotBeNull(); created!.AccountId.Should().Be(10); accounts.Verify(a => a.ExistsAsync(10), Times.Once); } [Fact] public async Task EditUser_Admin_ValidAccountId_PersistsOnEdit() { var existing = IdentityTestHelpers.User("u9"); existing.AccountId = 1; var userData = new Mock(); userData.Setup(u => u.GetForEditAsync("u9", It.IsAny())).ReturnsAsync(existing); var accounts = new Mock(); accounts.Setup(a => a.ExistsAsync(10)).ReturnsAsync(true); var service = NewService(userData, new Mock(), out var store, accounts); store.Setup(s => s.GetRolesAsync(existing, It.IsAny())) .ReturnsAsync(new List { "User" }); store.Setup(s => s.RemoveFromRoleAsync(existing, "User", It.IsAny())) .Returns(Task.CompletedTask); store.Setup(s => s.AddToRoleAsync(existing, "User", It.IsAny())) .Returns(Task.CompletedTask); var outcome = await service.EditUserAsync( new EditUserRequestDTO { Id = "u9", Name = "N", Email = "n@x.com", Role = "User", AccountId = 10 }, Principal("Admin"), CancellationToken.None); outcome.Success.Should().BeTrue(); existing.AccountId.Should().Be(10); userData.Verify(u => u.UpdateUserAsync(existing, It.IsAny()), Times.Once); } private static bool ContainsStrongGeneratedPassword(string body) { const string marker = "Your password is: "; var start = body.IndexOf(marker, StringComparison.Ordinal); if (start < 0) return false; var password = body[(start + marker.Length)..] .Split('\n', StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault(); return password is { Length: 8 } && password.Any(char.IsUpper) && password.Any(char.IsLower) && password.Any(char.IsDigit) && password.Any(character => !char.IsLetterOrDigit(character)); } [Theory] [InlineData(2, "alice@example.com", "Dispatcher", true)] [InlineData(1, "alice@example.com", "Scheduler", true)] [InlineData(1, "alice.new@example.com", "Dispatcher", true)] [InlineData(1, "ALICE@example.com", "dispatcher", false)] public async Task EditUser_EndsEarlierSessionsOnlyWhenTheTokenClaimsChange( int accountId, string email, string role, bool endsSessions) { var existing = IdentityTestHelpers.User("u1", userName: "alice@example.com"); existing.AccountId = 1; var stampBefore = existing.SecurityStamp; var userData = new Mock(); userData.Setup(u => u.GetForEditAsync("u1", It.IsAny())).ReturnsAsync(existing); var accounts = new Mock(); accounts.Setup(a => a.ExistsAsync(It.IsAny())).ReturnsAsync(true); var sessions = new Mock(); var service = NewService(userData, new Mock(), out var store, accounts, sessions); store.Setup(s => s.GetRolesAsync(existing, It.IsAny())) .ReturnsAsync(new List { "Dispatcher" }); var outcome = await service.EditUserAsync( new EditUserRequestDTO { Id = "u1", Name = "Alice", Email = email, Role = role, AccountId = accountId }, Principal("Admin"), CancellationToken.None); outcome.Success.Should().BeTrue(); if (endsSessions) { existing.SecurityStamp.Should().NotBe(stampBefore); sessions.Verify(s => s.Forget("u1"), Times.Once); } else { existing.SecurityStamp.Should().Be(stampBefore); sessions.Verify(s => s.Forget(It.IsAny()), Times.Never); } } }