|
| 1 | +#region Usings declarations |
| 2 | + |
| 3 | +using NFluent; |
| 4 | + |
| 5 | +#endregion |
| 6 | + |
| 7 | +namespace FirstClassErrors.RequestBinder.UnitTests; |
| 8 | + |
| 9 | +#region Value-type request DTO and value objects |
| 10 | + |
| 11 | +internal sealed record ValueTypeRequest( |
| 12 | + int? Quantity, |
| 13 | + bool? Flag, |
| 14 | + string? Note, |
| 15 | + IReadOnlyList<int?>? Quantities, |
| 16 | + IReadOnlyList<string?>? Notes, |
| 17 | + int Count = 0); |
| 18 | + |
| 19 | +/// <summary>A struct value object built from the underlying <see cref="int" /> — the shape a converter binds against.</summary> |
| 20 | +internal readonly struct PositiveQty { |
| 21 | + |
| 22 | + public int Value { get; private init; } |
| 23 | + |
| 24 | + public static Outcome<PositiveQty> From(int n) { |
| 25 | + return n > 0 |
| 26 | + ? Outcome<PositiveQty>.Success(new PositiveQty { Value = n }) |
| 27 | + : Outcome<PositiveQty>.Failure(BookingDomainError.NotAPositiveNumber(n.ToString())); |
| 28 | + } |
| 29 | + |
| 30 | +} |
| 31 | + |
| 32 | +/// <summary>A second struct value object over a different underlying value type (<see cref="bool" />).</summary> |
| 33 | +internal readonly struct Consent { |
| 34 | + |
| 35 | + public bool Given { get; private init; } |
| 36 | + |
| 37 | + public static Outcome<Consent> From(bool given) { |
| 38 | + return Outcome<Consent>.Success(new Consent { Given = given }); |
| 39 | + } |
| 40 | + |
| 41 | +} |
| 42 | + |
| 43 | +#endregion |
| 44 | + |
| 45 | +public sealed class ValueTypePropertyBindingTests { |
| 46 | + |
| 47 | + private static ValueTypeRequest Request(int? quantity = 5, bool? flag = true, string? note = "hello", |
| 48 | + IReadOnlyList<int?>? quantities = null, IReadOnlyList<string?>? notes = null) { |
| 49 | + return new ValueTypeRequest(quantity, flag, note, quantities, notes); |
| 50 | + } |
| 51 | + |
| 52 | + // ── Scalar value-type property ──────────────────────────────────────────────────────────────────────── |
| 53 | + |
| 54 | + [Fact(DisplayName = "A nullable value-type property binds via a method-group converter over its underlying type.")] |
| 55 | + public void ValueTypeRequiredBindsViaMethodGroup() { |
| 56 | + var bind = Bind.PropertiesOf(Request(quantity: 5)).FailWith(BookingEnvelopeError.CommandInvalid); |
| 57 | + |
| 58 | + RequiredField<PositiveQty> qty = bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From); |
| 59 | + |
| 60 | + Outcome<int> outcome = bind.New(s => s.Get(qty).Value); |
| 61 | + Check.That(outcome.IsSuccess).IsTrue(); |
| 62 | + Check.That(outcome.GetResultOrThrow()).IsEqualTo(5); |
| 63 | + } |
| 64 | + |
| 65 | + [Fact(DisplayName = "A required value-type property that is absent records REQUEST_ARGUMENT_REQUIRED with its path.")] |
| 66 | + public void ValueTypeRequiredMissingRecords() { |
| 67 | + var bind = Bind.PropertiesOf(Request(quantity: null)).FailWith(BookingEnvelopeError.CommandInvalid); |
| 68 | + |
| 69 | + bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From); |
| 70 | + |
| 71 | + Outcome<string> outcome = bind.New(_ => "never"); |
| 72 | + Check.That(outcome.IsFailure).IsTrue(); |
| 73 | + Error required = outcome.Error!.InnerErrors.Single(); |
| 74 | + Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); |
| 75 | + Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Quantity"); |
| 76 | + } |
| 77 | + |
| 78 | + [Fact(DisplayName = "A present-but-invalid value-type property records REQUEST_ARGUMENT_INVALID wrapping the converter error.")] |
| 79 | + public void ValueTypeRequiredInvalidRecords() { |
| 80 | + var bind = Bind.PropertiesOf(Request(quantity: -1)).FailWith(BookingEnvelopeError.CommandInvalid); |
| 81 | + |
| 82 | + bind.SimpleProperty(r => r.Quantity).AsRequired(PositiveQty.From); |
| 83 | + |
| 84 | + Outcome<string> outcome = bind.New(_ => "never"); |
| 85 | + Error invalid = outcome.Error!.InnerErrors.Single(); |
| 86 | + Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); |
| 87 | + Check.That(invalid.InnerErrors.Single().Code.ToString()).IsEqualTo("TEST_NOT_A_POSITIVE_NUMBER"); |
| 88 | + } |
| 89 | + |
| 90 | + [Fact(DisplayName = "AsOptionalValue on a value-type property yields the value when present and a real null when absent.")] |
| 91 | + public void ValueTypeOptionalValue() { |
| 92 | + var present = Bind.PropertiesOf(Request(quantity: 7)).FailWith(BookingEnvelopeError.CommandInvalid); |
| 93 | + OptionalValueField<PositiveQty> some = present.SimpleProperty(r => r.Quantity).AsOptionalValue(PositiveQty.From); |
| 94 | + Check.That(present.New(s => s.Get(some)!.Value.Value).GetResultOrThrow()).IsEqualTo(7); |
| 95 | + |
| 96 | + var absent = Bind.PropertiesOf(Request(quantity: null)).FailWith(BookingEnvelopeError.CommandInvalid); |
| 97 | + OptionalValueField<PositiveQty> none = absent.SimpleProperty(r => r.Quantity).AsOptionalValue(PositiveQty.From); |
| 98 | + Check.That(absent.New(s => s.Get(none) is null).GetResultOrThrow()).IsTrue(); |
| 99 | + } |
| 100 | + |
| 101 | + [Fact(DisplayName = "AsOptional with a fallback converts the underlying-typed fallback when the value-type property is absent.")] |
| 102 | + public void ValueTypeOptionalWithFallback() { |
| 103 | + var absent = Bind.PropertiesOf(Request(quantity: null)).FailWith(BookingEnvelopeError.CommandInvalid); |
| 104 | + |
| 105 | + RequiredField<PositiveQty> defaulted = absent.SimpleProperty(r => r.Quantity).AsOptional(PositiveQty.From, 1); |
| 106 | + |
| 107 | + Check.That(absent.New(s => s.Get(defaulted).Value).GetResultOrThrow()).IsEqualTo(1); |
| 108 | + } |
| 109 | + |
| 110 | + [Fact(DisplayName = "The underlying-type inference is not int-specific: a bool? property binds the same way.")] |
| 111 | + public void ValueTypeWorksForBool() { |
| 112 | + var bind = Bind.PropertiesOf(Request(flag: true)).FailWith(BookingEnvelopeError.CommandInvalid); |
| 113 | + |
| 114 | + RequiredField<Consent> consent = bind.SimpleProperty(r => r.Flag).AsRequired(Consent.From); |
| 115 | + |
| 116 | + Check.That(bind.New(s => s.Get(consent).Given).GetResultOrThrow()).IsTrue(); |
| 117 | + } |
| 118 | + |
| 119 | + // ── List of value-type elements ─────────────────────────────────────────────────────────────────────── |
| 120 | + |
| 121 | + [Fact(DisplayName = "A list of nullable value types binds each element via a method-group converter over the underlying type.")] |
| 122 | + public void ValueTypeListRequiredBinds() { |
| 123 | + var bind = Bind.PropertiesOf(Request(quantities: [1, 2, 3])).FailWith(BookingEnvelopeError.CommandInvalid); |
| 124 | + |
| 125 | + RequiredField<IReadOnlyList<PositiveQty>> qtys = bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); |
| 126 | + |
| 127 | + Outcome<int> outcome = bind.New(s => s.Get(qtys).Sum(q => q.Value)); |
| 128 | + Check.That(outcome.IsSuccess).IsTrue(); |
| 129 | + Check.That(outcome.GetResultOrThrow()).IsEqualTo(6); |
| 130 | + } |
| 131 | + |
| 132 | + [Fact(DisplayName = "A null element of a value-type list records REQUEST_ARGUMENT_REQUIRED under its indexed path.")] |
| 133 | + public void ValueTypeListNullElementRecords() { |
| 134 | + var bind = Bind.PropertiesOf(Request(quantities: [1, null, 3])).FailWith(BookingEnvelopeError.CommandInvalid); |
| 135 | + |
| 136 | + bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); |
| 137 | + |
| 138 | + Error required = bind.New(_ => "never").Error!.InnerErrors.Single(); |
| 139 | + Check.That(required.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); |
| 140 | + Check.That(BindingAssertions.ArgumentPathOf(required)).IsEqualTo("Quantities[1]"); |
| 141 | + } |
| 142 | + |
| 143 | + [Fact(DisplayName = "An invalid element of a value-type list records REQUEST_ARGUMENT_INVALID under its indexed path.")] |
| 144 | + public void ValueTypeListInvalidElementRecords() { |
| 145 | + var bind = Bind.PropertiesOf(Request(quantities: [1, -2, 3])).FailWith(BookingEnvelopeError.CommandInvalid); |
| 146 | + |
| 147 | + bind.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); |
| 148 | + |
| 149 | + Error invalid = bind.New(_ => "never").Error!.InnerErrors.Single(); |
| 150 | + Check.That(invalid.Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_INVALID"); |
| 151 | + Check.That(BindingAssertions.ArgumentPathOf(invalid)).IsEqualTo("Quantities[1]"); |
| 152 | + } |
| 153 | + |
| 154 | + [Fact(DisplayName = "An optional value-type list that is absent yields an empty list and records nothing; a required one records REQUEST_ARGUMENT_REQUIRED.")] |
| 155 | + public void ValueTypeListOptionalVsRequiredWhenAbsent() { |
| 156 | + var optional = Bind.PropertiesOf(Request(quantities: null)).FailWith(BookingEnvelopeError.CommandInvalid); |
| 157 | + RequiredField<IReadOnlyList<PositiveQty>> empty = optional.ListOfSimpleProperties(r => r.Quantities).AsOptional(PositiveQty.From); |
| 158 | + Check.That(optional.New(s => s.Get(empty).Count).GetResultOrThrow()).IsEqualTo(0); |
| 159 | + |
| 160 | + var required = Bind.PropertiesOf(Request(quantities: null)).FailWith(BookingEnvelopeError.CommandInvalid); |
| 161 | + required.ListOfSimpleProperties(r => r.Quantities).AsRequired(PositiveQty.From); |
| 162 | + Check.That(required.New(_ => "never").Error!.InnerErrors.Single().Code.ToString()).IsEqualTo("REQUEST_ARGUMENT_REQUIRED"); |
| 163 | + } |
| 164 | + |
| 165 | + // ── Regressions: reference/string paths keep resolving to the original overloads ────────────────────── |
| 166 | + |
| 167 | + [Fact(DisplayName = "A string property still resolves to the reference overload — no ambiguity introduced by the value-type overload.")] |
| 168 | + public void StringPropertyStillBindsViaReferenceOverload() { |
| 169 | + var bind = Bind.PropertiesOf(Request(note: "a@b.c")).FailWith(BookingEnvelopeError.CommandInvalid); |
| 170 | + |
| 171 | + RequiredField<EmailAddress> email = bind.SimpleProperty(r => r.Note).AsRequired(EmailAddress.Parse); |
| 172 | + |
| 173 | + Check.That(bind.New(s => s.Get(email).Value).GetResultOrThrow()).IsEqualTo("a@b.c"); |
| 174 | + } |
| 175 | + |
| 176 | + [Fact(DisplayName = "A list of strings still resolves to the reference list overload.")] |
| 177 | + public void StringListStillBindsViaReferenceOverload() { |
| 178 | + var bind = Bind.PropertiesOf(Request(notes: ["a", "b"])).FailWith(BookingEnvelopeError.CommandInvalid); |
| 179 | + |
| 180 | + RequiredField<IReadOnlyList<Tag>> tags = bind.ListOfSimpleProperties(r => r.Notes).AsOptional(Tag.Parse); |
| 181 | + |
| 182 | + Check.That(bind.New(s => s.Get(tags).Count).GetResultOrThrow()).IsEqualTo(2); |
| 183 | + } |
| 184 | + |
| 185 | + [Fact(DisplayName = "A non-nullable value-type property still throws ArgumentException — the value-type overload does not bypass the guard.")] |
| 186 | + public void NonNullableValueTypeStillThrows() { |
| 187 | + var bind = Bind.PropertiesOf(Request()).FailWith(BookingEnvelopeError.CommandInvalid); |
| 188 | + |
| 189 | + Check.ThatCode(() => bind.SimpleProperty(r => r.Count)).Throws<ArgumentException>(); |
| 190 | + } |
| 191 | + |
| 192 | +} |
0 commit comments