diff --git a/README.md b/README.md index 0639f04..c00b3b0 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 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: + +| 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 new file mode 100644 index 0000000..d3011cb --- /dev/null +++ b/ai-plans/0075-datetime-assertions.md @@ -0,0 +1,77 @@ +# Add `DateTimeKind` Assertions + +## Rationale + +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. + +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 + +- [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 + +### Semantics and normalization + +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`. + +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 | +| --- | --- | --- | +| `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` | + +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. + +`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. + +`IsLocal` completes the enum partition despite its limited value in portable APIs, where server-relative time is rarely desirable. + +### Errors and registration + +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"; +public const string Local = "Local"; +public const string Unspecified = "Unspecified"; +``` + +The terse names follow existing codes such as `Empty`, `Null`, and `Email`; the error target disambiguates `Unspecified` for consumers. + +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. + +Follow the `UuidV7` registration shape: + +- 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. + +### Tests and documentation + +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. + +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 { … }`. + +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.** `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. 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..01536fc 100644 --- a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj +++ b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj @@ -16,6 +16,10 @@ 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..74952bb --- /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 + ); +} + +file 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() {