request/src/request.validation.tests/RuleBuilderExtensionsTests.cs
Louis Seubert 68194d3cb3
fix(validation): guard null comparison bounds in comparison builders
Passing a null bound to GreaterThan/LessThan/Between etc. called
value.CompareTo(null), throwing NullReferenceException for non-null
reference-type values. Short-circuit when the bound is null so the rule is
satisfied instead of crashing.
2026-07-12 21:25:21 +02:00

402 lines
15 KiB
C#

// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text.RegularExpressions;
namespace Geekeey.Request.Validation.Tests;
internal sealed class RuleBuilderExtensionsTests
{
[Test]
public async Task I_can_validate_not_null_for_reference_types()
{
var validator = new PropertyValidator<ReferenceValueModel, object?>(model =>
model.Value, rule => rule.NotNull());
var invalid = validator.Validate(new ReferenceValueModel());
var valid = validator.Validate(new ReferenceValueModel { Value = new object() });
await AssertSingleProblem(invalid, nameof(ReferenceValueModel.Value), "Value is required.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_not_null_for_nullable_value_types()
{
var validator = new PropertyValidator<NullableIntValueModel, int?>(model =>
model.Value, rule => rule.NotNull());
var invalid = validator.Validate(new NullableIntValueModel());
var valid = validator.Validate(new NullableIntValueModel { Value = 1 });
await AssertSingleProblem(invalid, nameof(NullableIntValueModel.Value), "Value is required.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_not_empty_for_strings()
{
var validator = new PropertyValidator<StringValueModel, string?>(model =>
model.Value, rule => rule.NotEmpty());
var invalid = validator.Validate(new StringValueModel { Value = " " });
var valid = validator.Validate(new StringValueModel { Value = "abc" });
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value is required.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_remove_the_property_path()
{
var validator = new PropertyValidator<StringValueModel, string?>(model =>
model.Value, rule => rule.WithoutPropertyPath().NotEmpty());
var result = validator.Validate(new StringValueModel { Value = " " });
await AssertSingleProblem(result, string.Empty, "Value is required.");
}
[Test]
public async Task I_can_validate_not_empty_for_collections()
{
var validator = new PropertyValidator<CollectionValueModel, IEnumerable<string>?>(model =>
model.Value, rule => rule.NotEmpty());
var invalid = validator.Validate(new CollectionValueModel { Value = [] });
var valid = validator.Validate(new CollectionValueModel { Value = ["item"] });
await AssertSingleProblem(invalid, nameof(CollectionValueModel.Value), "Value is required.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_min_length()
{
var validator =
new PropertyValidator<StringValueModel, string?>(model =>
model.Value, rule => rule.MinLength(3));
var invalid = validator.Validate(new StringValueModel { Value = "ab" });
var valid = validator.Validate(new StringValueModel { Value = "abc" });
var ignoredNull = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be at least 3 characters long.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_max_length()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.MaxLength(5));
var invalid = validator.Validate(new StringValueModel { Value = "abcdef" });
var valid = validator.Validate(new StringValueModel { Value = "abcde" });
var ignoredNull = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be at most 5 characters long.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_length()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.Length(2, 4));
var invalid = validator.Validate(new StringValueModel { Value = "a" });
var valid = validator.Validate(new StringValueModel { Value = "ab" });
var ignoredNull = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value),
"Value must be between 2 and 4 characters long.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_greater_than()
{
var validator = new PropertyValidator<IntValueModel, int>(model
=> model.Value, rule => rule.GreaterThan(18));
var invalid = validator.Validate(new IntValueModel { Value = 18 });
var valid = validator.Validate(new IntValueModel { Value = 19 });
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be greater than 18.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_greater_than_or_equal_to()
{
var validator = new PropertyValidator<IntValueModel, int>(model
=> model.Value, rule => rule.GreaterThanOrEqualTo(1));
var invalid = validator.Validate(new IntValueModel { Value = 0 });
var valid = validator.Validate(new IntValueModel { Value = 1 });
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be greater than or equal to 1.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_less_than()
{
var validator = new PropertyValidator<IntValueModel, int>(model
=> model.Value, rule => rule.LessThan(10));
var invalid = validator.Validate(new IntValueModel { Value = 10 });
var valid = validator.Validate(new IntValueModel { Value = 9 });
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be less than 10.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_less_than_or_equal_to()
{
var validator = new PropertyValidator<IntValueModel, int>(model
=> model.Value, rule => rule.LessThanOrEqualTo(5));
var invalid = validator.Validate(new IntValueModel { Value = 6 });
var valid = validator.Validate(new IntValueModel { Value = 5 });
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be less than or equal to 5.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_between()
{
var validator = new PropertyValidator<IntValueModel, int>(model
=> model.Value, rule => rule.Between(0, 100));
var invalid = validator.Validate(new IntValueModel { Value = 101 });
var valid = validator.Validate(new IntValueModel { Value = 100 });
await AssertSingleProblem(invalid, nameof(IntValueModel.Value), "Value must be between 0 and 100.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_equal()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.Equal("ACTIVE"));
var invalid = validator.Validate(new StringValueModel { Value = "INACTIVE" });
var valid = validator.Validate(new StringValueModel { Value = "ACTIVE" });
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be equal to ACTIVE.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_not_equal()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.NotEqual("BANNED"));
var invalid = validator.Validate(new StringValueModel { Value = "BANNED" });
var valid = validator.Validate(new StringValueModel { Value = "ALLOWED" });
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must not be equal to BANNED.");
await Assert.That(valid.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_equal_with_null_reference_values()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.Equal("ACTIVE"));
var invalid = validator.Validate(new StringValueModel { Value = "INACTIVE" });
var valid = validator.Validate(new StringValueModel { Value = "ACTIVE" });
var nullValue = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be equal to ACTIVE.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(nullValue.IsValid).IsFalse();
}
[Test]
public async Task I_can_validate_not_equal_with_null_reference_values()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.NotEqual("BANNED"));
var invalid = validator.Validate(new StringValueModel { Value = "BANNED" });
var valid = validator.Validate(new StringValueModel { Value = "ALLOWED" });
var nullValue = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must not be equal to BANNED.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(nullValue.IsValid).IsTrue();
}
[Test]
public async Task I_can_skip_null_values_for_reference_greater_than()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.GreaterThan("B"));
var invalid = validator.Validate(new StringValueModel { Value = "A" });
var valid = validator.Validate(new StringValueModel { Value = "C" });
var ignoredNull = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be greater than B.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_skip_null_values_for_reference_between()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.Between("A", "C"));
var invalid = validator.Validate(new StringValueModel { Value = "D" });
var valid = validator.Validate(new StringValueModel { Value = "C" });
var ignoredNull = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value must be between A and C.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_matches_with_a_pattern()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.Matches("^[A-Z]+$"));
var invalid = validator.Validate(new StringValueModel { Value = "Abc" });
var valid = validator.Validate(new StringValueModel { Value = "ABC" });
var ignoredNull = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value is not in the correct format.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_matches_with_a_regex()
{
var validator = new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.Matches(new Regex(@"^\d{4}$", RegexOptions.CultureInvariant)));
var invalid = validator.Validate(new StringValueModel { Value = "12AB" });
var valid = validator.Validate(new StringValueModel { Value = "1234" });
var ignoredNull = validator.Validate(new StringValueModel());
await AssertSingleProblem(invalid, nameof(StringValueModel.Value), "Value is not in the correct format.");
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_see_it_throw_for_invalid_length_configuration()
{
await Assert.That(() => new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.Length(5, 4)))
.Throws<ArgumentOutOfRangeException>();
}
[Test]
public async Task I_can_see_it_throw_for_negative_min_length_configuration()
{
await Assert.That(() => new PropertyValidator<StringValueModel, string?>(model
=> model.Value, rule => rule.MinLength(-1)))
.Throws<ArgumentOutOfRangeException>();
}
[Test]
public async Task I_can_validate_greater_than_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.GreaterThan(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_greater_than_or_equal_to_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.GreaterThanOrEqualTo(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_less_than_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.LessThan(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_less_than_or_equal_to_with_null_bound_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.LessThanOrEqualTo(null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_validate_between_with_null_bounds_without_throwing()
{
var validator = new PropertyValidator<ComparableModel, ComparableValue?>(model
=> model.Value, rule => rule.Between(null, null));
var valid = validator.Validate(new ComparableModel { Value = new ComparableValue { Number = 1 } });
var ignoredNull = validator.Validate(new ComparableModel());
await Assert.That(valid.IsValid).IsTrue();
await Assert.That(ignoredNull.IsValid).IsTrue();
}
[Test]
public async Task I_can_see_it_throw_for_invalid_between_configuration()
{
await Assert.That(() => new PropertyValidator<IntValueModel, int>(model
=> model.Value, rule => rule.Between(10, 0)))
.Throws<ArgumentOutOfRangeException>();
}
private static async Task AssertSingleProblem(Validation validation, string propertyPath, string message)
{
await Assert.That(validation.Problems).Count().IsEqualTo(1);
var problem = validation.Problems.Single();
using (Assert.Multiple())
{
await Assert.That(problem.PropertyPath.ToString()).IsEqualTo(propertyPath);
await Assert.That(problem.Message).IsEqualTo(message);
}
}
}