From 391d775d48f256bd40f1f42b59a9f03850eaa335 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 09:20:55 -0500 Subject: [PATCH 1/2] Add the validation pipeline, custom validators and condition evaluation --- Directory.Packages.props | 1 + .../Content/Validation/ConditionEvaluator.cs | 58 ++++ .../Content/Validation/ConditionJson.cs | 53 +++ .../Validation/DeclarativeValidator.cs | 258 +++++++++++++++ .../Content/Validation/FieldValue.cs | 78 +++++ .../Content/Validation/ValidationPipeline.cs | 141 ++++++++ src/FluentCMS.Core/Schema/SchemaJsonReader.cs | 2 +- src/FluentCMS.Core/Schema/SchemaJsonWriter.cs | 2 +- src/FluentCMS.Sdk/Content/IEntryValidator.cs | 16 + src/FluentCMS.Sdk/Content/IFieldValidator.cs | 17 + .../Content/ValidationContext.cs | 110 ++++++ src/FluentCMS.Sdk/Content/ValidationError.cs | 7 + .../Content/ValidationErrorCodes.cs | 35 ++ .../Content/ValidationException.cs | 36 ++ src/FluentCMS.Sdk/Content/ValidationMode.cs | 11 + src/FluentCMS.Sdk/Content/ValidationResult.cs | 22 ++ src/FluentCMS.Sdk/PublicAPI.Unshipped.txt | 54 +++ .../Content/Validation/ConditionTests.cs | 177 ++++++++++ .../Validation/CustomValidatorTests.cs | 313 ++++++++++++++++++ .../Validation/DeclarativeRuleTests.cs | 212 ++++++++++++ .../Validation/NestedValidationTests.cs | 118 +++++++ .../Content/Validation/ValidationFixtures.cs | 55 +++ .../FluentCMS.Core.Tests.csproj | 1 + .../Content/ValidationTypesTests.cs | 62 ++++ 24 files changed, 1837 insertions(+), 2 deletions(-) create mode 100644 src/FluentCMS.Core/Content/Validation/ConditionEvaluator.cs create mode 100644 src/FluentCMS.Core/Content/Validation/ConditionJson.cs create mode 100644 src/FluentCMS.Core/Content/Validation/DeclarativeValidator.cs create mode 100644 src/FluentCMS.Core/Content/Validation/FieldValue.cs create mode 100644 src/FluentCMS.Core/Content/Validation/ValidationPipeline.cs create mode 100644 src/FluentCMS.Sdk/Content/IEntryValidator.cs create mode 100644 src/FluentCMS.Sdk/Content/IFieldValidator.cs create mode 100644 src/FluentCMS.Sdk/Content/ValidationContext.cs create mode 100644 src/FluentCMS.Sdk/Content/ValidationError.cs create mode 100644 src/FluentCMS.Sdk/Content/ValidationErrorCodes.cs create mode 100644 src/FluentCMS.Sdk/Content/ValidationException.cs create mode 100644 src/FluentCMS.Sdk/Content/ValidationMode.cs create mode 100644 src/FluentCMS.Sdk/Content/ValidationResult.cs create mode 100644 tests/FluentCMS.Core.Tests/Content/Validation/ConditionTests.cs create mode 100644 tests/FluentCMS.Core.Tests/Content/Validation/CustomValidatorTests.cs create mode 100644 tests/FluentCMS.Core.Tests/Content/Validation/DeclarativeRuleTests.cs create mode 100644 tests/FluentCMS.Core.Tests/Content/Validation/NestedValidationTests.cs create mode 100644 tests/FluentCMS.Core.Tests/Content/Validation/ValidationFixtures.cs create mode 100644 tests/FluentCMS.Sdk.Tests/Content/ValidationTypesTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 2f83626fe..4290572e7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,6 +7,7 @@ + diff --git a/src/FluentCMS.Core/Content/Validation/ConditionEvaluator.cs b/src/FluentCMS.Core/Content/Validation/ConditionEvaluator.cs new file mode 100644 index 000000000..d306b586d --- /dev/null +++ b/src/FluentCMS.Core/Content/Validation/ConditionEvaluator.cs @@ -0,0 +1,58 @@ +using System.Globalization; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content.Validation; + +public static class ConditionEvaluator +{ + public static bool Evaluate(Condition condition, IReadOnlyDictionary values) + { + ArgumentNullException.ThrowIfNull(condition); + ArgumentNullException.ThrowIfNull(values); + + switch (condition.Operator) + { + case ConditionOperator.Equal: + return Matches(Get(values, condition), condition.Values[0]); + case ConditionOperator.In: + var actual = Get(values, condition); + return condition.Values.Any(expected => Matches(actual, expected)); + case ConditionOperator.IsEmpty: + return FieldValue.IsEmpty(Get(values, condition)); + case ConditionOperator.And: + return condition.Operands.All(operand => Evaluate(operand, values)); + case ConditionOperator.Or: + return condition.Operands.Any(operand => Evaluate(operand, values)); + case ConditionOperator.Not: + return !Evaluate(condition.Operands[0], values); + default: + throw new ArgumentException($"Unknown condition operator {condition.Operator}.", nameof(condition)); + } + } + + // An unknown field is null. + private static object? Get(IReadOnlyDictionary values, Condition condition) => + condition.FieldName is { } name && values.TryGetValue(name, out var value) ? value : null; + + // A list matches when one of its items does (a multi select "equals" a value it contains), and an empty list matches null. + private static bool Matches(object? actual, object? expected) + { + if (FieldValue.Items(actual) is { } items) + { + var list = items.ToList(); + return expected is null ? list.Count == 0 : list.Any(item => Same(FieldValue.Normalize(item), expected)); + } + + return Same(FieldValue.Normalize(actual), expected); + } + + private static bool Same(object? actual, object? expected) => (actual, expected) switch + { + (null, null) => true, + (null, _) or (_, null) => false, + (string left, string right) => string.Equals(left, right, StringComparison.Ordinal), + (bool left, bool right) => left == right, + (long or decimal, long or decimal) => Convert.ToDecimal(actual, CultureInfo.InvariantCulture) == Convert.ToDecimal(expected, CultureInfo.InvariantCulture), + _ => false, + }; +} diff --git a/src/FluentCMS.Core/Content/Validation/ConditionJson.cs b/src/FluentCMS.Core/Content/Validation/ConditionJson.cs new file mode 100644 index 000000000..5a5d42613 --- /dev/null +++ b/src/FluentCMS.Core/Content/Validation/ConditionJson.cs @@ -0,0 +1,53 @@ +using System.Text; +using System.Text.Json; +using FluentCMS.Core.Schema; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content.Validation; + +// A condition in the JSON shape schema.json holds, by way of the schema reader and writer, so there is one format. +public static class ConditionJson +{ + public static void Write(Condition condition, Utf8JsonWriter writer) + { + ArgumentNullException.ThrowIfNull(condition); + ArgumentNullException.ThrowIfNull(writer); + SchemaJsonWriter.WriteCondition(writer, condition); + } + + public static string ToJson(Condition condition) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + Write(condition, writer); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + public static Condition Read(JsonElement element) + { + // The reader reports problems on a builder; one without types fails its build with exactly those problems. + var builder = new SchemaBuilder(); + var condition = new SchemaJsonReader(builder).ReadCondition(element, "condition"); + + try + { + builder.Build(); + } + catch (SchemaValidationException exception) + { + throw new JsonException($"The condition is not valid: {string.Join(" ", exception.Errors.Select(error => error.Message.TrimStart(':', ' ')))}"); + } + + return condition ?? throw new JsonException("The condition is not valid."); + } + + public static Condition Parse(string json) + { + ArgumentNullException.ThrowIfNull(json); + using var document = JsonDocument.Parse(json); + return Read(document.RootElement); + } +} diff --git a/src/FluentCMS.Core/Content/Validation/DeclarativeValidator.cs b/src/FluentCMS.Core/Content/Validation/DeclarativeValidator.cs new file mode 100644 index 000000000..e608bb508 --- /dev/null +++ b/src/FluentCMS.Core/Content/Validation/DeclarativeValidator.cs @@ -0,0 +1,258 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Text.RegularExpressions; +using FluentCMS.Content; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content.Validation; + +// A field that was checked, so the custom field validators see exactly the fields the declarative rules saw. +internal sealed record FieldVisit(SchemaType Owner, SchemaField Field, string Path, object? Value, IReadOnlyDictionary Siblings); + +// Required, RequiredWhen, length, range, pattern and select options, for every visible field, recursing into +// components and blocks. A block or component instance is a dictionary keyed by API field name (a block carries +// its block name under "type") or a CLR object whose public properties are the fields. +internal sealed class DeclarativeValidator(SchemaModel model) +{ + private const string TypeKey = "type"; + private const int MaxNestingDepth = 8; + private static readonly TimeSpan MatchTimeout = TimeSpan.FromSeconds(1); + + private readonly ConcurrentDictionary _patterns = new(); + + public void Validate(SchemaType type, IReadOnlyDictionary values, ValidationContext context, List visits) => + ValidateFields(type, values, "", context, visits, 0); + + private void ValidateFields(SchemaType type, IReadOnlyDictionary values, string prefix, ValidationContext context, List visits, int depth) + { + foreach (var field in type.Fields) + { + // A hidden field is not part of the entry, whatever it holds. + if (field.VisibleWhen is not null && !ConditionEvaluator.Evaluate(field.VisibleWhen, values)) + { + continue; + } + + values.TryGetValue(field.Name, out var value); + var path = prefix + field.Name; + visits.Add(new FieldVisit(type, field, path, value, values)); + + if (FieldValue.IsEmpty(value)) + { + CheckRequired(field, path, values, context); + continue; + } + + CheckLength(field, path, value, context); + CheckItems(field, path, value, context); + + if (depth < MaxNestingDepth) + { + Recurse(field, path, value, context, visits, depth + 1); + } + } + } + + private static void CheckRequired(SchemaField field, string path, IReadOnlyDictionary values, ValidationContext context) + { + if (context.Mode != ValidationMode.Publish) + { + return; + } + + if (field.Required) + { + context.AddError(path, ValidationErrorCodes.Required, "A value is required."); + } + else if (field.RequiredWhen is not null && ConditionEvaluator.Evaluate(field.RequiredWhen, values)) + { + context.AddError(path, ValidationErrorCodes.RequiredByCondition, $"A value is required when {field.RequiredWhen}."); + } + } + + // A text is limited in characters; a multiple field, and blocks, in items. + private static void CheckLength(SchemaField field, string path, object? value, ValidationContext context) + { + int length; + string unit; + if (IsList(field) && FieldValue.Items(value) is { } items) + { + length = items.Count(); + unit = "items"; + } + else if (!field.Multiple && value is string text) + { + length = text.Length; + unit = "characters"; + } + else + { + return; + } + + if (field.MinLength is { } min && length < min) + { + context.AddError(path, ValidationErrorCodes.MinLength, $"Must have at least {min} {unit}, has {length}."); + } + + if (field.MaxLength is { } max && length > max) + { + context.AddError(path, ValidationErrorCodes.MaxLength, $"Must have at most {max} {unit}, has {length}."); + } + } + + // The rules on the value itself, applied to each item of a multiple field at its own path (tags[1]). + private void CheckItems(SchemaField field, string path, object? value, ValidationContext context) + { + if (field.FieldType is ValidatedFieldTypes.Component or ValidatedFieldTypes.Blocks) + { + return; + } + + if (field.Multiple && FieldValue.Items(value) is { } items) + { + var index = 0; + foreach (var item in items) + { + CheckValue(field, $"{path}[{index++}]", item, context); + } + } + else if (!field.Multiple) + { + CheckValue(field, path, value, context); + } + } + + private void CheckValue(SchemaField field, string path, object? value, ValidationContext context) + { + if (FieldValue.IsEmpty(value)) + { + return; + } + + if (RangeValue(value) is { } number) + { + if (field.Minimum is { } min && number < min) + { + context.AddError(path, ValidationErrorCodes.BelowMinimum, $"Must be at least {Format(min)}."); + } + + if (field.Maximum is { } max && number > max) + { + context.AddError(path, ValidationErrorCodes.AboveMaximum, $"Must be at most {Format(max)}."); + } + } + + if (field.Pattern is not null && value is string text && !Matches(field, text)) + { + context.AddError(path, ValidationErrorCodes.PatternMismatch, $"Does not match the pattern {field.Pattern}."); + } + + if (field.FieldType == ValidatedFieldTypes.Select + && field.Settings.TryGetValue(FieldSettings.Options, out var setting) + && setting is IEnumerable options + && FieldValue.Normalize(value) is { } option + && !options.Contains(option as string, StringComparer.Ordinal)) + { + context.AddError(path, ValidationErrorCodes.NotAnAllowedOption, $"{option} is not one of: {string.Join(", ", options)}."); + } + } + + private void Recurse(SchemaField field, string path, object? value, ValidationContext context, List visits, int depth) + { + if (field.FieldType == ValidatedFieldTypes.Component + && field.Settings.TryGetValue(FieldSettings.Component, out var name) + && model.FindComponent((string)name) is { } component) + { + if (field.Multiple) + { + var index = 0; + foreach (var item in FieldValue.Items(value) ?? []) + { + ValidateInstance(component, item, $"{path}[{index++}].", context, visits, depth); + } + } + else + { + ValidateInstance(component, value, $"{path}.", context, visits, depth); + } + } + else if (field.FieldType == ValidatedFieldTypes.Blocks) + { + var allowed = field.Settings.TryGetValue(FieldSettings.Blocks, out var setting) && setting is IEnumerable names ? names.ToList() : []; + var index = 0; + foreach (var item in FieldValue.Items(value) ?? []) + { + var itemPath = $"{path}[{index++}]"; + var block = FindBlock(item); + if (block is null || !allowed.Contains(block.Name)) + { + context.AddError(itemPath, ValidationErrorCodes.NotAnAllowedOption, $"The block type {BlockName(item) ?? "(none)"} is not one of: {string.Join(", ", allowed)}."); + continue; + } + + ValidateInstance(block, item, $"{itemPath}.", context, visits, depth); + } + } + } + + private void ValidateInstance(SchemaType type, object? instance, string prefix, ValidationContext context, List visits, int depth) + { + if (FieldValue.ReadInstance(instance) is { } values) + { + ValidateFields(type, values, prefix, context, visits, depth); + } + } + + private SchemaType? FindBlock(object? item) => BlockName(item) is { } name ? model.FindBlock(name) : null; + + private string? BlockName(object? item) + { + if (item is IReadOnlyDictionary dictionary) + { + return dictionary.TryGetValue(TypeKey, out var name) ? name as string : null; + } + + if (item is IDictionary mutable) + { + return mutable.TryGetValue(TypeKey, out var name) ? name as string : null; + } + + return item is null ? null : model.Blocks.FirstOrDefault(block => block.ClrType == item.GetType())?.Name; + } + + private bool Matches(SchemaField field, string text) + { + var regex = _patterns.GetOrAdd(field, static f => new Regex(f.Pattern!, RegexOptions.CultureInvariant, MatchTimeout)); + try + { + return regex.IsMatch(text); + } + catch (RegexMatchTimeoutException) + { + return false; + } + } + + private static bool IsList(SchemaField field) => field.Multiple || field.FieldType == ValidatedFieldTypes.Blocks; + + // Numbers as they are; a date as days and a date-time as seconds since 1970-01-01, which the minimum and maximum then use. + private static decimal? RangeValue(object? value) => value switch + { + byte or sbyte or short or ushort or int or uint or long or ulong or decimal => Convert.ToDecimal(value, CultureInfo.InvariantCulture), + float or double => double.IsFinite(Convert.ToDouble(value, CultureInfo.InvariantCulture)) ? (decimal)Convert.ToDouble(value, CultureInfo.InvariantCulture) : null, + DateOnly day => day.DayNumber - DateOnly.FromDateTime(DateTime.UnixEpoch).DayNumber, + DateTimeOffset moment => moment.ToUnixTimeSeconds(), + DateTime moment => new DateTimeOffset(moment.ToUniversalTime()).ToUnixTimeSeconds(), + _ => null, + }; + + private static string Format(decimal number) => number.ToString("G29", CultureInfo.InvariantCulture); +} + +internal static class ValidatedFieldTypes +{ + public const string Select = "select"; + public const string Component = "component"; + public const string Blocks = "blocks"; +} diff --git a/src/FluentCMS.Core/Content/Validation/FieldValue.cs b/src/FluentCMS.Core/Content/Validation/FieldValue.cs new file mode 100644 index 000000000..534f8acf8 --- /dev/null +++ b/src/FluentCMS.Core/Content/Validation/FieldValue.cs @@ -0,0 +1,78 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Reflection; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content.Validation; + +// How validation reads a value: whether it is empty, what it compares as, and how a component or block instance +// is read, either a dictionary keyed by API field name or a CLR object whose public properties are the fields. +internal static class FieldValue +{ + private static readonly ConcurrentDictionary Properties = new(); + + public static bool IsEmpty(object? value) => value switch + { + null => true, + string text => string.IsNullOrWhiteSpace(text), + Guid id => id == Guid.Empty, + IEnumerable items => !items.GetEnumerator().MoveNext(), + _ => IsEmptyRef(value), + }; + + // The value as a schema stores it: null, string, bool, long or decimal. An enum member is its camelCase + // name, a reference is its id. Anything else cannot be compared with a condition value. + public static object? Normalize(object? value) + { + if (value is null) + { + return null; + } + + if (IsRef(value.GetType())) + { + return value.GetType().GetProperty("Id")!.GetValue(value)?.ToString(); + } + + try + { + return SchemaValue.Normalize(value); + } + catch (ArgumentException) + { + return value; + } + } + + public static IEnumerable? Items(object? value) => + value is IEnumerable and not string and not IReadOnlyDictionary ? ((IEnumerable)value).Cast() : null; + + public static IReadOnlyDictionary? ReadInstance(object? instance) + { + switch (instance) + { + case null: + return null; + case IReadOnlyDictionary dictionary: + return dictionary; + case IDictionary mutable: + return new Dictionary(mutable, StringComparer.Ordinal); + } + + var values = new Dictionary(StringComparer.Ordinal); + foreach (var property in Properties.GetOrAdd(instance.GetType(), ReadableProperties)) + { + values[SchemaNaming.ToCamelCase(property.Name)] = property.GetValue(instance); + } + + return values; + } + + private static PropertyInfo[] ReadableProperties(Type type) => + [.. type.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(property => property.GetIndexParameters().Length == 0 && property.CanRead)]; + + private static bool IsRef(Type type) => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Ref<>); + + private static bool IsEmptyRef(object value) => + IsRef(value.GetType()) && (bool)value.GetType().GetProperty("IsEmpty")!.GetValue(value)!; +} diff --git a/src/FluentCMS.Core/Content/Validation/ValidationPipeline.cs b/src/FluentCMS.Core/Content/Validation/ValidationPipeline.cs new file mode 100644 index 000000000..dd626fd5c --- /dev/null +++ b/src/FluentCMS.Core/Content/Validation/ValidationPipeline.cs @@ -0,0 +1,141 @@ +using System.Collections.Concurrent; +using System.Reflection; +using FluentCMS.Content; +using FluentCMS.Schema; + +namespace FluentCMS.Core.Content.Validation; + +public sealed class ValidationPipeline(SchemaModel model, IServiceProvider services) +{ + private readonly DeclarativeValidator _declarative = new(model); + private readonly ConcurrentDictionary<(Type Owner, string Field), Type?> _propertyTypes = new(); + + public async ValueTask ValidateAsync( + SchemaType type, + IReadOnlyDictionary values, + object? entry, + OperationContext operation, + ValidationMode mode, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(values); + ArgumentNullException.ThrowIfNull(operation); + + var context = new ValidationContext(type, values, operation, mode, entry); + + var visits = new List(); + _declarative.Validate(type, values, context, visits); + + foreach (var visit in visits) + { + await RunFieldValidatorsAsync(visit, context, cancellationToken); + } + + if (entry is not null) + { + await RunEntryValidatorsAsync(entry, context, cancellationToken); + } + + // Uniqueness (a database query, #2693) and reference integrity on publish (#2695) slot in here, after the + // validators that need no I/O, so they only run for an entry that is otherwise sound. + return context.Errors.Count == 0 ? ValidationResult.Success : new ValidationResult(context.Errors); + } + + private async ValueTask RunFieldValidatorsAsync(FieldVisit visit, ValidationContext root, CancellationToken cancellationToken) + { + var fieldType = FieldClrType(visit); + var context = root.ForField(visit.Field, visit.Path, visit.Siblings); + + if (fieldType is not null && fieldType != typeof(object)) + { + await FieldValidatorInvoker.For(fieldType).InvokeAsync(services, visit.Value, context, cancellationToken); + } + + await FieldValidatorInvoker.For(typeof(object)).InvokeAsync(services, visit.Value, context, cancellationToken); + } + + private async ValueTask RunEntryValidatorsAsync(object entry, ValidationContext root, CancellationToken cancellationToken) + { + var context = root.ForEntry(); + for (var type = entry.GetType(); type is not null && type != typeof(object); type = type.BaseType) + { + await EntryValidatorInvoker.For(type).InvokeAsync(services, entry, context, cancellationToken); + } + } + + // The type of the property the field was read from; a field of a type defined without a class has the type of its value. + private Type? FieldClrType(FieldVisit visit) + { + var declared = visit.Owner.ClrType is { } owner + ? _propertyTypes.GetOrAdd((owner, visit.Field.Name), static key => PropertyType(key.Owner, key.Field)) + : null; + + return declared ?? visit.Value?.GetType(); + } + + private static Type? PropertyType(Type owner, string fieldName) + { + var property = owner.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(candidate => SchemaNaming.ToCamelCase(candidate.Name) == fieldName); + + return property is null ? null : Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + } + + private abstract class FieldValidatorInvoker + { + private static readonly ConcurrentDictionary Cache = new(); + + public static FieldValidatorInvoker For(Type fieldType) => + Cache.GetOrAdd(fieldType, static type => (FieldValidatorInvoker)Activator.CreateInstance(typeof(FieldValidatorInvoker<>).MakeGenericType(type))!); + + public abstract ValueTask InvokeAsync(IServiceProvider services, object? value, ValidationContext context, CancellationToken cancellationToken); + } + + private sealed class FieldValidatorInvoker : FieldValidatorInvoker + { + public override async ValueTask InvokeAsync(IServiceProvider services, object? value, ValidationContext context, CancellationToken cancellationToken) + { + // A null cannot be given to a validator of a value type. + if (services.GetService(typeof(IEnumerable>)) is not IEnumerable> validators + || (value is null && typeof(T).IsValueType) + || (value is not null && value is not T)) + { + return; + } + + foreach (var validator in validators) + { + cancellationToken.ThrowIfCancellationRequested(); + await validator.ValidateAsync((T?)value, context, cancellationToken); + } + } + } + + private abstract class EntryValidatorInvoker + { + private static readonly ConcurrentDictionary Cache = new(); + + public static EntryValidatorInvoker For(Type entryType) => + Cache.GetOrAdd(entryType, static type => (EntryValidatorInvoker)Activator.CreateInstance(typeof(EntryValidatorInvoker<>).MakeGenericType(type))!); + + public abstract ValueTask InvokeAsync(IServiceProvider services, object entry, ValidationContext context, CancellationToken cancellationToken); + } + + private sealed class EntryValidatorInvoker : EntryValidatorInvoker + { + public override async ValueTask InvokeAsync(IServiceProvider services, object entry, ValidationContext context, CancellationToken cancellationToken) + { + if (services.GetService(typeof(IEnumerable>)) is not IEnumerable> validators) + { + return; + } + + foreach (var validator in validators) + { + cancellationToken.ThrowIfCancellationRequested(); + await validator.ValidateAsync((T)entry, context, cancellationToken); + } + } + } +} diff --git a/src/FluentCMS.Core/Schema/SchemaJsonReader.cs b/src/FluentCMS.Core/Schema/SchemaJsonReader.cs index d2e9ff7dd..3f116578c 100644 --- a/src/FluentCMS.Core/Schema/SchemaJsonReader.cs +++ b/src/FluentCMS.Core/Schema/SchemaJsonReader.cs @@ -356,7 +356,7 @@ private bool ReadDisplay(JsonElement element, string path, List f.Display(label, help, placeholder, group, tab, width)); } - private Condition? ReadCondition(JsonElement element, string path) + internal Condition? ReadCondition(JsonElement element, string path) { if (element.ValueKind != JsonValueKind.Object) { diff --git a/src/FluentCMS.Core/Schema/SchemaJsonWriter.cs b/src/FluentCMS.Core/Schema/SchemaJsonWriter.cs index 9b64d23ec..106a5c058 100644 --- a/src/FluentCMS.Core/Schema/SchemaJsonWriter.cs +++ b/src/FluentCMS.Core/Schema/SchemaJsonWriter.cs @@ -136,7 +136,7 @@ private static void WriteField(Utf8JsonWriter writer, SchemaField field) writer.WriteEndObject(); } - private static void WriteCondition(Utf8JsonWriter writer, Condition condition) + internal static void WriteCondition(Utf8JsonWriter writer, Condition condition) { writer.WriteStartObject(); switch (condition.Operator) diff --git a/src/FluentCMS.Sdk/Content/IEntryValidator.cs b/src/FluentCMS.Sdk/Content/IEntryValidator.cs new file mode 100644 index 000000000..8436f2db4 --- /dev/null +++ b/src/FluentCMS.Sdk/Content/IEntryValidator.cs @@ -0,0 +1,16 @@ +namespace FluentCMS.Content; + +/// +/// Validates a whole entry, for rules that span fields or need a service. Register it in the service collection; +/// it runs after every field has been checked, for entries whose CLR type is or derives from it. +/// +/// The CLR type of the entry. +public interface IEntryValidator +{ + /// Validates an entry, adding errors to the context. + /// The entry. + /// What the validator knows about the entry; is . + /// Cancels the validation. + /// A task that completes when the entry has been checked. + ValueTask ValidateAsync(T entry, ValidationContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Content/IFieldValidator.cs b/src/FluentCMS.Sdk/Content/IFieldValidator.cs new file mode 100644 index 000000000..d00baaecb --- /dev/null +++ b/src/FluentCMS.Sdk/Content/IFieldValidator.cs @@ -0,0 +1,17 @@ +namespace FluentCMS.Content; + +/// +/// Validates the value of a field. Register it in the service collection; it runs for every field whose +/// CLR type is (use to see every field) after the declarative +/// rules, and is skipped for a hidden field. A value reaches only validators of reference types. +/// +/// The CLR type of the field value. +public interface IFieldValidator +{ + /// Validates a value, adding errors to the context. + /// The value of the field. + /// What the validator knows about the entry, with set. + /// Cancels the validation. + /// A task that completes when the value has been checked. + ValueTask ValidateAsync(TField? value, ValidationContext context, CancellationToken cancellationToken); +} diff --git a/src/FluentCMS.Sdk/Content/ValidationContext.cs b/src/FluentCMS.Sdk/Content/ValidationContext.cs new file mode 100644 index 000000000..aae16836c --- /dev/null +++ b/src/FluentCMS.Sdk/Content/ValidationContext.cs @@ -0,0 +1,110 @@ +using FluentCMS.Schema; + +namespace FluentCMS.Content; + +/// What a custom validator knows about the entry being validated, and where it reports problems. +public sealed class ValidationContext +{ + private readonly List _errors; + + /// Creates a context, for the pipeline or for unit tests of a validator. + /// The type of the entry. + /// The values of the entry, by API field name. + /// The operation that causes the validation. + /// Whether a draft is saved or the entry is published. + /// The typed entry, if there is one. + /// The field being validated, or for an entry validator. + /// The path of that field; defaults to its name. + public ValidationContext( + SchemaType type, + IReadOnlyDictionary values, + OperationContext operation, + ValidationMode mode, + object? entry = null, + SchemaField? field = null, + string? path = null) + : this(type, values, operation, mode, entry, field, path, []) + { + } + + private ValidationContext( + SchemaType type, + IReadOnlyDictionary values, + OperationContext operation, + ValidationMode mode, + object? entry, + SchemaField? field, + string? path, + List errors) + { + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(values); + ArgumentNullException.ThrowIfNull(operation); + + Type = type; + Values = values; + Operation = operation; + Mode = mode; + Entry = entry; + Field = field; + Path = field is null ? "" : path ?? field.Name; + _errors = errors; + } + + /// The type of the entry. + public SchemaType Type { get; } + + /// The field being validated, or for an entry validator. + public SchemaField? Field { get; } + + /// The path of in the API shape, such as seo.metaTitle; empty for an entry validator. + public string Path { get; } + + /// The typed entry, or when the entry is only a set of values. + public object? Entry { get; } + + /// The values next to the field, by API field name. For a field of a component or block, those of the same component or block. + public IReadOnlyDictionary Values { get; } + + /// The operation that causes the validation; it gives the principal, origin, site and locale. + public OperationContext Operation { get; } + + /// Whether a draft is saved or the entry is published. + public ValidationMode Mode { get; } + + /// Every error added so far, by the declarative rules and by validators. + public IReadOnlyList Errors => _errors; + + /// Adds an error at a path. + /// The path in the API shape; empty for the entry as a whole. + /// A stable code; plugins should use their own prefix. An empty code becomes . + /// A message for people. + public void AddError(string path, string code, string message) + { + ArgumentNullException.ThrowIfNull(path); + ArgumentException.ThrowIfNullOrEmpty(message); + _errors.Add(new ValidationError(path, string.IsNullOrEmpty(code) ? ValidationErrorCodes.Custom : code, message)); + } + + /// Adds an error to the field being validated. + /// A stable code. An empty code becomes . + /// A message for people. + /// There is no current field, as in an entry validator. + public void AddError(string code, string message) + { + if (Field is null) + { + throw new InvalidOperationException("There is no current field; give the path of the error."); + } + + AddError(Path, code, message); + } + + // Shares the error list, so what a field validator reports lands in the result of the run. + internal ValidationContext ForField(SchemaField field, string path, IReadOnlyDictionary values) => + new(Type, values, Operation, Mode, Entry, field, path, _errors); + + // The entry-level view of the same run. + internal ValidationContext ForEntry() => + new(Type, Values, Operation, Mode, Entry, null, null, _errors); +} diff --git a/src/FluentCMS.Sdk/Content/ValidationError.cs b/src/FluentCMS.Sdk/Content/ValidationError.cs new file mode 100644 index 000000000..392dee4ef --- /dev/null +++ b/src/FluentCMS.Sdk/Content/ValidationError.cs @@ -0,0 +1,7 @@ +namespace FluentCMS.Content; + +/// One problem found while validating an entry. +/// The path of the field in the API shape, such as videoUrl, seo.metaTitle, sections[2].heading or tags[1]. Empty for a problem with the entry as a whole. +/// A stable code, FCMS21xx for the built-in rules; see . +/// A message for people. +public sealed record ValidationError(string Path, string Code, string Message); diff --git a/src/FluentCMS.Sdk/Content/ValidationErrorCodes.cs b/src/FluentCMS.Sdk/Content/ValidationErrorCodes.cs new file mode 100644 index 000000000..422702121 --- /dev/null +++ b/src/FluentCMS.Sdk/Content/ValidationErrorCodes.cs @@ -0,0 +1,35 @@ +namespace FluentCMS.Content; + +/// The codes of the built-in validation rules, so plugins and clients can match on them. +public static class ValidationErrorCodes +{ + /// A required field has no value. + public const string Required = "FCMS2101"; + + /// The value is shorter than the field's minimum length, or a multiple field has too few items. + public const string MinLength = "FCMS2102"; + + /// The value is longer than the field's maximum length, or a multiple field has too many items. + public const string MaxLength = "FCMS2103"; + + /// The value is below the field's minimum. + public const string BelowMinimum = "FCMS2104"; + + /// The value is above the field's maximum. + public const string AboveMaximum = "FCMS2105"; + + /// The value does not match the field's regular expression. + public const string PatternMismatch = "FCMS2106"; + + /// The value is not one of the allowed options, or a block is of a type the field does not allow. + public const string NotAnAllowedOption = "FCMS2107"; + + /// The value cannot be read as the type of the field. + public const string InvalidValue = "FCMS2108"; + + /// A custom validator rejected the value without giving a code. + public const string Custom = "FCMS2109"; + + /// The field's RequiredWhen condition holds and the field has no value. + public const string RequiredByCondition = "FCMS2110"; +} diff --git a/src/FluentCMS.Sdk/Content/ValidationException.cs b/src/FluentCMS.Sdk/Content/ValidationException.cs new file mode 100644 index 000000000..e57dc179d --- /dev/null +++ b/src/FluentCMS.Sdk/Content/ValidationException.cs @@ -0,0 +1,36 @@ +namespace FluentCMS.Content; + +/// +/// Thrown when an entry breaks validation rules. The HTTP layer maps it to a 422 problem details response +/// whose errors array holds { path, code, message } for each error. +/// +public sealed class ValidationException : Exception +{ + /// Creates the exception. + /// The errors; there must be at least one. + public ValidationException(IEnumerable errors) + : base(BuildMessage(errors, out var list)) + { + Errors = list; + } + + /// The errors, in the order they were found. + public IReadOnlyList Errors { get; } + + private static string BuildMessage(IEnumerable errors, out IReadOnlyList list) + { + ArgumentNullException.ThrowIfNull(errors); + list = [.. errors]; + if (list.Count == 0) + { + throw new ArgumentException("A validation exception needs at least one error.", nameof(errors)); + } + + return list.Count == 1 + ? $"Validation failed: {Describe(list[0])}" + : $"Validation failed with {list.Count} errors: {string.Join("; ", list.Select(Describe))}"; + } + + private static string Describe(ValidationError error) => + error.Path.Length == 0 ? $"{error.Code} {error.Message}" : $"{error.Path}: {error.Code} {error.Message}"; +} diff --git a/src/FluentCMS.Sdk/Content/ValidationMode.cs b/src/FluentCMS.Sdk/Content/ValidationMode.cs new file mode 100644 index 000000000..deef0d013 --- /dev/null +++ b/src/FluentCMS.Sdk/Content/ValidationMode.cs @@ -0,0 +1,11 @@ +namespace FluentCMS.Content; + +/// Which rules a validation run enforces. +public enum ValidationMode +{ + /// A draft save: Required and RequiredWhen are skipped so incomplete work can be saved. + Draft = 1, + + /// A publish: every rule is enforced. + Publish = 2, +} diff --git a/src/FluentCMS.Sdk/Content/ValidationResult.cs b/src/FluentCMS.Sdk/Content/ValidationResult.cs new file mode 100644 index 000000000..977d14e0c --- /dev/null +++ b/src/FluentCMS.Sdk/Content/ValidationResult.cs @@ -0,0 +1,22 @@ +namespace FluentCMS.Content; + +/// The outcome of validating an entry. +public sealed class ValidationResult +{ + /// Creates a result. + /// The errors, in the order they were found. + public ValidationResult(IEnumerable errors) + { + ArgumentNullException.ThrowIfNull(errors); + Errors = [.. errors]; + } + + /// The result of a valid entry. + public static ValidationResult Success { get; } = new([]); + + /// Whether no rule was broken. + public bool IsValid => Errors.Count == 0; + + /// The errors, in the order they were found: fields in declaration order, then custom field validators, then entry validators. + public IReadOnlyList Errors { get; } +} diff --git a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt index 826d5933d..50ea71f32 100644 --- a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt +++ b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt @@ -1,4 +1,8 @@ #nullable enable +FluentCMS.Content.IEntryValidator +FluentCMS.Content.IEntryValidator.ValidateAsync(T entry, FluentCMS.Content.ValidationContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask +FluentCMS.Content.IFieldValidator +FluentCMS.Content.IFieldValidator.ValidateAsync(TField? value, FluentCMS.Content.ValidationContext! context, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.ValueTask FluentCMS.Content.IOperationContextAccessor FluentCMS.Content.IOperationContextAccessor.Current.get -> FluentCMS.Content.OperationContext? FluentCMS.Content.IOperationContextAccessor.Current.set -> void @@ -43,6 +47,40 @@ FluentCMS.Content.PrincipalKind.ApiKey = 3 -> FluentCMS.Content.PrincipalKind FluentCMS.Content.PrincipalKind.Member = 2 -> FluentCMS.Content.PrincipalKind FluentCMS.Content.PrincipalKind.ServiceAccount = 4 -> FluentCMS.Content.PrincipalKind FluentCMS.Content.PrincipalKind.System = 5 -> FluentCMS.Content.PrincipalKind +FluentCMS.Content.ValidationContext +FluentCMS.Content.ValidationContext.AddError(string! code, string! message) -> void +FluentCMS.Content.ValidationContext.AddError(string! path, string! code, string! message) -> void +FluentCMS.Content.ValidationContext.Entry.get -> object? +FluentCMS.Content.ValidationContext.Errors.get -> System.Collections.Generic.IReadOnlyList! +FluentCMS.Content.ValidationContext.Field.get -> FluentCMS.Schema.SchemaField? +FluentCMS.Content.ValidationContext.Mode.get -> FluentCMS.Content.ValidationMode +FluentCMS.Content.ValidationContext.Operation.get -> FluentCMS.Content.OperationContext! +FluentCMS.Content.ValidationContext.Path.get -> string! +FluentCMS.Content.ValidationContext.Type.get -> FluentCMS.Schema.SchemaType! +FluentCMS.Content.ValidationContext.ValidationContext(FluentCMS.Schema.SchemaType! type, System.Collections.Generic.IReadOnlyDictionary! values, FluentCMS.Content.OperationContext! operation, FluentCMS.Content.ValidationMode mode, object? entry = null, FluentCMS.Schema.SchemaField? field = null, string? path = null) -> void +FluentCMS.Content.ValidationContext.Values.get -> System.Collections.Generic.IReadOnlyDictionary! +FluentCMS.Content.ValidationError +FluentCMS.Content.ValidationError.$() -> FluentCMS.Content.ValidationError! +FluentCMS.Content.ValidationError.Code.get -> string! +FluentCMS.Content.ValidationError.Code.init -> void +FluentCMS.Content.ValidationError.Deconstruct(out string! Path, out string! Code, out string! Message) -> void +FluentCMS.Content.ValidationError.Equals(FluentCMS.Content.ValidationError? other) -> bool +FluentCMS.Content.ValidationError.Message.get -> string! +FluentCMS.Content.ValidationError.Message.init -> void +FluentCMS.Content.ValidationError.Path.get -> string! +FluentCMS.Content.ValidationError.Path.init -> void +FluentCMS.Content.ValidationError.ValidationError(string! Path, string! Code, string! Message) -> void +FluentCMS.Content.ValidationErrorCodes +FluentCMS.Content.ValidationException +FluentCMS.Content.ValidationException.Errors.get -> System.Collections.Generic.IReadOnlyList! +FluentCMS.Content.ValidationException.ValidationException(System.Collections.Generic.IEnumerable! errors) -> void +FluentCMS.Content.ValidationMode +FluentCMS.Content.ValidationMode.Draft = 1 -> FluentCMS.Content.ValidationMode +FluentCMS.Content.ValidationMode.Publish = 2 -> FluentCMS.Content.ValidationMode +FluentCMS.Content.ValidationResult +FluentCMS.Content.ValidationResult.Errors.get -> System.Collections.Generic.IReadOnlyList! +FluentCMS.Content.ValidationResult.IsValid.get -> bool +FluentCMS.Content.ValidationResult.ValidationResult(System.Collections.Generic.IEnumerable! errors) -> void FluentCMS.Media.MediaRef FluentCMS.Media.MediaRef.Deconstruct(out System.Guid Id) -> void FluentCMS.Media.MediaRef.Equals(FluentCMS.Media.MediaRef other) -> bool @@ -369,6 +407,16 @@ FluentCMS.Sites.SiteId.SiteId() -> void FluentCMS.Sites.SiteId.SiteId(System.Guid Value) -> void FluentCMS.Sites.SiteId.Value.get -> System.Guid FluentCMS.Sites.SiteId.Value.init -> void +const FluentCMS.Content.ValidationErrorCodes.AboveMaximum = "FCMS2105" -> string! +const FluentCMS.Content.ValidationErrorCodes.BelowMinimum = "FCMS2104" -> string! +const FluentCMS.Content.ValidationErrorCodes.Custom = "FCMS2109" -> string! +const FluentCMS.Content.ValidationErrorCodes.InvalidValue = "FCMS2108" -> string! +const FluentCMS.Content.ValidationErrorCodes.MaxLength = "FCMS2103" -> string! +const FluentCMS.Content.ValidationErrorCodes.MinLength = "FCMS2102" -> string! +const FluentCMS.Content.ValidationErrorCodes.NotAnAllowedOption = "FCMS2107" -> string! +const FluentCMS.Content.ValidationErrorCodes.PatternMismatch = "FCMS2106" -> string! +const FluentCMS.Content.ValidationErrorCodes.Required = "FCMS2101" -> string! +const FluentCMS.Content.ValidationErrorCodes.RequiredByCondition = "FCMS2110" -> string! const FluentCMS.Schema.FieldSettings.Blocks = "blocks" -> string! const FluentCMS.Schema.FieldSettings.Component = "component" -> string! const FluentCMS.Schema.FieldSettings.Options = "options" -> string! @@ -383,6 +431,9 @@ override FluentCMS.Content.OperationContext.ToString() -> string! override FluentCMS.Content.Principal.Equals(object? obj) -> bool override FluentCMS.Content.Principal.GetHashCode() -> int override FluentCMS.Content.Principal.ToString() -> string! +override FluentCMS.Content.ValidationError.Equals(object? obj) -> bool +override FluentCMS.Content.ValidationError.GetHashCode() -> int +override FluentCMS.Content.ValidationError.ToString() -> string! override FluentCMS.Media.MediaRef.GetHashCode() -> int override FluentCMS.Media.MediaRef.ToString() -> string! override FluentCMS.Media.MediaRefList.Equals(object? obj) -> bool @@ -405,6 +456,9 @@ override FluentCMS.Sites.SiteId.ToString() -> string! static FluentCMS.Content.OperationContext.operator !=(FluentCMS.Content.OperationContext? left, FluentCMS.Content.OperationContext? right) -> bool static FluentCMS.Content.OperationContext.operator ==(FluentCMS.Content.OperationContext? left, FluentCMS.Content.OperationContext? right) -> bool static FluentCMS.Content.Principal.System.get -> FluentCMS.Content.Principal! +static FluentCMS.Content.ValidationError.operator !=(FluentCMS.Content.ValidationError? left, FluentCMS.Content.ValidationError? right) -> bool +static FluentCMS.Content.ValidationError.operator ==(FluentCMS.Content.ValidationError? left, FluentCMS.Content.ValidationError? right) -> bool +static FluentCMS.Content.ValidationResult.Success.get -> FluentCMS.Content.ValidationResult! static FluentCMS.Media.MediaRef.operator !=(FluentCMS.Media.MediaRef left, FluentCMS.Media.MediaRef right) -> bool static FluentCMS.Media.MediaRef.operator ==(FluentCMS.Media.MediaRef left, FluentCMS.Media.MediaRef right) -> bool static FluentCMS.Media.MediaRefList.Create(System.ReadOnlySpan items) -> FluentCMS.Media.MediaRefList! diff --git a/tests/FluentCMS.Core.Tests/Content/Validation/ConditionTests.cs b/tests/FluentCMS.Core.Tests/Content/Validation/ConditionTests.cs new file mode 100644 index 000000000..e3064abe0 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Content/Validation/ConditionTests.cs @@ -0,0 +1,177 @@ +#pragma warning disable CA1861 +using FluentCMS.Core.Content.Validation; +using FluentCMS.Core.Tests.Schema.Samples; +using FluentCMS.Media; +using FluentCMS.Schema; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Content.Validation; + +public class ConditionTests +{ + private static readonly Condition Video = Condition.Field("category").Equals(ArticleCategory.Video); + private static readonly Condition NoTitle = Condition.Field("title").IsEmpty(); + + private static Dictionary Values(params (string Name, object? Value)[] values) => + values.ToDictionary(pair => pair.Name, pair => pair.Value); + + public static TheoryData Conditions => new() + { + { "equal", Video }, + { "equal number", Condition.Field("count").Equals(5) }, + { "equal bool", Condition.Field("flag").Equals(true) }, + { "equal null", Condition.Field("title").Equals(null) }, + { "in", Condition.Field("category").In(ArticleCategory.News, "video", 3) }, + { "isEmpty", NoTitle }, + { "and", Video.And(NoTitle) }, + { "or", Video.Or(NoTitle) }, + { "not", Video.Not() }, + { "nested", Video.And(NoTitle.Not()).Or(Condition.Field("count").In(1, 2.5m).Not()).And(Condition.Field("flag").Equals(false)) }, + }; + + private static readonly Dictionary[] Samples = + [ + Values(), + Values(("category", ArticleCategory.Video), ("title", "T"), ("count", 5), ("flag", true)), + Values(("category", "news"), ("title", ""), ("count", 2.5m), ("flag", false)), + Values(("category", ArticleCategory.Opinion), ("title", null), ("count", 1L), ("flag", false)), + ]; + + [Theory] + [MemberData(nameof(Conditions))] + public void ConditionsRoundTripThroughJson(string name, Condition condition) + { + var json = ConditionJson.ToJson(condition); + + var read = ConditionJson.Parse(json); + + ConditionJson.ToJson(read).ShouldBe(json, name); + read.ToString().ShouldBe(condition.ToString(), name); + read.Operator.ShouldBe(condition.Operator, name); + } + + [Theory] + [MemberData(nameof(Conditions))] + public void ConditionsEvaluateTheSameFromCSharpAndFromJson(string name, Condition condition) + { + var fromJson = ConditionJson.Parse(ConditionJson.ToJson(condition)); + + foreach (var values in Samples) + { + ConditionEvaluator.Evaluate(fromJson, values).ShouldBe(ConditionEvaluator.Evaluate(condition, values), name); + } + } + + [Fact] + public void JsonHasTheShapeOfTheSchemaFile() + { + ConditionJson.ToJson(Video).ShouldBe("""{"field":"category","equals":"video"}"""); + ConditionJson.ToJson(Condition.Field("a").In(1, "b")).ShouldBe("""{"field":"a","in":[1,"b"]}"""); + ConditionJson.ToJson(NoTitle).ShouldBe("""{"field":"title","isEmpty":true}"""); + ConditionJson.ToJson(Video.And(NoTitle.Not())).ShouldBe( + """{"and":[{"field":"category","equals":"video"},{"not":{"field":"title","isEmpty":true}}]}"""); + } + + [Fact] + public void AnInvalidConditionIsRejected() + { + Should.Throw(() => ConditionJson.Parse("""{"field":"a","equals":1,"in":[1]}""")) + .Message.ShouldContain("exactly one"); + Should.Throw(() => ConditionJson.Parse("[]")); + } + + [Fact] + public void AnEnumValueMatchesItsCamelCaseName() + { + ConditionEvaluator.Evaluate(Video, Values(("category", ArticleCategory.Video))).ShouldBeTrue(); + ConditionEvaluator.Evaluate(Video, Values(("category", "video"))).ShouldBeTrue(); + ConditionEvaluator.Evaluate(Video, Values(("category", ArticleCategory.News))).ShouldBeFalse(); + } + + [Fact] + public void StringsAreComparedOrdinally() + { + ConditionEvaluator.Evaluate(Condition.Field("a").Equals("x"), Values(("a", "X"))).ShouldBeFalse(); + } + + [Fact] + public void NumbersAreComparedByValueAcrossTypes() + { + var five = Condition.Field("n").Equals(5); + + foreach (object value in new object[] { 5, 5L, 5m, 5.0m, (short)5, 5.0 }) + { + ConditionEvaluator.Evaluate(five, Values(("n", value))).ShouldBeTrue(value.GetType().Name); + } + + ConditionEvaluator.Evaluate(five, Values(("n", 6))).ShouldBeFalse(); + ConditionEvaluator.Evaluate(Condition.Field("n").Equals(2.5m), Values(("n", 2.5))).ShouldBeTrue(); + ConditionEvaluator.Evaluate(five, Values(("n", "5"))).ShouldBeFalse(); + } + + [Fact] + public void AnUnknownFieldIsNull() + { + var values = Values(); + + ConditionEvaluator.Evaluate(Condition.Field("missing").Equals(null), values).ShouldBeTrue(); + ConditionEvaluator.Evaluate(Condition.Field("missing").IsEmpty(), values).ShouldBeTrue(); + ConditionEvaluator.Evaluate(Condition.Field("missing").Equals("x"), values).ShouldBeFalse(); + ConditionEvaluator.Evaluate(Condition.Field("missing").In("x", null), values).ShouldBeTrue(); + } + + [Fact] + public void AMultiSelectEqualsAValueItContains() + { + var values = Values(("labels", new[] { "a", "b" })); + + ConditionEvaluator.Evaluate(Condition.Field("labels").Equals("b"), values).ShouldBeTrue(); + ConditionEvaluator.Evaluate(Condition.Field("labels").Equals("c"), values).ShouldBeFalse(); + ConditionEvaluator.Evaluate(Condition.Field("labels").In("c", "a"), values).ShouldBeTrue(); + ConditionEvaluator.Evaluate(Condition.Field("labels").Equals(null), Values(("labels", Array.Empty()))).ShouldBeTrue(); + } + + [Fact] + public void AndAndOrShortCircuit() + { + var values = Values(("a", 1)); + var holds = Condition.Field("a").Equals(1); + var fails = Condition.Field("a").Equals(2); + + ConditionEvaluator.Evaluate(holds.And(holds), values).ShouldBeTrue(); + ConditionEvaluator.Evaluate(holds.And(fails), values).ShouldBeFalse(); + ConditionEvaluator.Evaluate(fails.Or(holds), values).ShouldBeTrue(); + ConditionEvaluator.Evaluate(fails.Or(fails), values).ShouldBeFalse(); + ConditionEvaluator.Evaluate(fails.Not(), values).ShouldBeTrue(); + } + + public static TheoryData IsEmptyMatrix => new() + { + { "null", null, true }, + { "empty string", "", true }, + { "whitespace", " \t", true }, + { "empty array", Array.Empty(), true }, + { "empty list", new List(), true }, + { "empty block list", new BlockList(), true }, + { "empty ref list", new RefList(), true }, + { "empty media list", new MediaRefList(), true }, + { "default ref", default(Ref), true }, + { "empty guid", Guid.Empty, true }, + { "text", "x", false }, + { "zero", 0, false }, + { "false", false, false }, + { "list", new[] { "a" }, false }, + { "block list", new BlockList([new HeroBlock()]), false }, + { "ref list", new RefList([new Ref(Guid.NewGuid())]), false }, + { "ref", new Ref(Guid.NewGuid()), false }, + { "enum", ArticleCategory.News, false }, + }; + + [Theory] + [MemberData(nameof(IsEmptyMatrix))] + public void IsEmptyFollowsTheEmptinessRules(string name, object? value, bool expected) + { + ConditionEvaluator.Evaluate(Condition.Field("f").IsEmpty(), Values(("f", value))).ShouldBe(expected, name); + } +} diff --git a/tests/FluentCMS.Core.Tests/Content/Validation/CustomValidatorTests.cs b/tests/FluentCMS.Core.Tests/Content/Validation/CustomValidatorTests.cs new file mode 100644 index 000000000..ad0612d0f --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Content/Validation/CustomValidatorTests.cs @@ -0,0 +1,313 @@ +using FluentCMS.Content; +using FluentCMS.Core.Tests.Schema.Samples; +using FluentCMS.Schema; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Content.Validation; + +public class CustomValidatorTests +{ + private sealed class Seen + { + public List<(string? Field, string Path, object? Value, ValidationContext Context)> Fields { get; } = []; + + public List<(Article Entry, ValidationContext Context)> Entries { get; } = []; + } + + private sealed class TitleValidator(Seen seen) : IFieldValidator + { + public ValueTask ValidateAsync(string? value, ValidationContext context, CancellationToken cancellationToken) + { + seen.Fields.Add((context.Field?.Name, context.Path, value, context)); + if (context.Field?.Name == "title" && value == "Forbidden") + { + context.AddError("ACME0001", "That title is taken."); + } + + return ValueTask.CompletedTask; + } + } + + private sealed class AnyFieldValidator(Seen seen) : IFieldValidator + { + public ValueTask ValidateAsync(object? value, ValidationContext context, CancellationToken cancellationToken) + { + seen.Fields.Add((context.Field?.Name + "*", context.Path, value, context)); + return ValueTask.CompletedTask; + } + } + + private sealed class DateValidator(Seen seen) : IFieldValidator + { + public ValueTask ValidateAsync(DateTimeOffset value, ValidationContext context, CancellationToken cancellationToken) + { + seen.Fields.Add((context.Field?.Name + "@", context.Path, value, context)); + return ValueTask.CompletedTask; + } + } + + private sealed class ArticleValidator(Seen seen) : IEntryValidator
+ { + public ValueTask ValidateAsync(Article entry, ValidationContext context, CancellationToken cancellationToken) + { + seen.Entries.Add((entry, context)); + if (entry.Title.StartsWith("Draft:", StringComparison.Ordinal)) + { + context.AddError("title", "ACME0002", "A title must not start with Draft:."); + context.AddError("", "ACME0003", "The entry is not ready."); + } + + return ValueTask.CompletedTask; + } + } + + private sealed class ThrowingValidator : IEntryValidator
+ { + public ValueTask ValidateAsync(Article entry, ValidationContext context, CancellationToken cancellationToken) => + throw new NotSupportedException("boom"); + } + + private sealed class BaseEntryValidator(Seen seen) : IEntryValidator + { + public ValueTask ValidateAsync(ContentItem entry, ValidationContext context, CancellationToken cancellationToken) + { + seen.Entries.Add(((Article)entry, context)); + return ValueTask.CompletedTask; + } + } + + private static Action Register(Seen seen) => services => + { + services.AddSingleton(seen); + services.AddSingleton, TitleValidator>(); + services.AddSingleton, AnyFieldValidator>(); + services.AddSingleton, DateValidator>(); + services.AddSingleton, ArticleValidator>(); + }; + + [Fact] + public async Task AFieldValidatorReceivesTheFullContext() + { + var seen = new Seen(); + var values = new Dictionary { ["title"] = "Hello", ["category"] = ArticleCategory.News }; + + await ValidationFixtures.ValidateAsync(ValidationFixtures.Blog, "article", values, ValidationMode.Publish, register: Register(seen)); + + var (_, path, value, context) = seen.Fields.Single(item => item.Field == "title"); + path.ShouldBe("title"); + value.ShouldBe("Hello"); + context.Type.ShouldBeSameAs(ValidationFixtures.Article); + context.Field.ShouldBeSameAs(ValidationFixtures.Article.FindField("title")); + context.Values.ShouldBeSameAs(values); + context.Operation.ShouldBeSameAs(ValidationFixtures.Operation); + context.Operation.Principal.Name.ShouldBe("Amir"); + context.Operation.Locale.ShouldBe("fr-CA"); + context.Mode.ShouldBe(ValidationMode.Publish); + context.Entry.ShouldBeNull(); + } + + [Fact] + public async Task AFieldValidatorRejectsAFieldWithItsOwnCode() + { + var result = await ValidationFixtures.ValidateAsync( + ValidationFixtures.Blog, + "article", + new() { ["title"] = "Forbidden" }, + ValidationMode.Draft, + register: Register(new Seen())); + + result.Errors.ShouldHaveSingleItem().ShouldBe(new ValidationError("title", "ACME0001", "That title is taken.")); + } + + [Fact] + public async Task ValidatorsRunInDraftAndPublishAndSeeTheMode() + { + foreach (var mode in new[] { ValidationMode.Draft, ValidationMode.Publish }) + { + var seen = new Seen(); + + await ValidationFixtures.ValidateAsync(ValidationFixtures.Blog, "article", new() { ["title"] = "T" }, mode, register: Register(seen)); + + seen.Fields.ShouldAllBe(item => item.Context.Mode == mode); + seen.Fields.ShouldNotBeEmpty(); + } + } + + [Fact] + public async Task AValidatorOfObjectSeesEveryVisibleFieldAndAValueTypeValidatorSkipsNull() + { + var seen = new Seen(); + var values = new Dictionary { ["title"] = "T", ["category"] = ArticleCategory.News }; + + await ValidationFixtures.ValidateAsync(ValidationFixtures.Blog, "article", values, ValidationMode.Publish, register: Register(seen)); + + var visible = ValidationFixtures.Article.Fields.Where(field => field.Name != "videoUrl").Select(field => field.Name + "*"); + seen.Fields.Where(item => item.Field!.EndsWith('*')).Select(item => item.Field).ShouldBe(visible, "hidden videoUrl is skipped"); + seen.Fields.ShouldNotContain(item => item.Field == "publishedOn@"); + + values["publishedOn"] = DateTimeOffset.UnixEpoch; + seen.Fields.Clear(); + await ValidationFixtures.ValidateAsync(ValidationFixtures.Blog, "article", values, ValidationMode.Publish, register: Register(seen)); + seen.Fields.ShouldContain(item => item.Field == "publishedOn@"); + } + + [Fact] + public async Task ANestedFieldValidatorSeesItsSiblingsAndFullPath() + { + var seen = new Seen(); + var seo = new Dictionary { ["metaTitle"] = "Meta", ["metaDescription"] = "Desc" }; + + await ValidationFixtures.ValidateAsync(ValidationFixtures.Blog, "article", new() { ["title"] = "T", ["seo"] = seo }, ValidationMode.Publish, register: Register(seen)); + + var (_, path, value, context) = seen.Fields.Single(item => item.Path == "seo.metaTitle" && item.Field == "metaTitle*"); + value.ShouldBe("Meta"); + context.Values.ShouldBeSameAs(seo); + context.Type.ShouldBeSameAs(ValidationFixtures.Article); + } + + [Fact] + public async Task AnEntryValidatorReceivesTheEntryAndAddsErrorsAtAnyPath() + { + var seen = new Seen(); + var entry = new Article { Title = "Draft: x" }; + var values = new Dictionary { ["title"] = entry.Title }; + + var result = await ValidationFixtures.ValidateAsync(ValidationFixtures.Blog, "article", values, ValidationMode.Publish, entry, Register(seen)); + + var (seenEntry, context) = seen.Entries.ShouldHaveSingleItem(); + seenEntry.ShouldBeSameAs(entry); + context.Entry.ShouldBeSameAs(entry); + context.Field.ShouldBeNull(); + context.Path.ShouldBe(""); + context.Values.ShouldBeSameAs(values); + context.Operation.ShouldBeSameAs(ValidationFixtures.Operation); + context.Mode.ShouldBe(ValidationMode.Publish); + Should.Throw(() => context.AddError("X", "no field")); + + result.Errors.Select(error => (error.Path, error.Code)).ShouldBe([("title", "ACME0002"), ("", "ACME0003")]); + } + + [Fact] + public async Task AFieldValidatorSeesTheTypedEntryToo() + { + var seen = new Seen(); + var entry = new Article { Title = "T" }; + + await ValidationFixtures.ValidateAsync(ValidationFixtures.Blog, "article", new() { ["title"] = "T" }, ValidationMode.Publish, entry, Register(seen)); + + seen.Fields.ShouldAllBe(item => ReferenceEquals(item.Context.Entry, entry)); + } + + [Fact] + public async Task EntryValidatorsAlsoRunForTheBaseClassesOfTheEntry() + { + var seen = new Seen(); + + await ValidationFixtures.ValidateAsync( + ValidationFixtures.Blog, + "article", + new() { ["title"] = "T" }, + ValidationMode.Publish, + new Article { Title = "T" }, + services => services.AddSingleton>(new BaseEntryValidator(seen))); + + seen.Entries.Count.ShouldBe(1); + } + + [Fact] + public async Task EntryValidatorsDoNotRunWithoutAnEntry() + { + var seen = new Seen(); + + await ValidationFixtures.ValidateAsync(ValidationFixtures.Blog, "article", new() { ["title"] = "T" }, ValidationMode.Publish, register: Register(seen)); + + seen.Entries.ShouldBeEmpty(); + } + + [Fact] + public async Task ErrorsComeFromDeclarativeRulesThenFieldValidatorsThenEntryValidators() + { + var entry = new Article { Title = "Draft: x" }; + var values = new Dictionary { ["title"] = "Draft: x", ["category"] = ArticleCategory.Video }; + + var result = await ValidationFixtures.ValidateAsync( + ValidationFixtures.Blog, + "article", + values, + ValidationMode.Publish, + entry, + services => + { + services.AddSingleton>(new RejectingValidator("title", "ACME0004")); + services.AddSingleton>(new ArticleValidator(new Seen())); + }); + + ValidationFixtures.Describe(result).ShouldBe(["videoUrl|FCMS2110", "title|ACME0004", "title|ACME0002", "|ACME0003"]); + } + + [Fact] + public async Task ADefaultCodeIsAvailableForValidatorsWithoutOne() + { + var result = await ValidationFixtures.ValidateAsync( + ValidationFixtures.Blog, + "article", + new() { ["title"] = "T" }, + ValidationMode.Draft, + register: services => services.AddSingleton>(new RejectingValidator("title", ""))); + + result.Errors.Single().Code.ShouldBe(ValidationErrorCodes.Custom); + } + + [Fact] + public async Task AValidatorExceptionPropagates() + { + var thrown = await Should.ThrowAsync(async () => await ValidationFixtures.ValidateAsync( + ValidationFixtures.Blog, + "article", + new() { ["title"] = "T" }, + ValidationMode.Publish, + new Article(), + services => services.AddSingleton, ThrowingValidator>())); + + thrown.Message.ShouldBe("boom"); + } + + [Fact] + public async Task ACancelledTokenStopsValidation() + { + using var source = new CancellationTokenSource(); + await source.CancelAsync(); + var pipeline = ValidationFixtures.Pipeline(ValidationFixtures.Blog, services => services.AddSingleton>(new RejectingValidator("title", "X"))); + + await Should.ThrowAsync(async () => await pipeline.ValidateAsync( + ValidationFixtures.Article, + new Dictionary { ["title"] = "T" }, + null, + ValidationFixtures.Operation, + ValidationMode.Draft, + source.Token)); + } + + [Fact] + public async Task AnEntryWithNoProblemsIsTheSharedSuccess() + { + var result = await ValidationFixtures.ValidateArticleAsync(new() { ["title"] = "T" }, ValidationMode.Publish); + + result.ShouldBeSameAs(ValidationResult.Success); + } + + private sealed class RejectingValidator(string field, string code) : IFieldValidator + { + public ValueTask ValidateAsync(string? value, ValidationContext context, CancellationToken cancellationToken) + { + if (context.Field?.Name == field) + { + context.AddError(code, "rejected"); + } + + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/FluentCMS.Core.Tests/Content/Validation/DeclarativeRuleTests.cs b/tests/FluentCMS.Core.Tests/Content/Validation/DeclarativeRuleTests.cs new file mode 100644 index 000000000..50fcec907 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Content/Validation/DeclarativeRuleTests.cs @@ -0,0 +1,212 @@ +#pragma warning disable CA1861 +using FluentCMS.Content; +using FluentCMS.Core.Tests.Schema.Samples; +using FluentCMS.Schema; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Content.Validation; + +public class DeclarativeRuleTests +{ + private static readonly SchemaModel Rules = ValidationFixtures.Model(thing => thing + .Field("name", "text", f => f.Required()) + .Field("code", "text", f => f.Length(3, 5).Pattern("^[a-z]+$")) + .Field("count", "integer", f => f.Range(1, 10)) + .Field("opensAt", "dateTime", f => f.Range(1_577_836_800, 1_893_456_000)) + .Field("day", "date", f => f.Range(0, 100)) + .Field("kind", "select", f => f.Setting(FieldSettings.Options, new[] { "a", "b" })) + .Field("labels", "select", f => f.Multiple().Setting(FieldSettings.Options, new[] { "a", "b" })) + .Field("tags", "text", f => f.Multiple().Length(2, 3)) + .Field("scores", "integer", f => f.Multiple().Range(0, 5))); + + private static async Task Run(Dictionary values, ValidationMode mode = ValidationMode.Publish) + { + values.TryAdd("name", "n"); + return ValidationFixtures.Describe(await ValidationFixtures.ValidateAsync(Rules, "thing", values, mode)); + } + + [Fact] + public async Task ADraftMissingATitleIsValidAndPublishingItFails() + { + var values = new Dictionary { ["category"] = ArticleCategory.News }; + + (await ValidationFixtures.ValidateArticleAsync(values, ValidationMode.Draft)).IsValid.ShouldBeTrue(); + + var result = await ValidationFixtures.ValidateArticleAsync(values, ValidationMode.Publish); + + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldHaveSingleItem().ShouldBe(new ValidationError("title", ValidationErrorCodes.Required, "A value is required.")); + } + + [Fact] + public async Task AWhitespaceTitleCountsAsMissing() + { + var result = await ValidationFixtures.ValidateArticleAsync(new() { ["title"] = " " }, ValidationMode.Publish); + + ValidationFixtures.Describe(result).ShouldBe(["title|FCMS2101"]); + } + + [Fact] + public async Task VideoUrlIsRequiredOnlyForAVideo() + { + var video = new Dictionary { ["title"] = "T", ["category"] = ArticleCategory.Video }; + var news = new Dictionary { ["title"] = "T", ["category"] = ArticleCategory.News }; + + ValidationFixtures.Describe(await ValidationFixtures.ValidateArticleAsync(video, ValidationMode.Publish)).ShouldBe(["videoUrl|FCMS2110"]); + (await ValidationFixtures.ValidateArticleAsync(video, ValidationMode.Draft)).IsValid.ShouldBeTrue(); + (await ValidationFixtures.ValidateArticleAsync(news, ValidationMode.Publish)).IsValid.ShouldBeTrue(); + + video["videoUrl"] = "https://example.com/v"; + (await ValidationFixtures.ValidateArticleAsync(video, ValidationMode.Publish)).IsValid.ShouldBeTrue(); + } + + [Fact] + public async Task ARequiredFieldThatIsAlsoRequiredWhenReportsOnce() + { + var model = ValidationFixtures.Model(thing => thing + .Field("a", "text") + .Field("b", "text", f => f.Required().RequiredWhen(Condition.Field("a").IsEmpty()))); + + var result = await ValidationFixtures.ValidateAsync(model, "thing", new(), ValidationMode.Publish); + + ValidationFixtures.Describe(result).ShouldBe(["b|FCMS2101"]); + } + + [Fact] + public async Task AHiddenFieldWithABadValueIsNotReported() + { + var model = ValidationFixtures.Model(thing => thing + .Field("category", "select", f => f.Setting(FieldSettings.Options, new[] { "news", "video" })) + .Field("videoUrl", "text", f => f + .Length(null, 5) + .Required() + .VisibleWhen(Condition.Field("category").Equals("video")))); + + var tooLong = new Dictionary { ["category"] = "news", ["videoUrl"] = "far too long" }; + var shown = new Dictionary { ["category"] = "video", ["videoUrl"] = "far too long" }; + + (await ValidationFixtures.ValidateAsync(model, "thing", tooLong, ValidationMode.Publish)).IsValid.ShouldBeTrue(); + ValidationFixtures.Describe(await ValidationFixtures.ValidateAsync(model, "thing", shown, ValidationMode.Publish)).ShouldBe(["videoUrl|FCMS2103"]); + + var missing = new Dictionary { ["category"] = "news" }; + (await ValidationFixtures.ValidateAsync(model, "thing", missing, ValidationMode.Publish)).IsValid.ShouldBeTrue(); + } + + [Fact] + public async Task ARequiredFieldIsCheckedOnPublishOnly() + { + var values = new Dictionary { ["name"] = "" }; + + (await Run(values, ValidationMode.Draft)).ShouldBeEmpty(); + (await Run(values)).ShouldBe(["name|FCMS2101"]); + } + + [Theory] + [InlineData("abc", null)] + [InlineData("abcde", null)] + [InlineData("ab", "code|FCMS2102")] + [InlineData("abcdef", "code|FCMS2103")] + [InlineData("ABC", "code|FCMS2106")] + [InlineData("a1", "code|FCMS2102,code|FCMS2106")] + public async Task TextIsCheckedForLengthAndPattern(string code, string? expected) + { + (await Run(new() { ["code"] = code })).ShouldBe(expected?.Split(',') ?? []); + } + + [Theory] + [InlineData(1, null)] + [InlineData(10, null)] + [InlineData(0, "count|FCMS2104")] + [InlineData(11, "count|FCMS2105")] + public async Task NumbersAreCheckedAgainstTheRange(int count, string? expected) + { + (await Run(new() { ["count"] = count })).ShouldBe(expected is null ? [] : [expected]); + (await Run(new() { ["count"] = (decimal)count })).ShouldBe(expected is null ? [] : [expected]); + } + + [Fact] + public async Task DatesAndDateTimesAreCheckedAgainstTheRange() + { + (await Run(new() { ["opensAt"] = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero) })).ShouldBeEmpty(); + (await Run(new() { ["opensAt"] = new DateTimeOffset(2019, 1, 1, 0, 0, 0, TimeSpan.Zero) })).ShouldBe(["opensAt|FCMS2104"]); + (await Run(new() { ["opensAt"] = new DateTime(2031, 1, 1, 0, 0, 0, DateTimeKind.Utc) })).ShouldBe(["opensAt|FCMS2105"]); + (await Run(new() { ["day"] = new DateOnly(1970, 3, 1) })).ShouldBeEmpty(); + (await Run(new() { ["day"] = new DateOnly(1971, 1, 1) })).ShouldBe(["day|FCMS2105"]); + } + + [Fact] + public async Task ASingleSelectMustBeAnOption() + { + (await Run(new() { ["kind"] = "a" })).ShouldBeEmpty(); + (await Run(new() { ["kind"] = "c" })).ShouldBe(["kind|FCMS2107"]); + } + + [Fact] + public async Task AMultiSelectChecksEachItemAtItsOwnPath() + { + (await Run(new() { ["labels"] = new[] { "a", "b" } })).ShouldBeEmpty(); + (await Run(new() { ["labels"] = new[] { "a", "x", "y" } })).ShouldBe(["labels[1]|FCMS2107", "labels[2]|FCMS2107"]); + } + + [Fact] + public async Task EnumMembersMatchTheirCamelCaseOptions() + { + var result = await ValidationFixtures.ValidateArticleAsync(new() { ["title"] = "T", ["category"] = ArticleCategory.Opinion }, ValidationMode.Publish); + + result.IsValid.ShouldBeTrue(); + } + + [Fact] + public async Task LengthOnAMultipleFieldCountsItems() + { + (await Run(new() { ["tags"] = new[] { "a", "b" } })).ShouldBeEmpty(); + + var few = await ValidationFixtures.ValidateAsync(Rules, "thing", new() { ["name"] = "n", ["tags"] = new[] { "a" } }, ValidationMode.Publish); + few.Errors.ShouldHaveSingleItem().ShouldBe(new ValidationError("tags", ValidationErrorCodes.MinLength, "Must have at least 2 items, has 1.")); + + (await Run(new() { ["tags"] = new[] { "a", "b", "c", "d" } })).ShouldBe(["tags|FCMS2103"]); + } + + [Fact] + public async Task RangeOnAMultipleFieldChecksEachItem() + { + (await Run(new() { ["scores"] = new[] { 1, 6, -1 } })).ShouldBe(["scores[1]|FCMS2105", "scores[2]|FCMS2104"]); + } + + [Fact] + public async Task AnEmptyValueSkipsTheFormatRules() + { + (await Run(new() { ["code"] = null, ["count"] = null, ["kind"] = "", ["labels"] = Array.Empty() })).ShouldBeEmpty(); + } + + [Fact] + public async Task ErrorsFollowTheDeclarationOrderOfTheFields() + { + var values = new Dictionary + { + ["scores"] = new[] { 9 }, + ["kind"] = "z", + ["count"] = 99, + ["code"] = "A", + ["name"] = "", + }; + + var expected = new[] { "name|FCMS2101", "code|FCMS2102", "code|FCMS2106", "count|FCMS2105", "kind|FCMS2107", "scores[0]|FCMS2105" }; + + for (var i = 0; i < 3; i++) + { + (await Run(values)).ShouldBe(expected); + } + } + + [Fact] + public async Task APatternThatTakesTooLongRejectsTheValue() + { + var model = ValidationFixtures.Model(thing => thing.Field("s", "text", f => f.Pattern("^(a+)+$"))); + + var result = await ValidationFixtures.ValidateAsync(model, "thing", new() { ["s"] = new string('a', 60) + "!" }, ValidationMode.Publish); + + ValidationFixtures.Describe(result).ShouldBe(["s|FCMS2106"]); + } +} diff --git a/tests/FluentCMS.Core.Tests/Content/Validation/NestedValidationTests.cs b/tests/FluentCMS.Core.Tests/Content/Validation/NestedValidationTests.cs new file mode 100644 index 000000000..dbc7f3798 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Content/Validation/NestedValidationTests.cs @@ -0,0 +1,118 @@ +using FluentCMS.Content; +using FluentCMS.Core.Tests.Schema.Samples; +using FluentCMS.Schema; +using Shouldly; +using Xunit; + +namespace FluentCMS.Core.Tests.Content.Validation; + +public class NestedValidationTests +{ + private static readonly SchemaModel Nested = ValidationFixtures.Model( + thing => thing + .Field("home", "component", f => f.Setting(FieldSettings.Component, "address")) + .Field("addresses", "component", f => f.Multiple().Setting(FieldSettings.Component, "address")) + .Field("body", "blocks", f => f.Setting(FieldSettings.Blocks, new[] { "paragraph" })), + builder => builder + .AddComponent("address", address => address + .Field("street", "text", f => f.Required()) + .Field("city", "text", f => f.Length(null, 4))) + .AddBlock("paragraph", block => block.Field("text", "text", f => f.Required())) + .AddBlock("quote", block => block.Field("text", "text"))); + + private sealed class Address + { + public string Street { get; set; } = ""; + + public string? City { get; set; } + } + + private static async Task Run(SchemaModel model, Dictionary values, ValidationMode mode = ValidationMode.Publish) => + ValidationFixtures.Describe(await ValidationFixtures.ValidateAsync(model, "thing", values, mode)); + + [Fact] + public async Task ASingleComponentReportsUnderItsName() + { + var seo = new Seo { MetaTitle = new string('x', 71) }; + + var result = await ValidationFixtures.ValidateArticleAsync(new() { ["title"] = "T", ["seo"] = seo }, ValidationMode.Publish); + + ValidationFixtures.Describe(result).ShouldBe(["seo.metaTitle|FCMS2103"]); + } + + [Fact] + public async Task AComponentCanBeADictionaryOrAnObject() + { + var expected = new[] { "home.street|FCMS2101", "home.city|FCMS2103" }; + + (await Run(Nested, new() { ["home"] = new Dictionary { ["street"] = "", ["city"] = "Springfield" } })).ShouldBe(expected); + (await Run(Nested, new() { ["home"] = new Address { Street = "", City = "Springfield" } })).ShouldBe(expected); + (await Run(Nested, new() { ["home"] = new Address { Street = "Main", City = "Rome" } })).ShouldBeEmpty(); + } + + [Fact] + public async Task ADraftSkipsRequiredInsideAComponent() + { + (await Run(Nested, new() { ["home"] = new Address { Street = "" } }, ValidationMode.Draft)).ShouldBeEmpty(); + } + + [Fact] + public async Task ARepeatableComponentReportsUnderItsIndex() + { + var values = new Dictionary + { + ["addresses"] = new List
{ new() { Street = "Main" }, new() { Street = "", City = "Springfield" } }, + }; + + (await Run(Nested, values)).ShouldBe(["addresses[1].street|FCMS2101", "addresses[1].city|FCMS2103"]); + } + + [Fact] + public async Task ABlockIsReadByItsTypeKeyOrItsClass() + { + var blocks = new object[] + { + new Dictionary { ["type"] = "paragraph", ["text"] = "ok" }, + new Dictionary { ["type"] = "paragraph", ["text"] = "" }, + }; + + (await Run(Nested, new() { ["body"] = blocks })).ShouldBe(["body[1].text|FCMS2101"]); + } + + [Fact] + public async Task ABlockOfATypeTheFieldDoesNotAllowIsRejected() + { + var blocks = new object[] + { + new Dictionary { ["type"] = "quote", ["text"] = "x" }, + new Dictionary { ["type"] = "nothing" }, + new Dictionary { ["text"] = "no type" }, + }; + + (await Run(Nested, new() { ["body"] = blocks })).ShouldBe(["body[0]|FCMS2107", "body[1]|FCMS2107", "body[2]|FCMS2107"]); + } + + [Fact] + public async Task ClassBlocksAreMatchedByTheirClassAndValidated() + { + var sections = new BlockList([new HeroBlock { Heading = "Hi" }, new QuoteBlock { Text = "" }, new QuoteBlock { Text = "x" }]); + + var result = await ValidationFixtures.ValidateArticleAsync(new() { ["title"] = "T", ["sections"] = sections }, ValidationMode.Publish); + + ValidationFixtures.Describe(result).ShouldBe(["sections[1].text|FCMS2101"]); + } + + [Fact] + public async Task ABlockClassThatIsNotInTheSchemaIsRejected() + { + var result = await ValidationFixtures.ValidateArticleAsync(new() { ["title"] = "T", ["sections"] = new BlockList([new Address()]) }, ValidationMode.Publish); + + ValidationFixtures.Describe(result).ShouldBe(["sections[0]|FCMS2107"]); + } + + [Fact] + public async Task AnAbsentComponentIsNotDescendedInto() + { + (await Run(Nested, new() { ["home"] = null })).ShouldBeEmpty(); + } +} diff --git a/tests/FluentCMS.Core.Tests/Content/Validation/ValidationFixtures.cs b/tests/FluentCMS.Core.Tests/Content/Validation/ValidationFixtures.cs new file mode 100644 index 000000000..360f43152 --- /dev/null +++ b/tests/FluentCMS.Core.Tests/Content/Validation/ValidationFixtures.cs @@ -0,0 +1,55 @@ +using FluentCMS.Content; +using FluentCMS.Core.Content.Validation; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Tests.Schema; +using FluentCMS.Schema; +using FluentCMS.Sites; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Tests.Content.Validation; + +internal static class ValidationFixtures +{ + public static OperationContext Operation { get; } = new( + new Principal(PrincipalKind.AdminUser, Guid.Parse("00000000-0000-0000-0000-000000000001"), "Amir"), + Origin.Admin, + new SiteId(Guid.Parse("00000000-0000-0000-0000-0000000000a1")), + "fr-CA", + "admin-client", + "corr-1", + new DateTimeOffset(2026, 11, 2, 9, 0, 0, TimeSpan.Zero)); + + public static SchemaModel Blog { get; } = SampleSchema.FromClasses().Build(); + + public static SchemaType Article => Blog.FindContentType("article")!; + + public static ValidationPipeline Pipeline(SchemaModel model, Action? register = null) + { + var services = new ServiceCollection(); + register?.Invoke(services); + return new ValidationPipeline(model, services.BuildServiceProvider()); + } + + public static ValueTask ValidateAsync( + SchemaModel model, + string typeName, + Dictionary values, + ValidationMode mode, + object? entry = null, + Action? register = null) => + Pipeline(model, register).ValidateAsync(model.FindContentType(typeName)!, values, entry, Operation, mode, CancellationToken.None); + + public static ValueTask ValidateArticleAsync(Dictionary values, ValidationMode mode) => + ValidateAsync(Blog, "article", values, mode); + + // A schema of one content type "thing", with the components and blocks the test adds. + public static SchemaModel Model(Action thing, Action? others = null) + { + var builder = new SchemaBuilder().AddCollection("thing", thing); + others?.Invoke(builder); + return builder.Build(); + } + + public static string[] Describe(ValidationResult result) => + [.. result.Errors.Select(error => $"{error.Path}|{error.Code}")]; +} diff --git a/tests/FluentCMS.Core.Tests/FluentCMS.Core.Tests.csproj b/tests/FluentCMS.Core.Tests/FluentCMS.Core.Tests.csproj index 2eed25f3c..1425caf74 100644 --- a/tests/FluentCMS.Core.Tests/FluentCMS.Core.Tests.csproj +++ b/tests/FluentCMS.Core.Tests/FluentCMS.Core.Tests.csproj @@ -5,6 +5,7 @@ + diff --git a/tests/FluentCMS.Sdk.Tests/Content/ValidationTypesTests.cs b/tests/FluentCMS.Sdk.Tests/Content/ValidationTypesTests.cs new file mode 100644 index 000000000..81c0e67c5 --- /dev/null +++ b/tests/FluentCMS.Sdk.Tests/Content/ValidationTypesTests.cs @@ -0,0 +1,62 @@ +using System.Reflection; +using FluentCMS.Content; +using Shouldly; +using Xunit; + +namespace FluentCMS.Sdk.Tests.Content; + +public class ValidationTypesTests +{ + private static readonly ValidationError First = new("title", ValidationErrorCodes.Required, "A value is required."); + private static readonly ValidationError Second = new("seo.metaTitle", ValidationErrorCodes.MaxLength, "Too long."); + + [Fact] + public void SuccessIsValidAndHasNoErrors() + { + ValidationResult.Success.IsValid.ShouldBeTrue(); + ValidationResult.Success.Errors.ShouldBeEmpty(); + } + + [Fact] + public void AResultKeepsTheErrorsInOrder() + { + var result = new ValidationResult([Second, First]); + + result.IsValid.ShouldBeFalse(); + result.Errors.ShouldBe([Second, First]); + } + + [Fact] + public void ErrorsAreComparedByValue() + { + new ValidationError("a", "FCMS2101", "m").ShouldBe(new ValidationError("a", "FCMS2101", "m")); + } + + [Fact] + public void TheExceptionCarriesTheErrorsAndNamesThem() + { + var exception = new ValidationException([First, Second]); + + exception.Errors.ShouldBe([First, Second]); + exception.Message.ShouldBe("Validation failed with 2 errors: title: FCMS2101 A value is required.; seo.metaTitle: FCMS2103 Too long."); + new ValidationException([First]).Message.ShouldBe("Validation failed: title: FCMS2101 A value is required."); + } + + [Fact] + public void TheExceptionNeedsAnError() + { + Should.Throw(() => new ValidationException([])); + } + + [Fact] + public void CodesAreDistinctAndInTheContentValidationGroup() + { + var codes = typeof(ValidationErrorCodes).GetFields(BindingFlags.Public | BindingFlags.Static) + .Select(field => (string)field.GetRawConstantValue()!) + .ToList(); + + codes.Count.ShouldBe(10); + codes.Distinct().Count().ShouldBe(codes.Count); + codes.ShouldAllBe(code => code.StartsWith("FCMS21", StringComparison.Ordinal) && code.Length == 8); + } +} From 70086caaf2ff695ec5fc984a5f43da00dbebd771 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 2 Nov 2026 09:38:59 -0500 Subject: [PATCH 2/2] Address review: drop date ranges, handle MediaRef and float overflow, add Container --- Directory.Packages.props | 2 +- .../Validation/DeclarativeValidator.cs | 16 ++++-- .../Content/Validation/FieldValue.cs | 2 + .../Content/Validation/ValidationPipeline.cs | 2 +- .../Content/ValidationContext.cs | 17 ++++-- src/FluentCMS.Sdk/PublicAPI.Unshipped.txt | 3 +- src/FluentCMS.Sdk/Schema/ConditionField.cs | 2 +- .../Schema/RegularExpressionAttribute.cs | 5 +- .../Content/Validation/ConditionTests.cs | 4 +- .../Validation/CustomValidatorTests.cs | 5 +- .../Validation/DeclarativeRuleTests.cs | 52 +++++++++++++------ .../Content/Validation/ValidationFixtures.cs | 2 +- 12 files changed, 78 insertions(+), 34 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 4290572e7..e7165e6f2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,9 +7,9 @@ - + diff --git a/src/FluentCMS.Core/Content/Validation/DeclarativeValidator.cs b/src/FluentCMS.Core/Content/Validation/DeclarativeValidator.cs index e608bb508..00f397a17 100644 --- a/src/FluentCMS.Core/Content/Validation/DeclarativeValidator.cs +++ b/src/FluentCMS.Core/Content/Validation/DeclarativeValidator.cs @@ -236,17 +236,23 @@ private bool Matches(SchemaField field, string text) private static bool IsList(SchemaField field) => field.Multiple || field.FieldType == ValidatedFieldTypes.Blocks; - // Numbers as they are; a date as days and a date-time as seconds since 1970-01-01, which the minimum and maximum then use. + // Only numbers have a range; the model does not say whether a bound on a date means days or seconds. + // A float beyond what a decimal holds counts as the nearest decimal bound, so it is out of range in its direction. private static decimal? RangeValue(object? value) => value switch { byte or sbyte or short or ushort or int or uint or long or ulong or decimal => Convert.ToDecimal(value, CultureInfo.InvariantCulture), - float or double => double.IsFinite(Convert.ToDouble(value, CultureInfo.InvariantCulture)) ? (decimal)Convert.ToDouble(value, CultureInfo.InvariantCulture) : null, - DateOnly day => day.DayNumber - DateOnly.FromDateTime(DateTime.UnixEpoch).DayNumber, - DateTimeOffset moment => moment.ToUnixTimeSeconds(), - DateTime moment => new DateTimeOffset(moment.ToUniversalTime()).ToUnixTimeSeconds(), + float or double => Clamp(Convert.ToDouble(value, CultureInfo.InvariantCulture)), _ => null, }; + private static decimal? Clamp(double number) => number switch + { + double.NaN => null, + >= (double)decimal.MaxValue => decimal.MaxValue, + <= (double)decimal.MinValue => decimal.MinValue, + _ => (decimal)number, + }; + private static string Format(decimal number) => number.ToString("G29", CultureInfo.InvariantCulture); } diff --git a/src/FluentCMS.Core/Content/Validation/FieldValue.cs b/src/FluentCMS.Core/Content/Validation/FieldValue.cs index 534f8acf8..fc28792ff 100644 --- a/src/FluentCMS.Core/Content/Validation/FieldValue.cs +++ b/src/FluentCMS.Core/Content/Validation/FieldValue.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Collections.Concurrent; using System.Reflection; +using FluentCMS.Media; using FluentCMS.Schema; namespace FluentCMS.Core.Content.Validation; @@ -16,6 +17,7 @@ internal static class FieldValue null => true, string text => string.IsNullOrWhiteSpace(text), Guid id => id == Guid.Empty, + MediaRef media => media.IsEmpty, IEnumerable items => !items.GetEnumerator().MoveNext(), _ => IsEmptyRef(value), }; diff --git a/src/FluentCMS.Core/Content/Validation/ValidationPipeline.cs b/src/FluentCMS.Core/Content/Validation/ValidationPipeline.cs index dd626fd5c..2be217666 100644 --- a/src/FluentCMS.Core/Content/Validation/ValidationPipeline.cs +++ b/src/FluentCMS.Core/Content/Validation/ValidationPipeline.cs @@ -45,7 +45,7 @@ public async ValueTask ValidateAsync( private async ValueTask RunFieldValidatorsAsync(FieldVisit visit, ValidationContext root, CancellationToken cancellationToken) { var fieldType = FieldClrType(visit); - var context = root.ForField(visit.Field, visit.Path, visit.Siblings); + var context = root.ForField(visit.Owner, visit.Field, visit.Path, visit.Siblings); if (fieldType is not null && fieldType != typeof(object)) { diff --git a/src/FluentCMS.Sdk/Content/ValidationContext.cs b/src/FluentCMS.Sdk/Content/ValidationContext.cs index aae16836c..309bf0b1a 100644 --- a/src/FluentCMS.Sdk/Content/ValidationContext.cs +++ b/src/FluentCMS.Sdk/Content/ValidationContext.cs @@ -15,6 +15,7 @@ public sealed class ValidationContext /// The typed entry, if there is one. /// The field being validated, or for an entry validator. /// The path of that field; defaults to its name. + /// The type that declares the field; defaults to . public ValidationContext( SchemaType type, IReadOnlyDictionary values, @@ -22,8 +23,9 @@ public ValidationContext( ValidationMode mode, object? entry = null, SchemaField? field = null, - string? path = null) - : this(type, values, operation, mode, entry, field, path, []) + string? path = null, + SchemaType? container = null) + : this(type, values, operation, mode, entry, field, path, container, []) { } @@ -35,6 +37,7 @@ private ValidationContext( object? entry, SchemaField? field, string? path, + SchemaType? container, List errors) { ArgumentNullException.ThrowIfNull(type); @@ -42,6 +45,7 @@ private ValidationContext( ArgumentNullException.ThrowIfNull(operation); Type = type; + Container = container ?? type; Values = values; Operation = operation; Mode = mode; @@ -54,6 +58,9 @@ private ValidationContext( /// The type of the entry. public SchemaType Type { get; } + /// The type that declares : the entry type at the top level, the component or block type for a field inside one. + public SchemaType Container { get; } + /// The field being validated, or for an entry validator. public SchemaField? Field { get; } @@ -101,10 +108,10 @@ public void AddError(string code, string message) } // Shares the error list, so what a field validator reports lands in the result of the run. - internal ValidationContext ForField(SchemaField field, string path, IReadOnlyDictionary values) => - new(Type, values, Operation, Mode, Entry, field, path, _errors); + internal ValidationContext ForField(SchemaType container, SchemaField field, string path, IReadOnlyDictionary values) => + new(Type, values, Operation, Mode, Entry, field, path, container, _errors); // The entry-level view of the same run. internal ValidationContext ForEntry() => - new(Type, Values, Operation, Mode, Entry, null, null, _errors); + new(Type, Values, Operation, Mode, Entry, null, null, null, _errors); } diff --git a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt index 50ea71f32..adbb214ca 100644 --- a/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt +++ b/src/FluentCMS.Sdk/PublicAPI.Unshipped.txt @@ -50,6 +50,7 @@ FluentCMS.Content.PrincipalKind.System = 5 -> FluentCMS.Content.PrincipalKind FluentCMS.Content.ValidationContext FluentCMS.Content.ValidationContext.AddError(string! code, string! message) -> void FluentCMS.Content.ValidationContext.AddError(string! path, string! code, string! message) -> void +FluentCMS.Content.ValidationContext.Container.get -> FluentCMS.Schema.SchemaType! FluentCMS.Content.ValidationContext.Entry.get -> object? FluentCMS.Content.ValidationContext.Errors.get -> System.Collections.Generic.IReadOnlyList! FluentCMS.Content.ValidationContext.Field.get -> FluentCMS.Schema.SchemaField? @@ -57,7 +58,7 @@ FluentCMS.Content.ValidationContext.Mode.get -> FluentCMS.Content.ValidationMode FluentCMS.Content.ValidationContext.Operation.get -> FluentCMS.Content.OperationContext! FluentCMS.Content.ValidationContext.Path.get -> string! FluentCMS.Content.ValidationContext.Type.get -> FluentCMS.Schema.SchemaType! -FluentCMS.Content.ValidationContext.ValidationContext(FluentCMS.Schema.SchemaType! type, System.Collections.Generic.IReadOnlyDictionary! values, FluentCMS.Content.OperationContext! operation, FluentCMS.Content.ValidationMode mode, object? entry = null, FluentCMS.Schema.SchemaField? field = null, string? path = null) -> void +FluentCMS.Content.ValidationContext.ValidationContext(FluentCMS.Schema.SchemaType! type, System.Collections.Generic.IReadOnlyDictionary! values, FluentCMS.Content.OperationContext! operation, FluentCMS.Content.ValidationMode mode, object? entry = null, FluentCMS.Schema.SchemaField? field = null, string? path = null, FluentCMS.Schema.SchemaType? container = null) -> void FluentCMS.Content.ValidationContext.Values.get -> System.Collections.Generic.IReadOnlyDictionary! FluentCMS.Content.ValidationError FluentCMS.Content.ValidationError.$() -> FluentCMS.Content.ValidationError! diff --git a/src/FluentCMS.Sdk/Schema/ConditionField.cs b/src/FluentCMS.Sdk/Schema/ConditionField.cs index 7de75f68f..8601b6bfc 100644 --- a/src/FluentCMS.Sdk/Schema/ConditionField.cs +++ b/src/FluentCMS.Sdk/Schema/ConditionField.cs @@ -26,7 +26,7 @@ public Condition In(params object?[] values) return new(ConditionOperator.In, Name, values.Select(value => SchemaValue.Normalize(value)), []); } - /// Holds when the field has no value: , an empty string or an empty list. + /// Holds when the field has no value: , an empty or whitespace-only string, or an empty list. /// The condition. public Condition IsEmpty() => new(ConditionOperator.IsEmpty, Name, [], []); } diff --git a/src/FluentCMS.Sdk/Schema/RegularExpressionAttribute.cs b/src/FluentCMS.Sdk/Schema/RegularExpressionAttribute.cs index 6156aaa56..c8a398299 100644 --- a/src/FluentCMS.Sdk/Schema/RegularExpressionAttribute.cs +++ b/src/FluentCMS.Sdk/Schema/RegularExpressionAttribute.cs @@ -1,6 +1,9 @@ namespace FluentCMS.Schema; -/// Requires the value of a text field to match a regular expression. +/// +/// Requires the value of a text field to match a regular expression. The pattern may match anywhere in the value, +/// as an OpenAPI pattern does, so anchor it with ^ and $ to match the whole value. +/// /// The regular expression. [AttributeUsage(AttributeTargets.Property, Inherited = false)] public sealed class RegularExpressionAttribute(string pattern) : Attribute diff --git a/tests/FluentCMS.Core.Tests/Content/Validation/ConditionTests.cs b/tests/FluentCMS.Core.Tests/Content/Validation/ConditionTests.cs index e3064abe0..091419e64 100644 --- a/tests/FluentCMS.Core.Tests/Content/Validation/ConditionTests.cs +++ b/tests/FluentCMS.Core.Tests/Content/Validation/ConditionTests.cs @@ -133,7 +133,7 @@ public void AMultiSelectEqualsAValueItContains() } [Fact] - public void AndAndOrShortCircuit() + public void AndOrAndNotCombineTheirOperands() { var values = Values(("a", 1)); var holds = Condition.Field("a").Equals(1); @@ -156,6 +156,7 @@ public void AndAndOrShortCircuit() { "empty block list", new BlockList(), true }, { "empty ref list", new RefList(), true }, { "empty media list", new MediaRefList(), true }, + { "default media", default(MediaRef), true }, { "default ref", default(Ref), true }, { "empty guid", Guid.Empty, true }, { "text", "x", false }, @@ -165,6 +166,7 @@ public void AndAndOrShortCircuit() { "block list", new BlockList([new HeroBlock()]), false }, { "ref list", new RefList([new Ref(Guid.NewGuid())]), false }, { "ref", new Ref(Guid.NewGuid()), false }, + { "media", new MediaRef(Guid.NewGuid()), false }, { "enum", ArticleCategory.News, false }, }; diff --git a/tests/FluentCMS.Core.Tests/Content/Validation/CustomValidatorTests.cs b/tests/FluentCMS.Core.Tests/Content/Validation/CustomValidatorTests.cs index ad0612d0f..5fb04de58 100644 --- a/tests/FluentCMS.Core.Tests/Content/Validation/CustomValidatorTests.cs +++ b/tests/FluentCMS.Core.Tests/Content/Validation/CustomValidatorTests.cs @@ -161,10 +161,13 @@ public async Task ANestedFieldValidatorSeesItsSiblingsAndFullPath() await ValidationFixtures.ValidateAsync(ValidationFixtures.Blog, "article", new() { ["title"] = "T", ["seo"] = seo }, ValidationMode.Publish, register: Register(seen)); - var (_, path, value, context) = seen.Fields.Single(item => item.Path == "seo.metaTitle" && item.Field == "metaTitle*"); + var (_, _, value, context) = seen.Fields.Single(item => item.Path == "seo.metaTitle" && item.Field == "metaTitle*"); value.ShouldBe("Meta"); context.Values.ShouldBeSameAs(seo); context.Type.ShouldBeSameAs(ValidationFixtures.Article); + context.Container.ShouldBeSameAs(ValidationFixtures.Blog.FindComponent("seo")); + + seen.Fields.Single(item => item.Field == "title*").Context.Container.ShouldBeSameAs(ValidationFixtures.Article); } [Fact] diff --git a/tests/FluentCMS.Core.Tests/Content/Validation/DeclarativeRuleTests.cs b/tests/FluentCMS.Core.Tests/Content/Validation/DeclarativeRuleTests.cs index 50fcec907..6108609dd 100644 --- a/tests/FluentCMS.Core.Tests/Content/Validation/DeclarativeRuleTests.cs +++ b/tests/FluentCMS.Core.Tests/Content/Validation/DeclarativeRuleTests.cs @@ -1,6 +1,7 @@ #pragma warning disable CA1861 using FluentCMS.Content; using FluentCMS.Core.Tests.Schema.Samples; +using FluentCMS.Media; using FluentCMS.Schema; using Shouldly; using Xunit; @@ -126,13 +127,42 @@ public async Task NumbersAreCheckedAgainstTheRange(int count, string? expected) } [Fact] - public async Task DatesAndDateTimesAreCheckedAgainstTheRange() + public async Task DatesAreNotRangeChecked() { - (await Run(new() { ["opensAt"] = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero) })).ShouldBeEmpty(); - (await Run(new() { ["opensAt"] = new DateTimeOffset(2019, 1, 1, 0, 0, 0, TimeSpan.Zero) })).ShouldBe(["opensAt|FCMS2104"]); - (await Run(new() { ["opensAt"] = new DateTime(2031, 1, 1, 0, 0, 0, DateTimeKind.Utc) })).ShouldBe(["opensAt|FCMS2105"]); - (await Run(new() { ["day"] = new DateOnly(1970, 3, 1) })).ShouldBeEmpty(); - (await Run(new() { ["day"] = new DateOnly(1971, 1, 1) })).ShouldBe(["day|FCMS2105"]); + (await Run(new() { ["opensAt"] = new DateTimeOffset(2019, 1, 1, 0, 0, 0, TimeSpan.Zero), ["day"] = new DateOnly(2024, 1, 1) })).ShouldBeEmpty(); + (await Run(new() { ["opensAt"] = new DateTime(2031, 1, 1, 0, 0, 0, DateTimeKind.Unspecified) })).ShouldBeEmpty(); + } + + [Fact] + public async Task AFloatBeyondWhatADecimalHoldsIsOutOfRangeInItsDirection() + { + (await Run(new() { ["count"] = 1e300 })).ShouldBe(["count|FCMS2105"]); + (await Run(new() { ["count"] = -1e300 })).ShouldBe(["count|FCMS2104"]); + (await Run(new() { ["count"] = double.PositiveInfinity })).ShouldBe(["count|FCMS2105"]); + (await Run(new() { ["count"] = float.NegativeInfinity })).ShouldBe(["count|FCMS2104"]); + (await Run(new() { ["count"] = double.NaN })).ShouldBeEmpty(); + (await Run(new() { ["count"] = 5.5 })).ShouldBeEmpty(); + } + + [Fact] + public async Task APatternMatchesAnywhereInTheValueUnlessAnchored() + { + var model = ValidationFixtures.Model(thing => thing + .Field("loose", "text", f => f.Pattern("[0-9]+")) + .Field("anchored", "text", f => f.Pattern("^[0-9]+$"))); + + var result = await ValidationFixtures.ValidateAsync(model, "thing", new() { ["loose"] = "abc123def", ["anchored"] = "abc123def" }, ValidationMode.Publish); + + ValidationFixtures.Describe(result).ShouldBe(["anchored|FCMS2106"]); + } + + [Fact] + public async Task ADefaultMediaRefDoesNotSatisfyRequired() + { + var model = ValidationFixtures.Model(thing => thing.Field("hero", "media", f => f.Required())); + + ValidationFixtures.Describe(await ValidationFixtures.ValidateAsync(model, "thing", new() { ["hero"] = default(MediaRef) }, ValidationMode.Publish)).ShouldBe(["hero|FCMS2101"]); + (await ValidationFixtures.ValidateAsync(model, "thing", new() { ["hero"] = new MediaRef(Guid.NewGuid()) }, ValidationMode.Publish)).IsValid.ShouldBeTrue(); } [Fact] @@ -199,14 +229,4 @@ public async Task ErrorsFollowTheDeclarationOrderOfTheFields() (await Run(values)).ShouldBe(expected); } } - - [Fact] - public async Task APatternThatTakesTooLongRejectsTheValue() - { - var model = ValidationFixtures.Model(thing => thing.Field("s", "text", f => f.Pattern("^(a+)+$"))); - - var result = await ValidationFixtures.ValidateAsync(model, "thing", new() { ["s"] = new string('a', 60) + "!" }, ValidationMode.Publish); - - ValidationFixtures.Describe(result).ShouldBe(["s|FCMS2106"]); - } } diff --git a/tests/FluentCMS.Core.Tests/Content/Validation/ValidationFixtures.cs b/tests/FluentCMS.Core.Tests/Content/Validation/ValidationFixtures.cs index 360f43152..8ff577116 100644 --- a/tests/FluentCMS.Core.Tests/Content/Validation/ValidationFixtures.cs +++ b/tests/FluentCMS.Core.Tests/Content/Validation/ValidationFixtures.cs @@ -45,7 +45,7 @@ public static ValueTask ValidateArticleAsync(Dictionary thing, Action? others = null) { - var builder = new SchemaBuilder().AddCollection("thing", thing); + var builder = SampleSchema.NewBuilder().AddCollection("thing", thing); others?.Invoke(builder); return builder.Build(); }