diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs index 5542289..b10a37d 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs @@ -133,6 +133,28 @@ public class VendorCompanyRosterControllerTests result.Should().BeOfType(); } + [Fact] + public async Task Create_DuplicateName_ReturnsStable409() + { + var service = new Mock(); + service.Setup(x => x.CreateRosterAsync(It.IsAny(), "42", It.IsAny())) + .ThrowsAsync(new VendorRosterDuplicateNameException( + "database detail", + new InvalidOperationException("IX_VendorCompanies_NormalizedName"))); + + var result = await NewController(service).Create( + new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com" }, + CancellationToken.None); + + var conflict = result.Should().BeOfType().Subject; + conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); + var json = System.Text.Json.JsonSerializer.Serialize(conflict.Value); + json.Should().Contain("duplicate_vendor_company_name"); + json.Should().Contain("Another vendor company already uses that name."); + json.Should().NotContain("IX_VendorCompanies_NormalizedName"); + json.Should().NotContain("database detail"); + } + [Fact] public async Task Reconcile_Unauthenticated_Returns401() { diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs index 2997c41..6114c31 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs @@ -74,6 +74,26 @@ public class VendorCompanyRosterDataServiceTests context.Vendors.Where(v => v.CompanyId == company.Id).Should().OnlyContain(v => v.CompanyName == "Atomic Co"); } + [Fact] + public async Task CreateRoster_DuplicateNormalizedName_ThrowsWithoutPersisting() + { + var dbName = Guid.NewGuid().ToString(); + using var context = NewContext(dbName); + await SeedCompanyWithTechnicianAsync(context, "Eletrizante", "Existing"); + var service = new VendorCompanyRosterDataService(context); + + var call = () => service.CreateRosterAsync(new VendorCompanyRosterWriteModel + { + Name = " ELETRIZANTE ", + Email = "duplicate@example.com", + ActorUserId = "42" + }, CancellationToken.None); + + await call.Should().ThrowAsync(); + context.VendorCompanies.Should().ContainSingle(); + context.Vendors.Should().ContainSingle(); + } + [Fact] public async Task GetRoster_ByVendorId_ResolvesCompanyAndAllTechnicians() { diff --git a/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs b/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs index 8180b3b..fa5e4fa 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs @@ -82,6 +82,15 @@ namespace Api.SeaHavenIndustries.Controllers { return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); } + catch (VendorRosterDuplicateNameException dup) + { + return Conflict(new + { + Status = "Conflict", + Message = _logger.Sanitize(dup, "Another vendor company already uses that name."), + Code = "duplicate_vendor_company_name" + }); + } catch (Exception ex) { return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); diff --git a/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs b/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs index 2a23676..aacdf9c 100644 --- a/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs @@ -219,11 +219,24 @@ namespace SeaHaven.DataServices.Implementation { var now = DateTime.UtcNow; var trimmedName = roster.Name.Trim(); + var normalizedName = trimmedName.ToLowerInvariant(); + + // NormalizedName has an unfiltered unique index, so deleted companies still + // reserve their names. Detect the ordinary collision before tracking a new + // aggregate; the save guard below covers the concurrent-create race. + var nameTaken = await _context.VendorCompanies + .AnyAsync(company => company.NormalizedName == normalizedName, cancellationToken); + + if (nameTaken) + throw new VendorRosterDuplicateNameException( + "Another vendor company already uses that name.", + new InvalidOperationException( + $"NormalizedName '{normalizedName}' is already in use.")); var company = new VendorCompany { Name = trimmedName, - NormalizedName = trimmedName.ToLowerInvariant(), + NormalizedName = normalizedName, CompanyPhone = roster.CompanyPhone, Email = roster.Email, Address = roster.Address, @@ -263,12 +276,28 @@ namespace SeaHaven.DataServices.Implementation await _context.Vendors.AddAsync(vendor, cancellationToken); } - await _context.SaveChangesAsync(cancellationToken); + try + { + await _context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex)) + { + throw new VendorRosterDuplicateNameException( + "Another vendor company already uses that name.", + ex); + } return await GetRosterAsync(null, company.Id, cancellationToken) ?? throw new InvalidOperationException("Roster could not be reloaded after create."); } + private static bool IsUniqueConstraintViolation(DbUpdateException ex) + { + var message = ex.InnerException?.Message ?? ex.Message; + return message.Contains("duplicate", StringComparison.OrdinalIgnoreCase) + || message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase); + } + public async Task AddTechniciansAsync( VendorCompanyRosterAddWriteModel roster, CancellationToken cancellationToken)