shoc-backend/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs

458 lines
20 KiB
C#

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<IUserServiceAreaDataService>(),
Mock.Of<ITeamPermissionOverrideDataService>(),
Mock.Of<IUserDataService>(),
Mock.Of<ITeamPermissionService>(),
Mock.Of<ITeamMemberInviteService>());
var result = await service.CreateAsync(
ValidRequest() with { ServiceAreas = Array.Empty<string>() },
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<ApplicationUser>()), 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<string, UserPermissionState>
{
["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<ApplicationUser>()), 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<ApplicationUser>()))
.Callback<ApplicationUser>(user =>
{
user.Id = "new-user";
created = user;
})
.ReturnsAsync(IdentityResult.Success);
userManager
.Setup(manager => manager.FindByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((ApplicationUser?)null);
userManager
.Setup(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), "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<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "East", "West" })), It.IsAny<CancellationToken>()), Times.Once);
overrides.Verify(data => data.SetOverridesAsync("new-user", It.Is<IReadOnlyDictionary<string, UserPermissionState>>(value => value["deleteSites"] == UserPermissionState.Allow), It.IsAny<CancellationToken>()), 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<string> { "Admin" });
userData.Setup(data => data.IsAccountOwnerAsync(owner.Id, It.IsAny<CancellationToken>())).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<ApplicationUser>(), It.IsAny<CancellationToken>()), 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<string> { "Dispatcher" });
userManager.Setup(manager => manager.FindByEmailAsync("new@example.com")).ReturnsAsync((ApplicationUser?)null);
userManager.Setup(manager => manager.SetEmailAsync(user, "new@example.com"))
.Callback<ApplicationUser, string>((member, email) => member.Email = email)
.ReturnsAsync(IdentityResult.Success);
userManager.Setup(manager => manager.SetUserNameAsync(user, "new@example.com"))
.Callback<ApplicationUser, string>((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<CancellationToken>()))
.ReturnsAsync(new[] { "Central" });
permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TeamPermissionResult<TeamPermissionProfileDTO>.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<string, UserPermissionState>
{
["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<CancellationToken>()), Times.Once);
userManager.Verify(manager => manager.UpdateSecurityStampAsync(user), Times.Once);
areas.Verify(data => data.ReplaceAsync(user.Id, It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "West" })), It.IsAny<CancellationToken>()), Times.Once);
overrides.Verify(data => data.ClearOverridesAsync(user.Id, It.IsAny<CancellationToken>()), Times.Once);
overrides.Verify(data => data.SetOverridesAsync(user.Id, It.Is<IReadOnlyDictionary<string, UserPermissionState>>(value => value["createSites"] == UserPermissionState.Deny), It.IsAny<CancellationToken>()), 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<string> { "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<CancellationToken>()))
.ReturnsAsync(Array.Empty<string>());
permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TeamPermissionResult<TeamPermissionProfileDTO>.Success(new TeamPermissionProfileDTO
{
UserId = user.Id,
Permissions = Array.Empty<TeamPermissionValueDTO>()
}));
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<CancellationToken>()), 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<string> { "Dispatcher" });
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
areas.Setup(data => data.GetForUserAsync(user.Id, It.IsAny<CancellationToken>())).ReturnsAsync(Array.Empty<string>());
permissions.Setup(data => data.GetProfileAsync(user.Id, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(TeamPermissionResult<TeamPermissionProfileDTO>.Success(new TeamPermissionProfileDTO
{
UserId = user.Id,
Permissions = Array.Empty<TeamPermissionValueDTO>()
}));
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<string>(), It.IsAny<CancellationToken>()), Times.Never);
overrides.Verify(data => data.SetOverridesAsync(It.IsAny<string>(), It.IsAny<IReadOnlyDictionary<string, UserPermissionState>>(), It.IsAny<CancellationToken>()), Times.Never);
userData.Verify(data => data.UpdateUserAsync(user, It.IsAny<CancellationToken>()), 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<string>()))
.ReturnsAsync((ApplicationUser?)null);
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
userManager
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
.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<Func<CancellationToken, Task>>(), It.IsAny<CancellationToken>()),
Times.Once);
userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()), Times.Never);
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), 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<string>()))
.ReturnsAsync((ApplicationUser?)null);
userManager
.Setup(manager => manager.CreateAsync(It.IsAny<ApplicationUser>()))
.Callback<ApplicationUser>(user =>
{
user.Id = "mid-fail-user";
created = user;
})
.ReturnsAsync(IdentityResult.Success);
userManager
.Setup(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), "Dispatcher"))
.ReturnsAsync(IdentityResult.Success);
roleManager.Setup(manager => manager.RoleExistsAsync("Dispatcher")).ReturnsAsync(true);
areas
.Setup(data => data.ReplaceAsync(
"mid-fail-user",
It.Is<IReadOnlyCollection<string>>(value => value.SequenceEqual(new[] { "East", "West" })),
It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
overrides
.Setup(data => data.SetOverridesAsync(
"mid-fail-user",
It.IsAny<IReadOnlyDictionary<string, UserPermissionState>>(),
It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("injected mid-sequence failure"));
var failure = await Record.ExceptionAsync(() => service.CreateAsync(ValidRequest(), Admin(), CancellationToken.None));
failure.Should().BeOfType<InvalidOperationException>()
.Which.Message.Should().Be("injected mid-sequence failure");
created.Should().NotBeNull();
areas.Verify(
data => data.ReplaceAsync("mid-fail-user", It.IsAny<IReadOnlyCollection<string>>(), It.IsAny<CancellationToken>()),
Times.Once);
userManager.Verify(manager => manager.DeleteAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
private static TeamMemberService NewService(
out Mock<UserManager<ApplicationUser>> userManager,
out Mock<RoleManager<IdentityRole>> roleManager,
out Mock<IUserServiceAreaDataService> areas,
out Mock<ITeamPermissionOverrideDataService> overrides,
out Mock<IUserDataService> userData,
out Mock<ITeamPermissionService> permissions)
{
userManager = UserManager();
roleManager = RoleManager();
areas = new Mock<IUserServiceAreaDataService>();
overrides = new Mock<ITeamPermissionOverrideDataService>();
userData = new Mock<IUserDataService>();
permissions = new Mock<ITeamPermissionService>();
userData
.Setup(data => data.ExecuteTransactionalAsync(
It.IsAny<Func<CancellationToken, Task>>(),
It.IsAny<CancellationToken>()))
.Returns<Func<CancellationToken, Task>, CancellationToken>((callback, token) => callback(token));
return new TeamMemberService(
userManager.Object,
roleManager.Object,
areas.Object,
overrides.Object,
userData.Object,
permissions.Object,
Mock.Of<ITeamMemberInviteService>());
}
private static Mock<UserManager<ApplicationUser>> UserManager()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object,
Microsoft.Extensions.Options.Options.Create(new IdentityOptions()),
Mock.Of<IPasswordHasher<ApplicationUser>>(),
Array.Empty<IUserValidator<ApplicationUser>>(),
Array.Empty<IPasswordValidator<ApplicationUser>>(),
Mock.Of<ILookupNormalizer>(),
new IdentityErrorDescriber(),
Mock.Of<IServiceProvider>(),
Mock.Of<ILogger<UserManager<ApplicationUser>>>());
}
private static Mock<RoleManager<IdentityRole>> RoleManager()
{
var store = new Mock<IRoleStore<IdentityRole>>();
return new Mock<RoleManager<IdentityRole>>(
store.Object,
Array.Empty<IRoleValidator<IdentityRole>>(),
Mock.Of<ILookupNormalizer>(),
new IdentityErrorDescriber(),
Mock.Of<ILogger<RoleManager<IdentityRole>>>());
}
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<string, UserPermissionState>
{
["deleteSites"] = UserPermissionState.Allow
}
};
private static ClaimsPrincipal Admin() =>
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "test"));
}