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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions FirstClassErrors.RequestBinder.PropertyTests/BinderPropertyModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#region Usings declarations

using FsCheck;
using FsCheck.Fluent;

#endregion

namespace FirstClassErrors.RequestBinder.PropertyTests;

#region Request DTO and value object

/// <summary>A request DTO carrying a single list property — the surface the list-binding invariants bind against.</summary>
internal sealed record TokenListRequest(IReadOnlyList<string?>? Tokens);

/// <summary>
/// A minimal value object: valid exactly when the raw token is a non-empty run of letters or digits. It
/// deliberately rejects whitespace, so a generated token can be made valid or invalid on demand — which is what
/// lets a property know, before binding, which elements the binder must reject.
/// </summary>
internal sealed class Token {

private Token(string value) {
Value = value;
}

public string Value { get; }

public static Outcome<Token> Parse(string raw) {
return raw.Length > 0 && raw.All(char.IsLetterOrDigit)
? Outcome<Token>.Success(new Token(raw))
: Outcome<Token>.Failure(TokenError.Invalid(raw));
}

}

#endregion

#region Error factories

/// <summary>The leaf error a token fails to parse with.</summary>
internal static class TokenError {

private static readonly ErrorCode Code = ErrorCode.Create("PROP_TOKEN_INVALID");

public static DomainError Invalid(string raw) {
return DomainError.Create(Code, $"'{raw}' is not a valid token.")
.WithPublicMessage("The token is invalid.");
}

}

/// <summary>The envelope the binder groups every recorded violation into.</summary>
internal static class CommandError {

private static readonly ErrorCode Code = ErrorCode.Create("PROP_COMMAND_INVALID");

public static PrimaryPortError Invalid(PrimaryPortInnerErrors violations) {
return PrimaryPortError.Create(Code, "The command is invalid.", violations)
.WithPublicMessage("The request is invalid.");
}

}

#endregion

#region Generators

/// <summary>
/// Generates lists of token slots for the property-based tests. A slot pairs the raw value a request would
/// carry with whether that value must fail to bind — a classification that is a pure function of a generated
/// integer, so the expected outcome (which elements fail, and at which index) is known before binding.
/// </summary>
internal static class BinderGen {

#region Statics members declarations

/// <summary>
/// Classifies an arbitrary integer into a token slot: one third bind as a valid token, one third as an
/// invalid (whitespace-bearing) token, one third as a <c>null</c> element. Both invalid and null are failing
/// slots — the binder records exactly one error for each.
/// </summary>
public static (string? Raw, bool Fails) ToSlot(int seed) {
int nonNegative = seed & int.MaxValue;

return (nonNegative % 3) switch {
0 => ("t" + nonNegative, false), // letters and digits only → binds
1 => ("t " + nonNegative, true), // embeds a space → REQUEST_ARGUMENT_INVALID
_ => ((string?)null, true) // absent element → REQUEST_ARGUMENT_REQUIRED
};
}

/// <summary>
/// Generates a list of token slots of arbitrary length — the empty list included — each element
/// independently classified as valid, invalid, or null.
/// </summary>
public static Gen<(string? Raw, bool Fails)[]> Slots() {
return ArbMap.Default.GeneratorFor<int[]>().Select(seeds => seeds.Select(ToSlot).ToArray());
}

#endregion

}

#endregion
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- FsCheck drives the property-based (fuzzing-style) tests. The bare `using FsCheck;` in each test file
is also what OpenSSF Scorecard's Fuzzing check looks for to credit C# property-based testing. -->
<PackageReference Include="FsCheck" />
<PackageReference Include="JetBrains.Annotations" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\FirstClassErrors\FirstClassErrors.csproj" />
<ProjectReference Include="..\FirstClassErrors.RequestBinder\FirstClassErrors.RequestBinder.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#region Usings declarations

using FsCheck;
using FsCheck.Fluent;

using JetBrains.Annotations;

#endregion

namespace FirstClassErrors.RequestBinder.PropertyTests;

/// <summary>
/// Property-based (fuzzing-style) invariants for the request binder's list binding. Where the example-based
/// unit tests pin a handful of hand-picked cases, these assert the binder's defining guarantees across a wide
/// range of randomly generated lists: the collect-all contract — one recorded error per failing element, never
/// fewer, never more — and the positional stability of the indexed error paths, so that reordering the elements
/// reorders their paths accordingly and an element's path never depends on its neighbours.
/// </summary>
[TestSubject(typeof(ListOfSimplePropertiesConverter<,>))]
public sealed class ListBindingInvariantTests {

[Fact(DisplayName = "Collect-all: a required list records exactly one error per failing element, and none for a valid one.")]
public void CollectAllRecordsExactlyOneErrorPerFailingElement() {
Prop.ForAll(BinderGen.Slots().ToArbitrary(),
slots => {
int expectedFailures = slots.Count(slot => slot.Fails);
Outcome<IReadOnlyList<Token>> outcome = BindTokens(slots);

return expectedFailures == 0
? outcome.IsSuccess && outcome.GetResultOrThrow().Count == slots.Length
: outcome.IsFailure && outcome.Error!.InnerErrors.Count == expectedFailures;
})
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "Indexed paths are exactly the positions of the failing elements — nothing more, nothing less.")]
public void ErrorPathsAreExactlyThePositionsOfFailingElements() {
Prop.ForAll(BinderGen.Slots().ToArbitrary(),
slots => FailingPaths(BindTokens(slots)).SetEquals(PositionsOfFailures(slots)))
.QuickCheckThrowOnFailure();
}

[Fact(DisplayName = "Reordering the elements reorders their error paths accordingly: a path tracks position, never content or neighbours.")]
public void ReorderingElementsReordersTheirErrorPathsAccordingly() {
Prop.ForAll(BinderGen.Slots().ToArbitrary(),
slots => {
(string? Raw, bool Fails)[] reversed = slots.Reverse().ToArray();

return FailingPaths(BindTokens(slots)).SetEquals(PositionsOfFailures(slots))
&& FailingPaths(BindTokens(reversed)).SetEquals(PositionsOfFailures(reversed));
})
.QuickCheckThrowOnFailure();
}

#region Statics members declarations

/// <summary>Binds the generated tokens as a <b>required</b> list and returns the whole outcome.</summary>
private static Outcome<IReadOnlyList<Token>> BindTokens((string? Raw, bool Fails)[] slots) {
TokenListRequest request = new(slots.Select(slot => slot.Raw).ToArray());
var binder = Bind.PropertiesOf(request).FailWith(CommandError.Invalid);

RequiredField<IReadOnlyList<Token>> tokens = binder.ListOfSimpleProperties(r => r.Tokens).AsRequired(Token.Parse);

return binder.New(scope => scope.Get(tokens));
}

/// <summary>The indexed paths a correct binder must record — one per failing slot, at that slot's position.</summary>
private static ISet<string> PositionsOfFailures((string? Raw, bool Fails)[] slots) {
return slots.Select((slot, index) => (slot, index))
.Where(entry => entry.slot.Fails)
.Select(entry => $"Tokens[{entry.index}]")
.ToHashSet();
}

/// <summary>The set of indexed argument paths actually recorded by a binding outcome (empty on success).</summary>
private static ISet<string> FailingPaths(Outcome<IReadOnlyList<Token>> outcome) {
if (outcome.IsSuccess) { return new HashSet<string>(); }

return outcome.Error!.InnerErrors
.Select(ArgumentPathOf)
.Where(path => path is not null)
.Select(path => path!)
.ToHashSet();
}

private static string? ArgumentPathOf(Error error) {
error.Context.ToNameDictionary().TryGetValue("RequestArgument", out object? path);

return path as string;
}

#endregion

}
23 changes: 23 additions & 0 deletions FirstClassErrors.RequestBinder.UnitTests/ListBindingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ public void RequiredSimpleListMissing() {
Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Tags");
}

[Fact(DisplayName = "A required list that is present but empty is valid: it binds an empty list and records nothing — required constrains presence, not element count.")]
public void RequiredSimpleListPresentButEmptyBindsEmpty() {
var bind = Bind.PropertiesOf(RequestWith(tags: [])).FailWith(BookingEnvelopeError.CommandInvalid);

RequiredField<IReadOnlyList<Tag>> tags = bind.ListOfSimpleProperties(r => r.Tags).AsRequired(Tag.Parse);

Outcome<IReadOnlyList<Tag>> outcome = bind.New(s => s.Get(tags));
Check.That(outcome.IsSuccess).IsTrue();
Check.That(outcome.GetResultOrThrow()).IsEmpty();
}

[Fact(DisplayName = "Every failing element of a list is collected, each under its indexed path — one bad element never hides the others.")]
public void EveryFailingElementIsCollected() {
var bind = Bind.PropertiesOf(RequestWith(tags: ["ok", "not ok", null, "fine"])).FailWith(BookingEnvelopeError.CommandInvalid);
Expand Down Expand Up @@ -79,6 +90,18 @@ public void RequiredComplexListBinds() {
Check.That(outcome.GetResultOrThrow()[1].Email).IsNull();
}

[Fact(DisplayName = "A required complex list that is present but empty is valid: it binds an empty list and records nothing.")]
public void RequiredComplexListPresentButEmptyBindsEmpty() {
var bind = Bind.PropertiesOf(RequestWith(guests: [])).FailWith(BookingEnvelopeError.CommandInvalid);

RequiredField<IReadOnlyList<Guest>> guests =
bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest);

Outcome<IReadOnlyList<Guest>> outcome = bind.New(s => s.Get(guests));
Check.That(outcome.IsSuccess).IsTrue();
Check.That(outcome.GetResultOrThrow()).IsEmpty();
}

[Fact(DisplayName = "Each failing element of a complex list records its own envelope, whose inner paths carry the indexed prefix.")]
public void FailingComplexElementsRecordTheirEnvelopes() {
var bind = Bind.PropertiesOf(RequestWith(guests: [new GuestDto("Alice", null), new GuestDto(null, "nope"), new GuestDto(null, null)]))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,17 @@ public void ValueTypeListOptionalVsRequiredWhenAbsent() {
Check.That(required.New(_ => "never").Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
}

[Fact(DisplayName = "A required value-type list that is present but empty is valid: it binds an empty list and records nothing.")]
public void RequiredValueTypeListPresentButEmptyBindsEmpty() {
var bind = Bind.PropertiesOf(Request(quantities: [])).FailWith(BookingEnvelopeError.CommandInvalid);

RequiredField<IReadOnlyList<PositiveQty>> quantities = bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From);

Outcome<IReadOnlyList<PositiveQty>> outcome = bind.New(s => s.Get(quantities));
Check.That(outcome.IsSuccess).IsTrue();
Check.That(outcome.GetResultOrThrow()).IsEmpty();
}

// ── Regressions: reference/string paths keep resolving to the original overloads ──────────────────────

[Fact(DisplayName = "A string property still resolves to the reference overload — no ambiguity introduced by the value-type overload.")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ internal ListOfComplexPropertiesConverter(RequestBinder<TRequest>
#endregion

/// <summary>
/// Binds a required list: a missing list records <c>REQUEST_ARGUMENT_REQUIRED</c>; each failing element
/// records its envelope under its indexed path.
/// Binds a required list: only an <b>absent</b> (<c>null</c>) list records <c>REQUEST_ARGUMENT_REQUIRED</c> —
/// a list that is <b>present but empty</b> is valid and binds an empty list, because a required list
/// constrains the list's <b>presence</b>, not its element count. Each failing element records its envelope
/// under its indexed path.
/// </summary>
/// <typeparam name="TProperty">The type each element's nested binding produces.</typeparam>
/// <param name="bindElement">The nested binding function applied to each element (typically a method group).</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ internal ListOfSimplePropertiesConverter(RequestBinder<TRequest> binder, string
#endregion

/// <summary>
/// Binds a required list: a missing list records <c>REQUEST_ARGUMENT_REQUIRED</c>; each failing element
/// records <c>REQUEST_ARGUMENT_INVALID</c> under its indexed path.
/// Binds a required list: only an <b>absent</b> (<c>null</c>) list records <c>REQUEST_ARGUMENT_REQUIRED</c> —
/// a list that is <b>present but empty</b> is valid and binds an empty list, because a required list
/// constrains the list's <b>presence</b>, not its element count. Each failing element records
/// <c>REQUEST_ARGUMENT_INVALID</c> under its indexed path.
/// </summary>
/// <typeparam name="TProperty">The type of the element value object.</typeparam>
/// <param name="convertElement">The value-object converter applied to each element.</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@ internal ListOfSimpleValuePropertiesConverter(RequestBinder<TRequest> binder, st
#endregion

/// <summary>
/// Binds a required list: a missing list records <c>REQUEST_ARGUMENT_REQUIRED</c>; each failing element
/// records <c>REQUEST_ARGUMENT_INVALID</c> under its indexed path, and a <c>null</c> element records
/// Binds a required list: only an <b>absent</b> (<c>null</c>) list records <c>REQUEST_ARGUMENT_REQUIRED</c> —
/// a list that is <b>present but empty</b> is valid and binds an empty list, because a required list
/// constrains the list's <b>presence</b>, not its element count. Each failing element records
/// <c>REQUEST_ARGUMENT_INVALID</c> under its indexed path, and a <c>null</c> element records
/// <c>REQUEST_ARGUMENT_REQUIRED</c>.
/// </summary>
/// <typeparam name="TProperty">The type of the element value object.</typeparam>
Expand Down
15 changes: 15 additions & 0 deletions FirstClassErrors.sln
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dummies", "Dummies\Dummies.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dummies.UnitTests", "Dummies.UnitTests\Dummies.UnitTests.csproj", "{BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FirstClassErrors.RequestBinder.PropertyTests", "FirstClassErrors.RequestBinder.PropertyTests\FirstClassErrors.RequestBinder.PropertyTests.csproj", "{1FC2C501-8842-4ABE-845E-000ED6575DD6}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -261,6 +263,18 @@ Global
{BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Release|x64.Build.0 = Release|Any CPU
{BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Release|x86.ActiveCfg = Release|Any CPU
{BC4F2BE8-55C9-4AA4-90FA-699EC83A8985}.Release|x86.Build.0 = Release|Any CPU
{1FC2C501-8842-4ABE-845E-000ED6575DD6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1FC2C501-8842-4ABE-845E-000ED6575DD6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1FC2C501-8842-4ABE-845E-000ED6575DD6}.Debug|x64.ActiveCfg = Debug|Any CPU
{1FC2C501-8842-4ABE-845E-000ED6575DD6}.Debug|x64.Build.0 = Debug|Any CPU
{1FC2C501-8842-4ABE-845E-000ED6575DD6}.Debug|x86.ActiveCfg = Debug|Any CPU
{1FC2C501-8842-4ABE-845E-000ED6575DD6}.Debug|x86.Build.0 = Debug|Any CPU
{1FC2C501-8842-4ABE-845E-000ED6575DD6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1FC2C501-8842-4ABE-845E-000ED6575DD6}.Release|Any CPU.Build.0 = Release|Any CPU
{1FC2C501-8842-4ABE-845E-000ED6575DD6}.Release|x64.ActiveCfg = Release|Any CPU
{1FC2C501-8842-4ABE-845E-000ED6575DD6}.Release|x64.Build.0 = Release|Any CPU
{1FC2C501-8842-4ABE-845E-000ED6575DD6}.Release|x86.ActiveCfg = Release|Any CPU
{1FC2C501-8842-4ABE-845E-000ED6575DD6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -281,6 +295,7 @@ Global
{BB28120C-1D68-469D-928A-51AE454D2487} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB}
{7991C766-2720-43E5-A9CB-2F3D6C2025FC} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{75EA57DD-0128-4EB9-ADBE-567BFF602A93} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB}
{1FC2C501-8842-4ABE-845E-000ED6575DD6} = {B7C3D08D-EFC5-4F5D-8DE4-5B7938354DBB}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {4988972E-3E0D-4F48-8656-0E67ECE994BF}
Expand Down
Loading