shoc-backend/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs
Alexandre Brandizzi daf3ed9210 fix(team-members): map duplicate-email race to conflict error (SH-325)
CreateAsync checks FindByEmailAsync and then inserts, so two concurrent
creates with the same email can both pass the check and hit the unique
user-name index. That DbUpdateException was unhandled and surfaced as a
500. Catch it at the CreateAsync call and return the existing
"Email is already in use." message, matching the check-then-insert
converge pattern already used by SetOverrideCoreAsync.
2026-09-17 01:40:40 -03:00

174 lines
7.3 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 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>());
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 _);
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);
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 Create_ConcurrentDuplicateEmail_ReturnsAlreadyInUseInsteadOf500()
{
var service = NewService(out var userManager, out var roleManager, out _, 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.");
userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()), Times.Never);
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)
{
userManager = UserManager();
roleManager = RoleManager();
areas = new Mock<IUserServiceAreaDataService>();
overrides = new Mock<ITeamPermissionOverrideDataService>();
return new TeamMemberService(userManager.Object, roleManager.Object, areas.Object, overrides.Object);
}
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"));
}