From 082a90d54a759e3a0ceb20541cfd6f1c476ef4c2 Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Sun, 16 Aug 2026 00:29:52 -0300 Subject: [PATCH] Let a clock decide what "now" means during a validation A value the user gives is rarely a complete instant. "3 days ago" counts back from now, "10:00" means today at ten, a format without a year borrows the current one, and a comparison against "18 years ago" has to work out when that was. PHP completes each of those from the system clock, which it reads afresh every time, so no two values in the same validation are measured from quite the same moment. A value sitting exactly on a boundary then falls to whichever side the microseconds landed on: "7 years ago" counts as six years, because it is resolved a moment after the time it is measured against was read, and the count of years rounds the shortfall down. Introduce a PSR-20 clock, taken by every validator that needs to know the time: DateTimeDiff, the comparisons, Between, DateTime, LeapDate, LeapYear and Time. The clock is read where PHP read the system one and no more often, so a system clock, which is what a validator falls back to and what the container names by default, answers exactly as before, six years included. Name FrozenClock in the container instead and every chain builds one for itself, hands it to the validators it creates, attributes included, and holds it still while the validation runs, taking the time again on the next run so that a chain kept around never reports a stale one. Every validator of a chain then agrees on what "now" is, and "7 years ago" becomes the seven years it says it is. CanResolveDateTime completes a value the way PHP does, asking the clock where PHP asks the system: never for a value naming a full calendar date, and otherwise only once the value has been parsed. Validators receive the clock as the parameter they declare, so the fluent API is untouched, generated mixins and documented signatures included. --- composer.json | 1 + composer.lock | 114 +++++++++++- docs/configuration.md | 31 +++ docs/validators/DateTimeDiff.md | 5 + src-dev/Commands/LintMixinCommand.php | 2 +- .../Linters/ValidatorHeaderLinter.php | 1 + src/AutowiringLookup.php | 5 + src/ContainerRegistry.php | 2 + src/FluentValidatorFactory.php | 12 +- src/Helpers/CanCompareValues.php | 14 +- src/Helpers/CanResolveDateTime.php | 135 ++++++++++++++ src/ValidatorBuilder.php | 45 ++++- src/ValidatorFactory.php | 2 +- src/Validators/Between.php | 9 +- src/Validators/BetweenExclusive.php | 7 +- src/Validators/Core/Comparison.php | 10 +- src/Validators/DateTime.php | 10 +- src/Validators/DateTimeDiff.php | 24 +-- src/Validators/LeapDate.php | 12 +- src/Validators/LeapYear.php | 13 +- src/Validators/Time.php | 10 +- tests/feature/ClockTest.php | 54 ++++++ tests/src/Stubs/ForeignContainer.php | 30 +++ tests/src/Stubs/WithRelativeDate.php | 22 +++ tests/src/Validators/ClockProbe.php | 42 +++++ tests/unit/ContainerRegistryTest.php | 9 + tests/unit/FluentValidatorFactoryTest.php | 11 ++ tests/unit/Helpers/CanResolveDateTimeTest.php | 176 ++++++++++++++++++ tests/unit/ValidatorBuilderTest.php | 118 ++++++++++++ tests/unit/Validators/DateTimeDiffTest.php | 65 +++++++ tests/unit/Validators/LeapDateTest.php | 13 ++ 31 files changed, 964 insertions(+), 40 deletions(-) create mode 100644 src/Helpers/CanResolveDateTime.php create mode 100644 tests/feature/ClockTest.php create mode 100644 tests/src/Stubs/ForeignContainer.php create mode 100644 tests/src/Stubs/WithRelativeDate.php create mode 100644 tests/src/Validators/ClockProbe.php create mode 100644 tests/unit/Helpers/CanResolveDateTimeTest.php diff --git a/composer.json b/composer.json index c07cb11b5..18cb6a555 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ }, "require": { "php": ">=8.5", + "lcobucci/clock": "^3.6", "psr/container": "^2.0", "respect/config": "^3.0", "respect/fluent": "^2.0", diff --git a/composer.lock b/composer.lock index f5bb12f02..acf42bbb2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,120 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "683a4f0e3e7054eddcef31faf9cc9b1c", + "content-hash": "63dc943cc18f12c7b2d462687fbaf69b", "packages": [ + { + "name": "lcobucci/clock", + "version": "3.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/clock.git", + "reference": "4cdd88f761e9be9095ccbedf3e08d61ae216c643" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/4cdd88f761e9be9095ccbedf3e08d61ae216c643", + "reference": "4cdd88f761e9be9095ccbedf3e08d61ae216c643", + "shasum": "" + }, + "require": { + "php": "~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "infection/infection": "^0.32", + "lcobucci/coding-standard": "^12.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^13.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com" + } + ], + "description": "Yet another clock abstraction", + "support": { + "issues": "https://github.com/lcobucci/clock/issues", + "source": "https://github.com/lcobucci/clock/tree/3.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2026-04-13T21:30:16+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, { "name": "psr/container", "version": "2.0.2", diff --git a/docs/configuration.md b/docs/configuration.md index cfd9c59b7..7991343f7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,3 +17,34 @@ use Respect\Validation\ContainerRegistry; ContainerRegistry::setContainer($yourPsr11Container); ``` + +## Clock + +Validators that need to know what time it is take a [PSR-20](https://www.php-fig.org/psr/psr-20/) clock. The +`respect.validation.clock` definition names the class to use, and by default that is a system clock, which reads the +current time every time it is asked, just as PHP does on its own: + +```php +'respect.validation.clock' => SystemClock::class, +``` + +Naming `FrozenClock` instead gives each validation chain a clock of its own, held still at the moment the validation +starts and taken again on every run, so that every validator of a chain agrees on what "now" is: + +```php +use Lcobucci\Clock\FrozenClock; +use Respect\Validation\ContainerRegistry; + +ContainerRegistry::setContainer( + ContainerRegistry::createContainer([ + 'respect.validation.clock' => FrozenClock::class, + ]) +); +``` + +A chain takes its clock when it is built, so configure the container before building any validator. To validate +against a moment of your choosing, give the clock to the validator itself: + +```php +v::with(new DateTimeDiff('years', v::greaterThan(18), null, null, new FrozenClock($moment)))->assert($birthDate); +``` diff --git a/docs/validators/DateTimeDiff.md b/docs/validators/DateTimeDiff.md index 0ff134812..e0b4339f6 100644 --- a/docs/validators/DateTimeDiff.md +++ b/docs/validators/DateTimeDiff.md @@ -33,6 +33,11 @@ v::dateTimeDiff('months', v::between(1, 18))->assert('5 months ago'); // Validation passes successfully ``` +A value such as `"7 years ago"` is resolved when the validation runs, a moment after the time it is compared against +was read, so it falls just short of seven years and counts as six. Freezing the [clock](../configuration.md#clock) +makes both the comparison and the value use the very same moment, and such a value then counts as the seven years it +names. + The supported types are: - `years` diff --git a/src-dev/Commands/LintMixinCommand.php b/src-dev/Commands/LintMixinCommand.php index a31f6b188..80fa68b25 100644 --- a/src-dev/Commands/LintMixinCommand.php +++ b/src-dev/Commands/LintMixinCommand.php @@ -75,7 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int scanner: $scanner, methodBuilder: new MethodBuilder( excludedTypePrefixes: ['Sokil', 'Egulias', 'Ramsey', 'libphonenumber'], - excludedTypeNames: ['Respect\\Parameter\\Resolver'], + excludedTypeNames: ['Respect\\Parameter\\Resolver', 'Psr\\Clock\\ClockInterface'], ), interfaces: [ new InterfaceConfig( diff --git a/src-dev/Markdown/Linters/ValidatorHeaderLinter.php b/src-dev/Markdown/Linters/ValidatorHeaderLinter.php index ce6b61871..dbc4df654 100644 --- a/src-dev/Markdown/Linters/ValidatorHeaderLinter.php +++ b/src-dev/Markdown/Linters/ValidatorHeaderLinter.php @@ -174,6 +174,7 @@ private function getParameter(ReflectionParameter $reflection): array|null if ( str_starts_with($type->getName(), 'Sokil') || str_starts_with($type->getName(), 'Egulias') + || str_starts_with($type->getName(), 'Psr\\Clock') || $type->getName() === 'finfo' ) { return null; diff --git a/src/AutowiringLookup.php b/src/AutowiringLookup.php index fce97473b..7d0659766 100644 --- a/src/AutowiringLookup.php +++ b/src/AutowiringLookup.php @@ -34,6 +34,11 @@ public function withNamespace(string $namespace): static return clone ($this, ['lookup' => $this->lookup->withNamespace($namespace)]); } + public function withResolver(Resolver $parameterResolver): static + { + return clone ($this, ['parameterResolver' => $parameterResolver]); + } + /** @param array $arguments */ public function create(string $name, array $arguments = []): object { diff --git a/src/ContainerRegistry.php b/src/ContainerRegistry.php index 8f2820202..e4bcde1ef 100644 --- a/src/ContainerRegistry.php +++ b/src/ContainerRegistry.php @@ -11,6 +11,7 @@ namespace Respect\Validation; +use Lcobucci\Clock\SystemClock; use libphonenumber\PhoneNumberUtil; use Psr\Container\ContainerInterface; use Ramsey\Uuid\UuidFactory; @@ -78,6 +79,7 @@ public static function createContainer(array $definitions = []): Container 'respect.validation.formatter.full_message' => new Autowire(NestedListStringFormatter::class), 'respect.validation.formatter.messages' => new Autowire(NestedArrayFormatter::class), 'respect.validation.ignored_backtrace_paths' => [__DIR__ . '/ValidatorBuilder.php'], + 'respect.validation.clock' => SystemClock::class, 'respect.validation.rule_factory.namespaces' => ['Respect\\Validation\\Validators'], Resolver::class => static fn(Container $container) => new ContainerResolver($container), ValidatorFactory::class => static function (Container $container) { diff --git a/src/FluentValidatorFactory.php b/src/FluentValidatorFactory.php index 01ff56cb8..226adb72f 100644 --- a/src/FluentValidatorFactory.php +++ b/src/FluentValidatorFactory.php @@ -14,6 +14,7 @@ use Respect\Fluent\Exceptions\CouldNotCreate; use Respect\Fluent\Exceptions\CouldNotResolve; use Respect\Fluent\FluentFactory; +use Respect\Parameter\Resolver; use Respect\Validation\Exceptions\ComponentException; use Respect\Validation\Exceptions\InvalidClassException; @@ -27,7 +28,7 @@ public function __construct( ) { } - /** @param array $arguments */ + /** @param array $arguments */ public function create(string $ruleName, array $arguments = []): Validator { try { @@ -51,4 +52,13 @@ public function withNamespace(string $rulesNamespace): self { return new self($this->factory->withNamespace(trim($rulesNamespace, '\\'))); } + + public function withResolver(Resolver $resolver): self + { + if (!$this->factory instanceof AutowiringLookup) { + return $this; + } + + return new self($this->factory->withResolver($resolver)); + } } diff --git a/src/Helpers/CanCompareValues.php b/src/Helpers/CanCompareValues.php index cb7db71ba..7899ea046 100644 --- a/src/Helpers/CanCompareValues.php +++ b/src/Helpers/CanCompareValues.php @@ -13,9 +13,9 @@ namespace Respect\Validation\Helpers; use Countable; -use DateTimeImmutable; use DateTimeInterface; -use Throwable; +use Lcobucci\Clock\SystemClock; +use Psr\Clock\ClockInterface; use function is_numeric; use function is_scalar; @@ -24,7 +24,9 @@ trait CanCompareValues { - private function toComparable(mixed $value): mixed + use CanResolveDateTime; + + private function toComparable(mixed $value, ClockInterface|null $clock = null): mixed { if ($value instanceof Countable) { return $value->count(); @@ -38,11 +40,7 @@ private function toComparable(mixed $value): mixed return $value; } - try { - return new DateTimeImmutable($value); - } catch (Throwable) { - return $value; - } + return $this->resolveDateTime($value, $clock ?? SystemClock::fromSystemTimezone()) ?? $value; } private function isAbleToCompareValues(mixed $left, mixed $right): bool diff --git a/src/Helpers/CanResolveDateTime.php b/src/Helpers/CanResolveDateTime.php new file mode 100644 index 000000000..d66b61ca5 --- /dev/null +++ b/src/Helpers/CanResolveDateTime.php @@ -0,0 +1,135 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Helpers; + +use DateTimeImmutable; +use DateTimeZone; +use Psr\Clock\ClockInterface; +use Throwable; + +use function date_parse; +use function date_parse_from_format; +use function intdiv; +use function is_scalar; +use function preg_match; +use function round; +use function sprintf; +use function trim; + +trait CanResolveDateTime +{ + private function resolveDateTime(mixed $value, ClockInterface $clock): DateTimeImmutable|null + { + if (!is_scalar($value)) { + return null; + } + + $value = (string) $value; + if (trim($value) === '') { + return $clock->now(); + } + + $parsed = date_parse($value); + try { + if ($parsed['year'] !== false && $parsed['month'] !== false && $parsed['day'] !== false) { + return new DateTimeImmutable($value); + } + + $instant = $clock->now(); + $zone = $this->timeZoneOf($parsed); + $zoneType = $parsed['zone_type'] ?? false; + + if ($zone !== null && $zoneType === 3) { + $instant = $instant->setTimezone($zone); + } elseif ($zone !== null && isset($parsed['relative'])) { + $instant = new DateTimeImmutable($instant->format('Y-m-d H:i:s.u'), $zone); + } elseif ($zone !== null) { + return new DateTimeImmutable($instant->format('Y-m-d') . ' ' . $value); + } + + $resolved = $instant->modify($value); + if ($parsed['hour'] === false) { + if ($parsed['month'] === false && $parsed['day'] === false && $parsed['year'] === false) { + return $resolved; + } + + return $resolved->setTime(0, 0); + } + + $fraction = $parsed['fraction'] === false ? 0.0 : (float) $parsed['fraction']; + + return $resolved->setTime( + (int) $resolved->format('G'), + (int) $resolved->format('i'), + (int) $resolved->format('s'), + (int) round($fraction * 1000000), + ); + } catch (Throwable) { + return null; + } + } + + private function resolveDateTimeFromFormat( + string $format, + string $value, + ClockInterface $clock, + ): DateTimeImmutable|null { + $resolved = DateTimeImmutable::createFromFormat($format, $value); + if ($resolved === false) { + return null; + } + + if (preg_match('/(?now()->setTimezone($resolved->getTimezone()); + $parsed = date_parse_from_format($format, $value); + $resolved = $resolved->setDate( + $parsed['year'] === false ? (int) $instant->format('Y') : $parsed['year'], + $parsed['month'] === false ? (int) $instant->format('n') : $parsed['month'], + $parsed['day'] === false ? (int) $instant->format('j') : $parsed['day'], + ); + + if (preg_match('/(?setTime( + (int) $instant->format('G'), + (int) $instant->format('i'), + (int) $instant->format('s'), + ); + } + + /** @param array $parsed */ + private function timeZoneOf(array $parsed): DateTimeZone|null + { + $zoneType = $parsed['zone_type'] ?? false; + if ($zoneType === 1) { + $offset = (int) $parsed['zone']; + $minutes = intdiv($offset < 0 ? -$offset : $offset, 60); + + return new DateTimeZone(sprintf( + '%s%02d:%02d', + $offset < 0 ? '-' : '+', + intdiv($minutes, 60), + $minutes % 60, + )); + } + + if ($zoneType === 2 || $zoneType === 3) { + return new DateTimeZone((string) ($parsed['tz_id'] ?? $parsed['tz_abbr'])); + } + + return null; + } +} diff --git a/src/ValidatorBuilder.php b/src/ValidatorBuilder.php index d4af62934..d18305f70 100644 --- a/src/ValidatorBuilder.php +++ b/src/ValidatorBuilder.php @@ -12,6 +12,11 @@ namespace Respect\Validation; +use DateTimeImmutable; +use Lcobucci\Clock\FrozenClock; +use Psr\Clock\ClockInterface; +use Psr\Container\ContainerInterface; +use Respect\Config\Container; use Respect\Fluent\Attributes\AssuranceAssertion; use Respect\Fluent\Attributes\AssuranceParameter; use Respect\Fluent\Attributes\FluentNamespace; @@ -19,6 +24,8 @@ use Respect\Fluent\Factories\NamespaceLookup; use Respect\Fluent\Resolvers\ComposableMap; use Respect\Fluent\Resolvers\Ucfirst; +use Respect\Parameter\ContainerResolver; +use Respect\Parameter\Resolver; use Respect\Validation\Exceptions\ComponentException; use Respect\Validation\Exceptions\ValidationException; use Respect\Validation\Message\ArrayFormatter; @@ -61,6 +68,7 @@ public function __construct( private ArrayFormatter $messagesFormatter, private ResultFilter $resultFilter, private array $ignoredBacktracePaths, + private FrozenClock|null $clock = null, Validator ...$validators, ) { $this->validators = $validators; @@ -68,11 +76,14 @@ public function __construct( public static function init(Validator ...$validators): self { + $container = ContainerRegistry::getContainer(); + $builder = $container->get(self::class)->withClockFrom($container); + if ($validators === []) { - return ContainerRegistry::getContainer()->get(self::class); + return $builder; } - return ContainerRegistry::getContainer()->get(self::class)->with(...$validators); + return $builder->with(...$validators); } public function evaluate(mixed $input): Result @@ -83,11 +94,15 @@ public function evaluate(mixed $input): Result default => new AllOf(...$this->validators), }; + $this->freezeClock(); + return $validator->evaluate($input); } public function evaluateShortCircuit(mixed $input): Result { + $this->freezeClock(); + return (new ShortCircuit(...$this->validators))->evaluate($input); } @@ -145,6 +160,32 @@ public function getName(): Name|null return null; } + private function withClockFrom(ContainerInterface $container): self + { + if (!$container instanceof Container || !$this->validatorFactory instanceof FluentValidatorFactory) { + return $this; + } + + if ($container->get('respect.validation.clock') !== FrozenClock::class) { + return $this; + } + + $clock = FrozenClock::fromSystemTimezone(); + $chainContainer = clone $container; + $chainContainer->set(ClockInterface::class, $clock); + $chainContainer->set(Resolver::class, new ContainerResolver($chainContainer)); + + return clone ($this, [ + 'validatorFactory' => $this->validatorFactory->withResolver($chainContainer->get(Resolver::class)), + 'clock' => $clock, + ]); + } + + private function freezeClock(): void + { + $this->clock?->setTo(new DateTimeImmutable()); + } + /** @param array|string|null $template */ private function toResultQuery(Result $result, array|string|null $template): ResultQuery { diff --git a/src/ValidatorFactory.php b/src/ValidatorFactory.php index 24c25473e..607c68b67 100644 --- a/src/ValidatorFactory.php +++ b/src/ValidatorFactory.php @@ -13,6 +13,6 @@ interface ValidatorFactory { - /** @param array $arguments */ + /** @param array $arguments */ public function create(string $ruleName, array $arguments = []): Validator; } diff --git a/src/Validators/Between.php b/src/Validators/Between.php index 862dd6a86..bcd8caad8 100644 --- a/src/Validators/Between.php +++ b/src/Validators/Between.php @@ -15,6 +15,7 @@ namespace Respect\Validation\Validators; use Attribute; +use Psr\Clock\ClockInterface; use Respect\Fluent\Attributes\Composable; use Respect\Validation\Exceptions\InvalidValidatorException; use Respect\Validation\Helpers\CanCompareValues; @@ -31,16 +32,16 @@ final class Between extends Envelope { use CanCompareValues; - public function __construct(mixed $minValue, mixed $maxValue) + public function __construct(mixed $minValue, mixed $maxValue, ClockInterface|null $clock = null) { - if ($this->toComparable($minValue) >= $this->toComparable($maxValue)) { + if ($this->toComparable($minValue, $clock) >= $this->toComparable($maxValue, $clock)) { throw new InvalidValidatorException('Minimum cannot be less than or equals to maximum'); } parent::__construct( new AllOf( - new GreaterThanOrEqual($minValue), - new LessThanOrEqual($maxValue), + new GreaterThanOrEqual($minValue, $clock), + new LessThanOrEqual($maxValue, $clock), ), [ 'minValue' => $minValue, diff --git a/src/Validators/BetweenExclusive.php b/src/Validators/BetweenExclusive.php index 3fd65054c..9109724cd 100644 --- a/src/Validators/BetweenExclusive.php +++ b/src/Validators/BetweenExclusive.php @@ -12,6 +12,7 @@ namespace Respect\Validation\Validators; use Attribute; +use Psr\Clock\ClockInterface; use Respect\Fluent\Attributes\Composable; use Respect\Validation\Exceptions\InvalidValidatorException; use Respect\Validation\Helpers\CanCompareValues; @@ -28,14 +29,14 @@ final class BetweenExclusive extends Envelope { use CanCompareValues; - public function __construct(mixed $minimum, mixed $maximum) + public function __construct(mixed $minimum, mixed $maximum, ClockInterface|null $clock = null) { - if ($this->toComparable($minimum) >= $this->toComparable($maximum)) { + if ($this->toComparable($minimum, $clock) >= $this->toComparable($maximum, $clock)) { throw new InvalidValidatorException('Minimum cannot be less than or equals to maximum'); } parent::__construct( - new AllOf(new GreaterThan($minimum), new LessThan($maximum)), + new AllOf(new GreaterThan($minimum, $clock), new LessThan($maximum, $clock)), ['minValue' => $minimum, 'maxValue' => $maximum], ); } diff --git a/src/Validators/Core/Comparison.php b/src/Validators/Core/Comparison.php index 1e914c6f7..f38ae033c 100644 --- a/src/Validators/Core/Comparison.php +++ b/src/Validators/Core/Comparison.php @@ -13,6 +13,8 @@ namespace Respect\Validation\Validators\Core; +use Lcobucci\Clock\SystemClock; +use Psr\Clock\ClockInterface; use Respect\Validation\Helpers\CanCompareValues; use Respect\Validation\Result; use Respect\Validation\Validator; @@ -21,15 +23,19 @@ abstract class Comparison implements Validator { use CanCompareValues; + private readonly ClockInterface $clock; + public function __construct( private readonly mixed $compareTo, + ClockInterface|null $clock = null, ) { + $this->clock = $clock ?? SystemClock::fromSystemTimezone(); } public function evaluate(mixed $input): Result { - $left = $this->toComparable($input); - $right = $this->toComparable($this->compareTo); + $left = $this->toComparable($input, $this->clock); + $right = $this->toComparable($this->compareTo, $this->clock); $parameters = ['compareTo' => $this->compareTo]; diff --git a/src/Validators/DateTime.php b/src/Validators/DateTime.php index 8cef8308e..bc644d878 100644 --- a/src/Validators/DateTime.php +++ b/src/Validators/DateTime.php @@ -18,6 +18,8 @@ use Attribute; use DateTimeInterface; +use Lcobucci\Clock\SystemClock; +use Psr\Clock\ClockInterface; use Respect\Validation\Helpers\CanValidateDateTime; use Respect\Validation\Message\Template; use Respect\Validation\Result; @@ -44,9 +46,13 @@ final class DateTime implements Validator public const string TEMPLATE_FORMAT = '__format__'; + private readonly ClockInterface $clock; + public function __construct( private readonly string|null $format = null, + ClockInterface|null $clock = null, ) { + $this->clock = $clock ?? SystemClock::fromSystemTimezone(); } public function evaluate(mixed $input): Result @@ -62,7 +68,9 @@ public function evaluate(mixed $input): Result } if ($this->format === null) { - return Result::of(strtotime((string) $input) !== false, $input, $this, $parameters, $template); + $timestamp = strtotime((string) $input, $this->clock->now()->getTimestamp()); + + return Result::of($timestamp !== false, $input, $this, $parameters, $template); } return Result::of($this->isDateTime($this->format, (string) $input), $input, $this, $parameters, $template); diff --git a/src/Validators/DateTimeDiff.php b/src/Validators/DateTimeDiff.php index 194a117d1..3168749d6 100644 --- a/src/Validators/DateTimeDiff.php +++ b/src/Validators/DateTimeDiff.php @@ -14,12 +14,14 @@ use Attribute; use DateTimeImmutable; use DateTimeInterface; +use Lcobucci\Clock\SystemClock; +use Psr\Clock\ClockInterface; use Respect\Validation\Exceptions\InvalidValidatorException; +use Respect\Validation\Helpers\CanResolveDateTime; use Respect\Validation\Helpers\CanValidateDateTime; use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Validator; -use Throwable; use function in_array; @@ -46,18 +48,22 @@ )] final readonly class DateTimeDiff implements Validator { + use CanResolveDateTime; use CanValidateDateTime; public const string TEMPLATE_CUSTOMIZED = '__customized__'; public const string TEMPLATE_NOT_A_DATE = '__not_a_date__'; public const string TEMPLATE_WRONG_FORMAT = '__wrong_format__'; + private ClockInterface $clock; + /** @param "years"|"months"|"days"|"hours"|"minutes"|"seconds"|"microseconds" $type */ public function __construct( private string $type, private Validator $validator, private string|null $format = null, private DateTimeImmutable|null $now = null, + ClockInterface|null $clock = null, ) { $availableTypes = ['years', 'months', 'days', 'hours', 'minutes', 'seconds', 'microseconds']; if (!in_array($this->type, $availableTypes, true)) { @@ -67,11 +73,13 @@ public function __construct( $availableTypes, ); } + + $this->clock = $clock ?? SystemClock::fromSystemTimezone(); } public function evaluate(mixed $input): Result { - $now = $this->now ?? new DateTimeImmutable(); + $now = $this->now ?? $this->clock->now(); $compareTo = $this->createDateTimeObject($input); if ($compareTo === null) { $template = $this->format === null ? self::TEMPLATE_NOT_A_DATE : self::TEMPLATE_WRONG_FORMAT; @@ -130,19 +138,11 @@ private function createDateTimeObject(mixed $input): DateTimeInterface|null } if ($this->format === null) { - try { - return new DateTimeImmutable((string) $input); - } catch (Throwable) { - return null; - } + return $this->resolveDateTime($input, $this->clock); } $format = $this->getExceptionalFormats()[$this->format] ?? $this->format; - $dateTime = DateTimeImmutable::createFromFormat($format, (string) $input); - if ($dateTime === false) { - return null; - } - return $dateTime; + return $this->resolveDateTimeFromFormat($format, (string) $input, $this->clock); } } diff --git a/src/Validators/LeapDate.php b/src/Validators/LeapDate.php index 08c608826..acc962887 100644 --- a/src/Validators/LeapDate.php +++ b/src/Validators/LeapDate.php @@ -16,8 +16,10 @@ namespace Respect\Validation\Validators; use Attribute; -use DateTimeImmutable; use DateTimeInterface; +use Lcobucci\Clock\SystemClock; +use Psr\Clock\ClockInterface; +use Respect\Validation\Helpers\CanResolveDateTime; use Respect\Validation\Message\Template; use Respect\Validation\Validators\Core\Simple; @@ -30,9 +32,15 @@ )] final class LeapDate extends Simple { + use CanResolveDateTime; + + private readonly ClockInterface $clock; + public function __construct( private readonly string $format, + ClockInterface|null $clock = null, ) { + $this->clock = $clock ?? SystemClock::fromSystemTimezone(); } public function isValid(mixed $input): bool @@ -42,7 +50,7 @@ public function isValid(mixed $input): bool } if (is_scalar($input)) { - return $this->isValid(DateTimeImmutable::createFromFormat($this->format, (string) $input)); + return $this->isValid($this->resolveDateTimeFromFormat($this->format, (string) $input, $this->clock)); } return false; diff --git a/src/Validators/LeapYear.php b/src/Validators/LeapYear.php index b0fafefaf..af5262b91 100644 --- a/src/Validators/LeapYear.php +++ b/src/Validators/LeapYear.php @@ -17,6 +17,8 @@ use Attribute; use DateTimeInterface; +use Lcobucci\Clock\SystemClock; +use Psr\Clock\ClockInterface; use Respect\Validation\Message\Template; use Respect\Validation\Validators\Core\Simple; @@ -33,6 +35,13 @@ )] final class LeapYear extends Simple { + private readonly ClockInterface $clock; + + public function __construct(ClockInterface|null $clock = null) + { + $this->clock = $clock ?? SystemClock::fromSystemTimezone(); + } + public function isValid(mixed $input): bool { if (is_numeric($input)) { @@ -42,7 +51,9 @@ public function isValid(mixed $input): bool } if (is_scalar($input)) { - return $this->isValid((int) date('Y', (int) strtotime((string) $input))); + $timestamp = strtotime((string) $input, $this->clock->now()->getTimestamp()); + + return $this->isValid((int) date('Y', (int) $timestamp)); } if ($input instanceof DateTimeInterface) { diff --git a/src/Validators/Time.php b/src/Validators/Time.php index 297c4439a..b203af35c 100644 --- a/src/Validators/Time.php +++ b/src/Validators/Time.php @@ -17,6 +17,8 @@ namespace Respect\Validation\Validators; use Attribute; +use Lcobucci\Clock\SystemClock; +use Psr\Clock\ClockInterface; use Respect\Validation\Exceptions\InvalidValidatorException; use Respect\Validation\Helpers\CanValidateDateTime; use Respect\Validation\Message\Template; @@ -37,17 +39,23 @@ { use CanValidateDateTime; + private readonly ClockInterface $clock; + public function __construct( private string $format = 'H:i:s', + ClockInterface|null $clock = null, ) { if (!preg_match('/^[gGhHisuvaA\W]+$/', $format)) { throw new InvalidValidatorException('"%s" is not a valid date format', $format); } + + $this->clock = $clock ?? SystemClock::fromSystemTimezone(); } public function evaluate(mixed $input): Result { - $parameters = ['sample' => date($this->format, strtotime('23:59:59'))]; + $sample = strtotime('23:59:59', $this->clock->now()->getTimestamp()); + $parameters = ['sample' => date($this->format, (int) $sample)]; if (!is_scalar($input)) { return Result::failed($input, $this, $parameters); } diff --git a/tests/feature/ClockTest.php b/tests/feature/ClockTest.php new file mode 100644 index 000000000..05591528e --- /dev/null +++ b/tests/feature/ClockTest.php @@ -0,0 +1,54 @@ + + */ + +declare(strict_types=1); + +use Lcobucci\Clock\FrozenClock; +use Respect\Validation\ContainerRegistry; +use Respect\Validation\Test\Stubs\WithRelativeDate; + +beforeAll(fn() => ContainerRegistry::setContainer(ContainerRegistry::createContainer([ + 'respect.validation.clock' => FrozenClock::class, +]))); + +afterAll(fn() => ContainerRegistry::setContainer(ContainerRegistry::createContainer())); + +test('A relative input lands exactly on the boundary it names', function (): void { + $outcomes = []; + for ($i = 0; $i < 1000; $i++) { + $outcomes[v::dateTimeDiff('years', v::equals(7))->isValid('7 years ago')] = true; + } + + expect($outcomes)->toBe([true => true]); +}); + +test('Every validator of a chain compares against the same moment', function (): void { + $validator = v::dateTimeDiff('microseconds', v::equals(0.0)) + ->dateTimeDiff('seconds', v::equals(0)) + ->dateTimeDiff('years', v::equals(0)); + + expect($validator->isValid('now'))->toBeTrue(); +}); + +test('A relative bound is measured from the same moment as the value', function (): void { + $validator = v::lessThanOrEqual('18 years ago'); + + expect($validator->isValid('18 years ago'))->toBeTrue(); +}); + +test('Validators declared as attributes share the moment as well', function (): void { + expect(v::attributes()->isValid(new WithRelativeDate('7 years ago')))->toBeTrue(); +}); + +test('The moment is taken again on every run', function (): void { + $validator = v::dateTimeDiff('microseconds', v::equals(0.0)); + + expect($validator->isValid('now'))->toBeTrue(); + usleep(1000); + expect($validator->isValid('now'))->toBeTrue(); +}); diff --git a/tests/src/Stubs/ForeignContainer.php b/tests/src/Stubs/ForeignContainer.php new file mode 100644 index 000000000..6cc56da9e --- /dev/null +++ b/tests/src/Stubs/ForeignContainer.php @@ -0,0 +1,30 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Test\Stubs; + +use Psr\Container\ContainerInterface; + +final readonly class ForeignContainer implements ContainerInterface +{ + public function __construct(private ContainerInterface $container) + { + } + + public function get(string $id): mixed + { + return $this->container->get($id); + } + + public function has(string $id): bool + { + return $this->container->has($id); + } +} diff --git a/tests/src/Stubs/WithRelativeDate.php b/tests/src/Stubs/WithRelativeDate.php new file mode 100644 index 000000000..9781bfacb --- /dev/null +++ b/tests/src/Stubs/WithRelativeDate.php @@ -0,0 +1,22 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Test\Stubs; + +use Respect\Validation\Validators as Rule; + +final class WithRelativeDate +{ + public function __construct( + #[Rule\DateTimeDiff('years', new Rule\Equals(7))] + public string $since, + ) { + } +} diff --git a/tests/src/Validators/ClockProbe.php b/tests/src/Validators/ClockProbe.php new file mode 100644 index 000000000..7d176f40b --- /dev/null +++ b/tests/src/Validators/ClockProbe.php @@ -0,0 +1,42 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Test\Validators; + +use DateTimeImmutable; +use Lcobucci\Clock\SystemClock; +use Psr\Clock\ClockInterface; +use Respect\Validation\Message\Template; +use Respect\Validation\Result; +use Respect\Validation\Validator; + +#[Template( + '{{subject}} must be a clock probe', + '{{subject}} must not be a clock probe', +)] +final class ClockProbe implements Validator +{ + public ClockInterface $clock; + + /** @var array */ + public array $instants = []; + + public function __construct(ClockInterface|null $clock = null) + { + $this->clock = $clock ?? SystemClock::fromSystemTimezone(); + } + + public function evaluate(mixed $input): Result + { + $this->instants[] = $this->clock->now(); + + return Result::passed($input, $this); + } +} diff --git a/tests/unit/ContainerRegistryTest.php b/tests/unit/ContainerRegistryTest.php index f0d8c6674..c0d5c8e90 100644 --- a/tests/unit/ContainerRegistryTest.php +++ b/tests/unit/ContainerRegistryTest.php @@ -11,6 +11,7 @@ namespace Respect\Validation; +use Lcobucci\Clock\SystemClock; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; @@ -36,6 +37,14 @@ public function itShouldBeAbleToGiveDefinitionsToTheContainer(): void self::assertSame('bar', $container->get('foo')); } + #[Test] + public function itShouldProvideTheSystemClockByDefault(): void + { + $container = ContainerRegistry::createContainer(); + + self::assertSame(SystemClock::class, $container->get('respect.validation.clock')); + } + #[Test] public function itAlwaysReturnsTheSameInstanceOfTheContainer(): void { diff --git a/tests/unit/FluentValidatorFactoryTest.php b/tests/unit/FluentValidatorFactoryTest.php index e92d1b5c9..4c03947ea 100644 --- a/tests/unit/FluentValidatorFactoryTest.php +++ b/tests/unit/FluentValidatorFactoryTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\Attributes\Test; use Respect\Fluent\Factories\NamespaceLookup; use Respect\Fluent\Resolvers\Ucfirst; +use Respect\Parameter\ContainerResolver; use Respect\Validation\Exceptions\ComponentException; use Respect\Validation\Exceptions\InvalidClassException; use Respect\Validation\Test\TestCase; @@ -37,6 +38,16 @@ public function itShouldCreateValidatorByName(): void self::assertInstanceOf(Valid::class, $factory->create('valid')); } + #[Test] + public function itShouldKeepItselfWhenTheFactoryItWrapsTakesNoResolver(): void + { + $factory = new FluentValidatorFactory( + new NamespaceLookup(new Ucfirst(), Validator::class, self::TEST_NAMESPACE), + ); + + self::assertSame($factory, $factory->withResolver(new ContainerResolver(ContainerRegistry::createContainer()))); + } + #[Test] public function itShouldPassArgumentsToConstructor(): void { diff --git a/tests/unit/Helpers/CanResolveDateTimeTest.php b/tests/unit/Helpers/CanResolveDateTimeTest.php new file mode 100644 index 000000000..aa357b9d3 --- /dev/null +++ b/tests/unit/Helpers/CanResolveDateTimeTest.php @@ -0,0 +1,176 @@ + + */ + +declare(strict_types=1); + +namespace Respect\Validation\Helpers; + +use DateTimeImmutable; +use Lcobucci\Clock\FrozenClock; +use Lcobucci\Clock\SystemClock; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; +use Respect\Validation\Test\TestCase; + +use function abs; +use function date_default_timezone_get; +use function date_default_timezone_set; + +#[Group('helper')] +final class CanResolveDateTimeTest extends TestCase +{ + use CanResolveDateTime; + + private const string INSTANT = '2024-01-31 22:45:12.654321'; + + #[Test] + #[DataProvider('providerForValueAndResolvedDateTime')] + public function shouldResolveValueAgainstTheGivenInstant(string $value, string $expected): void + { + $resolved = $this->resolveDateTime($value, self::clockAt(self::INSTANT)); + + self::assertNotNull($resolved); + self::assertSame($expected, $resolved->format('Y-m-d H:i:s.u P')); + } + + #[Test] + #[DataProvider('providerForUnresolvableValue')] + public function shouldNotResolveValueThatIsNotDateTime(mixed $value): void + { + self::assertNull($this->resolveDateTime($value, self::clockAt(self::INSTANT))); + } + + #[Test] + #[DataProvider('providerForValueParsableByPhp')] + public function shouldResolveTheSameWayPhpDoesWhenGivenTheCurrentTime(string $value): void + { + $default = date_default_timezone_get(); + + try { + foreach (['UTC', 'Pacific/Kiritimati', 'Pacific/Midway', 'Asia/Tokyo', 'America/Sao_Paulo'] as $timezone) { + date_default_timezone_set($timezone); + + $resolved = $this->resolveDateTime($value, SystemClock::fromSystemTimezone()); + $parsedByPhp = new DateTimeImmutable($value); + + self::assertNotNull($resolved); + self::assertSame( + $parsedByPhp->format('P'), + $resolved->format('P'), + 'Resolving "' . $value . '" in ' . $timezone . ' landed in another time zone than PHP does', + ); + self::assertLessThan( + 1.0, + abs((float) $parsedByPhp->format('U.u') - (float) $resolved->format('U.u')), + 'Resolving "' . $value . '" in ' . $timezone . ' landed a second or more away from what PHP does', + ); + } + } finally { + date_default_timezone_set($default); + } + } + + #[Test] + #[DataProvider('providerForFormatAndResolvedDateTime')] + public function shouldResolveFormattedValueTakingMissingFieldsFromTheGivenInstant( + string $format, + string $value, + string $expected, + ): void { + $resolved = $this->resolveDateTimeFromFormat($format, $value, self::clockAt(self::INSTANT)); + + self::assertNotNull($resolved); + self::assertSame($expected, $resolved->format('Y-m-d H:i:s.u')); + } + + #[Test] + public function shouldNotResolveFormattedValueThatDoesNotMatchTheFormat(): void + { + $resolved = $this->resolveDateTimeFromFormat('Y-m-d', 'not a date', self::clockAt(self::INSTANT)); + + self::assertNull($resolved); + } + + /** @return array */ + public static function providerForValueAndResolvedDateTime(): array + { + return [ + 'relative' => ['1 year ago', '2023-01-31 22:45:12.654321 +00:00'], + 'relative, on the boundary' => ['7 years ago', '2017-01-31 22:45:12.654321 +00:00'], + 'relative, off the boundary' => ['1 year ago + 1 minute', '2023-01-31 22:46:12.654321 +00:00'], + 'relative, sub-second' => ['-500 microseconds', '2024-01-31 22:45:12.653821 +00:00'], + 'relative, several units' => ['+1 week 2 days', '2024-02-09 22:45:12.654321 +00:00'], + 'now' => ['now', '2024-01-31 22:45:12.654321 +00:00'], + 'today' => ['today', '2024-01-31 00:00:00.000000 +00:00'], + 'yesterday' => ['yesterday', '2024-01-30 00:00:00.000000 +00:00'], + 'weekday' => ['next monday', '2024-02-05 00:00:00.000000 +00:00'], + 'month boundary' => ['first day of next month', '2024-02-01 22:45:12.654321 +00:00'], + 'time only' => ['10:00', '2024-01-31 10:00:00.000000 +00:00'], + 'time only, sub-second' => ['10:00:00.5', '2024-01-31 10:00:00.500000 +00:00'], + 'relative, with offset' => ['1 day ago +02:00', '2024-01-30 22:45:12.654321 +02:00'], + 'relative, with half-hour offset' => ['+90 minutes -03:30', '2024-02-01 00:15:12.654321 -03:30'], + 'relative, with abbreviation' => ['yesterday EST', '2024-01-30 00:00:00.000000 -05:00'], + 'relative, with timezone' => ['1 day ago UTC', '2024-01-30 22:45:12.654321 +00:00'], + 'time only, with offset' => ['10:00+02:00', '2024-01-31 10:00:00.000000 +02:00'], + 'time only, with timezone' => ['10:00 Europe/Lisbon', '2024-01-31 10:00:00.000000 +00:00'], + 'time only, with abbreviation' => ['10:00 EST', '2024-01-31 10:00:00.000000 -05:00'], + 'time without separator' => ['2024', '2024-01-31 20:24:00.000000 +00:00'], + 'month only' => ['may', '2024-05-31 00:00:00.000000 +00:00'], + 'empty' => ['', '2024-01-31 22:45:12.654321 +00:00'], + 'full date' => ['2020-01-01', '2020-01-01 00:00:00.000000 +00:00'], + 'full date and time' => ['1988-09-09 10:00:00.123456', '1988-09-09 10:00:00.123456 +00:00'], + 'full date, with offset' => ['2020-01-01T10:00:00+02:00', '2020-01-01 10:00:00.000000 +02:00'], + 'month and year' => ['December 2020', '2020-12-01 00:00:00.000000 +00:00'], + 'timestamp' => ['@1600000000', '2020-09-13 12:26:40.000000 +00:00'], + ]; + } + + /** @return array */ + public static function providerForUnresolvableValue(): array + { + return [ + 'not a date' => ['invalid date'], + 'unsupported wording' => ['5 days later'], + 'impossible date' => ['02-29'], + 'array' => [['2020-01-01']], + 'object' => [new DateTimeImmutable()], + 'null' => [null], + ]; + } + + /** @return array */ + public static function providerForValueParsableByPhp(): array + { + $values = []; + foreach (self::providerForValueAndResolvedDateTime() as $name => [$value]) { + $values[$name] = [$value]; + } + + return $values; + } + + /** @return array */ + public static function providerForFormatAndResolvedDateTime(): array + { + return [ + 'date only, time from the instant' => ['d/m/Y', '09/12/1990', '1990-12-09 22:45:12.000000'], + 'year only, rest from the instant' => ['Y', '1990', '1990-01-31 22:45:12.000000'], + 'time only, date from the instant' => ['H:i', '10:00', '2024-01-31 10:00:00.000000'], + 'full date and time' => ['Y-m-d H:i:s', '1990-12-09 10:11:12', '1990-12-09 10:11:12.000000'], + 'sub-second in the value' => ['Y-m-d H:i:s.u', '1990-12-09 10:11:12.123456', '1990-12-09 10:11:12.123456'], + 'format resetting every field' => ['!d/m/Y', '09/12/1990', '1990-12-09 00:00:00.000000'], + 'timestamp format' => ['U', '1600000000', '2020-09-13 12:26:40.000000'], + ]; + } + + private static function clockAt(string $instant): FrozenClock + { + return new FrozenClock(new DateTimeImmutable($instant)); + } +} diff --git a/tests/unit/ValidatorBuilderTest.php b/tests/unit/ValidatorBuilderTest.php index 3f152a573..13c174595 100644 --- a/tests/unit/ValidatorBuilderTest.php +++ b/tests/unit/ValidatorBuilderTest.php @@ -14,14 +14,21 @@ namespace Respect\Validation; use InvalidArgumentException; +use Lcobucci\Clock\FrozenClock; +use Lcobucci\Clock\SystemClock; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; use PHPUnit\Framework\Attributes\Test; +use Psr\Clock\ClockInterface; use Respect\Validation\Exceptions\ComponentException; use Respect\Validation\Exceptions\ValidationException; +use Respect\Validation\Test\Stubs\ForeignContainer; use Respect\Validation\Test\TestCase; +use Respect\Validation\Test\Validators\ClockProbe; use Respect\Validation\Test\Validators\Stub; +use function array_filter; +use function array_values; use function sprintf; use function uniqid; @@ -187,4 +194,115 @@ public function itShouldThrowCustomExceptionWhenCallableUsedInAssertMethod(): vo sprintf('Got: %s', $e->getMessage()), )); } + + #[Test] + public function itShouldGiveEveryValidatorOfTheChainTheSameClock(): void + { + $this->useContainerWithClock(FrozenClock::class); + + [$first, $second] = $this->probesOf($this->chainWithTwoProbes()); + + self::assertSame($first->clock, $second->clock); + } + + #[Test] + public function itShouldGiveEachChainItsOwnClock(): void + { + $this->useContainerWithClock(FrozenClock::class); + + [$first] = $this->probesOf($this->chainWithTwoProbes()); + [$second] = $this->probesOf($this->chainWithTwoProbes()); + + self::assertNotSame($first->clock, $second->clock); + } + + #[Test] + public function itShouldLeaveClocksThatCannotBeHeldStillWhereTheyAre(): void + { + $this->useContainerWithClock(); + + [$first, $second] = $this->probesOf($this->chainWithTwoProbes()); + + self::assertNotSame($first->clock, $second->clock); + } + + #[Test] + public function itShouldLeaveTheClockAloneWhenTheContainerIsNotTheOneItKnows(): void + { + ContainerRegistry::setContainer(new ForeignContainer(ContainerRegistry::createContainer([ + 'respect.validation.clock' => FrozenClock::class, + 'respect.validation.rule_factory.namespaces' => [ + 'Respect\\Validation\\Test\\Validators', + 'Respect\\Validation\\Validators', + ], + ]))); + + [$first, $second] = $this->probesOf($this->chainWithTwoProbes()); + + self::assertNotSame($first->clock, $second->clock); + } + + #[Test] + public function itShouldFreezeTheClockWhileTheChainRuns(): void + { + $this->useContainerWithClock(FrozenClock::class); + + $validator = $this->chainWithTwoProbes(); + $validator->isValid('whatever'); + + [$first, $second] = $this->probesOf($validator); + + self::assertEquals($first->instants[0], $second->instants[0]); + } + + #[Test] + public function itShouldRenewTheFrozenInstantOnEachRun(): void + { + $this->useContainerWithClock(FrozenClock::class); + + $validator = $this->chainWithTwoProbes(); + $validator->isValid('whatever'); + $validator->isValid('whatever'); + + [$first] = $this->probesOf($validator); + + self::assertNotEquals($first->instants[0], $first->instants[1]); + } + + protected function tearDown(): void + { + ContainerRegistry::setContainer(ContainerRegistry::createContainer()); + } + + /** @param class-string $clock */ + private function useContainerWithClock(string $clock = SystemClock::class): void + { + $this->useContainer(['respect.validation.clock' => $clock]); + } + + /** @param array $definitions */ + private function useContainer(array $definitions): void + { + ContainerRegistry::setContainer(ContainerRegistry::createContainer($definitions + [ + 'respect.validation.rule_factory.namespaces' => [ + 'Respect\\Validation\\Test\\Validators', + 'Respect\\Validation\\Validators', + ], + ])); + } + + private function chainWithTwoProbes(): ValidatorBuilder + { + // @phpstan-ignore-next-line + return ValidatorBuilder::clockProbe()->clockProbe(); + } + + /** @return array */ + private function probesOf(ValidatorBuilder $validator): array + { + return array_values(array_filter( + $validator->getValidators(), + static fn(Validator $rule): bool => $rule instanceof ClockProbe, + )); + } } diff --git a/tests/unit/Validators/DateTimeDiffTest.php b/tests/unit/Validators/DateTimeDiffTest.php index 5ee71bc29..fd69826ad 100644 --- a/tests/unit/Validators/DateTimeDiffTest.php +++ b/tests/unit/Validators/DateTimeDiffTest.php @@ -12,6 +12,7 @@ namespace Respect\Validation\Validators; use DateTimeImmutable; +use Lcobucci\Clock\FrozenClock; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; @@ -36,6 +37,65 @@ public function isShouldThrowAnExceptionWhenTypeIsNotValid(): void new DateTimeDiff('invalid', Stub::daze()); } + #[Test] + public function itShouldCompareAgainstTheTimeGivenByTheClock(): void + { + $validator = Stub::pass(1); + + (new DateTimeDiff('years', $validator, null, null, self::clockAt('2024-01-01 12:00:00'))) + ->evaluate('2000-01-01'); + + self::assertSame(24, $validator->inputs[0]); + } + + #[Test] + public function itShouldCompareRelativeInputAgainstTheSameInstantItIsResolvedWith(): void + { + $validator = Stub::pass(1); + + (new DateTimeDiff('years', $validator, null, null, self::clockAt('2024-01-01 12:00:00'))) + ->evaluate('7 years ago'); + + self::assertSame(7, $validator->inputs[0]); + } + + #[Test] + public function itShouldTakeTheTimeTheFormatDoesNotAskForFromTheClock(): void + { + $validator = Stub::pass(1); + + (new DateTimeDiff('hours', $validator, 'Y-m-d', null, self::clockAt('2024-01-01 12:00:00'))) + ->evaluate('2024-01-01'); + + self::assertSame(0, $validator->inputs[0]); + } + + #[Test] + public function itShouldPreferTheGivenNowOverTheClock(): void + { + $validator = Stub::pass(1); + + (new DateTimeDiff( + 'years', + $validator, + null, + new DateTimeImmutable('2024-01-01 12:00:00'), + self::clockAt('1999-01-01 12:00:00'), + ))->evaluate('2000-01-01'); + + self::assertSame(24, $validator->inputs[0]); + } + + #[Test] + public function itShouldKeepReportingTimeAsNowWhenOnlyTheClockIsGiven(): void + { + $result = (new DateTimeDiff('years', Stub::fail(1), null, null, self::clockAt('2024-01-01 12:00:00'))) + ->evaluate('2000-01-01'); + + self::assertSame('now', $result->parameters['now']); + self::assertSame(DateTimeDiff::TEMPLATE_STANDARD, $result->template); + } + /** @return array */ public static function providerForValidInput(): array { @@ -85,4 +145,9 @@ public static function providerForInvalidInput(): array iterator_to_array(self::providerForNonScalarValues()), ); } + + private static function clockAt(string $instant): FrozenClock + { + return new FrozenClock(new DateTimeImmutable($instant)); + } } diff --git a/tests/unit/Validators/LeapDateTest.php b/tests/unit/Validators/LeapDateTest.php index 617cc5e77..7728f685f 100644 --- a/tests/unit/Validators/LeapDateTest.php +++ b/tests/unit/Validators/LeapDateTest.php @@ -14,14 +14,27 @@ namespace Respect\Validation\Validators; use DateTime; +use DateTimeImmutable; +use Lcobucci\Clock\FrozenClock; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\Test; use Respect\Validation\Test\RuleTestCase; #[Group('validator')] #[CoversClass(LeapDate::class)] final class LeapDateTest extends RuleTestCase { + #[Test] + public function itShouldTakeTheYearTheFormatDoesNotAskForFromTheClock(): void + { + $inLeapYear = new LeapDate('m-d', new FrozenClock(new DateTimeImmutable('2024-06-01'))); + $inCommonYear = new LeapDate('m-d', new FrozenClock(new DateTimeImmutable('2023-06-01'))); + + self::assertValidInput($inLeapYear, '02-29'); + self::assertInvalidInput($inCommonYear, '02-29'); + } + /** @return iterable */ public static function providerForValidInput(): iterable {