Merge branch 'dev' into feat/sh-254-be-confirm-deactivation

This commit is contained in:
Alexandre Brandizzi 2026-08-20 10:58:23 -03:00 • committed by GitHub
commit 6fffc1b591
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1019 additions and 24 deletions

View file

@ -213,4 +213,75 @@ public class VendorCompanyRosterControllerTests
serverError.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
serverError.Value!.ToString()!.Should().NotContain("secret stack details");
}
private static AddTechniciansVendorRosterDTO PatchDto() => new()
{
RowVersion = "AAAAAAAAD8I=",
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "Riley", IsActive = true } }
};
[Fact]
public async Task Patch_Unauthenticated_Returns401()
{
var service = new Mock<IVendorCompanyRosterService>();
var controller = NewController(service, userId: null);
var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None);
result.Should().BeOfType<UnauthorizedObjectResult>();
service.Verify(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Patch_AddsTechnicians_Returns200WithRoster()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(7, It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleRoster());
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.StatusCode.Should().Be(StatusCodes.Status200OK);
ok.Value.Should().BeEquivalentTo(SampleRoster());
}
[Fact]
public async Task Patch_ValidationException_Returns400()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
.ThrowsAsync(new ValidationException("Technician ids are not allowed when adding technicians."));
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
bad.StatusCode.Should().Be(StatusCodes.Status400BadRequest);
}
[Fact]
public async Task Patch_CompanyNotFound_Returns404()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException("not found"));
var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText()
{
var service = new Mock<IVendorCompanyRosterService>();
service.Setup(x => x.AddTechniciansAsync(It.IsAny<int>(), It.IsAny<AddTechniciansVendorRosterDTO>(), "42", It.IsAny<CancellationToken>()))
.ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ..."));
var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None);
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict);
conflict.Value!.ToString()!.Should().NotContain("internal provider detail");
}
}

View file

@ -18,9 +18,14 @@ public class VendorCompanyRosterDataServiceTests
return new ApplicationDbContext(options);
}
private static async Task<(int companyId, Vendor vendor)> SeedCompanyWithTechnicianAsync(ApplicationDbContext context, string companyName, params string[] technicianNames)
private static Task<(int companyId, Vendor vendor)> SeedCompanyWithTechnicianAsync(
ApplicationDbContext context, string companyName, params string[] technicianNames)
=> SeedCompanyWithTechnicianAsync(context, companyName, rowVersion: null, technicianNames);
private static async Task<(int companyId, Vendor vendor)> SeedCompanyWithTechnicianAsync(
ApplicationDbContext context, string companyName, byte[]? rowVersion, params string[] technicianNames)
{
var company = new VendorCompany { Name = companyName, NormalizedName = companyName.ToLowerInvariant() };
var company = new VendorCompany { Name = companyName, NormalizedName = companyName.ToLowerInvariant(), RowVersion = rowVersion };
context.VendorCompanies.Add(company);
foreach (var name in technicianNames)
@ -492,4 +497,322 @@ public class VendorCompanyRosterDataServiceTests
tech.Zip.Should().Be("00005");
tech.GoogleMapsUrl.Should().Be("https://create.example/map");
}
private static byte[] InitialRowVersion() => new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 };
[Fact]
public async Task AddTechnicians_RenameToTakenName_ThrowsDuplicateNameConflict()
{
var dbName = Guid.NewGuid().ToString();
int companyId;
using (var seed = NewContext(dbName))
{
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Gateway Plumbing", InitialRowVersion(), "First");
companyId = cid;
await SeedCompanyWithTechnicianAsync(seed, "Harbor Electric", InitialRowVersion(), "Other");
}
using (var act = NewContext(dbName))
{
var service = new VendorCompanyRosterDataService(act);
var call = () => service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
{
CompanyId = companyId,
RowVersion = InitialRowVersion(),
ActorUserId = "42",
CompanyFields = new VendorRosterCompanyFieldsWriteModel { Name = "Harbor Electric" },
AddTechnicians = new List<RosterTechnicianWriteModel>()
}, CancellationToken.None);
// SH-250: a colliding rename must surface as a stable client conflict, not as an
// unhandled DbUpdateException that the controller reports as a 500.
await call.Should().ThrowAsync<VendorRosterDuplicateNameException>();
}
using var verify = NewContext(dbName);
verify.VendorCompanies.Single(c => c.Id == companyId).Name.Should().Be("Gateway Plumbing");
}
[Fact]
public async Task AddTechnicians_RenameToFreeName_Succeeds()
{
var dbName = Guid.NewGuid().ToString();
int companyId;
using (var seed = NewContext(dbName))
{
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Gateway Plumbing", InitialRowVersion(), "First");
companyId = cid;
}
using (var act = NewContext(dbName))
{
var service = new VendorCompanyRosterDataService(act);
await service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
{
CompanyId = companyId,
RowVersion = InitialRowVersion(),
ActorUserId = "42",
CompanyFields = new VendorRosterCompanyFieldsWriteModel { Name = "Gateway Plumbing & Drain" },
AddTechnicians = new List<RosterTechnicianWriteModel>()
}, CancellationToken.None);
}
using var verify = NewContext(dbName);
var company = verify.VendorCompanies.Single(c => c.Id == companyId);
company.Name.Should().Be("Gateway Plumbing & Drain");
company.NormalizedName.Should().Be("gateway plumbing & drain");
}
[Fact]
public async Task AddTechnicians_LeavesAllPreExistingTechniciansIntact()
{
var dbName = Guid.NewGuid().ToString();
int companyId, firstId, secondId;
using (var seed = NewContext(dbName))
{
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Add Co", InitialRowVersion(), "First", "Second");
companyId = cid;
firstId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "First").Id;
secondId = seed.Vendors.First(v => v.CompanyId == cid && v.ContactName == "Second").Id;
}
using (var act = NewContext(dbName))
{
var service = new VendorCompanyRosterDataService(act);
var roster = await service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
{
CompanyId = companyId,
RowVersion = InitialRowVersion(),
ActorUserId = "42",
AddTechnicians = new List<RosterTechnicianWriteModel>
{
new() { ContactName = "Third", Phone = "(312) 555-0100", IsActive = true }
}
}, CancellationToken.None);
roster.Technicians.Select(t => t.ContactName).Should().BeEquivalentTo(new[] { "First", "Second", "Third" });
Convert.ToBase64String(roster.RowVersion!).Should().NotBe(Convert.ToBase64String(InitialRowVersion()));
}
using var verify = NewContext(dbName);
var first = verify.Vendors.Single(v => v.Id == firstId);
first.IsDeleted.Should().NotBeTrue();
first.IsActive.Should().BeTrue();
first.ContactName.Should().Be("First");
var second = verify.Vendors.Single(v => v.Id == secondId);
second.IsDeleted.Should().NotBeTrue();
second.IsActive.Should().BeTrue();
second.ContactName.Should().Be("Second");
var added = verify.Vendors.Single(v => v.ContactName == "Third");
added.CompanyId.Should().Be(companyId);
added.IsActive.Should().BeTrue();
added.Phone.Should().Be("(312) 555-0100");
}
[Fact]
public async Task AddTechnicians_SucceedsWhenExistingTechniciansHaveOpenWorkOrders()
{
var dbName = Guid.NewGuid().ToString();
int companyId, busyId;
using (var seed = NewContext(dbName))
{
var (cid, vendor) = await SeedCompanyWithTechnicianAsync(seed, "Busy Co", InitialRowVersion(), "Busy");
companyId = cid;
busyId = vendor.Id;
seed.workOrders.Add(new WorkOrder { Id = 900, LifecycleStatus = LifecycleStatus.Scheduled, Status = "Scheduled" });
seed.Dispatches.Add(new Dispatch { VendorId = busyId, WorkOrderId = 900, Vendor = vendor });
await seed.SaveChangesAsync();
}
using (var act = NewContext(dbName))
{
var service = new VendorCompanyRosterDataService(act);
var roster = await service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
{
CompanyId = companyId,
RowVersion = InitialRowVersion(),
ActorUserId = "42",
AddTechnicians = new List<RosterTechnicianWriteModel>
{
new() { ContactName = "Newcomer", IsActive = true }
}
}, CancellationToken.None);
roster.Technicians.Select(t => t.ContactName).Should().BeEquivalentTo(new[] { "Busy", "Newcomer" });
}
using var verify = NewContext(dbName);
var busy = verify.Vendors.Single(v => v.Id == busyId);
busy.IsDeleted.Should().NotBeTrue();
busy.IsActive.Should().BeTrue();
verify.Vendors.Count(v => v.CompanyId == companyId && (v.IsDeleted == null || v.IsDeleted == false)).Should().Be(2);
}
[Fact]
public async Task AddTechnicians_StaleRowVersion_ThrowsConcurrencyAndLeavesCompanyUnchanged()
{
var dbName = Guid.NewGuid().ToString();
int companyId;
using (var seed = NewContext(dbName))
{
var (cid, _) = await SeedCompanyWithTechnicianAsync(seed, "Race Co", InitialRowVersion(), "Original");
companyId = cid;
}
using (var concurrentWriter = NewContext(dbName))
{
var company = concurrentWriter.VendorCompanies.Single(c => c.Id == companyId);
company.Notes = "modified concurrently";
await concurrentWriter.SaveChangesAsync();
}
using (var act = NewContext(dbName))
{
var service = new VendorCompanyRosterDataService(act);
var actCall = () => service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
{
CompanyId = companyId,
RowVersion = InitialRowVersion(),
ActorUserId = "42",
AddTechnicians = new List<RosterTechnicianWriteModel>
{
new() { ContactName = "Should Not Persist", IsActive = true }
}
}, CancellationToken.None);
await actCall.Should().ThrowAsync<DbUpdateConcurrencyException>();
}
// The stale save must not clobber the concurrent writer's company state. (The
// InMemory provider does not roll back applied inserts when the rowversion-guarded
// update fails; on SQL Server the implicit SaveChanges transaction makes the whole
// batch atomic — the same mechanism the PUT reconcile relies on.)
using var verify = NewContext(dbName);
var stored = verify.VendorCompanies.Single(c => c.Id == companyId);
stored.Notes.Should().Be("modified concurrently");
stored.RowVersion.Should().NotEqual(InitialRowVersion());
}
[Fact]
public async Task AddTechnicians_UnknownCompany_ThrowsKeyNotFound()
{
var dbName = Guid.NewGuid().ToString();
using var context = NewContext(dbName);
var service = new VendorCompanyRosterDataService(context);
var act = () => service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
{
CompanyId = 404,
RowVersion = InitialRowVersion(),
ActorUserId = "42",
AddTechnicians = new List<RosterTechnicianWriteModel>
{
new() { ContactName = "Ghost", IsActive = true }
}
}, CancellationToken.None);
await act.Should().ThrowAsync<KeyNotFoundException>();
context.Vendors.Should().BeEmpty();
context.VendorCompanies.Should().BeEmpty();
}
[Fact]
public async Task AddTechnicians_WithCompanyFields_UpdatesOnlyProvidedFieldsAndPropagatesToExisting()
{
var dbName = Guid.NewGuid().ToString();
int companyId, existingId;
using (var seed = NewContext(dbName))
{
var company = new VendorCompany
{
Name = "Field Co",
NormalizedName = "field co",
CompanyPhone = "(111) 111-0001",
Email = "keep@example.com",
Address = "Old Addr",
City = "OldCity",
Notes = "keep notes",
RowVersion = InitialRowVersion()
};
seed.VendorCompanies.Add(company);
seed.Vendors.Add(new Vendor
{
CompanyName = "Field Co",
ContactName = "Keeper",
IsActive = true,
IsDeleted = false,
Company = company,
CompanyPhone = "(111) 111-0001",
Address = "Old Addr",
City = "OldCity"
});
await seed.SaveChangesAsync();
companyId = company.Id;
existingId = seed.Vendors.First(v => v.CompanyId == companyId).Id;
}
using (var act = NewContext(dbName))
{
var service = new VendorCompanyRosterDataService(act);
await service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
{
CompanyId = companyId,
RowVersion = InitialRowVersion(),
ActorUserId = "42",
CompanyFields = new VendorRosterCompanyFieldsWriteModel
{
Name = "Renamed Co",
CompanyPhone = "(222) 222-0002",
City = "NewCity"
},
AddTechnicians = new List<RosterTechnicianWriteModel>
{
new() { ContactName = "Fresh", IsActive = true }
}
}, CancellationToken.None);
}
using var verify = NewContext(dbName);
var companyRow = verify.VendorCompanies.Single(c => c.Id == companyId);
companyRow.Name.Should().Be("Renamed Co");
companyRow.NormalizedName.Should().Be("renamed co");
companyRow.CompanyPhone.Should().Be("(222) 222-0002");
companyRow.City.Should().Be("NewCity");
companyRow.Email.Should().Be("keep@example.com");
companyRow.Address.Should().Be("Old Addr");
companyRow.Notes.Should().Be("keep notes");
var existing = verify.Vendors.Single(v => v.Id == existingId);
existing.IsDeleted.Should().NotBeTrue();
existing.IsActive.Should().BeTrue();
existing.CompanyName.Should().Be("Renamed Co");
existing.CompanyPhone.Should().Be("(222) 222-0002");
existing.City.Should().Be("NewCity");
var added = verify.Vendors.Single(v => v.ContactName == "Fresh");
added.CompanyName.Should().Be("Renamed Co");
added.CompanyPhone.Should().Be("(222) 222-0002");
added.City.Should().Be("NewCity");
}
[Fact]
public async Task AddTechnicians_ForwardsCancellation()
{
var dbName = Guid.NewGuid().ToString();
using var context = NewContext(dbName);
var service = new VendorCompanyRosterDataService(context);
using var cts = new CancellationTokenSource();
cts.Cancel();
var act = () => service.AddTechniciansAsync(new VendorCompanyRosterAddWriteModel
{
CompanyId = 1,
RowVersion = InitialRowVersion(),
ActorUserId = "42",
AddTechnicians = new List<RosterTechnicianWriteModel> { new() { ContactName = "T" } }
}, cts.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}
}

View file

@ -380,4 +380,192 @@ public class VendorCompanyRosterServiceTests
data.Verify(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), cts.Token), Times.Once);
}
private static AddTechniciansVendorRosterDTO AddDto(params RosterTechnicianInputDTO[] technicians) => new()
{
RowVersion = "AAAAAAAAD8I=",
AddTechnicians = technicians.ToList()
};
[Fact]
public async Task AddTechnicians_WithoutAuthenticatedUser_Throws()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_RejectsTechnicianIds()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" });
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians));
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_RejectsEmptyPayload()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" };
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_RejectsMissingRowVersion()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new AddTechniciansVendorRosterDTO
{
RowVersion = "",
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "T" } }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion));
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_RejectsInvalidTechnicianPhoneFormat()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" });
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
(await act.Should().ThrowAsync<ValidationException>())
.Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone)));
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task AddTechnicians_NormalizesAndMapsBeforeForwarding()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterAddWriteModel? captured = null;
data.Setup(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterAddWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel(7, 1));
var dto = new AddTechniciansVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
AddTechnicians = new List<RosterTechnicianInputDTO>
{
new() { ContactName = "Riley", Phone = "+1 312 555 0100", TradeSpecialties = "HVAC" }
},
CompanyFields = new VendorRosterCompanyFieldsDTO
{
Name = " Renamed Co ",
Notes = "fresh notes"
}
};
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
captured.Should().NotBeNull();
captured!.CompanyId.Should().Be(7);
captured.RowVersion.Should().Equal(Convert.FromBase64String("AAAAAAAAD8I="));
captured.ActorUserId.Should().Be("42");
captured.AddTechnicians.Single().Phone.Should().Be("(312) 555-0100");
captured.AddTechnicians.Single().TradeSpecialties.Should().Be("HVAC");
captured.AddTechnicians.Single().IsActive.Should().BeTrue();
captured.CompanyFields.Should().NotBeNull();
captured.CompanyFields!.Name.Should().Be("Renamed Co");
captured.CompanyFields.Notes.Should().Be("fresh notes");
captured.CompanyFields.CompanyPhone.Should().BeNull();
captured.CompanyFields.Email.Should().BeNull();
}
[Fact]
public async Task AddTechnicians_BlankCompanyFieldsMeanUnchangedAndKeepContactGroup()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterAddWriteModel? captured = null;
data.Setup(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterAddWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel(7, 1));
var dto = new AddTechniciansVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
AddTechnicians = new List<RosterTechnicianInputDTO> { new() { ContactName = "T" } },
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
};
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
// SH-250: every field is blank, so there is no company change to apply. Forwarding a
// non-null write model made the data layer bump RowVersion and rewrite every
// technician's LastModificationTime for a request that changes no company data.
captured!.CompanyFields.Should().BeNull();
}
[Fact]
public async Task AddTechnicians_AllBlankCompanyFieldsAndNoTechnicians_FailsValidation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
var dto = new AddTechniciansVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
AddTechnicians = new List<RosterTechnicianInputDTO>(),
CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" }
};
var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
await act.Should().ThrowAsync<ValidationException>();
data.Verify(
x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task AddTechnicians_RealCompanyValueStillForwarded()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterAddWriteModel? captured = null;
data.Setup(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterAddWriteModel, CancellationToken>((model, _) => captured = model)
.ReturnsAsync(SampleReadModel(7, 1));
var dto = new AddTechniciansVendorRosterDTO
{
RowVersion = "AAAAAAAAD8I=",
AddTechnicians = new List<RosterTechnicianInputDTO>(),
CompanyFields = new VendorRosterCompanyFieldsDTO { City = "Norfolk" }
};
await NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None);
captured!.CompanyFields.Should().NotBeNull();
captured.CompanyFields!.City.Should().Be("Norfolk");
}
[Fact]
public async Task AddTechnicians_ForwardsCancellation()
{
var data = new Mock<IVendorCompanyRosterDataService>();
data.Setup(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(SampleReadModel(7, 1));
using var cts = new CancellationTokenSource();
await NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "42", cts.Token);
data.Verify(x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), cts.Token), Times.Once);
}
}

View file

@ -139,5 +139,61 @@ namespace Api.SeaHavenIndustries.Controllers
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
// Additive partial update (SH-250): inserts the submitted technicians and
// optionally updates company fields. A technician absent from the payload is
// never removed, so 409 here can only mean a stale row version.
[HttpPatch("{companyId:int}")]
public async Task<IActionResult> AddTechnicians(
int companyId,
[FromBody] AddTechniciansVendorRosterDTO model,
CancellationToken cancellationToken)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
try
{
var roster = await _rosterService.AddTechniciansAsync(companyId, model, userId, cancellationToken);
return Ok(roster);
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (UnauthorizedAccessException)
{
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Vendor company not found" });
}
catch (VendorRosterDuplicateNameException dup)
{
// SH-250: a colliding rename is a client conflict, not a server fault.
return Conflict(new
{
Status = "Conflict",
Message = _logger.Sanitize(dup, "Another vendor company already uses that name."),
Code = 409
});
}
catch (DbUpdateConcurrencyException)
{
return Conflict(new
{
Status = "Conflict",
Message = "The vendor company was modified by another user. Refresh and retry.",
Code = 409
});
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
}
}

View file

@ -269,6 +269,149 @@ namespace SeaHaven.DataServices.Implementation
?? throw new InvalidOperationException("Roster could not be reloaded after create.");
}
public async Task<VendorCompanyRosterReadModel> AddTechniciansAsync(
VendorCompanyRosterAddWriteModel roster,
CancellationToken cancellationToken)
{
var company = await _context.VendorCompanies
.FirstOrDefaultAsync(c => c.Id == roster.CompanyId, cancellationToken);
if (company == null)
throw new KeyNotFoundException($"Vendor company with ID {roster.CompanyId} not found.");
// Concurrency: pin the client-supplied row version as the original value so a
// stale save throws DbUpdateConcurrencyException (mapped to a stable 409).
if (roster.RowVersion != null && roster.RowVersion.Length > 0)
_context.Entry(company).Property(c => c.RowVersion).OriginalValue = roster.RowVersion;
var now = DateTime.UtcNow;
int? actorId = int.TryParse(roster.ActorUserId, out var parsedActorId) ? parsedActorId : null;
string? renamedTo = null;
// Partial company update: only fields present in CompanyFields are applied;
// a null field leaves the stored value untouched. The company row is always
// touched so the concurrency token fires on every additive save.
if (roster.CompanyFields != null)
{
var fields = roster.CompanyFields;
if (fields.Name != null)
{
var trimmedName = fields.Name.Trim();
var normalized = trimmedName.ToLowerInvariant();
if (normalized != company.NormalizedName)
{
var nameTaken = await _context.VendorCompanies
.AnyAsync(
other => other.Id != company.Id
&& (other.IsDeleted == null || other.IsDeleted == false)
&& other.NormalizedName == normalized,
cancellationToken);
if (nameTaken)
throw new VendorRosterDuplicateNameException(
"Another vendor company already uses that name.",
new InvalidOperationException(
$"NormalizedName '{normalized}' is already in use."));
renamedTo = normalized;
}
company.Name = trimmedName;
company.NormalizedName = normalized;
}
if (fields.CompanyPhone != null)
company.CompanyPhone = fields.CompanyPhone;
if (fields.Email != null)
company.Email = fields.Email;
if (fields.Address != null)
company.Address = fields.Address;
if (fields.City != null)
company.City = fields.City;
if (fields.State != null)
company.State = fields.State;
if (fields.Zip != null)
company.Zip = fields.Zip;
if (fields.GoogleMapsUrl != null)
company.GoogleMapsUrl = fields.GoogleMapsUrl;
if (fields.Notes != null)
company.Notes = fields.Notes;
// Keep the denormalized company columns on existing technicians aligned
// with the company row (same invariant SaveRosterAsync maintains). This
// never removes, deactivates or rewrites technician-owned fields.
var existingTechnicians = await _context.Vendors
.Where(v => v.CompanyId == company.Id && (v.IsDeleted == null || v.IsDeleted == false))
.ToListAsync(cancellationToken);
foreach (var existing in existingTechnicians)
{
existing.CompanyName = company.Name;
existing.CompanyPhone = company.CompanyPhone;
existing.Address = company.Address;
existing.City = company.City;
existing.State = company.State;
existing.Zip = company.Zip;
existing.GoogleMapsUrl = company.GoogleMapsUrl;
existing.LastModificationTime = now;
if (actorId.HasValue)
existing.LastModifierUserId = actorId;
}
}
company.LastModificationTime = now;
if (actorId.HasValue)
company.LastModifierUserId = actorId;
// Purely additive: every submitted technician is a new row. Existing
// technicians are deliberately not consulted for removal.
foreach (var technician in roster.AddTechnicians)
{
var vendor = new Vendor
{
CompanyName = company.Name,
ContactName = technician.ContactName,
Phone = technician.Phone,
Email = technician.Email,
PreferredContact = technician.PreferredContact,
TradeSpecialties = technician.TradeSpecialties,
IsActive = technician.IsActive,
CompanyId = company.Id,
CompanyPhone = company.CompanyPhone,
Address = company.Address,
City = company.City,
State = company.State,
Zip = company.Zip,
GoogleMapsUrl = company.GoogleMapsUrl,
CreatedDate = now,
createdby = roster.ActorUserId
};
await _context.Vendors.AddAsync(vendor, cancellationToken);
}
// SH-250: a rename can collide with the unique NormalizedName index. Without
// this guard the DbUpdateException reached the controller's generic handler
// and surfaced as a 500, even though SQL Server rolls the batch back cleanly.
// The pre-check above handles the ordinary case; this covers the race where a
// competing rename commits between that check and this save.
try
{
await _context.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex) when (renamedTo != null && ex is not DbUpdateConcurrencyException)
{
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 save.");
}
private async Task<List<LinkedWorkOrderInfo>> GetOpenLinkedWorkOrdersAsync(
IReadOnlyCollection<int> vendorIds,
CancellationToken cancellationToken)

View file

@ -24,5 +24,13 @@ namespace SeaHaven.DataServices.Interfaces
Task<VendorCompanyRosterReadModel> CreateRosterAsync(
VendorCompanyRosterWriteModel roster,
CancellationToken cancellationToken);
// Additive update (SH-250): inserts the submitted technicians and applies only
// the company fields present in CompanyFields (null = unchanged). Technicians
// absent from the payload are never removed, soft-deleted or deactivated, so no
// open-work-order check is needed. Persists in a single SaveChangesAsync.
Task<VendorCompanyRosterReadModel> AddTechniciansAsync(
VendorCompanyRosterAddWriteModel roster,
CancellationToken cancellationToken);
}
}

View file

@ -58,9 +58,49 @@ namespace SeaHaven.DataServices.Models
public bool IsActive { get; set; } = true;
}
// Additive roster update (SH-250): AddTechnicians rows are inserted and nothing
// is ever removed. CompanyFields carries optional company-level updates where a
// null property means "leave unchanged".
public sealed class VendorCompanyRosterAddWriteModel
{
public int CompanyId { get; set; }
public byte[]? RowVersion { get; set; }
public string? ActorUserId { get; set; }
public VendorRosterCompanyFieldsWriteModel? CompanyFields { get; set; }
public List<RosterTechnicianWriteModel> AddTechnicians { get; set; } = new();
}
public sealed class VendorRosterCompanyFieldsWriteModel
{
public string? Name { get; set; }
public string? CompanyPhone { get; set; }
public string? Email { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
}
// Raised by the roster data service when a technician removed from the snapshot
// still has open linked work orders, so the whole reconcile must fail. Carries the
// blocking work orders so the API can surface a stable 409 without leaking internals.
/// <summary>
/// Raised when a company rename collides with the unique NormalizedName index.
/// Distinct from <see cref="VendorRosterConflictException"/>, which reports open
/// linked work orders, so callers can return a stable client conflict instead of
/// letting the raw <see cref="Microsoft.EntityFrameworkCore.DbUpdateException"/>
/// surface as a 500.
/// </summary>
public sealed class VendorRosterDuplicateNameException : Exception
{
public VendorRosterDuplicateNameException(string message, Exception inner)
: base(message, inner)
{
}
}
public sealed class VendorRosterConflictException : Exception
{
public IReadOnlyList<LinkedWorkOrderInfo> BlockedWorkOrders { get; }

View file

@ -67,4 +67,27 @@ namespace SeaHaven.Services.DTOs
public string? Notes { get; set; }
public List<RosterTechnicianInputDTO> Technicians { get; set; } = new();
}
// Partial, purely additive roster update (SH-250): technicians listed here are
// inserted; any technician absent from the payload is never removed, soft-deleted
// or deactivated. Company fields are optional; a null field means "unchanged".
public class AddTechniciansVendorRosterDTO
{
public required string RowVersion { get; set; }
public List<RosterTechnicianInputDTO> AddTechnicians { get; set; } = new();
public VendorRosterCompanyFieldsDTO? CompanyFields { get; set; }
}
public class VendorRosterCompanyFieldsDTO
{
public string? Name { get; set; }
public string? CompanyPhone { get; set; }
public string? Email { get; set; }
public string? Address { get; set; }
public string? City { get; set; }
public string? State { get; set; }
public string? Zip { get; set; }
public string? GoogleMapsUrl { get; set; }
public string? Notes { get; set; }
}
}

View file

@ -105,6 +105,68 @@ namespace SeaHaven.Services.Implementation
return MapToDTO(saved);
}
public async Task<VendorRosterDTO> AddTechniciansAsync(
int companyId,
AddTechniciansVendorRosterDTO dto,
string userId,
CancellationToken cancellationToken)
{
EnsureAuthenticated(userId);
dto.AddTechnicians ??= new List<RosterTechnicianInputDTO>();
byte[] rowVersion = ParseRequiredRowVersion(dto.RowVersion);
var failures = new List<ValidationFailure>();
// Additive contract (SH-250): added technicians are always new rows, so an
// id from this or any other company is rejected instead of silently
// mutating an existing technician.
if (dto.AddTechnicians.Any(technician => technician.Id.HasValue))
failures.Add(new ValidationFailure(
nameof(AddTechniciansVendorRosterDTO.AddTechnicians),
"Technician ids are not allowed when adding technicians."));
var hasCompanyChange = dto.CompanyFields != null && HasAnyCompanyValue(dto.CompanyFields);
if (dto.AddTechnicians.Count == 0 && !hasCompanyChange)
failures.Add(new ValidationFailure(
nameof(AddTechniciansVendorRosterDTO.AddTechnicians),
"At least one technician to add or a company update is required."));
NormalizeAndValidateTechnicians(nameof(AddTechniciansVendorRosterDTO.AddTechnicians), dto.AddTechnicians, failures);
VendorRosterCompanyFieldsWriteModel? companyFields = null;
if (dto.CompanyFields != null)
{
companyFields = ValidateAndMapCompanyFields(dto.CompanyFields, failures);
// SH-250: blank company fields all map to null ("unchanged"), so an empty
// or all-blank CompanyFields object carries no company update. Forwarding
// it as a non-null write model made the data layer bump RowVersion and
// rewrite every technician's LastModificationTime for a no-op request.
if (HasNoCompanyChange(companyFields))
companyFields = null;
}
if (failures.Count > 0)
throw new ValidationException(failures);
// The contact-group invariant ("at least one company phone or email") is
// preserved by construction: blank company fields map to null ("unchanged"),
// so this endpoint can only set valid phone/email values, never clear them.
var writeModel = new VendorCompanyRosterAddWriteModel
{
CompanyId = companyId,
RowVersion = rowVersion,
ActorUserId = userId,
CompanyFields = companyFields,
AddTechnicians = MapTechnicians(dto.AddTechnicians)
};
var saved = await _rosterDataService.AddTechniciansAsync(writeModel, cancellationToken);
return MapToDTO(saved);
}
private static void EnsureAuthenticated(string userId)
{
if (string.IsNullOrWhiteSpace(userId))
@ -145,28 +207,7 @@ namespace SeaHaven.Services.Implementation
"Google Maps URL must be an absolute HTTPS URL."));
// Technician phones must be valid North American format when provided.
for (var i = 0; i < technicians.Count; i++)
{
var technician = technicians[i];
var normalized = VendorPhoneNormalizer.NormalizeToCanonical(technician.Phone);
technician.Phone = normalized;
if (!string.IsNullOrWhiteSpace(normalized) && !VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalized))
failures.Add(new ValidationFailure(
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.Phone)}",
"Phone must be in North American format: (XXX) XXX-XXXX"));
if (!string.IsNullOrWhiteSpace(technician.Email) && !BeValidEmail(technician.Email))
failures.Add(new ValidationFailure(
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.Email)}",
"Invalid email address."));
if (!string.IsNullOrWhiteSpace(technician.PreferredContact) &&
!VendorValidationRules.BeValidPreferredContact(technician.PreferredContact))
failures.Add(new ValidationFailure(
$"Technicians[{i}].{nameof(RosterTechnicianInputDTO.PreferredContact)}",
"PreferredContact must be one of: Phone, Email, Text"));
}
NormalizeAndValidateTechnicians(nameof(CreateVendorRosterDTO.Technicians), technicians, failures);
// Duplicate technician ids are not allowed.
var duplicateIds = technicians
@ -185,6 +226,102 @@ namespace SeaHaven.Services.Implementation
throw new ValidationException(failures);
}
// Normalizes technician phones in place and collects format failures using the
// supplied property-name prefix so each endpoint reports its own payload path.
private static void NormalizeAndValidateTechnicians(
string techniciansPropertyName,
List<RosterTechnicianInputDTO> technicians,
List<ValidationFailure> failures)
{
for (var i = 0; i < technicians.Count; i++)
{
var technician = technicians[i];
var normalized = VendorPhoneNormalizer.NormalizeToCanonical(technician.Phone);
technician.Phone = normalized;
if (!string.IsNullOrWhiteSpace(normalized) && !VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalized))
failures.Add(new ValidationFailure(
$"{techniciansPropertyName}[{i}].{nameof(RosterTechnicianInputDTO.Phone)}",
"Phone must be in North American format: (XXX) XXX-XXXX"));
if (!string.IsNullOrWhiteSpace(technician.Email) && !BeValidEmail(technician.Email))
failures.Add(new ValidationFailure(
$"{techniciansPropertyName}[{i}].{nameof(RosterTechnicianInputDTO.Email)}",
"Invalid email address."));
if (!string.IsNullOrWhiteSpace(technician.PreferredContact) &&
!VendorValidationRules.BeValidPreferredContact(technician.PreferredContact))
failures.Add(new ValidationFailure(
$"{techniciansPropertyName}[{i}].{nameof(RosterTechnicianInputDTO.PreferredContact)}",
"PreferredContact must be one of: Phone, Email, Text"));
}
}
// Validates optional company-level updates for the additive path and maps them
// to the write model. A null (or blank) field means "leave unchanged"; blank
// values normalize to null so a partial update can never clear data by accident.
private static bool HasAnyCompanyValue(VendorRosterCompanyFieldsDTO fields) =>
!string.IsNullOrWhiteSpace(fields.Name)
|| !string.IsNullOrWhiteSpace(fields.CompanyPhone)
|| !string.IsNullOrWhiteSpace(fields.Email)
|| !string.IsNullOrWhiteSpace(fields.Address)
|| !string.IsNullOrWhiteSpace(fields.City)
|| !string.IsNullOrWhiteSpace(fields.State)
|| !string.IsNullOrWhiteSpace(fields.Zip)
|| !string.IsNullOrWhiteSpace(fields.GoogleMapsUrl)
|| !string.IsNullOrWhiteSpace(fields.Notes);
private static bool HasNoCompanyChange(VendorRosterCompanyFieldsWriteModel model) =>
model.Name == null
&& model.CompanyPhone == null
&& model.Email == null
&& model.Address == null
&& model.City == null
&& model.State == null
&& model.Zip == null
&& model.GoogleMapsUrl == null
&& model.Notes == null;
private static VendorRosterCompanyFieldsWriteModel ValidateAndMapCompanyFields(
VendorRosterCompanyFieldsDTO fields,
List<ValidationFailure> failures)
{
var name = fields.Name?.Trim();
if (fields.Name != null && string.IsNullOrWhiteSpace(name))
failures.Add(new ValidationFailure(
nameof(VendorRosterCompanyFieldsDTO.Name),
"Company name is required."));
var normalizedPhone = VendorPhoneNormalizer.NormalizeToCanonical(fields.CompanyPhone);
if (normalizedPhone != null && !VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(normalizedPhone))
failures.Add(new ValidationFailure(
nameof(VendorRosterCompanyFieldsDTO.CompanyPhone),
"Company phone must be in North American format: (XXX) XXX-XXXX"));
if (!string.IsNullOrWhiteSpace(fields.Email) && !BeValidEmail(fields.Email))
failures.Add(new ValidationFailure(
nameof(VendorRosterCompanyFieldsDTO.Email),
"Invalid email address."));
if (!string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) && !BeValidAbsoluteHttpsUrl(fields.GoogleMapsUrl))
failures.Add(new ValidationFailure(
nameof(VendorRosterCompanyFieldsDTO.GoogleMapsUrl),
"Google Maps URL must be an absolute HTTPS URL."));
return new VendorRosterCompanyFieldsWriteModel
{
Name = name,
CompanyPhone = normalizedPhone,
Email = string.IsNullOrWhiteSpace(fields.Email) ? null : fields.Email,
Address = string.IsNullOrWhiteSpace(fields.Address) ? null : fields.Address,
City = string.IsNullOrWhiteSpace(fields.City) ? null : fields.City,
State = string.IsNullOrWhiteSpace(fields.State) ? null : fields.State,
Zip = string.IsNullOrWhiteSpace(fields.Zip) ? null : fields.Zip,
GoogleMapsUrl = string.IsNullOrWhiteSpace(fields.GoogleMapsUrl) ? null : fields.GoogleMapsUrl,
Notes = string.IsNullOrWhiteSpace(fields.Notes) ? null : fields.Notes
};
}
private async Task EnsureTechnicianIdsBelongToCompanyAsync(
int companyId,
List<RosterTechnicianInputDTO> technicians,

View file

@ -20,5 +20,11 @@ namespace SeaHaven.Services.Interfaces
ReconcileVendorRosterDTO dto,
string userId,
CancellationToken cancellationToken);
Task<VendorRosterDTO> AddTechniciansAsync(
int companyId,
AddTechniciansVendorRosterDTO dto,
string userId,
CancellationToken cancellationToken);
}
}