feat: add request validation

This commit is contained in:
Louis Seubert 2026-05-16 09:49:04 +02:00
commit de2d0e2693
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
32 changed files with 1829 additions and 1 deletions

View file

@ -0,0 +1,326 @@
// 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_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_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_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_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 propertyName, string message)
{
await Assert.That(validation.Problems).Count().IsEqualTo(1);
var problem = validation.Problems.Single();
using (Assert.Multiple())
{
await Assert.That(problem.PropertyName).IsEqualTo(propertyName);
await Assert.That(problem.Message).IsEqualTo(message);
}
}
}