From 24bcf29b60327d8e9311525e9d79c36b3eda3b12 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 19:02:28 +0200 Subject: [PATCH 1/4] docs(validation): plan the DateTimeKind assertions Plan IsUtc, IsLocal, and IsUnspecified for Check, following the IsUuidV7 shape: three metadata-free error codes, three customizable templates, and three built-in OpenAPI contracts. Named assertions rather than one parameterized HasKind, matching how IsEmpty/IsNotEmpty and IsNull/IsNotNull are split today. State the contract as the kind and nothing more: IsUtc accepts exactly those normalized values whose Kind is DateTimeKind.Utc. The predicate cannot observe where a value came from, so the plan keeps that separate from the System.Text.Json consequence, which is that a trailing Z is required and 2026-08-02T10:00:00+00:00 arrives as Local and is rejected despite denoting the same instant - measured on .NET 10, where the converter resolves every explicit offset against the server's time zone before the DTO exists. That consequence is real and belongs in the README and the XML remarks, because it is the actionable form for an API consumer, but it is false for a value arriving over gRPC or produced by DateTime.UtcNow. The message reads "must be represented in UTC" for the same reason: "must be in UTC" describes the instant and reads as already satisfied on a +00:00 payload, while naming the Z encoding would claim knowledge the assertion does not have. Drop the Check overload the first draft shared the Utc code with. DateTimeOffset has no kind, so the assertion would test Offset == TimeSpan.Zero, which accepts +00:00 that the DateTime overload rejects, and OpenAPI cannot expose the difference because both types map to string/date-time. An unzoned wire value also arrives carrying the server's own offset, which would make the verdict depend on the host's time zone. Moved to #74 as a separate ZeroOffset candidate. Correct two further claims the first draft got wrong. Check is constructed after the context-wide or per-check IValueNormalizer runs, so these assertions inspect the normalized value; the default TrimStringNormalizer preserves non-strings, but that is a default rather than a guarantee. And there is no generator diagnostic for an unregistered error code - document construction fails at runtime instead - so the OpenAPI criterion has to be verified by generating a document. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019vu3qgcqGWGr2TddYEpLpW --- ai-plans/0075-datetime-assertions.md | 96 ++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 ai-plans/0075-datetime-assertions.md diff --git a/ai-plans/0075-datetime-assertions.md b/ai-plans/0075-datetime-assertions.md new file mode 100644 index 0000000..63212d7 --- /dev/null +++ b/ai-plans/0075-datetime-assertions.md @@ -0,0 +1,96 @@ +# Add `DateTimeKind` Assertions + +## Rationale + +A `DateTime` arriving in a request DTO carries a `DateTimeKind` that records how the client encoded the timestamp: `System.Text.Json` maps a trailing `Z` to `Utc`, any explicit numeric offset to a value shifted into the server's local time with `Local`, and a timestamp with no zone at all to `Unspecified`. That kind is the only surviving evidence of the distinction, and the library cannot currently state a requirement about it — `Check` reaches only the generic comparable and equality assertions. + +The consequence is the classic boundary bug this library exists to prevent. An `Unspecified` value is persisted or compared as if it were UTC and is silently wrong by the server's offset; a `Local` value has already been shifted by an offset the client chose and the server never sees. Both look like ordinary timestamps at every later layer. `IsUtc`, `IsLocal`, and `IsUnspecified` make the requirement explicit at the boundary, where the information still exists, and turn a silent corruption into a validation error the client can act on. + +This is the first candidate from #74. It follows the shape `IsUuidV7` established in #72: dedicated error codes, customizable message templates, metadata-free OpenAPI contracts. + +## Acceptance Criteria + +- [ ] `Check` exposes `IsUtc`, `IsLocal`, and `IsUnspecified`, each in both the built-in-message and `ErrorOverrides` overloads, honoring `shortCircuitOnError` and the already-short-circuited state like every other built-in assertion. +- [ ] Failures carry the new `ValidationErrorCodes.Utc`, `Local`, and `Unspecified` codes, with messages from new customizable `ValidationErrorTemplates` properties that survive `with`-expression copies. +- [ ] Each of the three assertions is annotated for OpenAPI source generation, and a validator using all three generates a document successfully against the built-in contract registry — no generator errors, no `WithErrorMetadata` escape hatch, and no unregistered-error-code failure during document construction. +- [ ] A kind matrix asserts all three assertions against all three `DateTimeKind` values, establishing that each assertion accepts exactly one kind and that the three together partition the enum. +- [ ] A test deserializes the four ISO 8601 wire forms in the table below through `System.Text.Json` into a DTO and asserts which assertion accepts each, pinning the premise the whole feature rests on. `+00:00` is covered as its own case, distinct from a non-zero offset. +- [ ] The documented contract of `IsUtc` is that the normalized value's `Kind` is `DateTimeKind.Utc`, stated without reference to how the value was produced. The `System.Text.Json` consequence — a trailing `Z` is required, `+00:00` yields `Local` and is rejected — is documented as a consequence of the default converter, in the XML remarks and the README, not as the contract. +- [ ] Automated tests additionally cover both overload forms, short-circuit propagation, template customization, and the generated OpenAPI metadata. Solution test coverage stays above 95%. +- [ ] The README documents the assertions where request timestamps are validated, and the 0.7.0 `` of both affected packages record their own part of the change. + +## Technical Details + +### Semantics + +The predicate is `check.Value.Kind == DateTimeKind.Utc` and its two siblings — no portability question, since `DateTime.Kind` exists on `netstandard2.0`. Unlike #72 there is no bit manipulation to hide, so there is deliberately **no standalone `DateTimeExtensions` predicate class**: `value.Kind == DateTimeKind.Utc` is already the clearest possible spelling at a call site outside a check chain, and a public wrapper would add a name without adding meaning. That is the one structural departure from the `IsUuidV7` shape. + +**The contract is the kind and nothing more:** `IsUtc` accepts exactly those normalized values whose `Kind` is `DateTimeKind.Utc`. The predicate cannot see where a value came from, and must not be documented as though it could — `DateTime.UtcNow`, `DateTime.SpecifyKind`, a custom converter, and a gRPC or messaging transport all produce `Utc` without any JSON encoding being involved. + +What the wire format determines is *which values reach the assertion* over an HTTP JSON boundary, and that is a fact about the default `System.Text.Json` converter rather than about the rule. Measured against .NET 10 on a machine at UTC+2: + +| Wire value | `DateTime.Kind` | Accepted by | +| --- | --- | --- | +| `2026-08-02T10:00:00Z` | `Utc` | `IsUtc` | +| `2026-08-02T10:00:00+00:00` | `Local` | `IsLocal` | +| `2026-08-02T10:00:00+02:00` | `Local` | `IsLocal` | +| `2026-08-02T10:00:00` | `Unspecified` | `IsUnspecified` | + +The second row is the one that surprises: `+00:00` denotes exactly the same instant as `Z`, yet deserializes to `Local` and is rejected, because `System.Text.Json` resolves every explicit offset — including a zero one — against the server's time zone before the DTO exists. So over the default JSON stack, `IsUtc` does amount to "the client must send `Z`", and the README should say so plainly, since that is the actionable form for an API consumer. The distinction matters because the two statements come apart the moment a value arrives by any other route, and only the kind statement holds in every case. + +### Normalization + +These assertions inspect the **normalized** value, not the value as the deserializer produced it. `ValidationContext.Check` applies the per-check normalizer or `Options.ValueNormalizer` before constructing the `Check`, and `ValidationContextOptions.ValueNormalizer` is a public `init` property. + +The default `TrimStringNormalizer` returns every non-string value unchanged, so under the default configuration the deserialized kind reaches the assertion intact — which is what makes these assertions meaningful. A custom normalizer can rewrite a `DateTime`, and then the assertions describe the normalized value. That is the caller's choice and needs no defense in the implementation, but it is the second reason the XML documentation must state the contract as "the normalized value's `Kind`" and nothing stronger: neither the normalizer nor the deserializer is fixed, so the kind is the only thing the assertion can promise. Put the `System.Text.Json` behavior in ``, where it reads as the guidance it is. + +A normalizer that coerced `Unspecified` to `Utc` would destroy the signal these assertions exist to surface. That is worth a sentence in the README, not a guard in code. + +### Error codes and message templates + +Three separate metadata-free codes rather than one `HasKind(DateTimeKind)` rule carrying an `expectedKind` metadata value. The existing families already prefer named assertions with their own codes over a parameterized one — `IsEmpty`/`IsNotEmpty`, `IsNull`/`IsNotNull` — the messages are better when they are not assembled from a parameter, and each contract is free. + +The codes are terse, matching `Empty`, `Null`, and `Email`. `Unspecified` read alone is vague, but a code never appears alone: the error payload always carries the `target`, so a consumer sees which property is being described. + +```csharp +public const string Utc = "Utc"; +public const string Local = "Local"; +public const string Unspecified = "Unspecified"; +``` + +Templates default to `new DisplayName(" must be represented in UTC")`, `new DisplayName(" must be a local date and time")`, and `new DisplayName(" must not specify a time zone")`. The first is deliberately about *representation* rather than about the instant or the encoding. `"must be in UTC"` describes the instant and so reads as already satisfied on a `+00:00` payload; `"must be encoded with a trailing 'Z'"` describes an origin the assertion cannot observe and would be simply false for a value that arrived over gRPC. "Represented in UTC" is what `DateTimeKind.Utc` actually means, and it is the only phrasing true on every transport. The `Z` guidance belongs in the README and the OpenAPI description, where the JSON context is established. The third message is likewise phrased as an instruction rather than `"must have an unspecified kind"`, which names a CLR concept the client cannot see. + +`IsLocal` is included for completeness of the enum rather than because it is good API design — a server-relative kind rarely belongs in a portable result. It exists so the three assertions partition `DateTimeKind`, and so the matrix test can state that. + +### Registration points + +The six from #72, applied three times. Two still fail silently when missed: + +- `ValidationErrorTemplates`' **copy constructor** (`ValidationErrorTemplates.cs:118`) must copy all three new properties, since it is what `with` expressions run. Omitting a line silently resets a caller's customized template to the default. +- `BuiltInValidationErrorContracts.Contracts` must register all three as `ErrorMetadataContract.NoMetadata`, and the expected no-metadata list in `BuiltInValidationErrorContractsTests` must grow with them, because that test asserts the registry's full key set. Without the entries, document construction fails at runtime with the unregistered-error-code message from `PortableResultsOpenApiMessages` — there is no generator diagnostic for this, so the OpenAPI acceptance criterion has to be verified by generating a document, not by compiling a validator. + +The rest follow their `UuidV7` counterparts: the three `ValidationErrorCodes` constants; three definitions modeled on `UuidV7ValidationErrorDefinition`, each overriding `TryGetStableMessageProvider` because these messages have no per-error parameters and are cacheable; the three shared `BuiltInValidationErrorDefinitions` properties the assertions pass to `AddBuiltInError`; and the three `ValidationErrorTemplates` properties with their `Default*Template` fields. + +New files `Checks.Temporal.cs` and `Definitions/BuiltInValidationErrorDefinitions.Temporal.cs`, matching the partial-class-per-family layout. Source generation needs no generator change — it discovers each rule through `[ValidationRule(...)]` plus `[ValidationRuleMessage(...)]` on the built-in-message overload, and the default `ValidationRuleMetadataShape.Registered` is correct because none of the rules carries metadata. Note that these attributes are method-level: an assertion added later to this family without them is silently invisible to the generator. + +### Test data and cases + +The kind matrix is the central test: three assertions × three `DateTimeKind` values, asserting for each cell whether an error was added. Nine cells state both that each assertion accepts its own kind and that it rejects the other two — which is what makes the equality mutations Stryker generates on `Kind == DateTimeKind.X` killable, since flipping any comparison moves at least one cell. + +The wire-format test is the one that guards the rationale rather than the implementation. Deserialize a DTO through `System.Text.Json` from the four forms in the table above and assert which assertion accepts each. Assert on the resulting kind and validation outcome, never on the wall-clock value: both offset forms produce a value that depends on the machine's time zone, while their kind does not. If a future `System.Text.Json` changes this mapping, the assertions keep working but their documented meaning shifts, and this test is what surfaces that. + +Keep `DateTime.UtcNow`, `DateTime.Now`, and `default(DateTime)` as named cases — they state things about the platform's own values that the matrix cannot. `DateTime.UtcNow` carries a second job: it passes `IsUtc` without any JSON encoding existing, which is the executable statement that the contract is the kind rather than the wire format. Name it for that. `default(DateTime)` being `Unspecified` is a genuine trap for anyone reaching for `IsUtc` as a not-set check. + +Assertion-level tests then stay small, as in #72: both overload forms, short-circuit propagation, and template customization through `ValidationErrorTemplates.Default with { … }`. + +### Release notes + +**Validation** gains the three assertions, the three error codes, and the three customizable templates. **Validation.OpenApi** gains three built-in metadata contracts. The second is easy to skip because that package's own source barely changes, but its registry's key set is public behavior — a consumer reading the built-in contracts, or narrowing error schemas against them, sees three new entries. + +### Deliberately out of scope + +- **`Check` assertions.** Originally drafted here as an `IsUtc` overload sharing the `Utc` code, and removed: `DateTimeOffset` has no kind, so any such assertion tests `Offset == TimeSpan.Zero`, which is a different requirement wearing the same name. Measured on .NET 10, `2026-08-02T10:00:00+00:00` deserializes to `Local` as a `DateTime` (rejected) and to a zero offset as a `DateTimeOffset` (accepted), so one shared code and message would give the same wire value opposite verdicts — and OpenAPI cannot expose the difference, since both types map to `string`/`date-time` in `PortableOpenApiSchemaTypeMapper`. Worse, an unzoned wire value deserializes to a `DateTimeOffset` carrying the *server's* offset, so a zero-offset assertion would accept it on a UTC-configured host and reject it elsewhere. A `DateTimeOffset` rule is a canonicalization policy rather than an ambiguity check, needs its own code (`ZeroOffset`) and message, and needs a decision about that host dependence. Tracked separately in #74. +- **`IsInThePast` / `IsInTheFuture`.** Tracked separately in #74. They need a clock abstraction and a configurable tolerance, and the `TimeProvider` availability question on `netstandard2.0` is unresolved. +- **A UTC-converting normalizer.** Coercing `Unspecified` to `Utc` during normalization would silence exactly the signal these assertions exist to surface. If a service wants coercion rather than rejection, that is a mapping concern, not a validation one. +- **`Check` overloads.** Consistent with #72: no built-in assertion offers nullable value-type overloads, and `IsNotNull` covers the null case generically. +- **`DateOnly`, `TimeOnly`, and `TimeSpan`.** None carries a kind or an offset, so there is nothing to assert. From 49d7b05ab57683cd7cf08d64b9c5507088b12095 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 19:22:08 +0200 Subject: [PATCH 2/4] docs(validation): condense the DateTimeKind plan Cut the plan roughly in half, mostly by removing justification prose that ai-plans/AGENTS.md asks plans not to carry: explanations of routine implementation work and background a senior engineer already has. The three-section structure, every acceptance criterion, and every decision survive. Restore four items the first pass dropped that are decisions rather than prose. Chief among them, why the wire-format test is not redundant with the 3x3 kind matrix: it walks the same ground, so without a stated reason it reads as duplication and gets deleted, when it is really a regression detector for System.Text.Json behavior that the whole feature rests on. The 0072 plan needed the same note for its RFC-derived accepted set against its version-by-variant matrix. Also restore both rejected message wordings, since "must be represented in UTC" took two review rounds to reach and half the reasoning made reverting it to "must be in UTC" look like a simplification; why TryGetStableMessageProvider applies here, so a later parameterized rule in this family does not copy it blindly; and the note that the wire-format table was measured rather than assumed, with only the kind being host-independent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019vu3qgcqGWGr2TddYEpLpW --- ai-plans/0075-datetime-assertions.md | 89 +++++++++++----------------- 1 file changed, 35 insertions(+), 54 deletions(-) diff --git a/ai-plans/0075-datetime-assertions.md b/ai-plans/0075-datetime-assertions.md index 63212d7..c3c6b8c 100644 --- a/ai-plans/0075-datetime-assertions.md +++ b/ai-plans/0075-datetime-assertions.md @@ -2,32 +2,28 @@ ## Rationale -A `DateTime` arriving in a request DTO carries a `DateTimeKind` that records how the client encoded the timestamp: `System.Text.Json` maps a trailing `Z` to `Utc`, any explicit numeric offset to a value shifted into the server's local time with `Local`, and a timestamp with no zone at all to `Unspecified`. That kind is the only surviving evidence of the distinction, and the library cannot currently state a requirement about it — `Check` reaches only the generic comparable and equality assertions. +With the default `System.Text.Json` converter, a request `DateTime` retains the distinction between a trailing `Z` (`Utc`), an explicit numeric offset converted to server-local time (`Local`), and no zone (`Unspecified`). The library cannot currently require any of these kinds, leaving values vulnerable to later persistence or comparison under the wrong time-zone assumption. -The consequence is the classic boundary bug this library exists to prevent. An `Unspecified` value is persisted or compared as if it were UTC and is silently wrong by the server's offset; a `Local` value has already been shifted by an offset the client chose and the server never sees. Both look like ordinary timestamps at every later layer. `IsUtc`, `IsLocal`, and `IsUnspecified` make the requirement explicit at the boundary, where the information still exists, and turn a silent corruption into a validation error the client can act on. - -This is the first candidate from #74. It follows the shape `IsUuidV7` established in #72: dedicated error codes, customizable message templates, metadata-free OpenAPI contracts. +Add `IsUtc`, `IsLocal`, and `IsUnspecified` as the first candidate from #74, following `IsUuidV7` from #72: dedicated error codes, customizable messages, and metadata-free OpenAPI contracts. ## Acceptance Criteria -- [ ] `Check` exposes `IsUtc`, `IsLocal`, and `IsUnspecified`, each in both the built-in-message and `ErrorOverrides` overloads, honoring `shortCircuitOnError` and the already-short-circuited state like every other built-in assertion. -- [ ] Failures carry the new `ValidationErrorCodes.Utc`, `Local`, and `Unspecified` codes, with messages from new customizable `ValidationErrorTemplates` properties that survive `with`-expression copies. -- [ ] Each of the three assertions is annotated for OpenAPI source generation, and a validator using all three generates a document successfully against the built-in contract registry — no generator errors, no `WithErrorMetadata` escape hatch, and no unregistered-error-code failure during document construction. -- [ ] A kind matrix asserts all three assertions against all three `DateTimeKind` values, establishing that each assertion accepts exactly one kind and that the three together partition the enum. -- [ ] A test deserializes the four ISO 8601 wire forms in the table below through `System.Text.Json` into a DTO and asserts which assertion accepts each, pinning the premise the whole feature rests on. `+00:00` is covered as its own case, distinct from a non-zero offset. -- [ ] The documented contract of `IsUtc` is that the normalized value's `Kind` is `DateTimeKind.Utc`, stated without reference to how the value was produced. The `System.Text.Json` consequence — a trailing `Z` is required, `+00:00` yields `Local` and is rejected — is documented as a consequence of the default converter, in the XML remarks and the README, not as the contract. -- [ ] Automated tests additionally cover both overload forms, short-circuit propagation, template customization, and the generated OpenAPI metadata. Solution test coverage stays above 95%. -- [ ] The README documents the assertions where request timestamps are validated, and the 0.7.0 `` of both affected packages record their own part of the change. +- [ ] `Check` exposes `IsUtc`, `IsLocal`, and `IsUnspecified` in built-in-message and `ErrorOverrides` overloads; all honor `shortCircuitOnError` and an already-short-circuited check. +- [ ] Failures use new `ValidationErrorCodes.Utc`, `Local`, and `Unspecified` codes and customizable `ValidationErrorTemplates` properties that survive `with`-expression copies. +- [ ] All three built-in-message overloads are discoverable by OpenAPI source generation. A validator using them generates a document against the built-in registry without generator errors, `WithErrorMetadata`, or an unregistered-code failure. +- [ ] A 3×3 kind matrix proves that each assertion accepts exactly one `DateTimeKind` and that together they partition the enum. +- [ ] A DTO deserialization test covers the four ISO 8601 forms below, including `+00:00` separately from a non-zero offset, and asserts both the resulting kind and accepted assertion. +- [ ] `IsUtc` is documented as testing the normalized value's `Kind`, independent of its origin. XML remarks and the README separately explain that the default JSON converter requires trailing `Z` because `+00:00` produces `Local` and is rejected. +- [ ] Tests also cover both overload forms, short-circuit propagation, template customization, and generated OpenAPI metadata. Solution coverage remains above 95%. +- [ ] The README shows the assertions in request validation, and the 0.7.0 `` of both affected packages describe their respective changes. ## Technical Details -### Semantics - -The predicate is `check.Value.Kind == DateTimeKind.Utc` and its two siblings — no portability question, since `DateTime.Kind` exists on `netstandard2.0`. Unlike #72 there is no bit manipulation to hide, so there is deliberately **no standalone `DateTimeExtensions` predicate class**: `value.Kind == DateTimeKind.Utc` is already the clearest possible spelling at a call site outside a check chain, and a public wrapper would add a name without adding meaning. That is the one structural departure from the `IsUuidV7` shape. +### Semantics and normalization -**The contract is the kind and nothing more:** `IsUtc` accepts exactly those normalized values whose `Kind` is `DateTimeKind.Utc`. The predicate cannot see where a value came from, and must not be documented as though it could — `DateTime.UtcNow`, `DateTime.SpecifyKind`, a custom converter, and a gRPC or messaging transport all produce `Utc` without any JSON encoding being involved. +Each predicate compares `check.Value.Kind` with its corresponding `DateTimeKind`. The contract is only the kind of the normalized value: `DateTime.UtcNow`, `DateTime.SpecifyKind`, custom converters, and non-JSON transports may all produce `Utc`. Do not add a standalone `DateTimeExtensions` predicate; direct `Kind` comparison is already the clearest outside a check chain and is available on `netstandard2.0`. -What the wire format determines is *which values reach the assertion* over an HTTP JSON boundary, and that is a fact about the default `System.Text.Json` converter rather than about the rule. Measured against .NET 10 on a machine at UTC+2: +Measured on .NET 10 with the default `System.Text.Json` converter. Only the kind is host-independent; the two `Local` values themselves depend on the server's zone: | Wire value | `DateTime.Kind` | Accepted by | | --- | --- | --- | @@ -36,21 +32,15 @@ What the wire format determines is *which values reach the assertion* over an HT | `2026-08-02T10:00:00+02:00` | `Local` | `IsLocal` | | `2026-08-02T10:00:00` | `Unspecified` | `IsUnspecified` | -The second row is the one that surprises: `+00:00` denotes exactly the same instant as `Z`, yet deserializes to `Local` and is rejected, because `System.Text.Json` resolves every explicit offset — including a zero one — against the server's time zone before the DTO exists. So over the default JSON stack, `IsUtc` does amount to "the client must send `Z`", and the README should say so plainly, since that is the actionable form for an API consumer. The distinction matters because the two statements come apart the moment a value arrives by any other route, and only the kind statement holds in every case. - -### Normalization +Although `+00:00` and `Z` denote the same instant, the default converter maps them to different kinds. Present the trailing-`Z` requirement as JSON-specific guidance in XML remarks, the README, and the OpenAPI description—not as the assertion's transport-independent contract. -These assertions inspect the **normalized** value, not the value as the deserializer produced it. `ValidationContext.Check` applies the per-check normalizer or `Options.ValueNormalizer` before constructing the `Check`, and `ValidationContextOptions.ValueNormalizer` is a public `init` property. +`ValidationContext.Check` applies its per-check normalizer or `Options.ValueNormalizer` before creating `Check`. The default `TrimStringNormalizer` preserves non-strings, but a custom normalizer may rewrite a `DateTime`; the assertions then describe that normalized value. Document this and warn in the README that coercing `Unspecified` to `Utc` destroys the signal. Do not guard against that caller choice in code. -The default `TrimStringNormalizer` returns every non-string value unchanged, so under the default configuration the deserialized kind reaches the assertion intact — which is what makes these assertions meaningful. A custom normalizer can rewrite a `DateTime`, and then the assertions describe the normalized value. That is the caller's choice and needs no defense in the implementation, but it is the second reason the XML documentation must state the contract as "the normalized value's `Kind`" and nothing stronger: neither the normalizer nor the deserializer is fixed, so the kind is the only thing the assertion can promise. Put the `System.Text.Json` behavior in ``, where it reads as the guidance it is. +`IsLocal` completes the enum partition despite its limited value in portable APIs, where server-relative time is rarely desirable. -A normalizer that coerced `Unspecified` to `Utc` would destroy the signal these assertions exist to surface. That is worth a sentence in the README, not a guard in code. +### Errors and registration -### Error codes and message templates - -Three separate metadata-free codes rather than one `HasKind(DateTimeKind)` rule carrying an `expectedKind` metadata value. The existing families already prefer named assertions with their own codes over a parameterized one — `IsEmpty`/`IsNotEmpty`, `IsNull`/`IsNotNull` — the messages are better when they are not assembled from a parameter, and each contract is free. - -The codes are terse, matching `Empty`, `Null`, and `Email`. `Unspecified` read alone is vague, but a code never appears alone: the error payload always carries the `target`, so a consumer sees which property is being described. +Use three metadata-free rules rather than `HasKind(DateTimeKind)` with `expectedKind` metadata, consistent with the existing named empty/null rules: ```csharp public const string Utc = "Utc"; @@ -58,39 +48,30 @@ public const string Local = "Local"; public const string Unspecified = "Unspecified"; ``` -Templates default to `new DisplayName(" must be represented in UTC")`, `new DisplayName(" must be a local date and time")`, and `new DisplayName(" must not specify a time zone")`. The first is deliberately about *representation* rather than about the instant or the encoding. `"must be in UTC"` describes the instant and so reads as already satisfied on a `+00:00` payload; `"must be encoded with a trailing 'Z'"` describes an origin the assertion cannot observe and would be simply false for a value that arrived over gRPC. "Represented in UTC" is what `DateTimeKind.Utc` actually means, and it is the only phrasing true on every transport. The `Z` guidance belongs in the README and the OpenAPI description, where the JSON context is established. The third message is likewise phrased as an instruction rather than `"must have an unspecified kind"`, which names a CLR concept the client cannot see. - -`IsLocal` is included for completeness of the enum rather than because it is good API design — a server-relative kind rarely belongs in a portable result. It exists so the three assertions partition `DateTimeKind`, and so the matrix test can state that. - -### Registration points - -The six from #72, applied three times. Two still fail silently when missed: - -- `ValidationErrorTemplates`' **copy constructor** (`ValidationErrorTemplates.cs:118`) must copy all three new properties, since it is what `with` expressions run. Omitting a line silently resets a caller's customized template to the default. -- `BuiltInValidationErrorContracts.Contracts` must register all three as `ErrorMetadataContract.NoMetadata`, and the expected no-metadata list in `BuiltInValidationErrorContractsTests` must grow with them, because that test asserts the registry's full key set. Without the entries, document construction fails at runtime with the unregistered-error-code message from `PortableResultsOpenApiMessages` — there is no generator diagnostic for this, so the OpenAPI acceptance criterion has to be verified by generating a document, not by compiling a validator. - -The rest follow their `UuidV7` counterparts: the three `ValidationErrorCodes` constants; three definitions modeled on `UuidV7ValidationErrorDefinition`, each overriding `TryGetStableMessageProvider` because these messages have no per-error parameters and are cacheable; the three shared `BuiltInValidationErrorDefinitions` properties the assertions pass to `AddBuiltInError`; and the three `ValidationErrorTemplates` properties with their `Default*Template` fields. - -New files `Checks.Temporal.cs` and `Definitions/BuiltInValidationErrorDefinitions.Temporal.cs`, matching the partial-class-per-family layout. Source generation needs no generator change — it discovers each rule through `[ValidationRule(...)]` plus `[ValidationRuleMessage(...)]` on the built-in-message overload, and the default `ValidationRuleMetadataShape.Registered` is correct because none of the rules carries metadata. Note that these attributes are method-level: an assertion added later to this family without them is silently invisible to the generator. +The terse names follow existing codes such as `Empty`, `Null`, and `Email`; the error target disambiguates `Unspecified` for consumers. -### Test data and cases +Default templates are `new DisplayName(" must be represented in UTC")`, `new DisplayName(" must be a local date and time")`, and `new DisplayName(" must not specify a time zone")`. The UTC message describes representation without claiming a JSON origin; JSON-specific `Z` guidance belongs in contextual documentation. Two rejected alternatives, recorded so they are not reintroduced: `"must be in UTC"` describes the instant and so reads as already satisfied on a `+00:00` payload, and `"must be encoded with a trailing 'Z'"` is false for a value that never arrived as JSON. `IsUnspecified` likewise avoids `"must have an unspecified kind"`, which names a CLR concept the client cannot see. -The kind matrix is the central test: three assertions × three `DateTimeKind` values, asserting for each cell whether an error was added. Nine cells state both that each assertion accepts its own kind and that it rejects the other two — which is what makes the equality mutations Stryker generates on `Kind == DateTimeKind.X` killable, since flipping any comparison moves at least one cell. +Follow the `UuidV7` registration shape: -The wire-format test is the one that guards the rationale rather than the implementation. Deserialize a DTO through `System.Text.Json` from the four forms in the table above and assert which assertion accepts each. Assert on the resulting kind and validation outcome, never on the wall-clock value: both offset forms produce a value that depends on the machine's time zone, while their kind does not. If a future `System.Text.Json` changes this mapping, the assertions keep working but their documented meaning shifts, and this test is what surfaces that. +- Add the constants; three definitions with `TryGetStableMessageProvider`, which applies here because these messages have no per-error parameters and are therefore cacheable; three shared `BuiltInValidationErrorDefinitions` properties; and three template defaults/properties. +- Copy all three properties in `ValidationErrorTemplates`' copy constructor so customizations survive subsequent `with` expressions. +- Register all three as `ErrorMetadataContract.NoMetadata` and extend `BuiltInValidationErrorContractsTests`' exhaustive no-metadata list. This requires a generated-document test because a missing registry entry fails during document construction, not through a generator diagnostic. +- Put assertions and definitions in `Checks.Temporal.cs` and `Definitions/BuiltInValidationErrorDefinitions.Temporal.cs`. +- Annotate each built-in-message overload with `[ValidationRule(...)]` and `[ValidationRuleMessage(...)]`; the default `ValidationRuleMetadataShape.Registered` is correct. No generator change is needed, but omitted method-level attributes make a rule silently undiscoverable. -Keep `DateTime.UtcNow`, `DateTime.Now`, and `default(DateTime)` as named cases — they state things about the platform's own values that the matrix cannot. `DateTime.UtcNow` carries a second job: it passes `IsUtc` without any JSON encoding existing, which is the executable statement that the contract is the kind rather than the wire format. Name it for that. `default(DateTime)` being `Unspecified` is a genuine trap for anyone reaching for `IsUtc` as a not-set check. +### Tests and documentation -Assertion-level tests then stay small, as in #72: both overload forms, short-circuit propagation, and template customization through `ValidationErrorTemplates.Default with { … }`. +The 3×3 matrix is the central assertion test and must reject both wrong kinds for every rule, killing equality mutations. The wire-format test guards the JSON premise: assert kind and validation outcome, not the time-zone-dependent wall-clock value. It overlaps the matrix by design and must not be folded into it — it is a regression detector for third-party behavior, so if a future `System.Text.Json` release changes the mapping, the assertions keep working while their documented meaning shifts, and this test is what surfaces that. -### Release notes +Retain named cases for `DateTime.UtcNow`, `DateTime.Now`, and `default(DateTime)`. `UtcNow` proves that `IsUtc` tests kind rather than JSON origin; `default(DateTime)` documents the `Unspecified` default-value trap. Keep remaining assertion tests focused on both overloads, short-circuit behavior, and customization through `ValidationErrorTemplates.Default with { … }`. -**Validation** gains the three assertions, the three error codes, and the three customizable templates. **Validation.OpenApi** gains three built-in metadata contracts. The second is easy to skip because that package's own source barely changes, but its registry's key set is public behavior — a consumer reading the built-in contracts, or narrowing error schemas against them, sees three new entries. +The Validation package release notes cover the three assertions, codes, and templates. Validation.OpenApi covers the three new registry entries. ### Deliberately out of scope -- **`Check` assertions.** Originally drafted here as an `IsUtc` overload sharing the `Utc` code, and removed: `DateTimeOffset` has no kind, so any such assertion tests `Offset == TimeSpan.Zero`, which is a different requirement wearing the same name. Measured on .NET 10, `2026-08-02T10:00:00+00:00` deserializes to `Local` as a `DateTime` (rejected) and to a zero offset as a `DateTimeOffset` (accepted), so one shared code and message would give the same wire value opposite verdicts — and OpenAPI cannot expose the difference, since both types map to `string`/`date-time` in `PortableOpenApiSchemaTypeMapper`. Worse, an unzoned wire value deserializes to a `DateTimeOffset` carrying the *server's* offset, so a zero-offset assertion would accept it on a UTC-configured host and reject it elsewhere. A `DateTimeOffset` rule is a canonicalization policy rather than an ambiguity check, needs its own code (`ZeroOffset`) and message, and needs a decision about that host dependence. Tracked separately in #74. -- **`IsInThePast` / `IsInTheFuture`.** Tracked separately in #74. They need a clock abstraction and a configurable tolerance, and the `TimeProvider` availability question on `netstandard2.0` is unresolved. -- **A UTC-converting normalizer.** Coercing `Unspecified` to `Utc` during normalization would silence exactly the signal these assertions exist to surface. If a service wants coercion rather than rejection, that is a mapping concern, not a validation one. -- **`Check` overloads.** Consistent with #72: no built-in assertion offers nullable value-type overloads, and `IsNotNull` covers the null case generically. -- **`DateOnly`, `TimeOnly`, and `TimeSpan`.** None carries a kind or an offset, so there is nothing to assert. +- **`Check` assertions.** `Offset == TimeSpan.Zero` is a canonicalization rule, not a kind check. The same `+00:00` JSON value fails `DateTime.IsUtc` but would pass this rule, while both CLR types map to OpenAPI `string`/`date-time`; an unzoned `DateTimeOffset` also inherits the server offset and becomes host-dependent. Any future rule needs its own `ZeroOffset` code/message and design, tracked in #74. +- **`IsInThePast` / `IsInTheFuture`.** Tracked in #74; they need a clock abstraction, tolerance, and a resolved `TimeProvider` strategy for `netstandard2.0`. +- **A UTC-converting normalizer.** Coercion would hide the condition being validated and belongs in mapping. +- **`Check`.** No built-in assertion has nullable value-type overloads; use `IsNotNull` first. +- **`DateOnly`, `TimeOnly`, and `TimeSpan`.** They carry neither kind nor offset. From c2c7b3490091061eee2c4603a995feb656b1021f Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 19:38:53 +0200 Subject: [PATCH 3/4] feat: add DateTime assertions Signed-off-by: Kenny Pflug --- README.md | 21 + ai-plans/0075-datetime-assertions.md | 16 +- .../BuiltInValidationErrorContracts.cs | 5 +- ....PortableResults.Validation.OpenApi.csproj | 1 + .../Checks.Temporal.cs | 252 +++++++++++ ...ltInValidationErrorDefinitions.Temporal.cs | 84 ++++ .../Light.PortableResults.Validation.csproj | 6 +- .../Messaging/ValidationErrorTemplates.cs | 39 ++ .../ValidationErrorCodes.cs | 6 + .../BuiltInValidationErrorContractsTests.cs | 5 +- ...eratedValidationOpenApiIntegrationTests.cs | 74 ++++ .../DateTimeKindValidationTests.cs | 392 ++++++++++++++++++ .../ValidationErrorDefinitionTests.cs | 48 ++- 13 files changed, 934 insertions(+), 15 deletions(-) create mode 100644 src/Light.PortableResults.Validation/Checks.Temporal.cs create mode 100644 src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Temporal.cs create mode 100644 tests/Light.PortableResults.Validation.Tests/DateTimeKindValidationTests.cs diff --git a/README.md b/README.md index 0639f04..9c3d499 100644 --- a/README.md +++ b/README.md @@ -673,6 +673,7 @@ Use child validators when your DTO contains nested objects or collections that e public sealed record PurchaseOrderDto { public required Guid OrderId { get; set; } + public required DateTime PlacedAt { get; set; } public required string CustomerEmail { get; set; } = string.Empty; public required ShippingAddressDto ShippingAddress { get; set; } public required List Tags { get; set; } @@ -703,6 +704,11 @@ public sealed class PurchaseOrderValidator : Validator // The client mints the order ID, so require a UUIDv7 — its leading timestamp keeps // client-generated keys roughly sortable and index-friendly. Guid.Empty and v4 GUIDs fail. context.Check(dto.OrderId).IsUuidV7(); + + // Timestamps must be unambiguous, so require UTC. With the default System.Text.Json + // converter this means the payload has to carry a trailing "Z". + context.Check(dto.PlacedAt).IsUtc(); + dto.CustomerEmail = context.Check(dto.CustomerEmail).IsEmail(); // If dto.ShippingAddress is null the child validator emits a null error automatically. @@ -759,6 +765,21 @@ public sealed class OrderItemValidator : Validator `IsUuidV7` fails with the `UuidV7` error code unless the GUID's RFC 9562 version field is `7` **and** its variant bits are the RFC variant. The same invariant is available standalone as `guid.IsUuidV7()` (`GuidExtensions`) when a repository or message handler needs to guard it outside a check chain. +`IsUtc`, `IsLocal`, and `IsUnspecified` assert the `DateTime.Kind` of the checked value and fail with the `Utc`, `Local`, and `Unspecified` error codes. They partition `DateTimeKind`: every `DateTime` is accepted by exactly one of them. The contract is only the kind of the value the check sees, independent of its origin — `DateTime.UtcNow`, `DateTime.SpecifyKind`, a custom converter, and non-JSON transports all produce `Utc` just as a JSON payload with a trailing `Z` does. + +That matters for JSON requests, because the default `System.Text.Json` converter maps the ISO 8601 forms to kinds like this: + +| Wire value | `DateTime.Kind` | Accepted by | +| --- | --- | --- | +| `2026-08-02T10:00:00Z` | `Utc` | `IsUtc` | +| `2026-08-02T10:00:00+00:00` | `Local` | `IsLocal` | +| `2026-08-02T10:00:00+02:00` | `Local` | `IsLocal` | +| `2026-08-02T10:00:00` | `Unspecified` | `IsUnspecified` | + +So `IsUtc` effectively requires a trailing `Z` on the wire: an explicit numeric offset is converted to server-local time and deserializes as `Local`, and that includes `+00:00` even though it denotes the same instant as `Z`. Document the `Z` requirement for your clients — a `+00:00` payload is rejected. Conversely, the two `Local` values above only agree on the instant; their wall-clock value depends on the server's time zone, which is rarely what a portable API wants. + +`DateTime` values reach these assertions after the per-check normalizer or `ValidationContextOptions.ValueNormalizer` has run — the default `TrimStringNormalizer` passes non-strings through unchanged. Do not install a normalizer that coerces `Unspecified` to `Utc`: it destroys exactly the signal being validated, and `IsUtc` would then pass for every request. Convert to UTC in your mapping code, after validation. Note also that `default(DateTime)` is `Unspecified`, so `IsUnspecified` accepts a missing value; pair it with a range check when that matters. + > **What is `ValidatedValue`?** > > `ValidatedValue` is the handshake type between a validator and its callers within a single validation pipeline run. Rather than surfacing errors immediately as `Result`, it carries the signal back: either a successfully validated value via `ValidatedValue.Success(value)`, or `ValidatedValue.NoValue` when errors were added. `checkpoint.ToValidatedValue(dto)` chooses the right outcome based on whether any errors were added since the checkpoint was created. You never need to construct `ValidatedValue` directly unless you are writing a transforming validator — see [Mapping to Domain Objects](#mapping-to-domain-objects). diff --git a/ai-plans/0075-datetime-assertions.md b/ai-plans/0075-datetime-assertions.md index c3c6b8c..d3011cb 100644 --- a/ai-plans/0075-datetime-assertions.md +++ b/ai-plans/0075-datetime-assertions.md @@ -8,14 +8,14 @@ Add `IsUtc`, `IsLocal`, and `IsUnspecified` as the first candidate from #74, fol ## Acceptance Criteria -- [ ] `Check` exposes `IsUtc`, `IsLocal`, and `IsUnspecified` in built-in-message and `ErrorOverrides` overloads; all honor `shortCircuitOnError` and an already-short-circuited check. -- [ ] Failures use new `ValidationErrorCodes.Utc`, `Local`, and `Unspecified` codes and customizable `ValidationErrorTemplates` properties that survive `with`-expression copies. -- [ ] All three built-in-message overloads are discoverable by OpenAPI source generation. A validator using them generates a document against the built-in registry without generator errors, `WithErrorMetadata`, or an unregistered-code failure. -- [ ] A 3×3 kind matrix proves that each assertion accepts exactly one `DateTimeKind` and that together they partition the enum. -- [ ] A DTO deserialization test covers the four ISO 8601 forms below, including `+00:00` separately from a non-zero offset, and asserts both the resulting kind and accepted assertion. -- [ ] `IsUtc` is documented as testing the normalized value's `Kind`, independent of its origin. XML remarks and the README separately explain that the default JSON converter requires trailing `Z` because `+00:00` produces `Local` and is rejected. -- [ ] Tests also cover both overload forms, short-circuit propagation, template customization, and generated OpenAPI metadata. Solution coverage remains above 95%. -- [ ] The README shows the assertions in request validation, and the 0.7.0 `` of both affected packages describe their respective changes. +- [x] `Check` exposes `IsUtc`, `IsLocal`, and `IsUnspecified` in built-in-message and `ErrorOverrides` overloads; all honor `shortCircuitOnError` and an already-short-circuited check. +- [x] Failures use new `ValidationErrorCodes.Utc`, `Local`, and `Unspecified` codes and customizable `ValidationErrorTemplates` properties that survive `with`-expression copies. +- [x] All three built-in-message overloads are discoverable by OpenAPI source generation. A validator using them generates a document against the built-in registry without generator errors, `WithErrorMetadata`, or an unregistered-code failure. +- [x] A 3×3 kind matrix proves that each assertion accepts exactly one `DateTimeKind` and that together they partition the enum. +- [x] A DTO deserialization test covers the four ISO 8601 forms below, including `+00:00` separately from a non-zero offset, and asserts both the resulting kind and accepted assertion. +- [x] `IsUtc` is documented as testing the normalized value's `Kind`, independent of its origin. XML remarks and the README separately explain that the default JSON converter requires trailing `Z` because `+00:00` produces `Local` and is rejected. +- [x] Tests also cover both overload forms, short-circuit propagation, template customization, and generated OpenAPI metadata. Solution coverage remains above 95%. +- [x] The README shows the assertions in request validation, and the 0.7.0 `` of both affected packages describe their respective changes. ## Technical Details diff --git a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs index 81c8e1b..13f40d9 100644 --- a/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs +++ b/src/Light.PortableResults.Validation.OpenApi/BuiltInValidationErrorContracts.cs @@ -127,7 +127,10 @@ private static FrozenDictionary CreateContracts() [ValidationErrorCodes.Email] = ErrorMetadataContract.NoMetadata, [ValidationErrorCodes.DigitsOnly] = ErrorMetadataContract.NoMetadata, [ValidationErrorCodes.LettersAndDigitsOnly] = ErrorMetadataContract.NoMetadata, - [ValidationErrorCodes.UuidV7] = ErrorMetadataContract.NoMetadata + [ValidationErrorCodes.UuidV7] = ErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.Utc] = ErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.Local] = ErrorMetadataContract.NoMetadata, + [ValidationErrorCodes.Unspecified] = ErrorMetadataContract.NoMetadata }.ToFrozenDictionary(StringComparer.Ordinal); } diff --git a/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj b/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj index 7f39cd6..9686fa1 100644 --- a/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj +++ b/src/Light.PortableResults.Validation.OpenApi/Light.PortableResults.Validation.OpenApi.csproj @@ -13,6 +13,7 @@ - Opt-in source generator for deriving Minimal API and MVC validation OpenAPI metadata from synchronous validators. - Native AOT compatible. - Registers a built-in metadata-free contract for the new UuidV7 validation error code. + - Registers built-in metadata-free contracts for the new Utc, Local, and Unspecified validation error codes. diff --git a/src/Light.PortableResults.Validation/Checks.Temporal.cs b/src/Light.PortableResults.Validation/Checks.Temporal.cs new file mode 100644 index 0000000..a3da37f --- /dev/null +++ b/src/Light.PortableResults.Validation/Checks.Temporal.cs @@ -0,0 +1,252 @@ +using System; +using Light.PortableResults.Validation.Definitions; + +namespace Light.PortableResults.Validation; + +/// +/// Provides assertions for instances. +/// +public static partial class Checks +{ + /// + /// Adds a validation error when the checked date and time is not represented in UTC. + /// + /// The check carrying the value and validation context. + /// + /// When , marks the check as short-circuited after a failure so that subsequent + /// assertions in the chain are skipped; defaults to . + /// + /// The current check for fluent chaining. + /// + /// + /// The contract is the of the value this check sees, independent of where that + /// value came from: , , a custom JSON + /// converter, and non-JSON transports can all produce . If the validation + /// context applies a value normalizer that rewrites the , this assertion describes + /// the normalized value. + /// + /// + /// When values arrive as JSON through the default System.Text.Json converter, this means the payload + /// has to carry a trailing Z: a numeric offset — including +00:00, which denotes the same + /// instant as Z — is converted to server-local time and deserializes as + /// , so it is rejected. That is guidance about the JSON wire format, not + /// part of this assertion's contract. + /// + /// + [ValidationRule(ValidationErrorCodes.Utc)] + [ValidationRuleMessage("{displayName} must be represented in UTC")] + public static Check IsUtc(this Check check, bool shortCircuitOnError = false) => + check.IsShortCircuited || check.Value.Kind == DateTimeKind.Utc ? + check : + AddBuiltInError(check, BuiltInValidationErrorDefinitions.Utc, shortCircuitOnError); + + /// + /// Adds a validation error when the checked date and time is not represented in UTC, applying the + /// specified inline error overrides. + /// + /// The check carrying the value and validation context. + /// + /// Inline overrides for the built-in error details. Pass a plain to replace only + /// the message, or supply a full to also override the code, category, or + /// metadata. At least one field must be set. + /// + /// + /// When , marks the check as short-circuited after a failure so that subsequent + /// assertions in the chain are skipped; defaults to . + /// + /// The current check for fluent chaining. + /// + /// + /// The contract is the of the value this check sees, independent of where that + /// value came from: , , a custom JSON + /// converter, and non-JSON transports can all produce . If the validation + /// context applies a value normalizer that rewrites the , this assertion describes + /// the normalized value. + /// + /// + /// When values arrive as JSON through the default System.Text.Json converter, this means the payload + /// has to carry a trailing Z: a numeric offset — including +00:00, which denotes the same + /// instant as Z — is converted to server-local time and deserializes as + /// , so it is rejected. That is guidance about the JSON wire format, not + /// part of this assertion's contract. + /// + /// + /// + /// Thrown when has no field set, or when + /// is non- but empty or whitespace. + /// + public static Check IsUtc( + this Check check, + ErrorOverrides overrides, + bool shortCircuitOnError = false + ) + { + EnsureErrorOverrides(overrides); + return check.IsShortCircuited || check.Value.Kind == DateTimeKind.Utc ? + check : + AddBuiltInErrorWithOverrides( + check, + BuiltInValidationErrorDefinitions.Utc, + overrides, + shortCircuitOnError + ); + } + + /// + /// Adds a validation error when the checked date and time is not a local date and time. + /// + /// The check carrying the value and validation context. + /// + /// When , marks the check as short-circuited after a failure so that subsequent + /// assertions in the chain are skipped; defaults to . + /// + /// The current check for fluent chaining. + /// + /// + /// The contract is the of the value this check sees, independent of where that + /// value came from. If the validation context applies a value normalizer that rewrites the + /// , this assertion describes the normalized value. + /// + /// + /// When values arrive as JSON through the default System.Text.Json converter, + /// is what a numeric UTC offset deserializes to — including + /// +00:00 — after conversion to the server's time zone. The resulting wall-clock value therefore + /// depends on how the server is configured, which is rarely what a portable API wants. + /// + /// + [ValidationRule(ValidationErrorCodes.Local)] + [ValidationRuleMessage("{displayName} must be a local date and time")] + public static Check IsLocal(this Check check, bool shortCircuitOnError = false) => + check.IsShortCircuited || check.Value.Kind == DateTimeKind.Local ? + check : + AddBuiltInError(check, BuiltInValidationErrorDefinitions.Local, shortCircuitOnError); + + /// + /// Adds a validation error when the checked date and time is not a local date and time, applying the + /// specified inline error overrides. + /// + /// The check carrying the value and validation context. + /// + /// Inline overrides for the built-in error details. Pass a plain to replace only + /// the message, or supply a full to also override the code, category, or + /// metadata. At least one field must be set. + /// + /// + /// When , marks the check as short-circuited after a failure so that subsequent + /// assertions in the chain are skipped; defaults to . + /// + /// The current check for fluent chaining. + /// + /// + /// The contract is the of the value this check sees, independent of where that + /// value came from. If the validation context applies a value normalizer that rewrites the + /// , this assertion describes the normalized value. + /// + /// + /// When values arrive as JSON through the default System.Text.Json converter, + /// is what a numeric UTC offset deserializes to — including + /// +00:00 — after conversion to the server's time zone. The resulting wall-clock value therefore + /// depends on how the server is configured, which is rarely what a portable API wants. + /// + /// + /// + /// Thrown when has no field set, or when + /// is non- but empty or whitespace. + /// + public static Check IsLocal( + this Check check, + ErrorOverrides overrides, + bool shortCircuitOnError = false + ) + { + EnsureErrorOverrides(overrides); + return check.IsShortCircuited || check.Value.Kind == DateTimeKind.Local ? + check : + AddBuiltInErrorWithOverrides( + check, + BuiltInValidationErrorDefinitions.Local, + overrides, + shortCircuitOnError + ); + } + + /// + /// Adds a validation error when the checked date and time specifies a time zone. + /// + /// The check carrying the value and validation context. + /// + /// When , marks the check as short-circuited after a failure so that subsequent + /// assertions in the chain are skipped; defaults to . + /// + /// The current check for fluent chaining. + /// + /// + /// The contract is the of the value this check sees, independent of where that + /// value came from. If the validation context applies a value normalizer that rewrites the + /// , this assertion describes the normalized value. Note that + /// default(DateTime) is and therefore passes: combine this + /// assertion with a range or equality check when a missing value must be rejected. + /// + /// + /// When values arrive as JSON through the default System.Text.Json converter, this means the payload + /// must carry neither a trailing Z nor a numeric offset — a wall-clock timestamp such as + /// 2026-08-02T10:00:00 whose zone the client and server agree on out of band. + /// + /// + [ValidationRule(ValidationErrorCodes.Unspecified)] + [ValidationRuleMessage("{displayName} must not specify a time zone")] + public static Check IsUnspecified(this Check check, bool shortCircuitOnError = false) => + check.IsShortCircuited || check.Value.Kind == DateTimeKind.Unspecified ? + check : + AddBuiltInError(check, BuiltInValidationErrorDefinitions.Unspecified, shortCircuitOnError); + + /// + /// Adds a validation error when the checked date and time specifies a time zone, applying the specified + /// inline error overrides. + /// + /// The check carrying the value and validation context. + /// + /// Inline overrides for the built-in error details. Pass a plain to replace only + /// the message, or supply a full to also override the code, category, or + /// metadata. At least one field must be set. + /// + /// + /// When , marks the check as short-circuited after a failure so that subsequent + /// assertions in the chain are skipped; defaults to . + /// + /// The current check for fluent chaining. + /// + /// + /// The contract is the of the value this check sees, independent of where that + /// value came from. If the validation context applies a value normalizer that rewrites the + /// , this assertion describes the normalized value. Note that + /// default(DateTime) is and therefore passes: combine this + /// assertion with a range or equality check when a missing value must be rejected. + /// + /// + /// When values arrive as JSON through the default System.Text.Json converter, this means the payload + /// must carry neither a trailing Z nor a numeric offset — a wall-clock timestamp such as + /// 2026-08-02T10:00:00 whose zone the client and server agree on out of band. + /// + /// + /// + /// Thrown when has no field set, or when + /// is non- but empty or whitespace. + /// + public static Check IsUnspecified( + this Check check, + ErrorOverrides overrides, + bool shortCircuitOnError = false + ) + { + EnsureErrorOverrides(overrides); + return check.IsShortCircuited || check.Value.Kind == DateTimeKind.Unspecified ? + check : + AddBuiltInErrorWithOverrides( + check, + BuiltInValidationErrorDefinitions.Unspecified, + overrides, + shortCircuitOnError + ); + } +} diff --git a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Temporal.cs b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Temporal.cs new file mode 100644 index 0000000..9e0db18 --- /dev/null +++ b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Temporal.cs @@ -0,0 +1,84 @@ +using Light.PortableResults.Validation.Messaging; + +namespace Light.PortableResults.Validation.Definitions; + +public static partial class BuiltInValidationErrorDefinitions +{ + /// + /// Gets the shared definition for UTC date-and-time validation failures. + /// + public static ValidationErrorDefinition Utc { get; } = new UtcValidationErrorDefinition(); + + /// + /// Gets the shared definition for local date-and-time validation failures. + /// + public static ValidationErrorDefinition Local { get; } = new LocalValidationErrorDefinition(); + + /// + /// Gets the shared definition for unspecified-time-zone date-and-time validation failures. + /// + public static ValidationErrorDefinition Unspecified { get; } = new UnspecifiedValidationErrorDefinition(); + + /// + /// Reusable built-in validation error definition for UTC date-and-time validation failures. + /// + public sealed class UtcValidationErrorDefinition : ValidationErrorDefinition + { + /// + /// Initializes a new instance of . + /// + public UtcValidationErrorDefinition() : base(code: ValidationErrorCodes.Utc) { } + + /// + public override bool TryGetStableMessageProvider( + ReadOnlyValidationContext context, + out object provider + ) => TryGetStableProvider(context.ErrorTemplates.Utc, out provider); + + /// + public override ValidationErrorMessage ProvideMessage(in ValidationErrorMessageContext context) => + context.ValidationContext.ErrorTemplates.Utc.ProvideMessage(in context); + } + + /// + /// Reusable built-in validation error definition for local date-and-time validation failures. + /// + public sealed class LocalValidationErrorDefinition : ValidationErrorDefinition + { + /// + /// Initializes a new instance of . + /// + public LocalValidationErrorDefinition() : base(code: ValidationErrorCodes.Local) { } + + /// + public override bool TryGetStableMessageProvider( + ReadOnlyValidationContext context, + out object provider + ) => TryGetStableProvider(context.ErrorTemplates.Local, out provider); + + /// + public override ValidationErrorMessage ProvideMessage(in ValidationErrorMessageContext context) => + context.ValidationContext.ErrorTemplates.Local.ProvideMessage(in context); + } + + /// + /// Reusable built-in validation error definition for unspecified-time-zone date-and-time validation failures. + /// + public sealed class UnspecifiedValidationErrorDefinition : ValidationErrorDefinition + { + /// + /// Initializes a new instance of . + /// + public UnspecifiedValidationErrorDefinition() : base(code: ValidationErrorCodes.Unspecified) { } + + /// + public override bool TryGetStableMessageProvider( + ReadOnlyValidationContext context, + out object provider + ) => TryGetStableProvider(context.ErrorTemplates.Unspecified, out provider); + + /// + public override ValidationErrorMessage ProvideMessage(in ValidationErrorMessageContext context) => + context.ValidationContext.ErrorTemplates.Unspecified.ProvideMessage(in context); + } +} diff --git a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj index d8cb236..2119d41 100644 --- a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj +++ b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj @@ -16,7 +16,11 @@ validation-message encodings. - Adds a net10.0 asset so DateOnly and TimeOnly validation boundaries retain their dedicated metadata kinds. - Adds the IsUuidV7 assertion for Check<Guid>, with the UuidV7 error code, a customizable message template, and the standalone GuidExtensions.IsUuidV7 predicate. - + - Adds the IsUtc, IsLocal, and IsUnspecified assertions for Check<DateTime>, with the Utc, Local, and + Unspecified error codes and customizable message templates. They assert the DateTime.Kind of the checked + value; with the default System.Text.Json converter, IsUtc requires a trailing 'Z' because a numeric + offset — including +00:00 — deserializes as Local. + diff --git a/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs b/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs index cf5c3f1..f31242c 100644 --- a/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs +++ b/src/Light.PortableResults.Validation/Messaging/ValidationErrorTemplates.cs @@ -84,6 +84,15 @@ public sealed partial record ValidationErrorTemplates private static readonly IValidationErrorMessageTemplate DefaultUuidV7Template = new DisplayName(" must be a version 7 UUID"); + private static readonly IValidationErrorMessageTemplate DefaultUtcTemplate = + new DisplayName(" must be represented in UTC"); + + private static readonly IValidationErrorMessageTemplate DefaultLocalTemplate = + new DisplayName(" must be a local date and time"); + + private static readonly IValidationErrorMessageTemplate DefaultUnspecifiedTemplate = + new DisplayName(" must not specify a time zone"); + private static readonly IValidationErrorMessageTemplate DefaultCountTemplate = new DisplayNameWithParameter(" must contain exactly ", " item(s)"); @@ -140,6 +149,9 @@ private ValidationErrorTemplates(ValidationErrorTemplates original) DigitsOnly = original.DigitsOnly; LettersAndDigitsOnly = original.LettersAndDigitsOnly; UuidV7 = original.UuidV7; + Utc = original.Utc; + Local = original.Local; + Unspecified = original.Unspecified; Count = original.Count; MinCount = original.MinCount; MaxCount = original.MaxCount; @@ -381,6 +393,33 @@ public IValidationErrorMessageTemplate UuidV7 init => field = value ?? throw new ArgumentNullException(nameof(value)); } = DefaultUuidV7Template; + /// + /// Gets the template for UTC date-and-time validation failures. + /// + public IValidationErrorMessageTemplate Utc + { + get; + init => field = value ?? throw new ArgumentNullException(nameof(value)); + } = DefaultUtcTemplate; + + /// + /// Gets the template for local date-and-time validation failures. + /// + public IValidationErrorMessageTemplate Local + { + get; + init => field = value ?? throw new ArgumentNullException(nameof(value)); + } = DefaultLocalTemplate; + + /// + /// Gets the template for unspecified-time-zone date-and-time validation failures. + /// + public IValidationErrorMessageTemplate Unspecified + { + get; + init => field = value ?? throw new ArgumentNullException(nameof(value)); + } = DefaultUnspecifiedTemplate; + /// /// Gets the template for exact-count validation failures. /// diff --git a/src/Light.PortableResults.Validation/ValidationErrorCodes.cs b/src/Light.PortableResults.Validation/ValidationErrorCodes.cs index cdf6773..a34fa01 100644 --- a/src/Light.PortableResults.Validation/ValidationErrorCodes.cs +++ b/src/Light.PortableResults.Validation/ValidationErrorCodes.cs @@ -61,6 +61,12 @@ public static class ValidationErrorCodes public const string LettersAndDigitsOnly = "LettersAndDigitsOnly"; /// Validation error code for version 7 UUID failures. public const string UuidV7 = "UuidV7"; + /// Validation error code for non-UTC date-and-time failures. + public const string Utc = "Utc"; + /// Validation error code for non-local date-and-time failures. + public const string Local = "Local"; + /// Validation error code for date-and-time values that specify a time zone. + public const string Unspecified = "Unspecified"; /// Validation error code for predicate-based failures. public const string Predicate = "Predicate"; } diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs index 35228ff..61906a2 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/BuiltInValidationErrorContractsTests.cs @@ -109,7 +109,10 @@ public void Contracts_ShouldContainExpectedBuiltInCodes() ValidationErrorCodes.Email, ValidationErrorCodes.DigitsOnly, ValidationErrorCodes.LettersAndDigitsOnly, - ValidationErrorCodes.UuidV7 + ValidationErrorCodes.UuidV7, + ValidationErrorCodes.Utc, + ValidationErrorCodes.Local, + ValidationErrorCodes.Unspecified ]; BuiltInValidationErrorContracts.Contracts.Keys.Should() diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs index caa7755..770dbeb 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/GeneratedValidationOpenApiIntegrationTests.cs @@ -116,6 +116,54 @@ public async Task ProducesPortableValidationProblemFor_ShouldDocumentUuidV7FromT exampleErrors.ToJsonString().Should().Contain("\"message\":\"id must be a version 7 UUID\""); } + // A missing registry entry for one of these codes surfaces here rather than as a generator diagnostic: + // document construction fails when a discovered rule has no registered metadata contract. + [Fact] + public async Task ProducesPortableValidationProblemFor_ShouldDocumentDateTimeKindsFromTheBuiltInContracts() + { + await using var app = ValidationOpenApiDocumentTestUtilities.CreateApp( + contracts => contracts.RegisterBuiltInValidationErrors(), + endpoints => + { + endpoints + .MapPost("/generated-validation/date-time-kinds", static () => Results.BadRequest()) + .WithName("GeneratedDateTimeKindValidation") + .ProducesPortableValidationProblemFor( + configure: builder => builder.UseFormat(ValidationProblemSerializationFormat.Rich) + ); + } + ); + + var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + var operation = document.Paths["/generated-validation/date-time-kinds"].Operations![HttpMethod.Post]; + var response = (OpenApiResponse) operation.Responses![StatusCodes.Status400BadRequest.ToString()]; + var mediaType = response.Content!["application/problem+json"]; + var schemaReference = (OpenApiSchemaReference) mediaType.Schema!; + var envelope = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent( + document, + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId(schemaReference) + ); + var errors = (OpenApiSchema) ((OpenApiSchema) envelope.Properties!["errors"]).Items!; + + errors.OneOf!.Select( + static schema => + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) schema) + ) + .Should() + .BeEquivalentTo("PortableError__Utc", "PortableError__Local", "PortableError__Unspecified"); + + var example = (OpenApiExample) mediaType.Examples!["ValidationProblem"]; + var body = example.Value.Should().BeOfType().Subject; + var exampleErrors = body["errors"].Should().BeOfType().Subject; + var exampleJson = exampleErrors.ToJsonString(); + exampleJson.Should().Contain("\"code\":\"Utc\""); + exampleJson.Should().Contain("\"message\":\"recordedAt must be represented in UTC\""); + exampleJson.Should().Contain("\"code\":\"Local\""); + exampleJson.Should().Contain("\"message\":\"scheduledAt must be a local date and time\""); + exampleJson.Should().Contain("\"code\":\"Unspecified\""); + exampleJson.Should().Contain("\"message\":\"observedAt must not specify a time zone\""); + } + [Fact] public async Task MvcAttribute_ShouldApplyGeneratedSchemasExamplesAndOverrides() { @@ -357,6 +405,32 @@ GeneratedClientIdentifierDto dto } } +public sealed class GeneratedTimestampDto +{ + public DateTime RecordedAt { get; init; } + public DateTime ScheduledAt { get; init; } + public DateTime ObservedAt { get; init; } +} + +[GeneratePortableValidationOpenApi] +public sealed partial class GeneratedTimestampValidator : Validator +{ + public GeneratedTimestampValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + GeneratedTimestampDto dto + ) + { + context.Check(dto.RecordedAt).IsUtc(); + context.Check(dto.ScheduledAt).IsLocal(); + context.Check(dto.ObservedAt).IsUnspecified(); + return checkpoint.ToValidatedValue(dto); + } +} + public sealed class GeneratedValidationMvcMetadata { public string TraceId { get; init; } = string.Empty; diff --git a/tests/Light.PortableResults.Validation.Tests/DateTimeKindValidationTests.cs b/tests/Light.PortableResults.Validation.Tests/DateTimeKindValidationTests.cs new file mode 100644 index 0000000..7869d1d --- /dev/null +++ b/tests/Light.PortableResults.Validation.Tests/DateTimeKindValidationTests.cs @@ -0,0 +1,392 @@ +using System; +using System.Linq; +using System.Text.Json; +using FluentAssertions; +using Light.PortableResults.Validation.Messaging; +using Xunit; + +namespace Light.PortableResults.Validation.Tests; + +public sealed class DateTimeKindValidationTests +{ + private static readonly DateTime SampleDateTime = new (2026, 8, 2, 10, 0, 0, DateTimeKind.Unspecified); + + private static readonly KindAssertion[] KindAssertions = + [ + new ( + "IsUtc", + DateTimeKind.Utc, + "Utc", + "Timestamp must be represented in UTC", + static (check, shortCircuitOnError) => check.IsUtc(shortCircuitOnError), + static (check, overrides, shortCircuitOnError) => check.IsUtc(overrides, shortCircuitOnError), + static (templates, template) => templates with { Utc = template } + ), + new ( + "IsLocal", + DateTimeKind.Local, + "Local", + "Timestamp must be a local date and time", + static (check, shortCircuitOnError) => check.IsLocal(shortCircuitOnError), + static (check, overrides, shortCircuitOnError) => check.IsLocal(overrides, shortCircuitOnError), + static (templates, template) => templates with { Local = template } + ), + new ( + "IsUnspecified", + DateTimeKind.Unspecified, + "Unspecified", + "Timestamp must not specify a time zone", + static (check, shortCircuitOnError) => check.IsUnspecified(shortCircuitOnError), + static (check, overrides, shortCircuitOnError) => check.IsUnspecified(overrides, shortCircuitOnError), + static (templates, template) => templates with { Unspecified = template } + ) + ]; + + // The central assertion test: every rule must accept its own kind and reject both others, so that the three + // rules together partition DateTimeKind without overlap. + [Fact] + public void KindAssertions_ShouldAcceptExactlyTheirOwnKind() + { + foreach (var assertion in KindAssertions) + { + foreach (var kind in Enum.GetValues()) + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var value = DateTime.SpecifyKind(SampleDateTime, kind); + + assertion.Apply( + context.Check(value, target: "timestamp", displayName: "Timestamp"), + false + ); + + if (kind == assertion.AcceptedKind) + { + context.Errors.Should().BeEmpty("{0} must accept {1}", assertion.Name, kind); + } + else + { + context.Errors.Should().ContainSingle( + error => + error.Target == "timestamp" && + error.Code == assertion.ErrorCode && + error.Message == assertion.ExpectedMessage, + "{0} must reject {1}", + assertion.Name, + kind + ); + } + } + } + } + + // Keeps the partition claim above honest: if DateTimeKind ever gains a member, no assertion covers it and + // the matrix silently stops being exhaustive. + [Fact] + public void KindAssertions_ShouldCoverEveryDateTimeKind() => + KindAssertions.Select(static assertion => assertion.AcceptedKind) + .Should() + .BeEquivalentTo(Enum.GetValues()); + + // Guards the JSON premise the documentation rests on. This deliberately overlaps the matrix: it is a + // regression detector for third-party behavior. If System.Text.Json ever changes how it maps these wire + // formats, the assertions keep working while their documented meaning shifts, and this test is what says so. + // Only the kind is host-independent — the two Local values depend on the server's time zone, so the + // wall-clock value is deliberately not asserted. + [Theory] + [InlineData("2026-08-02T10:00:00Z", DateTimeKind.Utc)] + [InlineData("2026-08-02T10:00:00+00:00", DateTimeKind.Local)] + [InlineData("2026-08-02T10:00:00+02:00", DateTimeKind.Local)] + [InlineData("2026-08-02T10:00:00", DateTimeKind.Unspecified)] + public void DefaultJsonConverter_ShouldProduceTheDocumentedKind(string wireValue, DateTimeKind expectedKind) + { + var dto = JsonSerializer.Deserialize($$"""{"OccurredAt":"{{wireValue}}"}"""); + + dto!.OccurredAt.Kind.Should().Be(expectedKind); + + foreach (var assertion in KindAssertions) + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + assertion.Apply( + context.Check(dto.OccurredAt, target: "timestamp", displayName: "Timestamp"), + false + ); + + if (assertion.AcceptedKind == expectedKind) + { + context.Errors.Should().BeEmpty("{0} must accept {1}", assertion.Name, wireValue); + } + else + { + context.Errors.Should().ContainSingle( + error => error.Code == assertion.ErrorCode, + "{0} must reject {1}", + assertion.Name, + wireValue + ); + } + } + } + + // IsUtc tests the value's kind, not its provenance: UtcNow never passed through a JSON converter. + [Fact] + public void IsUtc_ShouldAcceptUtcNow() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + context.Check(DateTime.UtcNow, target: "timestamp", displayName: "Timestamp").IsUtc(); + + context.Errors.Should().BeEmpty(); + } + + [Fact] + public void IsLocal_ShouldAcceptNow() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + context.Check(DateTime.Now, target: "timestamp", displayName: "Timestamp").IsLocal(); + + context.Errors.Should().BeEmpty(); + } + + // Documents the default-value trap: an absent DateTime property is Unspecified and therefore passes. + [Fact] + public void IsUnspecified_ShouldAcceptTheDefaultDateTime() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + context.Check(default(DateTime), target: "timestamp", displayName: "Timestamp").IsUnspecified(); + + context.Errors.Should().BeEmpty(); + } + + [Fact] + public void KindAssertions_ShouldApplyOverrides_WhenTheKindIsWrong() + { + foreach (var assertion in KindAssertions) + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + assertion.ApplyWithOverrides( + context.Check(RejectedValueFor(assertion), target: "timestamp", displayName: "Timestamp"), + new ErrorOverrides { Code = "TimestampZoneMismatch" }, + false + ); + + context.Errors.Should().ContainSingle( + error => error.Target == "timestamp" && error.Code == "TimestampZoneMismatch", + "{0} must honor overrides", + assertion.Name + ); + } + } + + [Fact] + public void KindAssertions_ShouldNotAddError_WhenOverridesAreUsedAndTheKindIsCorrect() + { + foreach (var assertion in KindAssertions) + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + assertion.ApplyWithOverrides( + context.Check( + DateTime.SpecifyKind(SampleDateTime, assertion.AcceptedKind), + target: "timestamp", + displayName: "Timestamp" + ), + new ErrorOverrides { Code = "Unused" }, + false + ); + + context.Errors.Should().BeEmpty("{0} must accept its own kind", assertion.Name); + } + } + + [Fact] + public void KindAssertions_ShouldThrow_WhenOverridesAreEmpty() + { + foreach (var assertion in KindAssertions) + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check( + DateTime.SpecifyKind(SampleDateTime, assertion.AcceptedKind), + target: "timestamp" + ); + + var act = () => assertion.ApplyWithOverrides(check, new ErrorOverrides(), false); + + act.Should().Throw("{0} must reject empty overrides", assertion.Name); + } + } + + [Fact] + public void KindAssertions_ShouldRespectAnAlreadyShortCircuitedCheck() + { + foreach (var assertion in KindAssertions) + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check(RejectedValueFor(assertion), target: "timestamp").ShortCircuit(); + + assertion.Apply(check, false).IsShortCircuited.Should().BeTrue(); + context.Errors.Should().BeEmpty("{0} must skip a short-circuited check", assertion.Name); + } + } + + [Fact] + public void KindAssertions_ShouldRespectAnAlreadyShortCircuitedCheck_WhenOverridesAreUsed() + { + foreach (var assertion in KindAssertions) + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check(RejectedValueFor(assertion), target: "timestamp").ShortCircuit(); + + assertion.ApplyWithOverrides(check, new ErrorOverrides { Code = "Unused" }, false) + .IsShortCircuited.Should() + .BeTrue(); + context.Errors.Should().BeEmpty("{0} must skip a short-circuited check", assertion.Name); + } + } + + [Fact] + public void KindAssertions_ShouldShortCircuit_WhenRequested() + { + foreach (var assertion in KindAssertions) + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check( + RejectedValueFor(assertion), + target: "timestamp", + displayName: "Timestamp" + ); + + assertion.Apply(check, true).IsShortCircuited.Should().BeTrue(); + context.Errors.Should().ContainSingle( + error => error.Target == "timestamp" && error.Code == assertion.ErrorCode, + "{0} must still report the failure", + assertion.Name + ); + } + } + + [Fact] + public void KindAssertions_ShouldShortCircuit_WhenOverridesAreUsedAndRequested() + { + foreach (var assertion in KindAssertions) + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check( + RejectedValueFor(assertion), + target: "timestamp", + displayName: "Timestamp" + ); + + assertion + .ApplyWithOverrides(check, new ErrorOverrides { Message = "Timestamp has the wrong zone" }, true) + .IsShortCircuited.Should() + .BeTrue(); + context.Errors.Should().ContainSingle( + error => error.Target == "timestamp" && error.Message == "Timestamp has the wrong zone", + "{0} must honor the override message", + assertion.Name + ); + } + } + + [Fact] + public void KindAssertions_ShouldNotShortCircuit_WhenNotRequested() + { + foreach (var assertion in KindAssertions) + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + var check = context.Check( + RejectedValueFor(assertion), + target: "timestamp", + displayName: "Timestamp" + ); + + assertion.Apply(check, false).IsShortCircuited.Should().BeFalse("{0}", assertion.Name); + } + } + + [Fact] + public void KindAssertions_ShouldUseTheCustomizedTemplate() + { + foreach (var assertion in KindAssertions) + { + var context = CreateContextWithTemplates( + assertion.WithTemplate( + ValidationErrorTemplates.Default, + new ValidationErrorTemplates.Constant("The timestamp zone is not accepted") + ) + ); + + assertion.Apply( + context.Check(RejectedValueFor(assertion), target: "timestamp", displayName: "Timestamp"), + false + ); + + context.Errors.Should().ContainSingle( + error => + error.Code == assertion.ErrorCode && + error.Message == "The timestamp zone is not accepted", + "{0} must use its customized template", + assertion.Name + ); + } + } + + [Fact] + public void KindAssertions_ShouldKeepTheCustomizedTemplate_WhenTemplatesAreCopiedAgain() + { + foreach (var assertion in KindAssertions) + { + var customizedTemplates = assertion.WithTemplate( + ValidationErrorTemplates.Default, + new ValidationErrorTemplates.Constant("The timestamp zone is not accepted") + ); + var context = CreateContextWithTemplates( + customizedTemplates with { NotNull = new ValidationErrorTemplates.Constant("Value is required") } + ); + + assertion.Apply( + context.Check(RejectedValueFor(assertion), target: "timestamp", displayName: "Timestamp"), + false + ); + + context.Errors.Should().ContainSingle( + error => + error.Code == assertion.ErrorCode && + error.Message == "The timestamp zone is not accepted", + "{0} must survive a subsequent with-expression copy", + assertion.Name + ); + } + } + + private static DateTime RejectedValueFor(KindAssertion assertion) => + DateTime.SpecifyKind( + SampleDateTime, + assertion.AcceptedKind == DateTimeKind.Utc ? DateTimeKind.Unspecified : DateTimeKind.Utc + ); + + private static ValidationContext CreateContextWithTemplates(ValidationErrorTemplates templates) + { + var options = new ValidationContextOptions() with { ErrorTemplates = templates }; + return new DefaultValidationContextFactory(options).CreateValidationContext(); + } + + private sealed record KindAssertion( + string Name, + DateTimeKind AcceptedKind, + string ErrorCode, + string ExpectedMessage, + Func, bool, Check> Apply, + Func, ErrorOverrides, bool, Check> ApplyWithOverrides, + Func WithTemplate + ); +} + +public sealed class TimestampDto +{ + public DateTime OccurredAt { get; init; } +} diff --git a/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs b/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs index df6aefe..23c031f 100644 --- a/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs @@ -1,9 +1,7 @@ using System; -using System.Globalization; using System.Text.RegularExpressions; using FluentAssertions; using Light.PortableResults.Metadata; -using Light.PortableResults.Validation; using Light.PortableResults.Validation.Definitions; using Light.PortableResults.Validation.Messaging; using Light.PortableResults.Validation.Targeting; @@ -20,7 +18,8 @@ public void BuiltInDefinitions_ShouldExposeExpectedDefaults() BuiltInValidationErrorDefinitions.Null.Code.Should().Be(ValidationErrorCodes.Null); BuiltInValidationErrorDefinitions.Empty.Code.Should().Be(ValidationErrorCodes.Empty); BuiltInValidationErrorDefinitions.NotEmpty.Code.Should().Be(ValidationErrorCodes.NotEmpty); - BuiltInValidationErrorDefinitions.NotNullOrWhiteSpace.Code.Should().Be(ValidationErrorCodes.NotNullOrWhiteSpace); + BuiltInValidationErrorDefinitions.NotNullOrWhiteSpace.Code.Should() + .Be(ValidationErrorCodes.NotNullOrWhiteSpace); BuiltInValidationErrorDefinitions.Email.Code.Should().Be(ValidationErrorCodes.Email); BuiltInValidationErrorDefinitions.Predicate.Code.Should().Be(ValidationErrorCodes.Predicate); BuiltInValidationErrorDefinitions.NotNull.Metadata.Should().BeNull(); @@ -519,7 +518,7 @@ public void ParameterizedTemplateDefinitions_ShouldReportStableProviders() } [Fact] - public void CountEqualityStringEnumDecimalAndGuidDefinitions_ShouldExposeStableProviders() + public void CountEqualityStringEnumDecimalGuidAndTemporalDefinitions_ShouldExposeStableProviders() { var context = DefaultValidationContextFactory.Create().CreateValidationContext(); var readOnlyContext = context.AsReadOnly(); @@ -539,6 +538,9 @@ public void CountEqualityStringEnumDecimalAndGuidDefinitions_ShouldExposeStableP var enumValue = BuiltInValidationErrorDefinitions.IsInEnum(); var precisionScale = BuiltInValidationErrorDefinitions.PrecisionScale(4, 2, ignoreTrailingZeros: true); var uuidV7 = BuiltInValidationErrorDefinitions.UuidV7; + var utc = BuiltInValidationErrorDefinitions.Utc; + var local = BuiltInValidationErrorDefinitions.Local; + var unspecified = BuiltInValidationErrorDefinitions.Unspecified; count.TryGetStableMessageProvider(readOnlyContext, out var countProvider).Should().BeTrue(); minCount.TryGetStableMessageProvider(readOnlyContext, out var minCountProvider).Should().BeTrue(); @@ -557,6 +559,9 @@ public void CountEqualityStringEnumDecimalAndGuidDefinitions_ShouldExposeStableP enumValue.TryGetStableMessageProvider(readOnlyContext, out var enumProvider).Should().BeTrue(); precisionScale.TryGetStableMessageProvider(readOnlyContext, out var precisionScaleProvider).Should().BeTrue(); uuidV7.TryGetStableMessageProvider(readOnlyContext, out var uuidV7Provider).Should().BeTrue(); + utc.TryGetStableMessageProvider(readOnlyContext, out var utcProvider).Should().BeTrue(); + local.TryGetStableMessageProvider(readOnlyContext, out var localProvider).Should().BeTrue(); + unspecified.TryGetStableMessageProvider(readOnlyContext, out var unspecifiedProvider).Should().BeTrue(); countProvider.Should().BeSameAs(context.ErrorTemplates.Count); minCountProvider.Should().BeSameAs(context.ErrorTemplates.MinCount); @@ -574,6 +579,9 @@ public void CountEqualityStringEnumDecimalAndGuidDefinitions_ShouldExposeStableP enumProvider.Should().BeSameAs(context.ErrorTemplates.Enum); precisionScaleProvider.Should().BeSameAs(context.ErrorTemplates.PrecisionScale); uuidV7Provider.Should().BeSameAs(context.ErrorTemplates.UuidV7); + utcProvider.Should().BeSameAs(context.ErrorTemplates.Utc); + localProvider.Should().BeSameAs(context.ErrorTemplates.Local); + unspecifiedProvider.Should().BeSameAs(context.ErrorTemplates.Unspecified); } [Fact] @@ -721,6 +729,38 @@ public void UuidV7_ShouldProvideExpectedMessage() uuidV7.ProvideMessage(messageContext).Text.Should().ContainAll("Order ID", "version 7 UUID"); } + [Fact] + public void Utc_ShouldProvideExpectedMessage() + { + var context = DefaultValidationContextFactory.Create().CreateValidationContext(); + var messageContext = context.Check(default(DateTime), target: "occurredAt", displayName: "Occurred at") + .CreateMessageContext(); + var utc = BuiltInValidationErrorDefinitions.Utc; + utc.ProvideMessage(messageContext).Text.Should().ContainAll("Occurred at", "UTC"); + } + + [Fact] + public void Local_ShouldProvideExpectedMessage() + { + var context = DefaultValidationContextFactory.Create().CreateValidationContext(); + var messageContext = context.Check(default(DateTime), target: "occurredAt", displayName: "Occurred at") + .CreateMessageContext(); + var local = BuiltInValidationErrorDefinitions.Local; + local.ProvideMessage(messageContext).Text.Should().ContainAll("Occurred at", "local date and time"); + } + + [Fact] + public void Unspecified_ShouldProvideExpectedMessage() + { + var context = DefaultValidationContextFactory.Create().CreateValidationContext(); + var messageContext = context.Check(default(DateTime), target: "occurredAt", displayName: "Occurred at") + .CreateMessageContext(); + var unspecified = BuiltInValidationErrorDefinitions.Unspecified; + unspecified.ProvideMessage(messageContext) + .Text.Should() + .ContainAll("Occurred at", "must not specify a time zone"); + } + [Fact] public void Count_ShouldThrow_WhenCacheIsNull() { From b6550cef4dc2c923b1a57fd90af1cf054196b499 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sun, 2 Aug 2026 19:48:27 +0200 Subject: [PATCH 4/4] chore(validation): polish DateTime assertion docs and test scoping - Restore the indentation of the closing PackageReleaseNotes tag and use ASCII hyphens in the notes, matching the sibling packages. - Say that DateTime.SpecifyKind and friends *can* produce Utc, as the XML remarks already do, instead of claiming they always do. - Scope the JSON round-trip DTO to its file so it no longer occupies a name in the shared test namespace. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019vu3qgcqGWGr2TddYEpLpW Signed-off-by: Kenny Pflug --- README.md | 2 +- .../Light.PortableResults.Validation.csproj | 4 ++-- .../DateTimeKindValidationTests.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9c3d499..c00b3b0 100644 --- a/README.md +++ b/README.md @@ -765,7 +765,7 @@ public sealed class OrderItemValidator : Validator `IsUuidV7` fails with the `UuidV7` error code unless the GUID's RFC 9562 version field is `7` **and** its variant bits are the RFC variant. The same invariant is available standalone as `guid.IsUuidV7()` (`GuidExtensions`) when a repository or message handler needs to guard it outside a check chain. -`IsUtc`, `IsLocal`, and `IsUnspecified` assert the `DateTime.Kind` of the checked value and fail with the `Utc`, `Local`, and `Unspecified` error codes. They partition `DateTimeKind`: every `DateTime` is accepted by exactly one of them. The contract is only the kind of the value the check sees, independent of its origin — `DateTime.UtcNow`, `DateTime.SpecifyKind`, a custom converter, and non-JSON transports all produce `Utc` just as a JSON payload with a trailing `Z` does. +`IsUtc`, `IsLocal`, and `IsUnspecified` assert the `DateTime.Kind` of the checked value and fail with the `Utc`, `Local`, and `Unspecified` error codes. They partition `DateTimeKind`: every `DateTime` is accepted by exactly one of them. The contract is only the kind of the value the check sees, independent of its origin — `DateTime.UtcNow`, `DateTime.SpecifyKind`, a custom converter, and non-JSON transports can all produce `Utc` just as a JSON payload with a trailing `Z` does. That matters for JSON requests, because the default `System.Text.Json` converter maps the ISO 8601 forms to kinds like this: diff --git a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj index 2119d41..01536fc 100644 --- a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj +++ b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj @@ -19,8 +19,8 @@ - Adds the IsUtc, IsLocal, and IsUnspecified assertions for Check<DateTime>, with the Utc, Local, and Unspecified error codes and customizable message templates. They assert the DateTime.Kind of the checked value; with the default System.Text.Json converter, IsUtc requires a trailing 'Z' because a numeric - offset — including +00:00 — deserializes as Local. - + offset - including +00:00 - deserializes as Local. + diff --git a/tests/Light.PortableResults.Validation.Tests/DateTimeKindValidationTests.cs b/tests/Light.PortableResults.Validation.Tests/DateTimeKindValidationTests.cs index 7869d1d..74952bb 100644 --- a/tests/Light.PortableResults.Validation.Tests/DateTimeKindValidationTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/DateTimeKindValidationTests.cs @@ -386,7 +386,7 @@ Func