diff --git a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs index 17c1280..5991fd5 100644 --- a/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorCompanyRosterServiceTests.cs @@ -105,6 +105,48 @@ public class VendorCompanyRosterServiceTests 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", 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", 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() { @@ -236,6 +278,50 @@ public class VendorCompanyRosterServiceTests 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", 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", 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() { @@ -490,6 +576,46 @@ public class VendorCompanyRosterServiceTests 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", 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", 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() { diff --git a/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs b/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs index 16f2c36..c5b7676 100644 --- a/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs +++ b/SeaHaven.Services/Implementation/VendorCompanyRosterService.cs @@ -11,6 +11,9 @@ namespace SeaHaven.Services.Implementation { public class VendorCompanyRosterService : IVendorCompanyRosterService { + private const int MaxNotesLength = 500; + private const string NotesMaxLengthMessage = "Notes cannot exceed 500 characters."; + private readonly IVendorCompanyRosterDataService _rosterDataService; public VendorCompanyRosterService(IVendorCompanyRosterDataService rosterDataService) @@ -40,7 +43,14 @@ namespace SeaHaven.Services.Implementation { EnsureAuthenticated(userId); dto.Technicians ??= new List(); - NormalizeAndValidateCompanyFields(dto.Name, dto.CompanyPhone, dto.Email, dto.GoogleMapsUrl, dto.Technicians); + NormalizeAndValidateCompanyFields( + dto.Name, + dto.CompanyPhone, + dto.Email, + dto.GoogleMapsUrl, + dto.Notes, + nameof(CreateVendorRosterDTO.Notes), + dto.Technicians); if (dto.Technicians.Any(technician => technician.Id.HasValue)) throw new ValidationException(new[] @@ -77,7 +87,14 @@ namespace SeaHaven.Services.Implementation { EnsureAuthenticated(userId); dto.Technicians ??= new List(); - NormalizeAndValidateCompanyFields(dto.Name, dto.CompanyPhone, dto.Email, dto.GoogleMapsUrl, dto.Technicians); + NormalizeAndValidateCompanyFields( + dto.Name, + dto.CompanyPhone, + dto.Email, + dto.GoogleMapsUrl, + dto.Notes, + nameof(ReconcileVendorRosterDTO.Notes), + dto.Technicians); byte[] rowVersion = ParseRequiredRowVersion(dto.RowVersion); @@ -178,6 +195,8 @@ namespace SeaHaven.Services.Implementation string? companyPhone, string? email, string? googleMapsUrl, + string? notes, + string notesPropertyName, List technicians) { var failures = new List(); @@ -206,6 +225,8 @@ namespace SeaHaven.Services.Implementation nameof(CreateVendorRosterDTO.GoogleMapsUrl), "Google Maps URL must be an absolute HTTPS URL.")); + ValidateNotes(notes, notesPropertyName, failures); + // Technician phones must be valid North American format when provided. NormalizeAndValidateTechnicians(nameof(CreateVendorRosterDTO.Technicians), technicians, failures); @@ -308,6 +329,11 @@ namespace SeaHaven.Services.Implementation nameof(VendorRosterCompanyFieldsDTO.GoogleMapsUrl), "Google Maps URL must be an absolute HTTPS URL.")); + ValidateNotes( + fields.Notes, + $"{nameof(AddTechniciansVendorRosterDTO.CompanyFields)}.{nameof(VendorRosterCompanyFieldsDTO.Notes)}", + failures); + return new VendorRosterCompanyFieldsWriteModel { Name = name, @@ -322,6 +348,15 @@ namespace SeaHaven.Services.Implementation }; } + private static void ValidateNotes( + string? notes, + string propertyName, + List failures) + { + if (notes?.Length > MaxNotesLength) + failures.Add(new ValidationFailure(propertyName, NotesMaxLengthMessage)); + } + private async Task EnsureTechnicianIdsBelongToCompanyAsync( int companyId, List technicians,