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 {