fix: return vendor duplicate name conflict (#92)
Some checks failed
Validate and deploy dev / Validate deployable source bundle (push) Has been cancelled
Validate and deploy dev / Deploy shoc-backend to Elastic Beanstalk dev (push) Has been cancelled

This commit is contained in:
Alexandre Brandizzi 2026-08-27 14:55:09 -03:00 • committed by GitHub
parent e90e51d271
commit b605d5be02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 82 additions and 2 deletions

View file

@ -133,6 +133,28 @@ public class VendorCompanyRosterControllerTests
result.Should().BeOfType<BadRequestObjectResult>(); result.Should().BeOfType<BadRequestObjectResult>();
} }
[Fact]
public async Task Create_DuplicateName_ReturnsStable409()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.CreateRosterAsync(It.IsAny<CreateVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
.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<ConflictObjectResult>().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] [Fact]
public async Task Reconcile_Unauthenticated_Returns401() public async Task Reconcile_Unauthenticated_Returns401()
{ {

View file

@ -74,6 +74,26 @@ public class VendorCompanyRosterDataServiceTests
context.Vendors.Where(v => v.CompanyId == company.Id).Should().OnlyContain(v => v.CompanyName == "Atomic Co"); 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<VendorRosterDuplicateNameException>();
context.VendorCompanies.Should().ContainSingle();
context.Vendors.Should().ContainSingle();
}
[Fact] [Fact]
public async Task GetRoster_ByVendorId_ResolvesCompanyAndAllTechnicians() public async Task GetRoster_ByVendorId_ResolvesCompanyAndAllTechnicians()
{ {

View file

@ -82,6 +82,15 @@ namespace Api.SeaHavenIndustries.Controllers
{ {
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); 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) catch (Exception ex)
{ {
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) }); return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });

View file

@ -219,11 +219,24 @@ namespace SeaHaven.DataServices.Implementation
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var trimmedName = roster.Name.Trim(); 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 var company = new VendorCompany
{ {
Name = trimmedName, Name = trimmedName,
NormalizedName = trimmedName.ToLowerInvariant(), NormalizedName = normalizedName,
CompanyPhone = roster.CompanyPhone, CompanyPhone = roster.CompanyPhone,
Email = roster.Email, Email = roster.Email,
Address = roster.Address, Address = roster.Address,
@ -263,12 +276,28 @@ namespace SeaHaven.DataServices.Implementation
await _context.Vendors.AddAsync(vendor, cancellationToken); 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) return await GetRosterAsync(null, company.Id, cancellationToken)
?? throw new InvalidOperationException("Roster could not be reloaded after create."); ?? 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<VendorCompanyRosterReadModel> AddTechniciansAsync( public async Task<VendorCompanyRosterReadModel> AddTechniciansAsync(
VendorCompanyRosterAddWriteModel roster, VendorCompanyRosterAddWriteModel roster,
CancellationToken cancellationToken) CancellationToken cancellationToken)