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");
}
[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]
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<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]
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<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]
public async Task AddTechnicians_BlankCompanyFieldsMeanUnchangedAndKeepContactGroup()
{

View file

@ -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<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))
throw new ValidationException(new[]
@ -77,7 +87,14 @@ namespace SeaHaven.Services.Implementation
{
EnsureAuthenticated(userId);
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);
@ -178,6 +195,8 @@ namespace SeaHaven.Services.Implementation
string? companyPhone,
string? email,
string? googleMapsUrl,
string? notes,
string notesPropertyName,
List<RosterTechnicianInputDTO> technicians)
{
var failures = new List<ValidationFailure>();
@ -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<ValidationFailure> failures)
{
if (notes?.Length > MaxNotesLength)
failures.Add(new ValidationFailure(propertyName, NotesMaxLengthMessage));
}
private async Task EnsureTechnicianIdsBelongToCompanyAsync(
int companyId,
List<RosterTechnicianInputDTO> technicians,