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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ errors should stay structured, documented, and close to the code.
* `FirstClassErrors.GenDoc` — error-documentation generator (+ `.UnitTests`)
* `FirstClassErrors.GenDoc.Worker` — background worker for doc generation
* `FirstClassErrors.Cli` — command-line tool
* `FirstClassErrors.RequestBinder` — request binder for the primary-adapter boundary (+ `.UnitTests`)
* `FirstClassErrors.Usage` — usage examples
* `doc/` — documentation, including the French translation

Expand Down Expand Up @@ -93,7 +94,7 @@ The essentials, inlined so they hold even if `AGENTS.md` is not read:
* Follow `.github/pull_request_template.md` for every pull request.
* Do not open a pull request unless I explicitly ask for one.
* PR titles, descriptions, commits, and branch names must be written in English.
* Write every commit message per [`CONTRIBUTING.md`](CONTRIBUTING.md): Conventional Commits, a closed type list, the scopes `core, analyzers, cli, gendoc, testing`, an imperative header within 72 characters, and `Refs: #NN` in a footer when a GitHub issue exists (issue-closing keywords belong in the PR description, not the commit).
* Write every commit message per [`CONTRIBUTING.md`](CONTRIBUTING.md): Conventional Commits, a closed type list, the scopes `core, analyzers, binder, cli, gendoc, testing`, an imperative header within 72 characters, and `Refs: #NN` in a footer when a GitHub issue exists (issue-closing keywords belong in the PR description, not the commit).
* Write every pull request title per [`CONTRIBUTING.md`](CONTRIBUTING.md): name the whole change in English; a single-intention PR mirrors its commit header (`type(scope): description`), a multi-intention PR uses a short descriptive title, and issue references stay in the description, not the title.
* Enable the local commit-message hook once per clone with `git config core.hooksPath .githooks`; the same check runs in CI on every pull request.
* In PR descriptions, do not invent testing results. Only check items that were actually run.
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ The scope MAY be provided. When present it MUST be lowercase and MUST be one of:
|---|---|
| `core` | `FirstClassErrors` — the runtime library (`Error`, `Outcome`, `ErrorCode`, `ErrorContextKey`, …) |
| `analyzers` | `FirstClassErrors.Analyzers` — the Roslyn analyzers and their `FCExxx` diagnostics |
| `binder` | `FirstClassErrors.RequestBinder` — the request binder for the primary-adapter boundary |
| `cli` | `FirstClassErrors.Cli` — the command-line tool |
| `gendoc` | `FirstClassErrors.GenDoc` and its worker — the documentation generator |
| `testing` | `FirstClassErrors.Testing` — the test-support package |
Expand Down
396 changes: 396 additions & 0 deletions FirstClassErrors.RequestBinder.UnitTests/BindingContractTests.cs

Large diffs are not rendered by default.

96 changes: 96 additions & 0 deletions FirstClassErrors.RequestBinder.UnitTests/BookingEndToEndTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#region Usings declarations

using NFluent;

#endregion

namespace FirstClassErrors.RequestBinder.UnitTests;

/// <summary>
/// End-to-end scenario mirroring the reference example of the design spec (issue #126): a full booking command
/// bound in one pass, exercising every converter shape, then rejoining the Outcome pipeline.
/// </summary>
public sealed class BookingEndToEndTests {

private static Outcome<Stay> BindStay(RequestBinder<StayDto> stay) {
RequiredField<BookingDate> checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse);
RequiredField<BookingDate> checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse);

return stay.New(s => new Stay(s.Get(checkIn), s.Get(checkOut)));
}

private static Outcome<Guest> BindGuest(RequestBinder<GuestDto> guest) {
RequiredField<string> firstName = guest.SimpleProperty(g => g.FirstName).AsRequired();
OptionalReferenceField<EmailAddress> email = guest.SimpleProperty(g => g.Email).AsOptionalReference(EmailAddress.Parse);

return guest.New(s => new Guest(s.Get(firstName), s.Get(email)));
}

private static Outcome<BookingCommand> BindCommand(BookingRequest request) {
var bind = Bind.PropertiesOf(request).FailWith(BookingEnvelopeError.CommandInvalid);

RequiredField<EmailAddress> email = bind.SimpleProperty(r => r.GuestEmail).AsRequired(EmailAddress.Parse);
RequiredField<string> reference = bind.SimpleProperty(r => r.Reference).AsRequired();
RequiredField<Currency> currency = bind.SimpleProperty(r => r.Currency).AsOptional(Currency.Parse, "EUR");
OptionalValueField<int> maxNights = bind.SimpleProperty(r => r.MaxNights).AsOptionalValue(PositiveInt.Parse);
OptionalReferenceField<Stay> stay = bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay);
RequiredField<IReadOnlyList<Tag>> tags = bind.ListOfSimpleProperties(r => r.Tags).AsOptional(Tag.Parse);
RequiredField<IReadOnlyList<Guest>> guests = bind.ListOfComplexProperties(r => r.Guests).FailWith(BookingEnvelopeError.GuestInvalid).AsRequired(BindGuest);

return bind.New(s => new BookingCommand(
s.Get(email), s.Get(reference), s.Get(currency), s.Get(maxNights),
s.Get(stay), s.Get(tags), s.Get(guests)));
}

[Fact(DisplayName = "A fully valid request binds into the complete command and flows through Then/Finally.")]
public void FullyValidRequestBindsAndRejoinsThePipeline() {
BookingRequest request = new(
"alice@example.org", "REF-42", null, "7",
new StayDto("2026-08-10", "2026-08-14"),
["vip"],
[new GuestDto("Alice", "alice@example.org"), new GuestDto("Bob", null)]);

string confirmation = BindCommand(request)
.Then(command => Outcome<string>.Success($"{command.Reference}:{command.Currency.Code}:{command.MaxNights}:{command.Guests.Count}"))
.Finally(result => result, error => $"rejected:{error.Code}");

Check.That(confirmation).IsEqualTo("REF-42:EUR:7:2");
}

[Fact(DisplayName = "A request failing at every level yields the full error tree of the spec, and no exception.")]
public void InvalidRequestYieldsTheFullTree() {
BookingRequest request = new(
"not-an-email", null, "EURO", "0",
new StayDto("not-a-date", "2026-08-14"),
["ok", "not ok"],
[new GuestDto("Alice", null), new GuestDto(null, "still-not-an-email")]);

Outcome<BookingCommand> outcome = BindCommand(request);

Check.That(outcome.IsFailure).IsTrue();
Error envelope = outcome.Error!;
Check.That(envelope.Code.ToString()).IsEqualTo("TEST_BOOKING_COMMAND_INVALID");

// Envelope children, in declaration order:
// GuestEmail invalid, Reference missing, Currency invalid, MaxNights invalid,
// Stay envelope (present but invalid), Tags[1] invalid, Guests[1] envelope.
Check.That(envelope.InnerErrors).HasSize(7);
Check.That(envelope.InnerErrors.Select(e => e.Code.ToString())).ContainsExactly(
"REQUEST_ARGUMENT_INVALID", // GuestEmail
"REQUEST_ARGUMENT_REQUIRED", // Reference
"REQUEST_ARGUMENT_INVALID", // Currency
"REQUEST_ARGUMENT_INVALID", // MaxNights
"TEST_STAY_INVALID", // Stay (nested envelope)
"REQUEST_ARGUMENT_INVALID", // Tags[1]
"TEST_GUEST_INVALID"); // Guests[1] (nested envelope)

Error stayEnvelope = envelope.InnerErrors[4];
Check.That(stayEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf)).ContainsExactly("Stay.CheckIn");
Check.That(stayEnvelope.InnerErrors[0].InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_DATE_INVALID");

Error guestEnvelope = envelope.InnerErrors[6];
Check.That(guestEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
.ContainsExactly("Guests[1].FirstName", "Guests[1].Email");
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#region Usings declarations

using NFluent;

#endregion

namespace FirstClassErrors.RequestBinder.UnitTests;

public sealed class ComplexPropertyBindingTests {

private static Outcome<Stay> BindStay(RequestBinder<StayDto> stay) {
RequiredField<BookingDate> checkIn = stay.SimpleProperty(s => s.CheckIn).AsRequired(BookingDate.Parse);
RequiredField<BookingDate> checkOut = stay.SimpleProperty(s => s.CheckOut).AsRequired(BookingDate.Parse);

return stay.New(s => new Stay(s.Get(checkIn), s.Get(checkOut)));
}

private static BookingRequest RequestWith(StayDto? stay) {
return new BookingRequest("alice@example.org", "REF-1", "EUR", null, stay, Tags: null, Guests: null);
}

[Fact(DisplayName = "A required complex property binds through its nested binder when every field is valid.")]
public void RequiredComplexBinds() {
var bind = Bind.PropertiesOf(RequestWith(new StayDto("2026-08-10", "2026-08-14"))).FailWith(BookingEnvelopeError.CommandInvalid);

RequiredField<Stay> stay = bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay);

Outcome<Stay> outcome = bind.New(s => s.Get(stay));
Check.That(outcome.IsSuccess).IsTrue();
Check.That(outcome.GetResultOrThrow().CheckIn.Value).IsEqualTo(new DateOnly(2026, 8, 10));
}

[Fact(DisplayName = "A failed nested binding surfaces as its own envelope, whose inner paths are prefixed with the property name.")]
public void NestedFailureSurfacesAsItsEnvelopeWithPrefixedPaths() {
var bind = Bind.PropertiesOf(RequestWith(new StayDto("not-a-date", null))).FailWith(BookingEnvelopeError.CommandInvalid);

bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay);

Outcome<string> outcome = bind.New(_ => "never");
Check.That(outcome.IsFailure).IsTrue();

Error stayEnvelope = outcome.Error!.InnerErrors.Single();
Check.That(stayEnvelope.Code.ToString()).IsEqualTo("TEST_STAY_INVALID");

// Both nested failures are collected, and their paths carry the "Stay." prefix.
Check.That(stayEnvelope.InnerErrors).HasSize(2);
Check.That(stayEnvelope.InnerErrors.Select(BindingAssertions.ArgumentPathOf))
.ContainsExactly("Stay.CheckIn", "Stay.CheckOut");
Check.That(stayEnvelope.InnerErrors[0].Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID");
Check.That(stayEnvelope.InnerErrors[1].Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
}

[Fact(DisplayName = "A required complex property that is missing records REQUEST_ARGUMENT_REQUIRED; its envelope is never invoked.")]
public void RequiredComplexMissing() {
var bind = Bind.PropertiesOf(RequestWith(stay: null)).FailWith(BookingEnvelopeError.CommandInvalid);

bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsRequired(BindStay);

Outcome<string> outcome = bind.New(_ => "never");
Error required = outcome.Error!.InnerErrors.Single();
Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED");
Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Stay");
}

[Fact(DisplayName = "An optional complex property yields null when absent — recording nothing — and binds when present.")]
public void OptionalComplex() {
var absent = Bind.PropertiesOf(RequestWith(stay: null)).FailWith(BookingEnvelopeError.CommandInvalid);
OptionalReferenceField<Stay> none = absent.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay);
Check.That(absent.New(s => s.Get(none) is null).GetResultOrThrow()).IsTrue();

var present = Bind.PropertiesOf(RequestWith(new StayDto("2026-08-10", "2026-08-14"))).FailWith(BookingEnvelopeError.CommandInvalid);
OptionalReferenceField<Stay> some = present.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay);
Check.That(present.New(s => s.Get(some)!.CheckOut.Value).GetResultOrThrow()).IsEqualTo(new DateOnly(2026, 8, 14));
}

[Fact(DisplayName = "An optional complex property that is present but invalid records its envelope.")]
public void OptionalComplexPresentButInvalidRecords() {
var bind = Bind.PropertiesOf(RequestWith(new StayDto(null, null))).FailWith(BookingEnvelopeError.CommandInvalid);

bind.ComplexProperty(r => r.Stay).FailWith(BookingEnvelopeError.StayInvalid).AsOptional(BindStay);

Outcome<string> outcome = bind.New(_ => "never");
Check.That(outcome.IsFailure).IsTrue();
Check.That(outcome.Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_STAY_INVALID");
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<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>
<PackageReference Include="JetBrains.Annotations" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NFluent" />
<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" />
<ProjectReference Include="..\FirstClassErrors.Testing\FirstClassErrors.Testing.csproj" />
</ItemGroup>

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

</Project>
Loading