using Data.SeaHavenIndustries; using FluentAssertions; using FluentValidation; using Microsoft.Extensions.DependencyInjection; using Moq; using System.Security.Claims; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class VendorCompanyRosterServiceTests { private static readonly ClaimsPrincipal Dispatcher = Principal("User"); private static readonly ClaimsPrincipal Admin = Principal("Admin"); private static ClaimsPrincipal Principal(string role) => new(new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, "42"), new Claim(ClaimTypes.Role, role) }, "Test")); private static VendorCompanyRosterService NewService(Mock data) => new(data.Object); private static VendorCompanyRosterReadModel SampleReadModel(int companyId = 7, params int[] technicianIds) => new() { CompanyId = companyId, Name = "Acme", CompanyPhone = "(312) 555-0199", Email = "acme@example.com", RowVersion = new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 }, Technicians = technicianIds.Select(id => new VendorRosterTechnicianReadModel { Id = id, ContactName = $"Tech {id}", IsActive = true }).ToList() }; [Fact] public async Task Get_WithoutAuthenticatedUser_Throws() { var data = new Mock(); var act = () => NewService(data).GetRosterAsync(1, null, "", CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.GetRosterAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Get_WithoutVendorOrCompanyId_ThrowsValidation() { var data = new Mock(); var act = () => NewService(data).GetRosterAsync(null, null, "42", CancellationToken.None); await act.Should().ThrowAsync(); } [Fact] public async Task Create_WithoutCompanyPhoneOrEmail_ThrowsContactGroupValidation() { var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme" }; var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Create_InvalidCompanyPhone_DoesNotSatisfyContactGroup() { var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme", CompanyPhone = "not-a-phone" }; var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.CompanyPhone)); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Create_RejectsExistingTechnicianIds() { var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", Technicians = new List { new() { Id = 7, ContactName = "Riley" } } }; var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Create_AcceptsZeroTechniciansWhenContactGroupPresent() { var data = new Mock(); VendorCompanyRosterWriteModel? captured = null; data.Setup(x => x.CreateRosterAsync(It.IsAny(), It.IsAny())) .Callback((model, _) => captured = model) .ReturnsAsync(SampleReadModel()); await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", Technicians = new List() }, "42", Dispatcher, CancellationToken.None); captured.Should().NotBeNull(); captured!.Technicians.Should().BeEmpty(); captured.Name.Should().Be("Acme"); } [Fact] public async Task Create_AcceptsNotesAt500Characters() { var data = new Mock(); VendorCompanyRosterWriteModel? captured = null; data.Setup(x => x.CreateRosterAsync(It.IsAny(), It.IsAny())) .Callback((model, _) => captured = model) .ReturnsAsync(SampleReadModel()); var notes = new string('n', 500); await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", Notes = notes }, "42", Dispatcher, CancellationToken.None); captured!.Notes.Should().Be(notes); } [Fact] public async Task Create_RejectsNotesAt501Characters() { var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", Notes = new string('n', 501) }; var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => error.PropertyName == nameof(CreateVendorRosterDTO.Notes) && error.ErrorMessage == "Notes cannot exceed 500 characters."); data.Verify( x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Reconcile_RejectsDuplicateTechnicianIds() { var data = new Mock(); var dto = new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com", Technicians = new List { new() { Id = 5, ContactName = "Dup" }, new() { Id = 5, ContactName = "Dup Again" } } }; var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Reconcile_RejectsMismatchedTechnicianIds() { var data = new Mock(); data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) .ReturnsAsync(SampleReadModel(7, 1, 2)); var dto = new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com", Technicians = new List { new() { Id = 1, ContactName = "Ok" }, new() { Id = 999, ContactName = "Foreign" } } }; var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Create_RejectsNonHttpsMapUrl() { var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", GoogleMapsUrl = "http://maps.google.com/acme" }; var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorRosterDTO.GoogleMapsUrl)); } [Fact] public async Task Create_NormalizesTechnicianPhoneBeforeForwarding() { var data = new Mock(); VendorCompanyRosterWriteModel? captured = null; data.Setup(x => x.CreateRosterAsync(It.IsAny(), It.IsAny())) .Callback((model, _) => captured = model) .ReturnsAsync(SampleReadModel()); await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", Technicians = new List { new() { ContactName = "Riley", Phone = "+1 312 555 0100" } } }, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Single().Phone.Should().Be("(312) 555-0100"); } [Fact] public async Task Create_RejectsInvalidTechnicianPhoneFormat() { var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", Technicians = new List { new() { ContactName = "Riley", Phone = "555-1234" } } }; var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName.EndsWith(nameof(RosterTechnicianInputDTO.Phone))); } [Fact] public async Task Reconcile_ParsesRowVersionAndForwardsCompanyId() { var data = new Mock(); data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) .ReturnsAsync(SampleReadModel(7, 1)); VendorCompanyRosterWriteModel? captured = null; data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) .Callback((model, _) => captured = model) .ReturnsAsync(SampleReadModel(7, 1)); await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme Co", Email = "acme@example.com", Technicians = new List { new() { Id = 1, ContactName = "T" } } }, "42", Dispatcher, CancellationToken.None); captured.Should().NotBeNull(); captured!.CompanyId.Should().Be(7); captured.RowVersion.Should().Equal(Convert.FromBase64String("AAAAAAAAD8I=")); captured.Name.Should().Be("Acme Co"); } [Fact] public async Task Reconcile_AcceptsNotesAt500Characters() { var data = new Mock(); VendorCompanyRosterWriteModel? captured = null; data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) .Callback((model, _) => captured = model) .ReturnsAsync(SampleReadModel()); var notes = new string('n', 500); await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com", Notes = notes }, "42", Dispatcher, CancellationToken.None); captured!.Notes.Should().Be(notes); } [Fact] public async Task Reconcile_RejectsNotesAt501Characters() { var data = new Mock(); var dto = new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com", Notes = new string('n', 501) }; var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.Notes) && error.ErrorMessage == "Notes cannot exceed 500 characters."); data.Verify( x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Reconcile_RejectsMalformedRowVersion() { var data = new Mock(); data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) .ReturnsAsync(SampleReadModel(7)); var dto = new ReconcileVendorRosterDTO { RowVersion = "not-valid-base64!!", Name = "Acme", Email = "acme@example.com" }; var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(e => e.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion)); data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Reconcile_RejectsMissingRowVersion() { var data = new Mock(); var dto = new ReconcileVendorRosterDTO { RowVersion = "", Name = "Acme", Email = "acme@example.com" }; var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(error => error.PropertyName == nameof(ReconcileVendorRosterDTO.RowVersion)); data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Create_NullTechnicians_IsTreatedAsEmptyRoster() { var data = new Mock(); VendorCompanyRosterWriteModel? captured = null; data.Setup(x => x.CreateRosterAsync(It.IsAny(), It.IsAny())) .Callback((model, _) => captured = model) .ReturnsAsync(SampleReadModel()); var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", Technicians = null! }; await NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Should().BeEmpty(); } [Theory] [InlineData("Riley", "not-an-email", "Phone")] [InlineData("Riley", "tech@example.com", "CarrierPigeon")] public async Task Create_RejectsInvalidTechnicianFields(string name, string email, string preferredContact) { var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", Technicians = new List { new() { ContactName = name, Email = email, PreferredContact = preferredContact } } }; var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Create_AcceptsTechnicianWithoutNameOrPhone() { var data = new Mock(); VendorCompanyRosterWriteModel? captured = null; data.Setup(x => x.CreateRosterAsync(It.IsAny(), It.IsAny())) .Callback((model, _) => captured = model) .ReturnsAsync(SampleReadModel()); await NewService(data).CreateRosterAsync(new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", Technicians = new List { new() { ContactName = "", Phone = "", PreferredContact = "Phone" } } }, "42", Dispatcher, CancellationToken.None); captured!.Technicians.Should().ContainSingle(); captured.Technicians.Single().ContactName.Should().BeEmpty(); captured.Technicians.Single().Phone.Should().BeNullOrEmpty(); } [Fact] public async Task Reconcile_UnknownCompanyWithTechnicianIds_ThrowsValidation() { var data = new Mock(); data.Setup(x => x.GetRosterAsync(null, 404, It.IsAny())) .ReturnsAsync((VendorCompanyRosterReadModel?)null); var dto = new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com", Technicians = new List { new() { Id = 1, ContactName = "T" } } }; var act = () => NewService(data).ReconcileRosterAsync(404, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); } [Fact] public async Task Reconcile_ForwardsCancellation() { var data = new Mock(); data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) .ReturnsAsync(SampleReadModel(7, 1)); data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(SampleReadModel(7, 1)); using var cts = new CancellationTokenSource(); await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com", Technicians = new List { new() { Id = 1, ContactName = "T" } } }, "42", Dispatcher, cts.Token); 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" }), "", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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_AcceptsCompanyNotesAt500Characters() { var data = new Mock(); VendorCompanyRosterAddWriteModel? captured = null; data.Setup(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny())) .Callback((model, _) => captured = model) .ReturnsAsync(SampleReadModel()); var notes = new string('n', 500); await NewService(data).AddTechniciansAsync(7, new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = notes } }, "42", Dispatcher, CancellationToken.None); captured!.CompanyFields!.Notes.Should().Be(notes); } [Fact] public async Task AddTechnicians_RejectsCompanyNotesAt501Characters() { var data = new Mock(); var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", CompanyFields = new VendorRosterCompanyFieldsDTO { Notes = new string('n', 501) } }; var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().ContainSingle(error => error.PropertyName == "CompanyFields.Notes" && error.ErrorMessage == "Notes cannot exceed 500 characters."); data.Verify( x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); } [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", Dispatcher, 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", Dispatcher, 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", Dispatcher, 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", Dispatcher, cts.Token); data.Verify(x => x.AddTechniciansAsync(It.IsAny(), cts.Token), Times.Once); } private static VendorCompanyRosterReadModel ReadModelWithArea(int? areaId) { var model = SampleReadModel(7, 1); model.AreaId = areaId; return model; } [Fact] public async Task Create_NonAdminAssigningArea_IsForbiddenAndNotPersisted() { var data = new Mock(); var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 1 }; var act = () => NewService(data).CreateRosterAsync(dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Create_AdminAssigningUnknownOrArchivedArea_ThrowsValidation() { var data = new Mock(); data.Setup(x => x.IsActiveAreaAsync(99, It.IsAny())).ReturnsAsync(false); var dto = new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 99 }; var act = () => NewService(data).CreateRosterAsync(dto, "42", Admin, CancellationToken.None); (await act.Should().ThrowAsync()) .Which.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorRosterDTO.AreaId)); data.Verify(x => x.CreateRosterAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Create_AdminAssigningActiveArea_PersistsAreaId() { using var cts = new CancellationTokenSource(); var data = new Mock(); data.Setup(x => x.IsActiveAreaAsync(2, cts.Token)).ReturnsAsync(true); VendorCompanyRosterWriteModel? captured = null; data.Setup(x => x.CreateRosterAsync(It.IsAny(), It.IsAny())) .Callback((model, _) => captured = model) .ReturnsAsync(ReadModelWithArea(2)); var result = await NewService(data).CreateRosterAsync( new CreateVendorRosterDTO { Name = "Acme", Email = "acme@example.com", AreaId = 2 }, "42", Admin, cts.Token); captured!.AreaId.Should().Be(2); result.AreaId.Should().Be(2); data.Verify(x => x.IsActiveAreaAsync(2, cts.Token), Times.Once); } [Fact] public async Task Reconcile_NonAdminChangingOrClearingArea_IsForbiddenAndNotPersisted() { var data = new Mock(); data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) .ReturnsAsync(ReadModelWithArea(1)); foreach (int? requested in new int?[] { 3, null }) { var dto = new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com", AreaId = requested }; var act = () => NewService(data).ReconcileRosterAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); } data.Verify(x => x.SaveRosterAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task Reconcile_NonAdminRoundTripOrOmittedArea_SavesOtherFields() { var data = new Mock(); data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) .ReturnsAsync(ReadModelWithArea(1)); var captured = new List(); data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) .Callback((model, _) => captured.Add(model)) .ReturnsAsync(ReadModelWithArea(1)); await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme Renamed", Email = "acme@example.com", AreaId = 1 }, "42", Dispatcher, CancellationToken.None); await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme Renamed", Email = "acme@example.com" }, "42", Dispatcher, CancellationToken.None); captured.Should().HaveCount(2); captured[0].AreaIdProvided.Should().BeTrue(); captured[0].AreaId.Should().Be(1); captured[1].AreaIdProvided.Should().BeFalse(); } [Fact] public async Task Reconcile_AdminClearsAreaToUnassigned() { var data = new Mock(); data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) .ReturnsAsync(ReadModelWithArea(1)); VendorCompanyRosterWriteModel? captured = null; data.Setup(x => x.SaveRosterAsync(It.IsAny(), It.IsAny())) .Callback((model, _) => captured = model) .ReturnsAsync(ReadModelWithArea(null)); await NewService(data).ReconcileRosterAsync(7, new ReconcileVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", Name = "Acme", Email = "acme@example.com", AreaId = null }, "42", Admin, CancellationToken.None); captured!.AreaIdProvided.Should().BeTrue(); captured.AreaId.Should().BeNull(); data.Verify(x => x.IsActiveAreaAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task AddTechnicians_NonAdminAreaChange_IsForbidden() { var data = new Mock(); data.Setup(x => x.GetRosterAsync(null, 7, It.IsAny())) .ReturnsAsync(ReadModelWithArea(null)); var dto = new AddTechniciansVendorRosterDTO { RowVersion = "AAAAAAAAD8I=", CompanyFields = new VendorRosterCompanyFieldsDTO { AreaId = 2 } }; var act = () => NewService(data).AddTechniciansAsync(7, dto, "42", Dispatcher, CancellationToken.None); await act.Should().ThrowAsync(); data.Verify(x => x.AddTechniciansAsync(It.IsAny(), It.IsAny()), Times.Never); } [Fact] public void ReconcileDto_DistinguishesOmittedAreaFromExplicitNull() { var omitted = System.Text.Json.JsonSerializer.Deserialize( """{"rowVersion":"AAAAAAAAD8I=","name":"Acme"}""", new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; var cleared = System.Text.Json.JsonSerializer.Deserialize( """{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":null}""", new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; var assigned = System.Text.Json.JsonSerializer.Deserialize( """{"rowVersion":"AAAAAAAAD8I=","name":"Acme","areaId":3}""", new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!; omitted.AreaIdProvided.Should().BeFalse(); cleared.AreaIdProvided.Should().BeTrue(); cleared.AreaId.Should().BeNull(); assigned.AreaIdProvided.Should().BeTrue(); assigned.AreaId.Should().Be(3); } }