diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs index 2ee5d82..5542289 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterControllerTests.cs @@ -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 { new() { ContactName = "Riley", IsActive = true } } + }; + + [Fact] + public async Task Patch_Unauthenticated_Returns401() + { + var service = new Mock(); + var controller = NewController(service, userId: null); + + var result = await controller.AddTechnicians(7, PatchDto(), CancellationToken.None); + + result.Should().BeOfType(); + service.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Patch_AddsTechnicians_Returns200WithRoster() + { + var service = new Mock(); + service.Setup(x => x.AddTechniciansAsync(7, It.IsAny(), "42", It.IsAny())) + .ReturnsAsync(SampleRoster()); + + var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.StatusCode.Should().Be(StatusCodes.Status200OK); + ok.Value.Should().BeEquivalentTo(SampleRoster()); + } + + [Fact] + public async Task Patch_ValidationException_Returns400() + { + var service = new Mock(); + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + .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().Subject; + bad.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + } + + [Fact] + public async Task Patch_CompanyNotFound_Returns404() + { + var service = new Mock(); + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + .ThrowsAsync(new KeyNotFoundException("not found")); + + var result = await NewController(service).AddTechnicians(404, PatchDto(), CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Patch_StaleRowVersion_ReturnsStable409WithoutExceptionText() + { + var service = new Mock(); + service.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny(), "42", It.IsAny())) + .ThrowsAsync(new DbUpdateConcurrencyException("internal provider detail: UPDATE [VendorCompanies] ...")); + + var result = await NewController(service).AddTechnicians(7, PatchDto(), CancellationToken.None); + + var conflict = result.Should().BeOfType().Subject; + conflict.StatusCode.Should().Be(StatusCodes.Status409Conflict); + conflict.Value!.ToString()!.Should().NotContain("internal provider detail"); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs index d0f4bf4..2997c41 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterDataServiceTests.cs @@ -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() + }, 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(); + } + + 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() + }, 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 + { + 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 + { + 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 + { + new() { ContactName = "Should Not Persist", IsActive = true } + } + }, CancellationToken.None); + + await actCall.Should().ThrowAsync(); + } + + // 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 + { + new() { ContactName = "Ghost", IsActive = true } + } + }, CancellationToken.None); + + await act.Should().ThrowAsync(); + 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 + { + 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 { new() { ContactName = "T" } } + }, cts.Token); + + await act.Should().ThrowAsync(); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs index fdbb3c9..17c1280 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs @@ -380,4 +380,192 @@ public class VendorCompanyRosterServiceTests data.Verify(x => x.SaveRosterAsync(It.IsAny(), 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(); + var act = () => NewService(data).AddTechniciansAsync(7, AddDto(new RosterTechnicianInputDTO { ContactName = "T" }), "", CancellationToken.None); + await act.Should().ThrowAsync(); + data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddTechnicians_RejectsTechnicianIds() + { + var data = new Mock(); + var dto = AddDto(new RosterTechnicianInputDTO { Id = 999, ContactName = "Foreign" }); + + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.AddTechnicians)); + data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddTechnicians_RejectsEmptyPayload() + { + var data = new Mock(); + var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=" }; + + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddTechnicians_RejectsMissingRowVersion() + { + var data = new Mock(); + var dto = new AddTechniciansVendorRosterDTO + { + RowVersion = "", + AddTechnicians = new List { new() { ContactName = "T" } } + }; + + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().Contain(e => e.PropertyName == nameof(AddTechniciansVendorRosterDTO.RowVersion)); + data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddTechnicians_RejectsInvalidTechnicianPhoneFormat() + { + var data = new Mock(); + var dto = AddDto(new RosterTechnicianInputDTO { ContactName = "T", Phone = "555-1234" }); + + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone))); + data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddTechnicians_NormalizesAndMapsBeforeForwarding() + { + var data = new Mock(); + VendorCompanyRosterAddWriteModel? captured = null; + data.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(SampleReadModel(7, 1)); + + var dto = new AddTechniciansVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + AddTechnicians = new List + { + 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(); + VendorCompanyRosterAddWriteModel? captured = null; + data.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(SampleReadModel(7, 1)); + + var dto = new AddTechniciansVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + AddTechnicians = new List { 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(); + + var dto = new AddTechniciansVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + AddTechnicians = new List(), + CompanyFields = new VendorRosterCompanyFieldsDTO { CompanyPhone = " ", Email = "" } + }; + + var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", CancellationToken.None); + + await act.Should().ThrowAsync(); + data.Verify( + x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task AddTechnicians_RealCompanyValueStillForwarded() + { + var data = new Mock(); + VendorCompanyRosterAddWriteModel? captured = null; + data.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny())) + .Callback((model, _) => captured = model) + .ReturnsAsync(SampleReadModel(7, 1)); + + var dto = new AddTechniciansVendorRosterDTO + { + RowVersion = "AAAAAAAAD8I=", + AddTechnicians = new List(), + 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(); + data.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny())) + .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(), cts.Token), Times.Once); + } } diff --git a/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs b/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs index ec5fbca..8180b3b 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorCompanyRosterController.cs @@ -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 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) }); + } + } } } diff --git a/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs b/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs index be37058..2a23676 100644 --- a/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorCompanyRosterDataService.cs @@ -269,6 +269,149 @@ namespace SeaHaven.DataServices.Implementation ?? throw new InvalidOperationException("Roster could not be reloaded after create."); } + public async Task 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> GetOpenLinkedWorkOrdersAsync( IReadOnlyCollection vendorIds, CancellationToken cancellationToken) diff --git a/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs index c148793..45a7937 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorCompanyRosterDataService.cs @@ -24,5 +24,13 @@ namespace SeaHaven.DataServices.Interfaces Task 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 AddTechniciansAsync( + VendorCompanyRosterAddWriteModel roster, + CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs b/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs index c3899d5..33e1225 100644 --- a/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs +++ b/SeaHaven.DataServices/Models/VendorCompanyRosterModels.cs @@ -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 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. + /// + /// Raised when a company rename collides with the unique NormalizedName index. + /// Distinct from , which reports open + /// linked work orders, so callers can return a stable client conflict instead of + /// letting the raw + /// surface as a 500. + /// + public sealed class VendorRosterDuplicateNameException : Exception + { + public VendorRosterDuplicateNameException(string message, Exception inner) + : base(message, inner) + { + } + } + public sealed class VendorRosterConflictException : Exception { public IReadOnlyList BlockedWorkOrders { get; } diff --git a/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs b/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs index 0d97b68..3e20202 100644 --- a/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorCompanyRosterDTOs.cs @@ -67,4 +67,27 @@ namespace SeaHaven.Services.DTOs public string? Notes { get; set; } public List 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 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; } + } } diff --git a/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs b/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs index 30ca3b6..16f2c36 100644 --- a/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs +++ b/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs @@ -105,6 +105,68 @@ namespace SeaHaven.Services.Implementation return MapToDTO(saved); } + public async Task AddTechniciansAsync( + int companyId, + AddTechniciansVendorRosterDTO dto, + string userId, + CancellationToken cancellationToken) + { + EnsureAuthenticated(userId); + dto.AddTechnicians ??= new List(); + + byte[] rowVersion = ParseRequiredRowVersion(dto.RowVersion); + + var failures = new List(); + + // 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 technicians, + List 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 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 technicians, diff --git a/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs b/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs index 125b151..27d9d79 100644 --- a/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs +++ b/SeaHaven.Services/Interfaces/IVendorCompanyRosterService.cs @@ -20,5 +20,11 @@ namespace SeaHaven.Services.Interfaces ReconcileVendorRosterDTO dto, string userId, CancellationToken cancellationToken); + + Task AddTechniciansAsync( + int companyId, + AddTechniciansVendorRosterDTO dto, + string userId, + CancellationToken cancellationToken); } }