fix(vendors): enforce notes length limit

This commit is contained in:
Alexandre Brandizzi 2026-08-20 18:17:48 -03:00
parent 7350da3e2f
commit 518d856334
2 changed files with 163 additions and 2 deletions

View file

@ -105,6 +105,48 @@ public class VendorCompanyRosterServiceTests
captured.Name.Should().Be("Acme"); captured.Name.Should().Be("Acme");
} }
[Fact]
public async Task Create_AcceptsNotesAt500Characters()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((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<IVendorCompanyRosterDataService>();
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<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
error.PropertyName == nameof(CreateVendorRosterDTO.Notes)
&& error.ErrorMessage == "Notes cannot exceed 500 characters.");
data.Verify(
x => x.CreateRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact] [Fact]
public async Task Reconcile_RejectsDuplicateTechnicianIds() public async Task Reconcile_RejectsDuplicateTechnicianIds()
{ {
@ -236,6 +278,50 @@ public class VendorCompanyRosterServiceTests
captured.Name.Should().Be("Acme Co"); captured.Name.Should().Be("Acme Co");
} }
[Fact]
public async Task Reconcile_AcceptsNotesAt500Characters()
{
var data = new Mock<IVendorCompanyRosterDataService>();
VendorCompanyRosterWriteModel? captured = null;
data.Setup(x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()))
.Callback<VendorCompanyRosterWriteModel, CancellationToken>((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<IVendorCompanyRosterDataService>();
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<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
error.PropertyName == nameof(ReconcileVendorRosterDTO.Notes)
&& error.ErrorMessage == "Notes cannot exceed 500 characters.");
data.Verify(
x => x.SaveRosterAsync(It.IsAny<VendorCompanyRosterWriteModel>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact] [Fact]
public async Task Reconcile_RejectsMalformedRowVersion() public async Task Reconcile_RejectsMalformedRowVersion()
{ {
@ -490,6 +576,46 @@ public class VendorCompanyRosterServiceTests
captured.CompanyFields.Email.Should().BeNull(); captured.CompanyFields.Email.Should().BeNull();
} }
[Fact]
public async Task AddTechnicians_AcceptsCompanyNotesAt500Characters()
{
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());
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<IVendorCompanyRosterDataService>();
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<ValidationException>())
.Which.Errors.Should().ContainSingle(error =>
error.PropertyName == "CompanyFields.Notes"
&& error.ErrorMessage == "Notes cannot exceed 500 characters.");
data.Verify(
x => x.AddTechniciansAsync(It.IsAny<VendorCompanyRosterAddWriteModel>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact] [Fact]
public async Task AddTechnicians_BlankCompanyFieldsMeanUnchangedAndKeepContactGroup() public async Task AddTechnicians_BlankCompanyFieldsMeanUnchangedAndKeepContactGroup()
{ {

View file

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