mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
fix: return vendor duplicate name conflict (#92)
This commit is contained in:
parent
e90e51d271
commit
b605d5be02
4 changed files with 82 additions and 2 deletions
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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) });
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue