diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs index d005ed6..5f5204b 100644 --- a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs @@ -3,6 +3,7 @@ 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; @@ -315,6 +316,26 @@ public sealed class TeamMemberServiceTests 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 _, 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."); + userManager.Verify(manager => manager.AddToRoleAsync(It.IsAny(), It.IsAny()), Times.Never); + userManager.Verify(manager => manager.DeleteAsync(It.IsAny()), Times.Never); + } + private static TeamMemberService NewService( out Mock> userManager, out Mock> roleManager, diff --git a/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs b/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs index 26b9d82..db90df2 100644 --- a/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs +++ b/SeaHaven.DataServices/Implementation/TeamPermissionOverrideDataService.cs @@ -102,7 +102,8 @@ public sealed class TeamPermissionOverrideDataService : ITeamPermissionOverrideD } catch (DbUpdateException) { - // A concurrent PUT inserted the same composite key between the read and save. + // A concurrent write (create or PUT) inserted the same composite key + // between our existence check and this save. Converge on the caller's intent. _context.Entry(addition).State = EntityState.Detached; var winner = await _context.UserPermissionOverrides .SingleAsync( diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs index 8933bac..ba2cd2b 100644 --- a/SeaHaven.Services/Implementation/TeamMemberService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberService.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Constants; using SeaHaven.Services.DTOs; @@ -71,7 +72,20 @@ public sealed class TeamMemberService : ITeamMemberService PendingRegistrationCreatedDate = now }; - var createResult = await _userManager.CreateAsync(user); + IdentityResult createResult; + try + { + createResult = await _userManager.CreateAsync(user); + } + catch (DbUpdateException) + { + // A concurrent create won the race on the unique user-name index + // between the FindByEmailAsync check above and this insert. Surface + // the same conflict message instead of letting the database + // exception bubble up as a 500. + return Failure("Email is already in use."); + } + if (!createResult.Succeeded) return Failure(createResult.Errors.FirstOrDefault()?.Description ?? "Unable to create team member.");