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>();
}
[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]
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");
}
[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]
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" });
}
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) });

View file

@ -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<VendorCompanyRosterReadModel> AddTechniciansAsync(
VendorCompanyRosterAddWriteModel roster,
CancellationToken cancellationToken)