Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ public UpdateFarmSettingsValidator()
RuleFor(x => x.Name)
.Must(n => !string.IsNullOrWhiteSpace(n))
.WithMessage("A farm name is required.")
.MaximumLength(Account.MaxNameLength);
.WithErrorCode("FarmSettings.Name.Required")
.MaximumLength(Account.MaxNameLength)
.WithErrorCode("FarmSettings.Name.MaxLength");

// The timezone gate that keeps #35's fail-closed path unreachable in
// practice: FarmClock refuses to guess a date when the stored zone is
Expand All @@ -25,43 +27,57 @@ public UpdateFarmSettingsValidator()
RuleFor(x => x.TimeZoneId)
.Must(tz => !string.IsNullOrWhiteSpace(tz))
.WithMessage("A timezone is required.")
.WithErrorCode("FarmSettings.TimeZoneId.Required")
.MaximumLength(Account.MaxTimeZoneIdLength)
.WithErrorCode("FarmSettings.TimeZoneId.MaxLength")
.Must(BeAKnownTimeZone)
.WithMessage("Timezone must be a valid IANA timezone id, for example America/Los_Angeles.");
.WithMessage("Timezone must be a valid IANA timezone id, for example America/Los_Angeles.")
.WithErrorCode("FarmSettings.TimeZoneId.Known");

RuleFor(x => x.Locale)
.Must(l => !string.IsNullOrWhiteSpace(l))
.WithMessage("A locale is required.")
.WithErrorCode("FarmSettings.Locale.Required")
.MaximumLength(Account.MaxLocaleLength)
.WithErrorCode("FarmSettings.Locale.MaxLength")
.Must(BeASpecificCulture)
.WithMessage("Locale must be a BCP 47 tag that includes a region, for example en-US, es-MX or ja-JP.");
.WithMessage("Locale must be a BCP 47 tag that includes a region, for example en-US, es-MX or ja-JP.")
.WithErrorCode("FarmSettings.Locale.Format");

// Format only. An unlisted-but-well-formed code is legal and takes the
// §4.6 fallback (symbol = code, minor unit = 2) rather than a 400.
RuleFor(x => x.CurrencyCode)
.Must(CurrencyCatalog.IsWellFormedCode)
.WithMessage("Currency must be a three-letter ISO 4217 code, for example USD.");
.WithMessage("Currency must be a three-letter ISO 4217 code, for example USD.")
.WithErrorCode("FarmSettings.CurrencyCode.Format");

RuleFor(x => x.UnitSystem)
.Must(BeEnumName<UnitSystem>)
.WithMessage("Unit system must be Metric or Imperial.");
.WithMessage("Unit system must be Metric or Imperial.")
.WithErrorCode("FarmSettings.UnitSystem.Allowed");

RuleFor(x => x.FirstDayOfWeek)
.Must(d => string.IsNullOrWhiteSpace(d) || BeEnumName<DayOfWeek>(d))
.WithMessage("First day of week must be a day name, for example Monday, or empty to follow the locale.");
.WithMessage("First day of week must be a day name, for example Monday, or empty to follow the locale.")
.WithErrorCode("FarmSettings.FirstDayOfWeek.Allowed");

RuleFor(x => x.DateFormatOverride)
.MaximumLength(Account.MaxFormatOverrideLength)
.WithErrorCode("FarmSettings.DateFormatOverride.MaxLength")
.Must(BeAUsableFormat)
.WithMessage("Date format is not a usable .NET format string.");
.WithMessage("Date format is not a usable .NET format string.")
.WithErrorCode("FarmSettings.DateFormatOverride.Format");

RuleFor(x => x.TimeFormatOverride)
.MaximumLength(Account.MaxFormatOverrideLength)
.WithErrorCode("FarmSettings.TimeFormatOverride.MaxLength")
.Must(BeAUsableFormat)
.WithMessage("Time format is not a usable .NET format string.");
.WithMessage("Time format is not a usable .NET format string.")
.WithErrorCode("FarmSettings.TimeFormatOverride.Format");

RuleFor(x => x.Version)
.GreaterThanOrEqualTo(0);
.GreaterThanOrEqualTo(0)
.WithErrorCode("FarmSettings.Version.NonNegative");
}

// Exactly one name, nothing else. Enum.TryParse is far more permissive than
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,26 @@ internal static bool IsEggUnit(string input) =>

public CreateProductValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(Product.MaxNameLength);
RuleFor(x => x.Name).NotEmpty().WithErrorCode("Product.Name.Required")
.MaximumLength(Product.MaxNameLength).WithErrorCode("Product.Name.MaxLength");
RuleFor(x => x.ProductType)
// Only egg products are sellable today; the other types exist in
// the enum so the schema is ready, but creating one would produce
// a product nothing can price, allocate, or sell (#97 part 1).
.Must(t => !string.IsNullOrEmpty(t) && char.IsLetter(t[0])
&& Enum.TryParse<ProductType>(t, ignoreCase: true, out var parsed)
&& parsed == ProductType.Egg)
.WithMessage("Only egg products can be created in this phase.");
.WithMessage("Only egg products can be created in this phase.")
.WithErrorCode("Product.ProductType.Allowed");
RuleFor(x => x.DefaultUnit)
.Must(IsEggUnit)
.WithMessage("Egg products sell per egg, dozen, flat, tray, carton, or case.");
RuleFor(x => x.DefaultPriceMinorUnits).GreaterThanOrEqualTo(0)
.WithMessage("Egg products sell per egg, dozen, flat, tray, carton, or case.")
.WithErrorCode("Product.DefaultUnit.Allowed");
RuleFor(x => x.DefaultPriceMinorUnits).GreaterThanOrEqualTo(0).WithErrorCode("Product.DefaultPrice.NonNegative")
.When(x => x.DefaultPriceMinorUnits is not null);
RuleFor(x => x.EggGradeId).NotEmpty()
.WithMessage("An egg product must map to an egg grade.");
RuleFor(x => x.Notes).MaximumLength(Product.MaxNotesLength);
.WithMessage("An egg product must map to an egg grade.")
.WithErrorCode("Product.EggGradeId.Required");
RuleFor(x => x.Notes).MaximumLength(Product.MaxNotesLength).WithErrorCode("Product.Notes.MaxLength");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ public sealed class UpdateEggUnitConversionValidator : AbstractValidator<UpdateE
{
public UpdateEggUnitConversionValidator()
{
RuleFor(x => x.EggsPerUnit).GreaterThanOrEqualTo(1);
RuleFor(x => x.EggsPerUnit).GreaterThanOrEqualTo(1).WithErrorCode("EggUnitConversion.EggsPerUnit.Min");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ public sealed class UpdateProductValidator : AbstractValidator<UpdateProductComm
{
public UpdateProductValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(Product.MaxNameLength);
RuleFor(x => x.Name).NotEmpty().WithErrorCode("Product.Name.Required")
.MaximumLength(Product.MaxNameLength).WithErrorCode("Product.Name.MaxLength");
// All products are egg products this phase, so the egg-unit whitelist
// applies to updates too (codex review of #98).
RuleFor(x => x.DefaultUnit)
.Must(CreateProduct.CreateProductValidator.IsEggUnit)
.WithMessage("Egg products sell per egg, dozen, flat, tray, carton, or case.");
RuleFor(x => x.DefaultPriceMinorUnits).GreaterThanOrEqualTo(0)
.WithMessage("Egg products sell per egg, dozen, flat, tray, carton, or case.")
.WithErrorCode("Product.DefaultUnit.Allowed");
RuleFor(x => x.DefaultPriceMinorUnits).GreaterThanOrEqualTo(0).WithErrorCode("Product.DefaultPrice.NonNegative")
.When(x => x.DefaultPriceMinorUnits is not null);
RuleFor(x => x.EggGradeId).NotEmpty()
.WithMessage("An egg product must map to an egg grade.");
RuleFor(x => x.Notes).MaximumLength(Product.MaxNotesLength);
.WithMessage("An egg product must map to an egg grade.")
.WithErrorCode("Product.EggGradeId.Required");
RuleFor(x => x.Notes).MaximumLength(Product.MaxNotesLength).WithErrorCode("Product.Notes.MaxLength");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,20 @@ public CreateCustomerValidator()
// NotEmpty alone lets " " through to the domain guard (500, not 400).
RuleFor(x => x.Name)
.Must(v => !string.IsNullOrWhiteSpace(v)).WithMessage("Name is required.")
.MaximumLength(Customer.MaxNameLength);
.WithErrorCode("Customer.Name.Required")
.MaximumLength(Customer.MaxNameLength)
.WithErrorCode("Customer.Name.MaxLength");
RuleFor(x => x.Phone)
.Must(v => !string.IsNullOrWhiteSpace(v)).WithMessage("Phone is required.")
.MaximumLength(Customer.MaxPhoneLength);
RuleFor(x => x.Email).EmailAddress().MaximumLength(Customer.MaxEmailLength)
.WithErrorCode("Customer.Phone.Required")
.MaximumLength(Customer.MaxPhoneLength)
.WithErrorCode("Customer.Phone.MaxLength");
RuleFor(x => x.Email).EmailAddress().WithErrorCode("Customer.Email.Format")
.MaximumLength(Customer.MaxEmailLength).WithErrorCode("Customer.Email.MaxLength")
.When(x => !string.IsNullOrWhiteSpace(x.Email));
RuleFor(x => x.Address).MaximumLength(Customer.MaxAddressLength);
RuleFor(x => x.Note).MaximumLength(Customer.MaxNoteLength);
RuleFor(x => x.Address).MaximumLength(Customer.MaxAddressLength)
.WithErrorCode("Customer.Address.MaxLength");
RuleFor(x => x.Note).MaximumLength(Customer.MaxNoteLength)
.WithErrorCode("Customer.Note.MaxLength");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,38 @@ public sealed class AdjustDailyEntryValidator : AbstractValidator<AdjustDailyEnt
{
public AdjustDailyEntryValidator()
{
RuleFor(x => x.DailyEntryId).NotEmpty();
RuleFor(x => x.Version).GreaterThanOrEqualTo(0);
RuleFor(x => x.TotalEggs).GreaterThanOrEqualTo(0);
RuleFor(x => x.CrackedEggs).GreaterThanOrEqualTo(0);
RuleFor(x => x.DirtyEggs).GreaterThanOrEqualTo(0);
RuleFor(x => x.DiscardedEggs).GreaterThanOrEqualTo(0);
RuleFor(x => x.MortalityCount).GreaterThanOrEqualTo(0);
RuleFor(x => x.DailyEntryId).NotEmpty().WithErrorCode("DailyEntry.DailyEntryId.Required");
RuleFor(x => x.Version).GreaterThanOrEqualTo(0).WithErrorCode("DailyEntry.Version.NonNegative");
RuleFor(x => x.TotalEggs).GreaterThanOrEqualTo(0).WithErrorCode("DailyEntry.TotalEggs.NonNegative");
RuleFor(x => x.CrackedEggs).GreaterThanOrEqualTo(0).WithErrorCode("DailyEntry.CrackedEggs.NonNegative");
RuleFor(x => x.DirtyEggs).GreaterThanOrEqualTo(0).WithErrorCode("DailyEntry.DirtyEggs.NonNegative");
RuleFor(x => x.DiscardedEggs).GreaterThanOrEqualTo(0).WithErrorCode("DailyEntry.DiscardedEggs.NonNegative");
RuleFor(x => x.MortalityCount).GreaterThanOrEqualTo(0).WithErrorCode("DailyEntry.MortalityCount.NonNegative");
// long accumulation — three ints can overflow past a positive total.
RuleFor(x => x)
.Must(x => (long)x.CrackedEggs + x.DirtyEggs + x.DiscardedEggs <= x.TotalEggs)
.WithName("Eggs")
.WithMessage("Cracked + dirty + discarded cannot exceed total eggs.");
.WithMessage("Cracked + dirty + discarded cannot exceed total eggs.")
.WithErrorCode("DailyEntry.Eggs.SumWithinTotal");
// NotEmpty alone lets " " through to the domain guard (500, not 400).
RuleFor(x => x.Reason)
.Must(v => !string.IsNullOrWhiteSpace(v)).WithMessage("A reason is required.")
.MaximumLength(DailyEntry.MaxReasonLength);
.WithErrorCode("DailyEntry.Reason.Required")
.MaximumLength(DailyEntry.MaxReasonLength)
.WithErrorCode("DailyEntry.Reason.MaxLength");

When(x => x.Grades is { Count: > 0 }, () =>
{
RuleForEach(x => x.Grades!).ChildRules(g =>
{
g.RuleFor(x => x.EggGradeId).NotEmpty();
g.RuleFor(x => x.Quantity).GreaterThan(0);
g.RuleFor(x => x.EggGradeId).NotEmpty().WithErrorCode("DailyEntry.GradeEggGradeId.Required");
g.RuleFor(x => x.Quantity).GreaterThan(0).WithErrorCode("DailyEntry.GradeQuantity.Positive");
});

RuleFor(x => x.Grades!)
.Must(grades => grades.Select(g => g.EggGradeId).Distinct().Count() == grades.Count)
.WithMessage("Each grade may appear only once.");
.WithMessage("Each grade may appear only once.")
.WithErrorCode("DailyEntry.Grades.Unique");
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,37 @@ public sealed class RecordDailyEntryValidator : AbstractValidator<RecordDailyEnt
{
public RecordDailyEntryValidator(IFarmClock farmClock)
{
RuleFor(x => x.FarmId).NotEmpty();
RuleFor(x => x.HouseId).NotEmpty();
RuleFor(x => x.FlockId).NotEmpty();
RuleFor(x => x.FarmId).NotEmpty().WithErrorCode("DailyEntry.FarmId.Required");
RuleFor(x => x.HouseId).NotEmpty().WithErrorCode("DailyEntry.HouseId.Required");
RuleFor(x => x.FlockId).NotEmpty().WithErrorCode("DailyEntry.FlockId.Required");
// #35: the boundary is the farm's own today. The old rule compared
// against UTC today + 1 day of slack, which was a stopgap for farms
// ahead of UTC — it let a farm behind UTC post a genuinely future day.
RuleFor(x => x.Date)
.NotEmpty()
.WithErrorCode("DailyEntry.Date.Required")
.MustAsync(async (date, ct) => date <= await farmClock.TodayAsync(ct))
.WithMessage("Entry date cannot be in the future.");
RuleFor(x => x.TotalEggs).GreaterThanOrEqualTo(0);
RuleFor(x => x.CrackedEggs).GreaterThanOrEqualTo(0);
RuleFor(x => x.DirtyEggs).GreaterThanOrEqualTo(0);
RuleFor(x => x.DiscardedEggs).GreaterThanOrEqualTo(0);
RuleFor(x => x.MortalityCount).GreaterThanOrEqualTo(0);
.WithMessage("Entry date cannot be in the future.")
.WithErrorCode("DailyEntry.Date.NotFuture");
RuleFor(x => x.TotalEggs).GreaterThanOrEqualTo(0).WithErrorCode("DailyEntry.TotalEggs.NonNegative");
RuleFor(x => x.CrackedEggs).GreaterThanOrEqualTo(0).WithErrorCode("DailyEntry.CrackedEggs.NonNegative");
RuleFor(x => x.DirtyEggs).GreaterThanOrEqualTo(0).WithErrorCode("DailyEntry.DirtyEggs.NonNegative");
RuleFor(x => x.DiscardedEggs).GreaterThanOrEqualTo(0).WithErrorCode("DailyEntry.DiscardedEggs.NonNegative");
RuleFor(x => x.MortalityCount).GreaterThanOrEqualTo(0).WithErrorCode("DailyEntry.MortalityCount.NonNegative");
// long accumulation — three ints can overflow past a positive total
// (same fix as the adjust validator, codex review of PR #80).
RuleFor(x => x)
.Must(x => (long)x.CrackedEggs + x.DirtyEggs + x.DiscardedEggs <= x.TotalEggs)
.WithName("Eggs")
.WithMessage("Cracked + dirty + discarded cannot exceed total eggs.");
.WithMessage("Cracked + dirty + discarded cannot exceed total eggs.")
.WithErrorCode("DailyEntry.Eggs.SumWithinTotal");

When(x => x.Grades is { Count: > 0 }, () =>
{
RuleForEach(x => x.Grades!).ChildRules(g =>
{
g.RuleFor(x => x.EggGradeId).NotEmpty();
g.RuleFor(x => x.Quantity).GreaterThan(0);
g.RuleFor(x => x.EggGradeId).NotEmpty().WithErrorCode("DailyEntry.GradeEggGradeId.Required");
g.RuleFor(x => x.Quantity).GreaterThan(0).WithErrorCode("DailyEntry.GradeQuantity.Positive");
});

RuleFor(x => x.Grades!)
Expand All @@ -43,15 +46,17 @@ public RecordDailyEntryValidator(IFarmClock farmClock)
.Distinct()
.Count() == grades.Count)
.WithName("Grades")
.WithMessage("Each grade may appear only once.");
.WithMessage("Each grade may appear only once.")
.WithErrorCode("DailyEntry.Grades.Unique");

// Grades are the sellable portion of the same total the losses come out
// of; long accumulation so a pathological payload can't overflow Sum.
RuleFor(x => x)
.Must(x => x.Grades!.Sum(g => (long)g.Quantity)
<= (long)x.TotalEggs - x.CrackedEggs - x.DirtyEggs - x.DiscardedEggs)
.WithName("Grades")
.WithMessage("Graded quantities cannot exceed total eggs minus cracked/dirty/discarded.");
.WithMessage("Graded quantities cannot exceed total eggs minus cracked/dirty/discarded.")
.WithErrorCode("DailyEntry.Grades.WithinAvailable");
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ public sealed class VoidDailyEntryValidator : AbstractValidator<VoidDailyEntryCo
{
public VoidDailyEntryValidator()
{
RuleFor(x => x.DailyEntryId).NotEmpty();
RuleFor(x => x.Version).GreaterThanOrEqualTo(0);
RuleFor(x => x.DailyEntryId).NotEmpty().WithErrorCode("DailyEntry.DailyEntryId.Required");
RuleFor(x => x.Version).GreaterThanOrEqualTo(0).WithErrorCode("DailyEntry.Version.NonNegative");
RuleFor(x => x.Reason)
.Must(v => !string.IsNullOrWhiteSpace(v)).WithMessage("A reason is required.")
.MaximumLength(DailyEntry.MaxReasonLength);
.WithErrorCode("DailyEntry.Reason.Required")
.MaximumLength(DailyEntry.MaxReasonLength)
.WithErrorCode("DailyEntry.Reason.MaxLength");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ public CreateEggGradeValidator()
// EggGrade.Create and surface as a 500 instead of a 400.
.Must(n => !string.IsNullOrWhiteSpace(n))
.WithMessage("Grade name is required.")
.WithErrorCode("EggGrade.Name.Required")
.Must(n => n is null || n.Trim().Length <= EggGrade.MaxNameLength)
.WithMessage($"Grade name cannot exceed {EggGrade.MaxNameLength} characters.");
.WithMessage($"Grade name cannot exceed {EggGrade.MaxNameLength} characters.")
.WithErrorCode("EggGrade.Name.MaxLength");

RuleFor(c => c.GradeType)
// TryParse alone accepts numeric strings like "999"; IsDefined pins
// the value to the declared enum members.
.Must(t => Enum.TryParse<EggGradeType>(t, ignoreCase: true, out var parsed)
&& Enum.IsDefined(parsed))
.WithMessage("Grade type must be one of: Size, Quality, Custom.");
.WithMessage("Grade type must be one of: Size, Quality, Custom.")
.WithErrorCode("EggGrade.GradeType.Allowed");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ public sealed class UpdateEggGradeValidator : AbstractValidator<UpdateEggGradeCo
{
public UpdateEggGradeValidator()
{
RuleFor(c => c.EggGradeId).NotEmpty();
RuleFor(c => c.EggGradeId).NotEmpty().WithErrorCode("EggGrade.EggGradeId.Required");

RuleFor(c => c.Name)
.Must(n => !string.IsNullOrWhiteSpace(n))
.WithMessage("Grade name is required.")
.WithErrorCode("EggGrade.Name.Required")
.Must(n => n is null || n.Trim().Length <= EggGrade.MaxNameLength)
.WithMessage($"Grade name cannot exceed {EggGrade.MaxNameLength} characters.");
.WithMessage($"Grade name cannot exceed {EggGrade.MaxNameLength} characters.")
.WithErrorCode("EggGrade.Name.MaxLength");
}
}
Loading