diff --git a/src/cache/src/Redis/Support/Serialization.php b/src/cache/src/Redis/Support/Serialization.php index 8ee2aefc9..d45b097d8 100644 --- a/src/cache/src/Redis/Support/Serialization.php +++ b/src/cache/src/Redis/Support/Serialization.php @@ -59,7 +59,7 @@ public function serialize(RedisConnection $connection, mixed $value): mixed * serialize Lua ARGV parameters. * * This method handles three scenarios: - * 1. Serializer configured (igbinary/json/php): Use pack() which calls _serialize() + * 1. Serializer configured (igbinary/json/php): Pack with native serialization and compression * 2. No serializer, but compression enabled: PHP serialize, then compress * 3. No serializer, no compression: Just PHP serialize * @@ -69,24 +69,17 @@ public function serialize(RedisConnection $connection, mixed $value): mixed */ public function serializeForLua(RedisConnection $connection, mixed $value): string { - // Case 1: Serializer configured (e.g. igbinary/json) - // pack() calls _serialize() which handles serialization and compression if ($connection->serialized()) { return $connection->pack([$value])[0]; } - // No serializer - must PHP-serialize first $serialized = $this->phpSerialize($value); - // Case 2: Check if compression is enabled (even without serializer) if ($connection->getOption(Redis::OPT_COMPRESSION) !== Redis::COMPRESSION_NONE) { - // _serialize() applies compression even with SERIALIZER_NONE - // Cast to string in case serialize() returned a numeric value - return $connection->_serialize(is_numeric($serialized) ? (string) $serialized : $serialized); + // Lua arguments need native packing for compression. Preserve numeric types for pack_ignore_numbers. + return $connection->pack([$serialized])[0]; } - // Case 3: No serializer, no compression - // Cast to string in case serialize() returned a numeric value return is_numeric($serialized) ? (string) $serialized : $serialized; } diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php index acc97302b..d6418fcea 100644 --- a/src/collections/src/LazyCollection.php +++ b/src/collections/src/LazyCollection.php @@ -1116,6 +1116,8 @@ public function shuffle(): static /** * Create chunks representing a "sliding window" view of the items in the collection. * + * @param positive-int $size + * @param positive-int $step * @return static * * @throws InvalidArgumentException @@ -1137,7 +1139,7 @@ public function sliding(int $size = 2, int $step = 1): static while ($iterator->valid()) { $chunk[$iterator->key()] = $iterator->current(); - if (count($chunk) == $size) { + if (count($chunk) === $size) { yield $this->newInstance($chunk)->tap(function () use (&$chunk, $step) { $chunk = array_slice($chunk, $step, null, true); }); diff --git a/src/data/src/Support/Validation/RuleDenormalizer.php b/src/data/src/Support/Validation/RuleDenormalizer.php index 39434d6bc..f2dd0a2e1 100644 --- a/src/data/src/Support/Validation/RuleDenormalizer.php +++ b/src/data/src/Support/Validation/RuleDenormalizer.php @@ -14,6 +14,7 @@ use Hypervel\Data\Attributes\Validation\StringValidationAttribute; use Hypervel\Data\Support\Validation\References\ExternalReference; use Hypervel\Data\Support\Validation\References\FieldReference; +use Hypervel\Support\Arr; class RuleDenormalizer { @@ -73,6 +74,7 @@ protected function normalizeStringValidationAttribute( ValidationPath $path, ): array { $parameters = []; + $quoteParameters = ! in_array($rule->keyword(), ['regex', 'not_regex'], true); foreach ($rule->parameters() as $key => $value) { $parameter = $this->normalizeRuleParameter($value, $path); @@ -81,7 +83,16 @@ protected function normalizeStringValidationAttribute( continue; } - $parameters[] = is_string($key) ? "{$key}={$parameter}" : $parameter; + foreach (Arr::wrap($parameter) as $index => $field) { + if (is_string($key) && $index === 0) { + $field = "{$key}={$field}"; + } + + // Quote after adding the name so the entire parameter remains one CSV field. + $parameters[] = $quoteParameters && strpbrk($field, ',"') !== false + ? '"' . str_replace('"', '""', $field) . '"' + : $field; + } } if ($parameters === []) { @@ -92,12 +103,14 @@ protected function normalizeStringValidationAttribute( } /** - * Convert one rule parameter into Validator string form. + * Normalize one rule parameter while preserving its field boundaries. + * + * @return null|list|string */ protected function normalizeRuleParameter( mixed $parameter, ValidationPath $path, - ): ?string { + ): array|string|null { if ($parameter === null) { return null; } @@ -117,11 +130,11 @@ protected function normalizeRuleParameter( if (is_array($parameter)) { // ValidatesAttributes::convertValuesToNull() decodes list values from this literal token. $subParameters = array_map( - fn (mixed $subParameter): string => $this->normalizeRuleParameter($subParameter, $path) ?? 'null', + fn (mixed $subParameter): array|string => $this->normalizeRuleParameter($subParameter, $path) ?? 'null', $parameter ); - return implode(',', $subParameters); + return Arr::flatten($subParameters); } if ($parameter instanceof DateTimeInterface) { diff --git a/src/docs/blade.md b/src/docs/blade.md index 271f99cc1..6bbc3b6e1 100644 --- a/src/docs/blade.md +++ b/src/docs/blade.md @@ -1895,7 +1895,7 @@ If you would like to prepend content onto the beginning of a stack, you should u @endprepend ``` -The `@hasStack` directive may be used to determine if a stack is empty: +The `@hasStack` directive may be used to render markup when a stack has content: ```blade @hasStack('list') diff --git a/src/docs/cache.md b/src/docs/cache.md index 51ead9fac..09c4a89c3 100644 --- a/src/docs/cache.md +++ b/src/docs/cache.md @@ -384,7 +384,7 @@ Cache::decrement('key'); Cache::decrement('key', $amount); ``` -When using a Redis cache store with PhpRedis serialization, atomic counters require either `Redis::SERIALIZER_NONE` or the PhpRedis 6.2+ `pack_ignore_numbers` option. See the [PhpRedis serialization documentation](/docs/{{version}}/redis#phpredis-serialization) for configuration details. +When using a Redis cache store, atomic counters require disabling both PhpRedis serialization and compression or enabling the PhpRedis 6.2+ `pack_ignore_numbers` option. See the [PhpRedis serialization documentation](/docs/{{version}}/redis#phpredis-serialization) for configuration details. #### Retrieve and Store diff --git a/src/docs/eloquent-resources.md b/src/docs/eloquent-resources.md index 3fb9f6dc8..fa13b3eda 100644 --- a/src/docs/eloquent-resources.md +++ b/src/docs/eloquent-resources.md @@ -943,9 +943,10 @@ The generated class will extend `Hypervel\Http\Resources\JsonApi\JsonApiResource ```php UserResource::class, 'comments', ]; @@ -1099,6 +1102,8 @@ public $relationships = [ Alternatively, you may override the `toRelationships` method on the resource: ```php +use Hypervel\Http\Request; + /** * Get the resource's relationships. */ @@ -1199,6 +1204,8 @@ By default, the resource's `type` is derived from the resource class name. For e If you need to customize these values, you may override the `toType` and `toId` methods on your resource: ```php +use Hypervel\Http\Request; + /** * Get the resource's type. */ @@ -1256,6 +1263,8 @@ return $post->load('author', 'comments') You may add links and meta information to your JSON:API resource objects by overriding the `toLinks` and `toMeta` methods on the resource: ```php +use Hypervel\Http\Request; + /** * Get the resource's links. */ diff --git a/src/docs/helpers.md b/src/docs/helpers.md index eff07769b..5b3e0fc2c 100644 --- a/src/docs/helpers.md +++ b/src/docs/helpers.md @@ -3461,6 +3461,12 @@ return now()->minus(hours: 8); return now()->minus(weeks: 4); ``` +When adding or subtracting months or years, you may pass `overflow: false` to keep the resulting date within the target month: + +```php +CarbonImmutable::parse('2026-01-31')->plus(months: 1, overflow: false); // 2026-02-28 +``` + Since the default date is immutable, assign the result of a modifier when you want to retain the changed value: ```php @@ -3482,7 +3488,7 @@ For a thorough discussion of Carbon and its features, please consult the [offici #### Interval Functions -Hypervel also offers `milliseconds`, `seconds`, `minutes`, `hours`, `days`, `weeks`, `months`, and `years` functions that return `CarbonInterval` instances, which extend PHP's [DateInterval](https://www.php.net/manual/en/class.dateinterval.php) class. These functions may be used anywhere that Hypervel accepts a `DateInterval` instance: +Hypervel also offers `microseconds`, `milliseconds`, `seconds`, `minutes`, `hours`, `days`, `weeks`, `months`, and `years` functions that return `CarbonInterval` instances, which extend PHP's [DateInterval](https://www.php.net/manual/en/class.dateinterval.php) class. These functions may be used anywhere that Hypervel accepts a `DateInterval` instance: ```php use Hypervel\Support\Facades\Cache; @@ -3492,6 +3498,8 @@ use function Hypervel\Support\{minutes}; Cache::put('metrics', $metrics, minutes(10)); ``` +The functions from `microseconds` through `days` also accept fractional amounts, such as `seconds(1.5)`. + ### Deferred Functions diff --git a/src/docs/http-client.md b/src/docs/http-client.md index 5107e0700..642a68940 100644 --- a/src/docs/http-client.md +++ b/src/docs/http-client.md @@ -485,17 +485,17 @@ return Http::post(/* ... */)->throw(function (Response $response, RequestExcepti })->json(); ``` -By default, `RequestException` messages are truncated to 120 characters when logged or reported. To customize or disable this behavior, you may utilize the `truncateRequestExceptionsAt` and `dontTruncateRequestExceptions` methods when configuring your application's exception handling behavior in your `bootstrap/app.php` file: +By default, `RequestException` messages are truncated to 120 characters when logged or reported. To customize or disable this behavior, you may utilize the `truncateAt` and `dontTruncate` methods when configuring your application's registered behavior in your `bootstrap/app.php` file: ```php -use Hypervel\Foundation\Configuration\Exceptions; +use Hypervel\Http\Client\RequestException; -->withExceptions(function (Exceptions $exceptions): void { +->registered(function (): void { // Truncate request exception messages to 240 characters... - $exceptions->truncateRequestExceptionsAt(240); + RequestException::truncateAt(240); // Disable request exception message truncation... - $exceptions->dontTruncateRequestExceptions(); + RequestException::dontTruncate(); }) ``` diff --git a/src/docs/migrations.md b/src/docs/migrations.md index 069da386b..9bcd9dbba 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -76,6 +76,16 @@ php artisan schema:dump --database=testing --prune You should commit your database schema file to source control so that other new developers on your team may quickly create your application's initial database structure. +To prevent schema dumps in production, call `DumpCommand::prohibit` from the `boot` method of your application's `AppServiceProvider`: + +```php +use Hypervel\Database\Console\DumpCommand; + +DumpCommand::prohibit($this->app->isProduction()); +``` + +When prohibited, `schema:dump` exits without dumping the schema or pruning migrations. + > [!WARNING] > Migration squashing is only available for the MariaDB, MySQL, PostgreSQL, and SQLite databases and utilizes the database's command-line client. diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 01a894f96..a86cbf65d 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -25,6 +25,7 @@ - [HTTP Client and Concurrency](#http-client-and-concurrency) - [Scout](#scout) - [JSON Schema](#json-schema) + - [Validation](#validation) - [Data Objects](#data-objects) - [Rate Limiting](#rate-limiting) - [Pagination](#pagination) @@ -500,6 +501,11 @@ Hypervel compiles integer and float values passed to Scout's Algolia `where`, `w When porting schemas that place sibling assertions beside a local `$ref` or use nullable composition, make overlapping assertions identical. Hypervel rejects conflicts instead of silently replacing referenced constraints. See the [JSON Schema documentation](/docs/{{version}}/json-schema#reconstructing-schemas). + +### Validation + +Handwritten validation parameters use standard CSV quoting. Replace backslash-escaped quotes inside quoted parameters with doubled quotes; backslashes are literal. Fluent rule builders handle quoting for you. See [rule parameters](/docs/{{version}}/validation#rule-parameters). + ### Data Objects @@ -575,7 +581,7 @@ Hypervel's `migrate:fresh` command discovers the connection declared by each mig Hypervel's Redis integration uses the PhpRedis extension exclusively. Its default `config/database.php` file does not contain a `client` option or `REDIS_CLIENT` environment variable. Remove those Laravel settings when porting configuration. A copied `client` option with any value other than `phpredis` is rejected; Predis is not supported. -Laravel's top-level `database.redis.clusters` configuration is also rejected. Each Hypervel Redis connection selects its standalone, Sentinel, or Cluster topology within the named connection, so begin with the matching Hypervel example instead of adapting Laravel's connection shape. Optional advanced members use their documented defaults when omitted. Hypervel does not support Laravel's `retry_interval` setting; configure retries with `max_retries`, `backoff_algorithm`, `backoff_base`, and `backoff_cap`. Configure Redis Cluster by adding a `cluster` array to a named Redis connection. See the [Redis configuration](/docs/{{version}}/redis#configuration) and [cluster documentation](/docs/{{version}}/redis#clusters). +Laravel's top-level `database.redis.clusters` configuration is also rejected. Each Hypervel Redis connection selects its standalone, Sentinel, or Cluster topology within the named connection, so begin with the matching Hypervel example instead of adapting Laravel's connection shape. Optional advanced members use their documented defaults when omitted. Hypervel does not support Laravel's `retry_interval` or `command_retries` settings and does not replay failed commands; configure PhpRedis connection retries with `max_retries`, `backoff_algorithm`, `backoff_base`, and `backoff_cap`. Configure Redis Cluster by adding a `cluster` array to a named Redis connection. See the [Redis configuration](/docs/{{version}}/redis#configuration) and [cluster documentation](/docs/{{version}}/redis#clusters). ### Cache diff --git a/src/docs/queues.md b/src/docs/queues.md index bba6ea549..bc81f0098 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -2855,13 +2855,27 @@ php artisan queue:pause database:default In this example, `database` is the queue connection name and `default` is the queue name. Once a queue is paused, any workers processing jobs from that queue will continue to finish their current job, but will not pick up any new jobs until the queue is resumed. +To pause job processing for every queue on every connection, use the `--all` option: + +```shell +php artisan queue:pause --all +``` + To resume processing jobs on a paused queue, use the `queue:resume` command: ```shell php artisan queue:resume database:default ``` -After resuming a queue, workers will begin processing new jobs from that queue immediately. The `queue:continue` command is available as an alias for `queue:resume`. Note that pausing a queue does not stop the worker process itself - it only prevents the worker from processing new jobs from the specified queue. +To resume job processing for every queue on every connection, use the `--all` option with the `queue:resume` command: + +```shell +php artisan queue:resume --all +``` + +After resuming a queue, workers will begin processing new jobs from that queue immediately. Resuming all queues does not resume queues that were paused individually. The `queue:continue` command is available as an alias for `queue:resume`. Note that pausing a queue does not stop the worker process itself - it only prevents the worker from processing new jobs from the specified queue. + +Queue workers report paused and resumed queues in their console output. #### Worker Restart and Pause Signals diff --git a/src/docs/redis.md b/src/docs/redis.md index 06961e831..4deb4b70d 100644 --- a/src/docs/redis.md +++ b/src/docs/redis.md @@ -168,7 +168,7 @@ The PhpRedis extension may also be configured to use a variety of serializers an Currently supported serializers include: `Redis::SERIALIZER_NONE` (default), `Redis::SERIALIZER_PHP`, `Redis::SERIALIZER_JSON`, `Redis::SERIALIZER_IGBINARY`, and `Redis::SERIALIZER_MSGPACK`. -Redis atomic counters must remain unencoded. Connections used for atomic increments or decrements should either use `Redis::SERIALIZER_NONE` or enable `pack_ignore_numbers` with PhpRedis 6.2 or later. This applies to standalone and Cluster connections. +Redis atomic counters must remain unencoded. Connections used to store atomic counters must disable both serialization and compression, or enable `pack_ignore_numbers` with PhpRedis 6.2 or later. This applies to standalone and Cluster connections. When relying on Cache's [serializable class allowlist](/docs/{{version}}/cache#serializable-cached-objects), configure the connection used by the Redis cache store with `Redis::SERIALIZER_NONE`. Options in the shared `options` array also apply to that connection. diff --git a/src/docs/requests.md b/src/docs/requests.md index 2d01d9031..311c5b5b4 100644 --- a/src/docs/requests.md +++ b/src/docs/requests.md @@ -448,6 +448,29 @@ Input values containing arrays may be retrieved using the `array` method. This m $versions = $request->array('versions'); ``` + +#### Retrieving Fluent Input Values + +The `fluent` method retrieves input as a `Hypervel\Support\Fluent` instance, allowing you to access its values as properties: + +```php +$user = $request->fluent('user'); + +$name = $user->name; +``` + +If the input is missing or `null`, an empty instance is returned. You may pass an array of default values as the second argument: + +```php +$user = $request->fluent('user', ['name' => 'Guest']); +``` + +You may also pass an array of keys to build the instance from only those input values: + +```php +$user = $request->fluent(['name', 'role']); +``` + #### Retrieving Date Input Values diff --git a/src/docs/routing.md b/src/docs/routing.md index 7e617b0a2..623a6d56e 100644 --- a/src/docs/routing.md +++ b/src/docs/routing.md @@ -205,6 +205,14 @@ php artisan route:list -v php artisan route:list -vv ``` +You may use the `--middleware` option to only show routes whose listed middleware contains a given string: + +```shell +php artisan route:list -v --middleware=auth +``` + +Use `-vv` to match middleware within middleware groups. + You may also instruct Hypervel to only show routes that begin with a given URI: ```shell diff --git a/src/docs/validation.md b/src/docs/validation.md index 9addbe387..2545a4f32 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -147,6 +147,22 @@ $validatedData = $request->validateWithBag('post', [ ]); ``` + +#### Rule Parameters + +Fluent rule builders quote parameter values for you. When writing a rule string yourself, enclose values containing commas or quotes in double quotes and double any quotes within the value. Backslashes are preserved literally: + +```php +use Hypervel\Validation\Rule; + +$request->validate([ + 'name' => [Rule::in(['Taylor, "Otwell"'])], + 'alias' => ['in:"Taylor, ""Otwell"""'], +]); +``` + +If a parameter contains `|`, use a rule object or an array of individual rules instead of joining the rules into a single string. Regular expression parameters retain their regular expression syntax. + #### Stopping on First Validation Failure @@ -1050,6 +1066,15 @@ $messages = [ ]; ``` +You may capitalize `:attribute` and supported rule placeholders to control the casing of their replacements: + +```php +$messages = [ + 'same' => 'The :Attribute and :Other must match.', + 'in' => 'The :attribute must be one of the following: :VALUES', +]; +``` + #### Specifying a Custom Message for a Given Attribute @@ -1400,6 +1425,7 @@ Below is a list of all available validation rules and their function:
[Array](#rule-array) +[Array Keys](#rule-array-keys) [Between](#rule-between) [Contains](#rule-contains) [Doesnt Contain](#rule-doesnt-contain) @@ -1650,6 +1676,21 @@ Validator::make($input, [ In general, you should always specify the array keys that are allowed to be present within your array. + +#### array_keys:_foo_,_bar_,... + +The field under validation must be a PHP `array` whose keys are all included in the given list. At least one key must be provided: + +```php +'user' => ['array_keys:name,username'], +``` + +For convenience, you may use the `Rule::arrayKeys` method: + +```php +'user' => [Rule::arrayKeys('name', 'username')], +``` + #### ascii diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index 18a8119bf..1c41f184d 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -707,7 +707,11 @@ public function getCachedRoutesPath(): string */ public function eventsAreCached(): bool { - return is_file($this->getCachedEventsPath()); + if ($this->bound('events.cached')) { + return (bool) $this->make('events.cached'); + } + + return $this->instance('events.cached', is_file($this->getCachedEventsPath())); } /** @@ -1357,6 +1361,7 @@ protected function registerCoreContainerAliases(): void \Hypervel\Auth\Passwords\PasswordBroker::class, \Hypervel\Contracts\Auth\PasswordBroker::class, ], + 'blade.compiler' => [\Hypervel\View\Compilers\BladeCompiler::class], 'cache' => [ \Hypervel\Cache\CacheManager::class, \Hypervel\Contracts\Cache\Factory::class, @@ -1366,6 +1371,7 @@ protected function registerCoreContainerAliases(): void \Hypervel\Contracts\Cache\Repository::class, \Psr\SimpleCache\CacheInterface::class, ], + 'composer' => [\Hypervel\Support\Composer::class], 'config' => [ \Hypervel\Config\Repository::class, \Hypervel\Contracts\Config\Repository::class, @@ -1375,7 +1381,6 @@ protected function registerCoreContainerAliases(): void \Hypervel\Contracts\Cookie\Factory::class, \Hypervel\Contracts\Cookie\QueueingFactory::class, ], - 'composer' => [\Hypervel\Support\Composer::class], 'db' => [ \Hypervel\Database\DatabaseManager::class, \Hypervel\Database\ConnectionResolverInterface::class, @@ -1437,6 +1442,9 @@ protected function registerCoreContainerAliases(): void 'queue.failer' => [\Hypervel\Queue\Failed\FailedJobProviderInterface::class], 'queue.listener' => [\Hypervel\Queue\Listener::class], 'queue.worker' => [\Hypervel\Queue\Worker::class], + 'redirect' => [ + \Hypervel\Routing\Redirector::class, + ], 'redis' => [ \Hypervel\Redis\RedisManager::class, \Hypervel\Contracts\Redis\Factory::class, @@ -1456,38 +1464,34 @@ protected function registerCoreContainerAliases(): void \Hypervel\Contracts\Routing\Registrar::class, \Hypervel\Contracts\Routing\BindingRegistrar::class, ], - 'redirect' => [ - \Hypervel\Routing\Redirector::class, + 'session' => [\Hypervel\Session\SessionManager::class], + 'session.store' => [ + \Hypervel\Session\Store::class, + \Hypervel\Contracts\Session\Session::class, + ], + 'translation.loader' => [ + \Hypervel\Translation\FileLoader::class, + \Hypervel\Contracts\Translation\Loader::class, + ], + 'translator' => [ + \Hypervel\Translation\Translator::class, + \Hypervel\Contracts\Translation\Translator::class, ], 'url' => [ \Hypervel\Routing\UrlGenerator::class, \Hypervel\Contracts\Routing\UrlGenerator::class, ], + 'validation.presence' => [\Hypervel\Validation\DatabasePresenceVerifierInterface::class], 'validator' => [ \Hypervel\Validation\Factory::class, \Hypervel\Contracts\Validation\Factory::class, ], - 'validation.presence' => [\Hypervel\Validation\DatabasePresenceVerifierInterface::class], 'view' => [ \Hypervel\View\Factory::class, \Hypervel\Contracts\View\Factory::class, ], 'view.engine.resolver' => [\Hypervel\View\Engines\EngineResolver::class], 'view.finder' => [\Hypervel\View\ViewFinderInterface::class], - 'blade.compiler' => [\Hypervel\View\Compilers\BladeCompiler::class], - 'session' => [\Hypervel\Session\SessionManager::class], - 'session.store' => [ - \Hypervel\Session\Store::class, - \Hypervel\Contracts\Session\Session::class, - ], - 'translator' => [ - \Hypervel\Translation\Translator::class, - \Hypervel\Contracts\Translation\Translator::class, - ], - 'translation.loader' => [ - \Hypervel\Translation\FileLoader::class, - \Hypervel\Contracts\Translation\Loader::class, - ], ] as $key => $aliases) { foreach ($aliases as $alias) { $this->alias($key, $alias); diff --git a/src/foundation/src/Console/RouteListCommand.php b/src/foundation/src/Console/RouteListCommand.php index 3597fe26f..d194d5e5e 100644 --- a/src/foundation/src/Console/RouteListCommand.php +++ b/src/foundation/src/Console/RouteListCommand.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Console\Command; +use Hypervel\Contracts\Http\Kernel as HttpKernel; use Hypervel\Contracts\Routing\UrlGenerator; use Hypervel\Routing\Route; use Hypervel\Routing\Router; @@ -71,18 +72,22 @@ public function __construct( /** * Execute the console command. */ - public function handle() + public function handle(): void { - if (! $this->output->isVeryVerbose()) { - $this->router->flushMiddlewareGroups(); - } + // Console bootstrap leaves the HTTP kernel unresolved. Resolving it installs + // the application's middleware groups, aliases, and priority on the router. + $this->hypervel->make(HttpKernel::class); if (! $this->router->getRoutes()->count()) { - return $this->components->error("Your application doesn't have any routes."); // @phpstan-ignore method.void + $this->components->error("Your application doesn't have any routes."); + + return; } if (empty($routes = $this->getRoutes())) { - return $this->components->error("Your application doesn't have any routes matching the given criteria."); // @phpstan-ignore method.void + $this->components->error("Your application doesn't have any routes matching the given criteria."); + + return; } $this->displayRoutes($routes); @@ -187,7 +192,13 @@ protected function resolveUri(Route $route): string */ protected function getMiddleware(Route $route): string { - return (new Collection($this->router->gatherRouteMiddleware($route))) + // Collapsed output must preserve the worker's middleware groups and must + // not replace the route's cached executable middleware with group names. + $middleware = $this->output->isVeryVerbose() + ? $this->router->gatherRouteMiddleware($route) + : $this->router->resolveMiddlewareWithoutGroups($route->gatherMiddleware(), $route->excludedMiddleware()); + + return (new Collection($middleware)) ->map(fn ($middleware) => $middleware instanceof Closure ? 'Closure' : $middleware) ->implode("\n"); } diff --git a/src/foundation/src/Console/stubs/resource-json-api.stub b/src/foundation/src/Console/stubs/resource-json-api.stub index a6cd0eb07..b163dd065 100644 --- a/src/foundation/src/Console/stubs/resource-json-api.stub +++ b/src/foundation/src/Console/stubs/resource-json-api.stub @@ -4,7 +4,6 @@ declare(strict_types=1); namespace {{ namespace }}; -use Hypervel\Http\Request; use Hypervel\Http\Resources\JsonApi\JsonApiResource; class {{ class }} extends JsonApiResource @@ -12,14 +11,14 @@ class {{ class }} extends JsonApiResource /** * The resource's attributes. */ - public $attributes = [ + public array $attributes = [ // ... ]; /** * The resource's relationships. */ - public $relationships = [ + public array $relationships = [ // ... ]; } diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index 231deb20b..e9efc7d6a 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -245,8 +245,9 @@ public function __construct( 'timeout' => 30, ], $options); + // A bound callback would keep the request alive until cyclic garbage collection runs. $this->beforeSendingCallbacks = new Collection([ - function (Request $request, array $options, PendingRequest $pendingRequest) { + static function (Request $request, array $options, PendingRequest $pendingRequest): void { $pendingRequest->request = $request; $pendingRequest->cookies = $options['cookies']; @@ -700,7 +701,7 @@ public function afterResponse(callable $callback): static */ public function throw(?callable $callback = null): static { - $this->throwCallback = $callback === null ? fn () => null : $callback(...); + $this->throwCallback = $callback === null ? static fn (): null => null : $callback(...); return $this; } @@ -727,7 +728,7 @@ public function throwIf(bool|callable $condition, ?callable $callback = null): s public function throwUnless(bool|callable $condition, ?callable $callback = null): static { if (is_callable($condition)) { - return $this->throwIf(fn (Response $response) => ! $condition($response), $callback); + return $this->throwIf(static fn (Response $response): bool => ! $condition($response), $callback); } return $this->throwIf(! $condition, $callback); @@ -740,7 +741,7 @@ public function dump(): static { $values = func_get_args(); - return $this->beforeSending(function (Request $request, array $options) use ($values) { + return $this->beforeSending(static function (Request $request, array $options) use ($values): void { foreach (array_merge($values, [$request, $options]) as $value) { VarDumper::dump($value); } @@ -754,7 +755,7 @@ public function dd(): static { $values = func_get_args(); - return $this->beforeSending(function (Request $request, array $options) use ($values) { + return $this->beforeSending(static function (Request $request, array $options) use ($values): never { foreach (array_merge($values, [$request, $options]) as $value) { VarDumper::dump($value); } diff --git a/src/http/src/Resources/JsonApi/JsonApiRequest.php b/src/http/src/Resources/JsonApi/JsonApiRequest.php index b48457721..e9a4a8310 100644 --- a/src/http/src/Resources/JsonApi/JsonApiRequest.php +++ b/src/http/src/Resources/JsonApi/JsonApiRequest.php @@ -27,7 +27,7 @@ public function sparseFields(string $key): array { if (is_null($this->cachedSparseFields)) { $this->cachedSparseFields = (new Collection($this->array('fields'))) - ->transform(fn ($fieldsets) => empty($fieldsets) ? [] : explode(',', $fieldsets)) + ->transform(fn ($fieldsets) => ! is_string($fieldsets) || empty($fieldsets) ? [] : explode(',', $fieldsets)) ->all(); } @@ -52,7 +52,7 @@ public function hasSparseFieldset(string $key): bool public function sparseIncluded(?string $key = null): ?array { if (is_null($this->cachedSparseIncluded)) { - $included = (string) $this->string('include', ''); + $included = is_string($included = $this->input('include')) ? $included : ''; $this->cachedSparseIncluded = (new Collection(empty($included) ? [] : explode(',', $included))) ->transform(function ($item) { @@ -80,7 +80,7 @@ public function sparseIncluded(?string $key = null): ?array return null; } - $item = implode('.', Arr::take(explode('.', $item), JsonApiResource::$maxRelationshipDepth - 1)); + $item = implode('.', Arr::take(explode('.', $item), max(0, JsonApiResource::$maxRelationshipDepth - 1))); return ! empty($item) ? $item : null; })->filter()->all(); diff --git a/src/mail/src/Mailables/Address.php b/src/mail/src/Mailables/Address.php index 211efc187..6a9794a8d 100644 --- a/src/mail/src/Mailables/Address.php +++ b/src/mail/src/Mailables/Address.php @@ -4,6 +4,8 @@ namespace Hypervel\Mail\Mailables; +use InvalidArgumentException; + class Address { /** @@ -16,5 +18,8 @@ public function __construct( public string $address, public ?string $name = null ) { + if (preg_match('/[\r\n]/', $address) > 0) { + throw new InvalidArgumentException('Email addresses may not contain line break characters.'); + } } } diff --git a/src/mail/src/Message.php b/src/mail/src/Message.php index ba09abab8..f5afba8cb 100644 --- a/src/mail/src/Message.php +++ b/src/mail/src/Message.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Mail\Attachable; use Hypervel\Support\Collection; use Hypervel\Support\Traits\ForwardsCalls; +use InvalidArgumentException; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; use Symfony\Component\Mime\Part\DataPart; @@ -33,8 +34,8 @@ public function __construct( public function from(array|string $address, ?string $name = null): static { is_array($address) - ? $this->message->from(...$address) - : $this->message->from(new Address($address, (string) $name)); + ? $this->message->from(...$this->ensureAddressesAreSafe($address)) + : $this->message->from($this->createAddress($address, (string) $name)); return $this; } @@ -45,8 +46,8 @@ public function from(array|string $address, ?string $name = null): static public function sender(array|string $address, ?string $name = null): static { is_array($address) - ? $this->message->sender(...$address) - : $this->message->sender(new Address($address, (string) $name)); + ? $this->message->sender(...$this->ensureAddressesAreSafe($address)) + : $this->message->sender($this->createAddress($address, (string) $name)); return $this; } @@ -54,8 +55,10 @@ public function sender(array|string $address, ?string $name = null): static /** * Set the "return path" of the message. */ - public function returnPath(string $address): static + public function returnPath(Address|string $address): static { + $this->ensureAddressIsSafe($address); + $this->message->returnPath($address); return $this; @@ -68,8 +71,8 @@ public function to(array|string $address, ?string $name = null, bool $override = { if ($override) { is_array($address) - ? $this->message->to(...$address) - : $this->message->to(new Address($address, (string) $name)); + ? $this->message->to(...$this->ensureAddressesAreSafe($address)) + : $this->message->to($this->createAddress($address, (string) $name)); return $this; } @@ -99,8 +102,8 @@ public function cc(array|string $address, ?string $name = null, bool $override = { if ($override) { is_array($address) - ? $this->message->cc(...$address) - : $this->message->cc(new Address($address, (string) $name)); + ? $this->message->cc(...$this->ensureAddressesAreSafe($address)) + : $this->message->cc($this->createAddress($address, (string) $name)); return $this; } @@ -130,8 +133,8 @@ public function bcc(array|string $address, ?string $name = null, bool $override { if ($override) { is_array($address) - ? $this->message->bcc(...$address) - : $this->message->bcc(new Address($address, (string) $name)); + ? $this->message->bcc(...$this->ensureAddressesAreSafe($address)) + : $this->message->bcc($this->createAddress($address, (string) $name)); return $this; } @@ -170,30 +173,64 @@ protected function addAddresses(array|string $address, ?string $name, string $ty if (is_array($address)) { $type = lcfirst($type); - $addresses = (new Collection($address))->map(function ($address, $key) { + $addresses = (new Collection($address))->map(function (Address|array|string|null $address, int|string $key): Address|string { if (is_string($key) && is_string($address)) { - return new Address($key, $address); + return $this->createAddress($key, $address); } if (is_array($address)) { - return new Address($address['email'] ?? $address['address'], $address['name'] ?? null); + return $this->createAddress($address['email'] ?? $address['address'], $address['name'] ?? null); } if (is_null($address)) { - return new Address($key); + return $this->createAddress($key); } - return $address; + return $this->ensureAddressIsSafe($address); })->all(); $this->message->{"{$type}"}(...$addresses); } else { - $this->message->{"add{$type}"}(new Address($address, (string) $name)); + $this->message->{"add{$type}"}($this->createAddress($address, (string) $name)); } return $this; } + /** + * Create a safe Symfony address instance. + */ + protected function createAddress(string $address, ?string $name = null): Address + { + $this->ensureAddressIsSafe($address); + + return new Address($address, (string) $name); + } + + /** + * Ensure the given address cannot inject additional headers or commands. + */ + protected function ensureAddressIsSafe(Address|string $address): Address|string + { + // Check raw strings before Symfony trims them; constructed Address instances are already validated. + if (is_string($address) && preg_match('/[\r\n]/', $address) > 0) { + throw new InvalidArgumentException('Email addresses may not contain line break characters.'); + } + + return $address; + } + + /** + * Ensure the given addresses cannot inject additional headers or commands. + * + * @param array $addresses + * @return array + */ + protected function ensureAddressesAreSafe(array $addresses): array + { + return array_map(fn (Address|string $address): Address|string => $this->ensureAddressIsSafe($address), $addresses); + } + /** * Add an address debug header for a list of recipients. * diff --git a/src/queue/src/Console/PauseCommand.php b/src/queue/src/Console/PauseCommand.php index 73f17b88b..3f8ce1ea9 100644 --- a/src/queue/src/Console/PauseCommand.php +++ b/src/queue/src/Console/PauseCommand.php @@ -19,7 +19,9 @@ class PauseCommand extends Command /** * The console command name. */ - protected ?string $signature = 'queue:pause {queue : The name of the queue to pause}'; + protected ?string $signature = 'queue:pause + {queue? : The name of the queue to pause} + {--all : Pause job processing for all queues on all connections}'; /** * The console command description. @@ -31,19 +33,36 @@ class PauseCommand extends Command */ public function handle(QueueFactory $manager): int { - [$connection, $queue] = $this->parseQueue($this->argument('queue')); - if (! Worker::$pausable) { $this->components->error('Queue pausing is currently disabled.'); - return 1; + return self::FAILURE; } /** @var QueueManager $manager */ + if ($this->option('all')) { + $manager->pauseAll(); + + $this->components->info('Job processing on all queues across all connections has been paused.'); + + return self::SUCCESS; + } + + /** @var null|string $queue */ + $queue = $this->argument('queue'); + + if ($queue === null || $queue === '') { + $this->components->error('A queue name is required unless the --all option is used.'); + + return self::FAILURE; + } + + [$connection, $queue] = $this->parseQueue($queue); + $manager->pause($connection, $queue); $this->components->info("Job processing on queue [{$connection}:{$queue}] has been paused."); - return 0; + return self::SUCCESS; } } diff --git a/src/queue/src/Console/ResumeCommand.php b/src/queue/src/Console/ResumeCommand.php index 1237dd509..6a62924c9 100644 --- a/src/queue/src/Console/ResumeCommand.php +++ b/src/queue/src/Console/ResumeCommand.php @@ -18,7 +18,9 @@ class ResumeCommand extends Command /** * The console command name. */ - protected ?string $signature = 'queue:resume {queue : The name of the queue that should resume processing}'; + protected ?string $signature = 'queue:resume + {queue? : The name of the queue that should resume processing} + {--all : Resume job processing for all queues on all connections}'; /** * The console command name aliases. @@ -37,13 +39,30 @@ class ResumeCommand extends Command */ public function handle(QueueFactory $manager): int { - [$connection, $queue] = $this->parseQueue($this->argument('queue')); - /** @var QueueManager $manager */ + if ($this->option('all')) { + $manager->resumeAll(); + + $this->components->info('Job processing on all queues across all connections has been resumed.'); + + return self::SUCCESS; + } + + /** @var null|string $queue */ + $queue = $this->argument('queue'); + + if ($queue === null || $queue === '') { + $this->components->error('A queue name is required unless the --all option is used.'); + + return self::FAILURE; + } + + [$connection, $queue] = $this->parseQueue($queue); + $manager->resume($connection, $queue); $this->components->info("Job processing on queue [{$connection}:{$queue}] has been resumed."); - return 0; + return self::SUCCESS; } } diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php index 2754b5835..034033368 100644 --- a/src/queue/src/Console/WorkCommand.php +++ b/src/queue/src/Console/WorkCommand.php @@ -14,6 +14,8 @@ use Hypervel\Queue\Events\JobProcessed; use Hypervel\Queue\Events\JobProcessing; use Hypervel\Queue\Events\JobReleasedAfterException; +use Hypervel\Queue\Events\WorkerQueuePaused; +use Hypervel\Queue\Events\WorkerQueueResumed; use Hypervel\Queue\Events\WorkerStopping; use Hypervel\Queue\Failed\FailedJobProviderInterface; use Hypervel\Queue\InvalidPayloadException; @@ -198,6 +200,14 @@ protected function listenForEvents(): void $command?->writeOutput($event->job, 'failed', $event->exception); }); + $events->listen(WorkerQueuePaused::class, static function (WorkerQueuePaused $event): void { + static::currentCommand()?->writeQueueStatus($event->queue, 'paused'); + }); + + $events->listen(WorkerQueueResumed::class, static function (WorkerQueueResumed $event): void { + static::currentCommand()?->writeQueueStatus($event->queue, 'resumed'); + }); + $events->listen(WorkerStopping::class, static function (WorkerStopping $event): void { // Graceful stopping runs outside the configured job coroutine context. $command = $event->workerOptions?->coroutineContext[self::CURRENT_COMMAND_CONTEXT_KEY] ?? null; @@ -224,6 +234,36 @@ protected function writeOutput(Job $job, string $status, ?Throwable $exception = : $this->writeOutputForCli($job, $status); } + /** + * Write the status output for a paused or resumed queue. + */ + protected function writeQueueStatus(string $queue, string $status): void + { + if ($this->output->isQuiet() || $this->output->isSilent()) { + return; + } + + if ($this->outputUsingJson()) { + $this->output->writeln(json_encode([ + 'level' => 'warning', + 'queue' => $queue, + 'status' => $status, + 'timestamp' => $this->now()->format('Y-m-d\TH:i:s.uP'), + ])); + + return; + } + + $this->output->writeln(sprintf( + ' %s Queue %s %s', + $this->now()->format('Y-m-d H:i:s'), + $queue, + $status === 'paused' + ? 'PAUSED' + : 'RESUMED', + )); + } + /** * Write the status output for a queue worker that is stopping. */ @@ -425,7 +465,7 @@ protected function outputUsingJson(): bool } /** - * Get the queue work command for the currently running job coroutine. + * Get the queue work command for the current coroutine. */ protected static function currentCommand(): ?self { diff --git a/src/queue/src/Events/QueuesPaused.php b/src/queue/src/Events/QueuesPaused.php new file mode 100644 index 000000000..ba7a979b0 --- /dev/null +++ b/src/queue/src/Events/QueuesPaused.php @@ -0,0 +1,9 @@ +app->make('events'); - if ($events->hasListeners(Events\QueuePaused::class)) { - $events->dispatch(new Events\QueuePaused($connection, $queue)); + if ($events->hasListeners(QueuePaused::class)) { + $events->dispatch(new QueuePaused($connection, $queue)); } } @@ -196,8 +200,26 @@ public function pauseFor(string $connection, string $queue, DateInterval|DateTim /** @var Dispatcher $events */ $events = $this->app->make('events'); - if ($events->hasListeners(Events\QueuePaused::class)) { - $events->dispatch(new Events\QueuePaused($connection, $queue, $ttl)); + if ($events->hasListeners(QueuePaused::class)) { + $events->dispatch(new QueuePaused($connection, $queue, $ttl)); + } + } + + /** + * Pause job processing for all queues on all connections. + */ + public function pauseAll(): void + { + // Use Laravel's key for cross-framework queue interoperability. + $this->app->make('cache') + ->store() + ->forever('illuminate:queues:paused', true); + + /** @var Dispatcher $events */ + $events = $this->app->make('events'); + + if ($events->hasListeners(QueuesPaused::class)) { + $events->dispatch(new QueuesPaused); } } @@ -214,8 +236,28 @@ public function resume(string $connection, string $queue): void /** @var Dispatcher $events */ $events = $this->app->make('events'); - if ($events->hasListeners(Events\QueueResumed::class)) { - $events->dispatch(new Events\QueueResumed($connection, $queue)); + if ($events->hasListeners(QueueResumed::class)) { + $events->dispatch(new QueueResumed($connection, $queue)); + } + } + + /** + * Resume job processing for all queues on all connections. + * + * Queues paused individually are not affected. + */ + public function resumeAll(): void + { + // Use Laravel's key for cross-framework queue interoperability. + $this->app->make('cache') + ->store() + ->forget('illuminate:queues:paused'); + + /** @var Dispatcher $events */ + $events = $this->app->make('events'); + + if ($events->hasListeners(QueuesResumed::class)) { + $events->dispatch(new QueuesResumed); } } @@ -225,9 +267,10 @@ public function resume(string $connection, string $queue): void public function isPaused(string $connection, string $queue): bool { // IMPORTANT: Uses Laravel's key for cross-framework queue interoperability. - return (bool) $this->app->make('cache') - ->store() - ->get("illuminate:queue:paused:{$connection}:{$queue}", false); + $cache = $this->app->make('cache')->store(); + + return (bool) ($cache->get('illuminate:queues:paused', false) + ?: $cache->get("illuminate:queue:paused:{$connection}:{$queue}", false)); } /** @@ -235,12 +278,19 @@ public function isPaused(string $connection, string $queue): bool */ public function getPausedQueues(string $connection, array $queues): array { + $cache = $this->app->make('cache')->store(); + + // Keep the global key separate: cluster proxies may reject cross-slot batches. + if ($cache->get('illuminate:queues:paused', false)) { + return array_values($queues); + } + $keys = array_map( static fn (string $queue): string => "illuminate:queue:paused:{$connection}:{$queue}", $queues, ); - $states = $this->app->make('cache')->store()->many($keys); + $states = $cache->many($keys); return array_values(array_filter( $queues, diff --git a/src/queue/src/Worker.php b/src/queue/src/Worker.php index e63910162..32888f95f 100644 --- a/src/queue/src/Worker.php +++ b/src/queue/src/Worker.php @@ -30,6 +30,8 @@ use Hypervel\Queue\Events\WorkerIdle; use Hypervel\Queue\Events\WorkerInterrupted; use Hypervel\Queue\Events\WorkerPausing; +use Hypervel\Queue\Events\WorkerQueuePaused; +use Hypervel\Queue\Events\WorkerQueueResumed; use Hypervel\Queue\Events\WorkerResuming; use Hypervel\Queue\Events\WorkerStarting; use Hypervel\Queue\Events\WorkerStopping; @@ -147,6 +149,23 @@ class Worker */ public bool $paused = false; + /** + * The queues the worker last observed to be paused. + * + * @var array + */ + protected array $pausedQueues = []; + + /** + * The connection used for the last pause-state observation. + */ + protected ?string $lastPolledConnection = null; + + /** + * The queue list used for the last pause-state observation. + */ + protected ?string $lastPolledQueues = null; + /** * The callbacks used to pop jobs from queues. * @@ -239,6 +258,11 @@ public function daemon(string $connectionName, string $queue, WorkerOptions $opt $this->lastJobProcessedAt = null; $this->stopReason = null; + // A new daemon run must report initially paused queues even when this worker is reused. + $this->pausedQueues = []; + $this->lastPolledConnection = null; + $this->lastPolledQueues = null; + $lifecycleWaiter = new Waiter(-1); $lastRestart = $lifecycleWaiter->wait(fn (): ?int => $this->withCoroutineContext( $options, @@ -635,9 +659,12 @@ protected function stopIfNecessary( */ public function runNextJob(string $connectionName, string $queue, WorkerOptions $options): null { - $job = $this->getNextJob( - $this->manager->connection($connectionName), - $queue + $job = $this->withCoroutineContext( + $options, + fn (): ?JobContract => $this->getNextJob( + $this->manager->connection($connectionName), + $queue, + ), ); // If we're able to pull a job off of the stack, we will process it and then return @@ -675,7 +702,19 @@ protected function getNextJob(QueueContract $connection, string $queue): ?JobCon } $queues = explode(',', $queue); - $paused = array_flip($this->getPausedQueues($connection->getConnectionName(), $queues)); + $connectionName = $connection->getConnectionName(); + $paused = $this->getPausedQueues($connectionName, $queues); + + // A different selection says nothing about whether the old queues resumed. + if ($this->lastPolledConnection !== $connectionName || $this->lastPolledQueues !== $queue) { + $this->pausedQueues = []; + $this->lastPolledConnection = $connectionName; + $this->lastPolledQueues = $queue; + } + + $this->raisePausedQueueEvents($connectionName, $paused); + + $paused = array_flip($paused); foreach ($queues as $index => $queue) { if (isset($paused[$queue])) { @@ -720,6 +759,26 @@ protected function getPausedQueues(string $connectionName, array $queues): array return $manager->getPausedQueues($connectionName, $queues); } + /** + * Raise events for any queues that have been paused or resumed since the last check. + */ + protected function raisePausedQueueEvents(string $connectionName, array $paused): void + { + if ($this->events->hasListeners(WorkerQueuePaused::class)) { + foreach (array_diff($paused, $this->pausedQueues) as $queue) { + $this->events->dispatch(new WorkerQueuePaused($connectionName, $queue)); + } + } + + if ($this->events->hasListeners(WorkerQueueResumed::class)) { + foreach (array_diff($this->pausedQueues, $paused) as $queue) { + $this->events->dispatch(new WorkerQueueResumed($connectionName, $queue)); + } + } + + $this->pausedQueues = $paused; + } + /** * Process the given job. */ diff --git a/src/redis/src/Exceptions/InvalidRedisOptionException.php b/src/redis/src/Exceptions/InvalidRedisOptionException.php index d08fb7a5a..3b60f70c4 100644 --- a/src/redis/src/Exceptions/InvalidRedisOptionException.php +++ b/src/redis/src/Exceptions/InvalidRedisOptionException.php @@ -4,8 +4,8 @@ namespace Hypervel\Redis\Exceptions; -use RuntimeException; +use InvalidArgumentException; -class InvalidRedisOptionException extends RuntimeException +class InvalidRedisOptionException extends InvalidArgumentException { } diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index 3600c039e..7c75af06c 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -396,6 +396,7 @@ public function __call($name, $arguments) $name = strtolower($name); $result = $this->executeCommand($name, $arguments); } catch (RedisException|RedisClusterException $exception) { + // REMOVED: Laravel's command retry loop can replay writes Redis already committed. if ($this->shouldInvalidateAfter($exception)) { $this->markInvalid(); } @@ -639,6 +640,8 @@ protected function phpRedisOption(string $name): int /** * Parse a friendly phpredis backoff algorithm name. + * + * @throws InvalidRedisOptionException */ protected function parseBackoffAlgorithm(mixed $algorithm): int { @@ -794,18 +797,21 @@ public function release(): void } if ($queueing || $this->watching) { - try { - $this->log( - $queueing - ? 'Discarding Redis connection left in MULTI or PIPELINE mode.' - : 'Discarding Redis connection left in WATCH state.', - LogLevel::CRITICAL - ); - } catch (CanceledException $cancellation) { - // Native close must not start while cancellation is unwinding. - $this->releaseAfterCancellation($cancellation); - } catch (Throwable) { - // Reporting must not prevent terminal ownership cleanup. + // An invalidated operation is undergoing failure cleanup, not silently abandoning its state. + if (! $this->invalid) { + try { + $this->log( + $queueing + ? 'Discarding Redis connection left in MULTI or PIPELINE mode.' + : 'Discarding Redis connection left in WATCH state.', + LogLevel::CRITICAL + ); + } catch (CanceledException $cancellation) { + // Native close must not start while cancellation is unwinding. + $this->releaseAfterCancellation($cancellation); + } catch (Throwable) { + // Reporting must not prevent terminal ownership cleanup. + } } $this->resetReleaseState(false); @@ -969,12 +975,9 @@ protected function shouldInvalidateAfter(RedisException|RedisClusterException $e return true; } - if (! ($this->config['sentinel']['enabled'] ?? false)) { - return false; - } - $errorCode = explode(' ', $exception->getMessage(), 2)[0]; + // Managed primary endpoints can fail over without Sentinel; reopen to resolve the current primary. return in_array($errorCode, ['READONLY', 'MASTERDOWN'], true); } diff --git a/src/routing/src/Router.php b/src/routing/src/Router.php index c65649ad4..ba91b3cfc 100644 --- a/src/routing/src/Router.php +++ b/src/routing/src/Router.php @@ -785,17 +785,37 @@ public function gatherRouteMiddleware(Route $route): array * @return array */ public function resolveMiddleware(array $middleware, array $excluded = []): array + { + return $this->resolveMiddlewareUsingGroups($middleware, $excluded, $this->middlewareGroups); + } + + /** + * Resolve middleware aliases without expanding middleware groups. + * + * @return array + */ + public function resolveMiddlewareWithoutGroups(array $middleware, array $excluded = []): array + { + return $this->resolveMiddlewareUsingGroups($middleware, $excluded, []); + } + + /** + * Resolve middleware using the given middleware groups. + * + * @return array + */ + protected function resolveMiddlewareUsingGroups(array $middleware, array $excluded, array $middlewareGroups): array { $excluded = $excluded === [] ? $excluded : (new Collection($excluded)) - ->map(fn (string|Closure $name): string|Closure|array => MiddlewareNameResolver::resolve($name, $this->middleware, $this->middlewareGroups)) + ->map(fn (string|Closure $name): string|Closure|array => MiddlewareNameResolver::resolve($name, $this->middleware, $middlewareGroups)) ->flatten() ->values() ->all(); $middleware = (new Collection($middleware)) - ->map(fn (string|Closure $name): string|Closure|array => MiddlewareNameResolver::resolve($name, $this->middleware, $this->middlewareGroups)) + ->map(fn (string|Closure $name): string|Closure|array => MiddlewareNameResolver::resolve($name, $this->middleware, $middlewareGroups)) ->flatten() ->when( ! empty($excluded), diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index 3bcfd1b4f..bbb81991a 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -27,12 +27,14 @@ * @method static bool isPaused(string $connection, string $queue) * @method static void looping(mixed $callback) * @method static void pause(string $connection, string $queue) + * @method static void pauseAll() * @method static void pauseFor(string $connection, string $queue, \DateInterval|\DateTimeInterface|int $ttl) * @method static void purge(string|null $name = null) * @method static \Hypervel\Queue\QueueManager removePoolable(string $driver) * @method static string|null resolveConnectionFromQueueRoute(object $queueable) * @method static string|null resolveQueueFromQueueRoute(object $queueable) * @method static void resume(string $connection, string $queue) + * @method static void resumeAll() * @method static void route(array|string $class, \UnitEnum|string|null $queue = null, \UnitEnum|string|null $connection = null) * @method static \Hypervel\Queue\QueueManager setApplication(\Hypervel\Contracts\Container\Container $app) * @method static void setDefaultDriver(\UnitEnum|string $name) diff --git a/src/support/src/Facades/Route.php b/src/support/src/Facades/Route.php index c297213fc..9b4165be4 100644 --- a/src/support/src/Facades/Route.php +++ b/src/support/src/Facades/Route.php @@ -67,6 +67,7 @@ * @method static \Hypervel\Routing\Route redirect(string $uri, string $destination, int $status = 302) * @method static \Hypervel\Routing\Router removeMiddlewareFromGroup(string $group, array|string $middleware) * @method static array resolveMiddleware(array $middleware, array $excluded = []) + * @method static array resolveMiddlewareWithoutGroups(array $middleware, array $excluded = []) * @method static \Hypervel\Routing\PendingResourceRegistration resource(string $name, string $controller, array $options = []) * @method static void resourceParameters(array $parameters = []) * @method static void resources(array $resources, array $options = []) diff --git a/src/support/src/Sleep.php b/src/support/src/Sleep.php index 227ea1819..fb2766507 100644 --- a/src/support/src/Sleep.php +++ b/src/support/src/Sleep.php @@ -138,7 +138,8 @@ protected function duration(DateInterval|float|int $duration): static */ public function minutes(): static { - $this->duration->add('minutes', $this->pullPending()); + // Build numeric intervals so tiny durations are never parsed from scientific notation. + $this->duration->add(minutes($this->pullPending())); return $this; } @@ -156,7 +157,7 @@ public function minute(): static */ public function seconds(): static { - $this->duration->add('seconds', $this->pullPending()); + $this->duration->add(seconds($this->pullPending())); return $this; } @@ -174,7 +175,7 @@ public function second(): static */ public function milliseconds(): static { - $this->duration->add('milliseconds', $this->pullPending()); + $this->duration->add(microseconds(round($this->pullPending() * Carbon::MICROSECONDS_PER_MILLISECOND))); return $this; } @@ -192,13 +193,13 @@ public function millisecond(): static */ public function microseconds(): static { - $this->duration->add('microseconds', $this->pullPending()); + $this->duration->add(microseconds($this->pullPending())); return $this; } /** - * Sleep for on microsecond. + * Sleep for one microsecond. */ public function microsecond(): static { diff --git a/src/support/src/Str.php b/src/support/src/Str.php index 47906ae55..f907508db 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -183,6 +183,8 @@ public static function betweenFirst(string $subject, string|int|float|bool|BaseS /** * Convert a value to camel case. + * + * @return ($value is '' ? '' : string) */ public static function camel(string $value): string { @@ -205,6 +207,8 @@ public static function charAt(string $subject, mixed $index): string|false /** * Remove the given string(s) if it exists at the start of the haystack. + * + * @param string|string[] $needle */ public static function chopStart(string $subject, string|array $needle): string { @@ -219,6 +223,8 @@ public static function chopStart(string $subject, string|array $needle): string /** * Remove the given string(s) if it exists at the end of the haystack. + * + * @param string|string[] $needle */ public static function chopEnd(string $subject, string|array $needle): string { @@ -235,6 +241,7 @@ public static function chopEnd(string $subject, string|array $needle): string * Determine if a given string contains a given substring. * * @param iterable|string $needles + * @return ($needles is array{} ? false : ($haystack is non-empty-string ? bool : false)) */ public static function contains(string $haystack, string|iterable $needles, bool $ignoreCase = false): bool { @@ -263,22 +270,28 @@ public static function contains(string $haystack, string|iterable $needles, bool * Determine if a given string contains all array values. * * @param iterable $needles + * @return ($needles is array{} ? false : ($haystack is non-empty-string ? bool : false)) */ public static function containsAll(string $haystack, iterable $needles, bool $ignoreCase = false): bool { + $any = false; + foreach ($needles as $needle) { + $any = true; + if (! static::contains($haystack, $needle, $ignoreCase)) { return false; } } - return true; + return $any; } /** * Determine if a given string doesn't contain a given substring. * * @param iterable|string $needles + * @return ($needles is array{} ? true : ($haystack is non-empty-string ? bool : true)) */ public static function doesntContain(string $haystack, string|iterable $needles, bool $ignoreCase = false): bool { @@ -287,6 +300,9 @@ public static function doesntContain(string $haystack, string|iterable $needles, /** * Convert the case of a string. + * + * @param MB_CASE_FOLD|MB_CASE_FOLD_SIMPLE|MB_CASE_LOWER|MB_CASE_LOWER_SIMPLE|MB_CASE_TITLE|MB_CASE_TITLE_SIMPLE|MB_CASE_UPPER|MB_CASE_UPPER_SIMPLE $mode + * @return ($string is '' ? '' : string) */ public static function convertCase(string $string, int $mode = MB_CASE_FOLD, ?string $encoding = 'UTF-8'): string { @@ -305,6 +321,7 @@ public static function counted(string $value, int|array|Countable $count): strin * Replace consecutive instances of a given character with a single character in the given string. * * @param array|string $characters + * @return ($string is '' ? '' : string) */ public static function deduplicate(string $string, array|string $characters = ' '): string { @@ -322,7 +339,8 @@ public static function deduplicate(string $string, array|string $characters = ' /** * Determine if a given string ends with a given substring. * - * @param iterable|string $needles + * @param null|BaseStringable|bool|float|int|iterable|string $needles + * @return ($needles is array{} ? false : ($haystack is null|''|false ? false : bool)) */ public static function endsWith(string|int|float|bool|BaseStringable|null $haystack, string|int|float|bool|BaseStringable|iterable|null $needles): bool { @@ -333,7 +351,7 @@ public static function endsWith(string|int|float|bool|BaseStringable|null $hayst $haystack = (string) $haystack; if (! is_iterable($needles)) { - $needles = (array) $needles; + $needles = [$needles]; } foreach ($needles as $needle) { @@ -350,7 +368,8 @@ public static function endsWith(string|int|float|bool|BaseStringable|null $hayst /** * Determine if a given string doesn't end with a given substring. * - * @param iterable|string $needles + * @param null|BaseStringable|bool|float|int|iterable|string $needles + * @return ($needles is array{} ? true : ($haystack is null|''|false ? true : bool)) */ public static function doesntEndWith(string|int|float|bool|BaseStringable|null $haystack, string|int|float|bool|BaseStringable|iterable|null $needles): bool { @@ -358,9 +377,9 @@ public static function doesntEndWith(string|int|float|bool|BaseStringable|null $ } /** - * Extracts an excerpt from text that matches the first instance of a phrase. + * Extract an excerpt from text that matches the first instance of a phrase. * - * @param array{radius?: float|int, omission?: string} $options + * @param array{radius?: int, omission?: string} $options */ public static function excerpt(string|int|float|bool|BaseStringable|null $text, string|int|float|bool|BaseStringable|null $phrase = '', array $options = []): ?string { @@ -395,6 +414,8 @@ public static function excerpt(string|int|float|bool|BaseStringable|null $text, /** * Cap a string with a single instance of a given value. + * + * @return ($value is '' ? ($cap is '' ? '' : non-empty-string) : non-empty-string) */ public static function finish(string $value, string $cap): string { @@ -405,6 +426,8 @@ public static function finish(string $value, string $cap): string /** * Wrap the string with the given strings. + * + * @return ($value is '' ? ($before is '' ? ($after is '' ? '' : ($after is null ? '' : non-empty-string)) : non-empty-string) : non-empty-string) */ public static function wrap(string $value, string $before, ?string $after = null): string { @@ -479,6 +502,8 @@ public static function isAscii(string|int|float|bool|BaseStringable|null $value) /** * Determine if a given value is valid JSON. + * + * @phpstan-assert-if-true =non-empty-string $value */ public static function isJson(mixed $value): bool { @@ -493,6 +518,8 @@ public static function isJson(mixed $value): bool * Determine if a given value is a valid URL. * * @param string[] $protocols + * + * @phpstan-assert-if-true =non-empty-string $value */ public static function isUrl(mixed $value, array $protocols = []): bool { @@ -545,6 +572,8 @@ public static function isUrl(mixed $value, array $protocols = []): bool * Determine if a given value is a valid UUID. * * @param null|'max'|'nil'|int<0, 8> $version + * + * @phpstan-assert-if-true =non-empty-string $value */ public static function isUuid(mixed $value, int|string|null $version = null): bool { @@ -573,6 +602,8 @@ public static function isUuid(mixed $value, int|string|null $version = null): bo /** * Determine if a given value is a valid ULID. + * + * @phpstan-assert-if-true =non-empty-string $value */ public static function isUlid(mixed $value): bool { @@ -585,6 +616,8 @@ public static function isUlid(mixed $value): bool /** * Convert a string to kebab case. + * + * @return ($value is '' ? '' : string) */ public static function kebab(string $value): string { @@ -593,6 +626,8 @@ public static function kebab(string $value): string /** * Return the length of the given string. + * + * @return non-negative-int */ public static function length(string $value, ?string $encoding = null): int { @@ -625,6 +660,8 @@ public static function limit(string $value, int $limit = 100, string $end = '... /** * Convert the given string to lower-case. + * + * @return ($value is '' ? '' : lowercase-string&non-empty-string) */ public static function lower(string $value): string { @@ -646,9 +683,10 @@ public static function words(string $value, int $words = 100, string $end = '... } /** - * Converts GitHub flavored Markdown into HTML. + * Convert GitHub flavored Markdown into HTML. * * @param \League\CommonMark\Extension\ExtensionInterface[] $extensions + * @return ($string is '' ? '' : string) */ public static function markdown(string $string, array $options = [], array $extensions = []): string { @@ -664,9 +702,10 @@ public static function markdown(string $string, array $options = [], array $exte } /** - * Converts inline Markdown into HTML. + * Convert inline Markdown into HTML. * * @param \League\CommonMark\Extension\ExtensionInterface[] $extensions + * @return ($string is '' ? '' : string) */ public static function inlineMarkdown(string $string, array $options = [], array $extensions = []): string { @@ -733,6 +772,7 @@ public static function match(string $pattern, string $subject): string * Determine if a given string matches a given pattern. * * @param iterable|string $pattern + * @return ($pattern is array{} ? false : bool) */ public static function isMatch(string|iterable $pattern, string $value): bool { @@ -767,10 +807,13 @@ public static function matchAll(string $pattern, string $subject): Collection /** * Remove all non-numeric characters from a string. + * + * @param string|string[] $value + * @return ($value is string ? string : string[]) */ public static function numbers(string|array $value): string|array { - return preg_replace('/[^0-9]/', '', $value); + return preg_replace('/\D/', '', $value); } /** @@ -852,6 +895,8 @@ public static function pluralPascal(string $value, int|array|Countable $count = /** * Generate a random, secure password. + * + * @return ($letters is false ? ($numbers is true ? ($symbols is false ? ($spaces is false ? numeric-string : string) : string) : string) : string) */ public static function password(int $length = 32, bool $letters = true, bool $numbers = true, bool $symbols = true, bool $spaces = false): string { @@ -888,6 +933,8 @@ public static function password(int $length = 32, bool $letters = true, bool $nu /** * Find the multi-byte safe position of the first occurrence of a given substring in a string. + * + * @return ($needle is '' ? int : ($haystack is '' ? false : false|int)) */ public static function position(string $haystack, string $needle, int $offset = 0, ?string $encoding = null): int|false { @@ -1029,6 +1076,7 @@ private static function toStringOr(mixed $value, string $fallback): string * @param iterable|string $search * @param iterable|string $replace * @param iterable|string $subject + * @return ($subject is string ? string : string[]) */ public static function replace(string|iterable $search, string|iterable $replace, string|iterable $subject, bool $caseSensitive = true): string|array { @@ -1046,7 +1094,58 @@ public static function replace(string|iterable $search, string|iterable $replace return $caseSensitive ? str_replace($search, $replace, $subject) - : str_ireplace($search, $replace, $subject); + : static::replaceWhileIgnoringCase($search, $replace, $subject); + } + + /** + * Replace the given value in the given string regardless of case. + * + * @param string|string[] $search + * @param string|string[] $replace + * @param string|string[] $subject + * @return ($subject is string ? string : string[]) + */ + protected static function replaceWhileIgnoringCase(string|array $search, string|array $replace, string|array $subject): string|array + { + if (! is_array($search) && is_array($replace)) { + return str_ireplace($search, $replace, $subject); + } + + if (is_string($search) ? static::isAscii($search) : array_all($search, static::isAscii(...))) { + return str_ireplace($search, $replace, $subject); + } + + $searches = is_array($search) ? array_values($search) : [$search]; + + $replacements = is_array($replace) + ? array_values($replace) + : array_fill(0, count($searches), $replace); + + // Validate every input first: replacement bytes can invalidate later UTF-8 matching. + foreach ([$searches, $replacements, (array) $subject] as $values) { + foreach ($values as $value) { + if (! preg_match('//u', (string) $value)) { + return str_ireplace($search, $replace, $subject); + } + } + } + + foreach ($searches as $index => $term) { + $term = (string) $term; + + if ($term === '') { + continue; + } + + $replacement = (string) ($replacements[$index] ?? ''); + + // ASCII terms retain native case folding even alongside Unicode terms. + $subject = static::isAscii($term) + ? str_ireplace($term, $replacement, $subject) + : preg_replace_callback('/' . preg_quote($term, '/') . '/iu', fn (): string => $replacement, $subject); + } + + return $subject; } /** @@ -1129,6 +1228,7 @@ public static function replaceEnd(string|int|float|bool|BaseStringable|null $sea * @param string|string[] $pattern * @param (Closure(array): string)|string|string[] $replace * @param string|string[] $subject + * @return ($subject is array ? null|string[] : null|string) */ public static function replaceMatches(string|array $pattern, Closure|array|string $replace, string|array $subject, int $limit = -1): string|array|null { @@ -1152,7 +1252,7 @@ public static function remove(string|iterable $search, string $subject, bool $ca return $caseSensitive ? str_replace($search, '', $subject) - : str_ireplace($search, '', $subject); + : static::replaceWhileIgnoringCase($search, '', $subject); } /** @@ -1165,6 +1265,8 @@ public static function reverse(string $value): string /** * Begin a string with a single instance of a given value. + * + * @return ($value is '' ? ($prefix is '' ? '' : non-empty-string) : non-empty-string) */ public static function start(string $value, string $prefix): string { @@ -1175,6 +1277,8 @@ public static function start(string $value, string $prefix): string /** * Convert the given string to upper-case. + * + * @return ($value is '' ? '' : non-empty-string&uppercase-string) */ public static function upper(string $value): string { @@ -1394,10 +1498,10 @@ public static function squish(string $value): string /** * Determine if a given string starts with a given substring. * - * @param iterable|string $needles - * @return ($needles is array{} ? false : ($haystack is non-empty-string ? bool : false)) + * @param null|BaseStringable|bool|float|int|iterable|string $needles + * @return ($needles is array{} ? false : ($haystack is null|''|false ? false : bool)) * - * @phpstan-assert-if-true =non-empty-string $haystack + * @phpstan-assert-if-true =non-empty-string|int|float|true|BaseStringable $haystack */ public static function startsWith(string|int|float|bool|BaseStringable|null $haystack, string|int|float|bool|BaseStringable|iterable|null $needles): bool { @@ -1425,10 +1529,10 @@ public static function startsWith(string|int|float|bool|BaseStringable|null $hay /** * Determine if a given string doesn't start with a given substring. * - * @param iterable|string $needles - * @return ($needles is array{} ? true : ($haystack is non-empty-string ? bool : true)) + * @param null|BaseStringable|bool|float|int|iterable|string $needles + * @return ($needles is array{} ? true : ($haystack is null|''|false ? true : bool)) * - * @phpstan-assert-if-false =non-empty-string $haystack + * @phpstan-assert-if-false =non-empty-string|int|float|true|BaseStringable $haystack */ public static function doesntStartWith(string|int|float|bool|BaseStringable|null $haystack, string|int|float|bool|BaseStringable|iterable|null $needles): bool { diff --git a/src/support/src/Stringable.php b/src/support/src/Stringable.php index cc2cefd7a..74e4e0ef4 100644 --- a/src/support/src/Stringable.php +++ b/src/support/src/Stringable.php @@ -189,6 +189,8 @@ public function doesntContain(string|iterable $needles, bool $ignoreCase = false /** * Convert the case of a string. + * + * @param MB_CASE_FOLD|MB_CASE_FOLD_SIMPLE|MB_CASE_LOWER|MB_CASE_LOWER_SIMPLE|MB_CASE_TITLE|MB_CASE_TITLE_SIMPLE|MB_CASE_UPPER|MB_CASE_UPPER_SIMPLE $mode */ public function convertCase(int $mode = MB_CASE_FOLD, ?string $encoding = 'UTF-8'): static { @@ -222,7 +224,7 @@ public function dirname(int $levels = 1): static /** * Determine if a given string ends with a given substring. * - * @param iterable|string $needles + * @param null|BaseStringable|bool|float|int|iterable|string $needles */ public function endsWith(string|int|float|bool|BaseStringable|iterable|null $needles): bool { @@ -232,7 +234,7 @@ public function endsWith(string|int|float|bool|BaseStringable|iterable|null $nee /** * Determine if a given string doesn't end with a given substring. * - * @param iterable|string $needles + * @param null|BaseStringable|bool|float|int|iterable|string $needles */ public function doesntEndWith(string|int|float|bool|BaseStringable|iterable|null $needles): bool { @@ -252,7 +254,9 @@ public function exactly(mixed $value): bool } /** - * Extracts an excerpt from text that matches the first instance of a phrase. + * Extract an excerpt from text that matches the first instance of a phrase. + * + * @param array{radius?: int, omission?: string} $options */ public function excerpt(string $phrase = '', array $options = []): ?string { @@ -745,7 +749,7 @@ public function snake(string $delimiter = '_'): static /** * Determine if a given string starts with a given substring. * - * @param iterable|string $needles + * @param null|BaseStringable|bool|float|int|iterable|string $needles */ public function startsWith(string|int|float|bool|BaseStringable|iterable|null $needles): bool { @@ -755,7 +759,7 @@ public function startsWith(string|int|float|bool|BaseStringable|iterable|null $n /** * Determine if a given string doesn't start with a given substring. * - * @param iterable|string $needles + * @param null|BaseStringable|bool|float|int|iterable|string $needles */ public function doesntStartWith(string|int|float|bool|BaseStringable|iterable|null $needles): bool { diff --git a/src/support/src/Traits/DateHelpers.php b/src/support/src/Traits/DateHelpers.php index 01fdf8b98..b59e73254 100644 --- a/src/support/src/Traits/DateHelpers.php +++ b/src/support/src/Traits/DateHelpers.php @@ -33,7 +33,7 @@ public static function createFromId(Uuid|Ulid|string $id): static } /** - * Get the current date / time plus a given amount of time. + * Get the date / time plus a given amount of time. */ public function plus( int $years = 0, @@ -43,16 +43,28 @@ public function plus( int $hours = 0, int $minutes = 0, int $seconds = 0, - int $microseconds = 0 + int $microseconds = 0, + ?bool $overflow = null ): static { - return $this->add(" - {$years} years {$months} months {$weeks} weeks {$days} days + $date = $this; + + // Zero-unit operations also clone immutable dates. + if ($years !== 0) { + $date = $date->add('years', $years, $overflow); + } + + if ($months !== 0) { + $date = $date->add('months', $months, $overflow); + } + + return $date->add(" + {$weeks} weeks {$days} days {$hours} hours {$minutes} minutes {$seconds} seconds {$microseconds} microseconds "); } /** - * Get the current date / time minus a given amount of time. + * Get the date / time minus a given amount of time. */ public function minus( int $years = 0, @@ -62,10 +74,21 @@ public function minus( int $hours = 0, int $minutes = 0, int $seconds = 0, - int $microseconds = 0 + int $microseconds = 0, + ?bool $overflow = null ): static { - return $this->sub(" - {$years} years {$months} months {$weeks} weeks {$days} days + $date = $this; + + if ($years !== 0) { + $date = $date->sub('years', $years, $overflow); + } + + if ($months !== 0) { + $date = $date->sub('months', $months, $overflow); + } + + return $date->sub(" + {$weeks} weeks {$days} days {$hours} hours {$minutes} minutes {$seconds} seconds {$microseconds} microseconds "); } diff --git a/src/support/src/functions.php b/src/support/src/functions.php index 3c64cf149..81c8dceb2 100644 --- a/src/support/src/functions.php +++ b/src/support/src/functions.php @@ -68,7 +68,7 @@ function now(DateTimeZone|UnitEnum|string|null $tz = null): CarbonInterface } /** - * Get the current date / time plus the given number of microseconds. + * Create an interval of the given number of microseconds. */ function microseconds(int|float $microseconds): CarbonInterval { @@ -76,7 +76,7 @@ function microseconds(int|float $microseconds): CarbonInterval } /** - * Get the current date / time plus the given number of milliseconds. + * Create an interval of the given number of milliseconds. */ function milliseconds(int|float $milliseconds): CarbonInterval { @@ -84,39 +84,88 @@ function milliseconds(int|float $milliseconds): CarbonInterval } /** - * Get the current date / time plus the given number of seconds. + * Create an interval of the given number of seconds. */ function seconds(int|float $seconds): CarbonInterval { - return CarbonInterval::seconds($seconds); + if (is_int($seconds)) { + return CarbonInterval::seconds($seconds); + } + + $whole = $seconds < 0 ? ceil($seconds) : floor($seconds); + $microseconds = (int) round(($seconds - $whole) * CarbonInterface::MICROSECONDS_PER_SECOND); + + // A rounded fraction can reach a full second, which must not stay in the microsecond field. + return CarbonInterval::seconds($whole + intdiv($microseconds, CarbonInterface::MICROSECONDS_PER_SECOND)) + ->microseconds($microseconds % CarbonInterface::MICROSECONDS_PER_SECOND); } /** - * Get the current date / time plus the given number of minutes. + * Create an interval of the given number of minutes. */ function minutes(int|float $minutes): CarbonInterval { - return CarbonInterval::minutes($minutes); + if (is_int($minutes)) { + return CarbonInterval::minutes($minutes); + } + + $microsecondsPerMinute = CarbonInterface::MICROSECONDS_PER_SECOND * CarbonInterface::SECONDS_PER_MINUTE; + $whole = $minutes < 0 ? ceil($minutes) : floor($minutes); + $microseconds = (int) round(($minutes - $whole) * $microsecondsPerMinute); + $remainder = $microseconds % $microsecondsPerMinute; + + return CarbonInterval::minutes($whole + intdiv($microseconds, $microsecondsPerMinute)) + ->seconds(intdiv($remainder, CarbonInterface::MICROSECONDS_PER_SECOND)) + ->microseconds($remainder % CarbonInterface::MICROSECONDS_PER_SECOND); } /** - * Get the current date / time plus the given number of hours. + * Create an interval of the given number of hours. */ function hours(int|float $hours): CarbonInterval { - return CarbonInterval::hours($hours); + if (is_int($hours)) { + return CarbonInterval::hours($hours); + } + + $microsecondsPerMinute = CarbonInterface::MICROSECONDS_PER_SECOND * CarbonInterface::SECONDS_PER_MINUTE; + $microsecondsPerHour = $microsecondsPerMinute * CarbonInterface::MINUTES_PER_HOUR; + $whole = $hours < 0 ? ceil($hours) : floor($hours); + $microseconds = (int) round(($hours - $whole) * $microsecondsPerHour); + $remainder = $microseconds % $microsecondsPerHour; + + return CarbonInterval::hours($whole + intdiv($microseconds, $microsecondsPerHour)) + ->minutes(intdiv($remainder, $microsecondsPerMinute)) + ->seconds(intdiv($remainder % $microsecondsPerMinute, CarbonInterface::MICROSECONDS_PER_SECOND)) + ->microseconds($remainder % CarbonInterface::MICROSECONDS_PER_SECOND); } /** - * Get the current date / time plus the given number of days. + * Create an interval of the given number of days. */ function days(int|float $days): CarbonInterval { - return CarbonInterval::days($days); + if (is_int($days)) { + return CarbonInterval::days($days); + } + + $microsecondsPerMinute = CarbonInterface::MICROSECONDS_PER_SECOND * CarbonInterface::SECONDS_PER_MINUTE; + $microsecondsPerHour = $microsecondsPerMinute * CarbonInterface::MINUTES_PER_HOUR; + $microsecondsPerDay = $microsecondsPerHour * CarbonInterface::HOURS_PER_DAY; + $whole = $days < 0 ? ceil($days) : floor($days); + $microseconds = (int) round(($days - $whole) * $microsecondsPerDay); + $remainder = $microseconds % $microsecondsPerDay; + + // Keep whole calendar days intact; cascading can turn them into months. + return CarbonInterval::days($whole + intdiv($microseconds, $microsecondsPerDay)) + ->hours(intdiv($remainder, $microsecondsPerHour)) + ->minutes(intdiv($remainder % $microsecondsPerHour, $microsecondsPerMinute)) + ->seconds(intdiv($remainder % $microsecondsPerMinute, CarbonInterface::MICROSECONDS_PER_SECOND)) + ->microseconds($remainder % CarbonInterface::MICROSECONDS_PER_SECOND); } /** - * Get the current date / time plus the given number of weeks. + * Create an interval of the given number of weeks. */ function weeks(int $weeks): CarbonInterval { @@ -124,7 +173,7 @@ function weeks(int $weeks): CarbonInterval } /** - * Get the current date / time plus the given number of months. + * Create an interval of the given number of months. */ function months(int $months): CarbonInterval { @@ -132,7 +181,7 @@ function months(int $months): CarbonInterval } /** - * Get the current date / time plus the given number of years. + * Create an interval of the given number of years. */ function years(int $years): CarbonInterval { diff --git a/src/translation/lang/en/validation.php b/src/translation/lang/en/validation.php index 1c3c27386..32f05c552 100644 --- a/src/translation/lang/en/validation.php +++ b/src/translation/lang/en/validation.php @@ -24,6 +24,7 @@ 'alpha_num' => 'The :attribute field must only contain letters and numbers.', 'any_of' => 'The :attribute field is invalid.', 'array' => 'The :attribute field must be an array.', + 'array_keys' => 'The :attribute field must only contain the following keys: :values.', 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.', 'base64' => 'The :attribute field must be a valid Base64 string.', 'before' => 'The :attribute field must be a date before :date.', diff --git a/src/validation/README.md b/src/validation/README.md index 7a06d1a62..b24e7c4a6 100644 --- a/src/validation/README.md +++ b/src/validation/README.md @@ -7,6 +7,7 @@ Documentation: https://hypervel.org/docs/validation ## Differences From Laravel +- String rule parameters use standard CSV quoting with literal backslashes. See [rule parameters](https://hypervel.org/docs/validation#rule-parameters). - Scalar `in` and `not_in` rules compare the submitted value with the rule's literal values as strings. Numeric strings are not loosely coerced. - Date comparison rules allow a referenced field to be missing or `null` unless it is also required. Unparseable date strings and invalid referenced values fail validation instead of being compared with `null`. - Rule keys may escape a literal asterisk as `\*`, matching the existing `\.` literal-dot syntax. diff --git a/src/validation/src/Concerns/FormatsMessages.php b/src/validation/src/Concerns/FormatsMessages.php index b1926bfaf..1b06876f1 100644 --- a/src/validation/src/Concerns/FormatsMessages.php +++ b/src/validation/src/Concerns/FormatsMessages.php @@ -20,10 +20,6 @@ trait FormatsMessages */ protected function getMessage(string $attribute, string $rule): string { - $attributeWithPlaceholders = $attribute; - - $attribute = $this->replacePlaceholderInString($attribute); - $inlineMessage = $this->getInlineMessage($attribute, $rule); // First we will retrieve the custom message for the validation rule if one @@ -54,7 +50,7 @@ protected function getMessage(string $attribute, string $rule): string // specific error message for the type of attribute being validated such // as a number, file or string which all have different message types. if (in_array($rule, $this->sizeRules, true)) { - return $this->getSizeMessage($attributeWithPlaceholders, $rule); + return $this->getSizeMessage($attribute, $rule); } // Finally, if no developer specified messages have been set, and no other @@ -98,6 +94,8 @@ protected function getFromLocalArray(string $attribute, string $lowerRule, ?arra { $source = $source ?: $this->customMessages; + $displayAttribute = $this->replacePlaceholderInString($attribute); + $keys = ["{$attribute}.{$lowerRule}", $lowerRule, $attribute]; if ($this->getAttributeType($attribute) !== 'file') { @@ -112,11 +110,13 @@ protected function getFromLocalArray(string $attribute, string $lowerRule, ?arra // message for the fields, then we will check for a general custom line // that is not attribute specific. If we find either we'll return it. foreach ($keys as $key) { + $displayKey = $this->replacePlaceholderInString($key); + foreach (array_keys($source) as $sourceKey) { - if (str_contains($sourceKey, '*')) { - $pattern = str_replace('\*', '([^.]*)', preg_quote($sourceKey, '#')); + $sourceKey = (string) $sourceKey; - if (preg_match('#^' . $pattern . '\z#u', $key) === 1) { + if (str_contains($sourceKey, '*')) { + if (preg_match($this->getWildcardMessagePattern($sourceKey), $key) === 1) { $message = $source[$sourceKey]; if (is_array($message) && isset($message[$lowerRule])) { @@ -129,10 +129,10 @@ protected function getFromLocalArray(string $attribute, string $lowerRule, ?arra continue; } - if (Str::is($sourceKey, $key)) { + if ($sourceKey === $displayKey) { $message = $source[$sourceKey]; - if ($sourceKey === $attribute && is_array($message)) { + if ($sourceKey === $displayAttribute && is_array($message)) { return $message[$lowerRule] ?? null; } @@ -150,7 +150,9 @@ protected function getFromLocalArray(string $attribute, string $lowerRule, ?arra protected function getCustomMessageFromTranslator(array|string $keys): string { foreach (Arr::wrap($keys) as $key) { - if (($message = $this->translator->string($key)) !== $key) { + $displayKey = $this->replacePlaceholderInString($key); + + if (($message = $this->translator->string($displayKey)) !== $displayKey) { return $message; } @@ -180,9 +182,12 @@ protected function getCustomMessageFromTranslator(array|string $keys): string */ protected function getWildcardCustomMessages(array $messages, string $search, string $default): string { + $displaySearch = $this->replacePlaceholderInString($search); + foreach ($messages as $key => $message) { $key = (string) $key; - if ($search === $key || (Str::contains($key, ['*']) && Str::is($key, $search))) { + if ($displaySearch === $key || (str_contains($key, '*') + && preg_match($this->getWildcardMessagePattern($key, multipleSegments: true), $search) === 1)) { return $message; } } @@ -190,6 +195,27 @@ protected function getWildcardCustomMessages(array $messages, string $search, st return $default; } + /** + * Build a wildcard message pattern that preserves literal path segments. + */ + protected function getWildcardMessagePattern(string $key, bool $multipleSegments = false): string + { + $segments = []; + + foreach (explode('.', $key) as $segment) { + $pattern = str_replace('\*', $multipleSegments ? '.*' : '[^.]*', preg_quote($segment, '#')); + + // Fixed dots may name literal keys, but a wildcard segment must not split one. + $segments[] = str_contains($segment, '*') + ? '(?replaceOrdinalPositionPlaceholder($message, $attribute); if (isset($this->replacers[Str::snake($rule)])) { - return $this->callReplacer($message, $attribute, Str::snake($rule), $parameters, $this); + return $this->callReplacer( + $message, + $this->replacePlaceholderInString($attribute), + Str::snake($rule), + $this->dependsOnOtherFields($rule) ? $this->replaceDotPlaceholderInParameters($parameters) : $parameters, + $this + ); } if (method_exists($this, $replacer = "replace{$rule}")) { return $this->{$replacer}($message, $attribute, $rule, $parameters); @@ -253,6 +288,7 @@ public function getDisplayableAttribute(string $attribute): string { $primaryAttribute = $this->getPrimaryAttribute($attribute); + // Resolve wildcard metadata before decoding a literal dot into a path separator. $expectedAttributes = $attribute !== $primaryAttribute ? [$attribute, $primaryAttribute] : [$attribute]; @@ -273,6 +309,8 @@ public function getDisplayableAttribute(string $attribute): string } } + $attribute = $this->replacePlaceholderInString($attribute); + // When no language line has been specified for the attribute and it is also // an implicit attribute we will display the raw attribute's name and not // modify it with any of these replacements before we display the name. @@ -304,15 +342,17 @@ protected function getAttributeFromLocalArray(string $attribute, ?array $source { $source = $source ?: $this->customAttributes; - if (isset($source[$attribute])) { - return $source[$attribute]; + $displayAttribute = $this->replacePlaceholderInString($attribute); + + if (isset($source[$displayAttribute])) { + return $source[$displayAttribute]; } foreach (array_keys($source) as $sourceKey) { - if (str_contains($sourceKey, '*')) { - $pattern = str_replace('\*', '([^.]*)', preg_quote($sourceKey, '#')); + $sourceKey = (string) $sourceKey; - if (preg_match('#^' . $pattern . '\z#u', $attribute) === 1) { + if (str_contains($sourceKey, '*')) { + if (preg_match($this->getWildcardMessagePattern($sourceKey), $attribute) === 1) { return $source[$sourceKey]; } } @@ -453,6 +493,8 @@ protected function replaceInputPlaceholder(string $message, string $attribute): */ public function getDisplayableValue(string $attribute, mixed $value): string { + $attribute = $this->replacePlaceholderInString($attribute); + if (isset($this->customValues[$attribute][$value])) { return $this->customValues[$attribute][$value]; } diff --git a/src/validation/src/Concerns/ReplacesAttributes.php b/src/validation/src/Concerns/ReplacesAttributes.php index aa12d8bf4..c49f2d743 100644 --- a/src/validation/src/Concerns/ReplacesAttributes.php +++ b/src/validation/src/Concerns/ReplacesAttributes.php @@ -25,14 +25,12 @@ protected function replaceAcceptedIf(string $message, string $attribute, string /** * Replace all place-holders for the declined_if rule. + * + * @param array $parameters */ protected function replaceDeclinedIf(string $message, string $attribute, string $rule, array $parameters): string { - $parameters[1] = $this->getDisplayableValue($parameters[0], Arr::get($this->data, $parameters[0])); - - $parameters[0] = $this->getDisplayableAttribute($parameters[0]); - - return str_replace([':other', ':value'], $parameters, $message); + return $this->replaceAcceptedIf($message, $attribute, $rule, $parameters); } /** @@ -168,11 +166,7 @@ protected function replaceMaxDigits(string $message, string $attribute, string $ */ protected function replaceMissingIf(string $message, string $attribute, string $rule, array $parameters): string { - $parameters[1] = $this->getDisplayableValue($parameters[0], Arr::get($this->data, $parameters[0])); - - $parameters[0] = $this->getDisplayableAttribute($parameters[0]); - - return str_replace([':other', ':value'], $parameters, $message); + return $this->replaceAcceptedIf($message, $attribute, $rule, $parameters); } /** @@ -280,6 +274,29 @@ protected function replaceInArrayKeys(string $message, string $attribute, string return $this->replaceIn($message, $attribute, $rule, $parameters); } + /** + * Replace all place-holders for the array_keys rule. + * + * @param array $parameters + */ + protected function replaceArrayKeys(string $message, string $attribute, string $rule, array $parameters): string + { + $message = $this->replaceIn($message, $attribute, $rule, $parameters); + + $value = $this->getValue($attribute); + + $unexpected = is_array($value) + ? array_keys(array_diff_key($value, $this->acceptedArrayKeys($parameters))) + : []; + + $unexpected = array_map( + fn (int|string $key): string => $this->getDisplayableValue($attribute, $this->replacePlaceholderInString((string) $key)), + $unexpected, + ); + + return $this->replaceWhileKeepingCase($message, ['unexpected' => implode(', ', $unexpected)]); + } + /** * Replace all place-holders for the required_array_keys rule. * @@ -287,11 +304,7 @@ protected function replaceInArrayKeys(string $message, string $attribute, string */ protected function replaceRequiredArrayKeys(string $message, string $attribute, string $rule, array $parameters): string { - foreach ($parameters as &$parameter) { - $parameter = $this->getDisplayableValue($attribute, $parameter); - } - - return str_replace(':values', implode(', ', $parameters), $message); + return $this->replaceIn($message, $attribute, $rule, $parameters); } /** @@ -321,10 +334,7 @@ protected function replaceMimes(string $message, string $attribute, string $rule */ protected function replacePresentIf(string $message, string $attribute, string $rule, array $parameters): string { - $parameters[1] = $this->getDisplayableValue($parameters[0], Arr::get($this->data, $parameters[0])); - $parameters[0] = $this->getDisplayableAttribute($parameters[0]); - - return str_replace([':other', ':value'], $parameters, $message); + return $this->replaceAcceptedIf($message, $attribute, $rule, $parameters); } /** @@ -334,10 +344,7 @@ protected function replacePresentIf(string $message, string $attribute, string $ */ protected function replacePresentUnless(string $message, string $attribute, string $rule, array $parameters): string { - return str_replace([':other', ':value'], [ - $this->getDisplayableAttribute($parameters[0]), - $this->getDisplayableValue($parameters[0], $parameters[1]), - ], $message); + return $this->replaceMissingUnless($message, $attribute, $rule, $parameters); } /** @@ -447,11 +454,7 @@ protected function replaceGt(string $message, string $attribute, string $rule, a */ protected function replaceLt(string $message, string $attribute, string $rule, array $parameters): string { - if (is_null($value = $this->getValue($parameters[0]))) { - return str_replace(':value', $this->getDisplayableAttribute($parameters[0]), $message); - } - - return str_replace(':value', (string) $this->getSize($attribute, $value), $message); + return $this->replaceGt($message, $attribute, $rule, $parameters); } /** @@ -461,11 +464,7 @@ protected function replaceLt(string $message, string $attribute, string $rule, a */ protected function replaceGte(string $message, string $attribute, string $rule, array $parameters): string { - if (is_null($value = $this->getValue($parameters[0]))) { - return str_replace(':value', $this->getDisplayableAttribute($parameters[0]), $message); - } - - return str_replace(':value', (string) $this->getSize($attribute, $value), $message); + return $this->replaceGt($message, $attribute, $rule, $parameters); } /** @@ -475,11 +474,7 @@ protected function replaceGte(string $message, string $attribute, string $rule, */ protected function replaceLte(string $message, string $attribute, string $rule, array $parameters): string { - if (is_null($value = $this->getValue($parameters[0]))) { - return str_replace(':value', $this->getDisplayableAttribute($parameters[0]), $message); - } - - return str_replace(':value', (string) $this->getSize($attribute, $value), $message); + return $this->replaceGt($message, $attribute, $rule, $parameters); } /** @@ -489,11 +484,7 @@ protected function replaceLte(string $message, string $attribute, string $rule, */ protected function replaceRequiredIf(string $message, string $attribute, string $rule, array $parameters): string { - $parameters[1] = $this->getDisplayableValue($parameters[0], Arr::get($this->data, $parameters[0])); - - $parameters[0] = $this->getDisplayableAttribute($parameters[0]); - - return str_replace([':other', ':value'], $parameters, $message); + return $this->replaceAcceptedIf($message, $attribute, $rule, $parameters); } /** @@ -513,11 +504,9 @@ protected function replaceRequiredIfAccepted(string $message, string $attribute, * * @param array $parameters */ - public function replaceRequiredIfDeclined(string $message, string $attribute, string $rule, array $parameters): string + protected function replaceRequiredIfDeclined(string $message, string $attribute, string $rule, array $parameters): string { - $parameters[0] = $this->getDisplayableAttribute($parameters[0]); - - return str_replace([':other'], $parameters, $message); + return $this->replaceRequiredIfAccepted($message, $attribute, $rule, $parameters); } /** @@ -574,7 +563,7 @@ protected function replaceProhibitedIfAccepted(string $message, string $attribut * * @param array $parameters */ - public function replaceProhibitedIfDeclined(string $message, string $attribute, string $rule, array $parameters): string + protected function replaceProhibitedIfDeclined(string $message, string $attribute, string $rule, array $parameters): string { return $this->replaceRequiredIfAccepted($message, $attribute, $rule, $parameters); } @@ -586,15 +575,7 @@ public function replaceProhibitedIfDeclined(string $message, string $attribute, */ protected function replaceProhibitedUnless(string $message, string $attribute, string $rule, array $parameters): string { - $other = $this->getDisplayableAttribute($parameters[0]); - - $values = []; - - foreach (array_slice($parameters, 1) as $value) { - $values[] = $this->getDisplayableValue($parameters[0], $value); - } - - return str_replace([':other', ':values'], [$other, implode(', ', $values)], $message); + return $this->replaceRequiredUnless($message, $attribute, $rule, $parameters); } /** @@ -706,11 +687,7 @@ protected function replaceDimensions(string $message, string $attribute, string */ protected function replaceEndsWith(string $message, string $attribute, string $rule, array $parameters): string { - foreach ($parameters as &$parameter) { - $parameter = $this->getDisplayableValue($attribute, $parameter); - } - - return str_replace(':values', implode(', ', $parameters), $message); + return $this->replaceIn($message, $attribute, $rule, $parameters); } /** @@ -720,11 +697,7 @@ protected function replaceEndsWith(string $message, string $attribute, string $r */ protected function replaceDoesntEndWith(string $message, string $attribute, string $rule, array $parameters): string { - foreach ($parameters as &$parameter) { - $parameter = $this->getDisplayableValue($attribute, $parameter); - } - - return str_replace(':values', implode(', ', $parameters), $message); + return $this->replaceIn($message, $attribute, $rule, $parameters); } /** @@ -744,11 +717,7 @@ protected function replaceStartsWith(string $message, string $attribute, string */ protected function replaceDoesntStartWith(string $message, string $attribute, string $rule, array $parameters): string { - foreach ($parameters as &$parameter) { - $parameter = $this->getDisplayableValue($attribute, $parameter); - } - - return str_replace(':values', implode(', ', $parameters), $message); + return $this->replaceIn($message, $attribute, $rule, $parameters); } /** diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index e2b968e00..7a662d175 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -415,7 +415,37 @@ public function validateArray(string $attribute, mixed $value, array $parameters return true; } - return empty(array_diff_key($value, array_fill_keys($parameters, ''))); + return empty(array_diff_key($value, $this->acceptedArrayKeys($parameters))); + } + + /** + * Get the accepted literal and encoded array keys. + * + * @param array $parameters + * @return array + */ + protected function acceptedArrayKeys(array $parameters): array + { + // Validator data has encoded keys, while direct validation calls may supply literal keys. + $keys = array_fill_keys($parameters, ''); + + return $keys + ValidationData::encodeKeys($keys); + } + + /** + * Validate that an array does not contain any keys other than the given keys. + * + * @param array $parameters + */ + public function validateArrayKeys(string $attribute, mixed $value, array $parameters): bool + { + $this->requireParameterCount(1, $parameters, 'array_keys'); + + if (! is_array($value)) { + return false; + } + + return empty(array_diff_key($value, $this->acceptedArrayKeys($parameters))); } /** @@ -437,8 +467,8 @@ public function validateRequiredArrayKeys(string $attribute, mixed $value, array return false; } - foreach ($parameters as $param) { - if (! Arr::exists($value, $param)) { + foreach ($parameters as $parameter) { + if (! Arr::exists($value, $parameter) && ! Arr::exists($value, ValidationData::encodeKey((string) $parameter))) { return false; } } @@ -1036,7 +1066,7 @@ public function validateUnique(string $attribute, mixed $value, mixed $parameter [$idColumn, $id] = $this->getUniqueIds($idColumn, $parameters); if (! is_null($id)) { - $id = stripslashes((string) $id); + $id = (string) $id; } } @@ -1495,8 +1525,8 @@ public function validateInArrayKeys(string $attribute, mixed $value, array $para return false; } - foreach ($parameters as $param) { - if (Arr::exists($value, $param)) { + foreach ($parameters as $parameter) { + if (Arr::exists($value, $parameter) || Arr::exists($value, ValidationData::encodeKey((string) $parameter))) { return true; } } diff --git a/src/validation/src/Rule.php b/src/validation/src/Rule.php index 480bc9e45..299303b5f 100644 --- a/src/validation/src/Rule.php +++ b/src/validation/src/Rule.php @@ -13,6 +13,7 @@ use Hypervel\Support\Arr; use Hypervel\Support\Traits\Macroable; use Hypervel\Validation\Rules\AnyOf; +use Hypervel\Validation\Rules\ArrayKeys; use Hypervel\Validation\Rules\ArrayRule; use Hypervel\Validation\Rules\Can; use Hypervel\Validation\Rules\Contains; @@ -88,6 +89,14 @@ public static function array(mixed $keys = null): ArrayRule return new ArrayRule(...func_get_args()); } + /** + * Get an array keys rule builder instance. + */ + public static function arrayKeys(array|Arrayable|UnitEnum|int|string $keys): ArrayKeys + { + return new ArrayKeys(...func_get_args()); + } + /** * Create a new nested rule set. */ diff --git a/src/validation/src/Rules/ArrayKeys.php b/src/validation/src/Rules/ArrayKeys.php new file mode 100644 index 000000000..df5dfc5c6 --- /dev/null +++ b/src/validation/src/Rules/ArrayKeys.php @@ -0,0 +1,44 @@ +toArray(); + } + + $this->keys = is_array($keys) ? $keys : func_get_args(); + } + + /** + * Convert the rule to a validation string. + */ + public function __toString(): string + { + $keys = array_map( + static fn ($key): string => '"' . str_replace('"', '""', (string) enum_value($key)) . '"', + $this->keys, + ); + + return 'array_keys:' . implode(',', $keys); + } +} diff --git a/src/validation/src/Rules/ArrayRule.php b/src/validation/src/Rules/ArrayRule.php index 10f4dd655..2c74cce97 100644 --- a/src/validation/src/Rules/ArrayRule.php +++ b/src/validation/src/Rules/ArrayRule.php @@ -38,7 +38,7 @@ public function __toString(): string } $keys = array_map( - static fn ($key) => enum_value($key), + static fn ($key): string => '"' . str_replace('"', '""', (string) enum_value($key)) . '"', $this->keys, ); diff --git a/src/validation/src/Rules/Date.php b/src/validation/src/Rules/Date.php index ac3d23665..174ae0fa4 100644 --- a/src/validation/src/Rules/Date.php +++ b/src/validation/src/Rules/Date.php @@ -162,9 +162,11 @@ protected function addRule(array|string $rules): static */ protected function formatDate(DateTimeInterface|string $date): string { - return $date instanceof DateTimeInterface + $date = $date instanceof DateTimeInterface ? $date->format($this->format ?? 'Y-m-d') : $date; + + return '"' . str_replace('"', '""', $date) . '"'; } /** @@ -172,10 +174,20 @@ protected function formatDate(DateTimeInterface|string $date): string */ public function __toString(): string { - return implode('|', [ - $this->format === null ? 'date' : 'date_format:' . $this->format, + return implode('|', $this->toArray()); + } + + /** + * Convert the rule to an array of validation rules. + * + * @return list + */ + public function toArray(): array + { + return [ + $this->format === null ? 'date' : 'date_format:"' . str_replace('"', '""', $this->format) . '"', ...$this->constraints, - ]); + ]; } /** diff --git a/src/validation/src/Rules/Numeric.php b/src/validation/src/Rules/Numeric.php index 61155b6c4..811e20dbc 100644 --- a/src/validation/src/Rules/Numeric.php +++ b/src/validation/src/Rules/Numeric.php @@ -44,7 +44,7 @@ public function decimal(int $min, ?int $max = null): static */ public function different(string $field): static { - return $this->addRule('different:' . $field); + return $this->addRule('different:"' . str_replace('"', '""', $field) . '"'); } /** @@ -68,7 +68,7 @@ public function digitsBetween(int $min, int $max): static */ public function greaterThan(string $field): static { - return $this->addRule('gt:' . $field); + return $this->addRule('gt:"' . str_replace('"', '""', $field) . '"'); } /** @@ -76,7 +76,7 @@ public function greaterThan(string $field): static */ public function greaterThanOrEqualTo(string $field): static { - return $this->addRule('gte:' . $field); + return $this->addRule('gte:"' . str_replace('"', '""', $field) . '"'); } /** @@ -92,7 +92,7 @@ public function integer(): static */ public function lessThan(string $field): static { - return $this->addRule('lt:' . $field); + return $this->addRule('lt:"' . str_replace('"', '""', $field) . '"'); } /** @@ -100,7 +100,7 @@ public function lessThan(string $field): static */ public function lessThanOrEqualTo(string $field): static { - return $this->addRule('lte:' . $field); + return $this->addRule('lte:"' . str_replace('"', '""', $field) . '"'); } /** @@ -148,7 +148,7 @@ public function multipleOf(float|int $value): static */ public function same(string $field): static { - return $this->addRule('same:' . $field); + return $this->addRule('same:"' . str_replace('"', '""', $field) . '"'); } /** @@ -164,7 +164,17 @@ public function exactly(int $value): static */ public function __toString(): string { - return implode('|', array_unique($this->constraints)); + return implode('|', $this->toArray()); + } + + /** + * Convert the rule to an array of validation rules. + * + * @return list + */ + public function toArray(): array + { + return array_values(array_unique($this->constraints)); } /** diff --git a/src/validation/src/Rules/StringRule.php b/src/validation/src/Rules/StringRule.php index 03003c5d1..d636d1d77 100644 --- a/src/validation/src/Rules/StringRule.php +++ b/src/validation/src/Rules/StringRule.php @@ -62,7 +62,7 @@ public function between(int $min, int $max): static */ public function doesntEndWith(string ...$values): static { - return $this->addRule('doesnt_end_with:' . implode(',', $values)); + return $this->addRule('doesnt_end_with:' . $this->formatValues($values)); } /** @@ -70,7 +70,7 @@ public function doesntEndWith(string ...$values): static */ public function doesntStartWith(string ...$values): static { - return $this->addRule('doesnt_start_with:' . implode(',', $values)); + return $this->addRule('doesnt_start_with:' . $this->formatValues($values)); } /** @@ -78,7 +78,7 @@ public function doesntStartWith(string ...$values): static */ public function endsWith(string ...$values): static { - return $this->addRule('ends_with:' . implode(',', $values)); + return $this->addRule('ends_with:' . $this->formatValues($values)); } /** @@ -118,7 +118,7 @@ public function min(int $value): static */ public function startsWith(string ...$values): static { - return $this->addRule('starts_with:' . implode(',', $values)); + return $this->addRule('starts_with:' . $this->formatValues($values)); } /** @@ -134,7 +134,30 @@ public function uppercase(): static */ public function __toString(): string { - return implode('|', array_unique($this->constraints)); + return implode('|', $this->toArray()); + } + + /** + * Convert the rule to an array of validation rules. + * + * @return list + */ + public function toArray(): array + { + return array_values(array_unique($this->constraints)); + } + + /** + * Format literal values as CSV parameters. + * + * @param list $values + */ + protected function formatValues(array $values): string + { + return implode(',', array_map( + static fn (string $value): string => '"' . str_replace('"', '""', $value) . '"', + $values, + )); } /** diff --git a/src/validation/src/Rules/Unique.php b/src/validation/src/Rules/Unique.php index 3f897fb67..3134a7846 100644 --- a/src/validation/src/Rules/Unique.php +++ b/src/validation/src/Rules/Unique.php @@ -58,7 +58,7 @@ public function __toString(): string 'unique:%s,%s,%s,%s,%s', $this->table, $this->column, - $this->ignore !== null ? '"' . addslashes((string) $this->ignore) . '"' : 'NULL', + $this->ignore !== null ? '"' . str_replace('"', '""', (string) $this->ignore) . '"' : 'NULL', $this->idColumn, $this->formatWheres() ), ','); diff --git a/src/validation/src/ValidationData.php b/src/validation/src/ValidationData.php index 8a1945ba9..62129a0f5 100644 --- a/src/validation/src/ValidationData.php +++ b/src/validation/src/ValidationData.php @@ -42,6 +42,20 @@ public static function decodeAttribute(string $attribute): string ); } + /** + * Encode literal dots and asterisks in a data key. + */ + public static function encodeKey(int|string $key): string + { + $placeholderHash = static::placeholderHash(); + + return str_replace( + ['.', '*'], + ['__dot__' . $placeholderHash, '__asterisk__' . $placeholderHash], + (string) $key, + ); + } + /** * Encode literal dots and asterisks in data keys. */ @@ -55,6 +69,7 @@ public static function encodeKeys(array $data): array $value = static::encodeKeys($value); } + // Keep encoding inline to avoid a method and hash lookup for every input key. $key = str_replace( ['.', '*'], ['__dot__' . $placeholderHash, '__asterisk__' . $placeholderHash], diff --git a/src/validation/src/ValidationRuleParser.php b/src/validation/src/ValidationRuleParser.php index c59e94c67..670c7c55b 100644 --- a/src/validation/src/ValidationRuleParser.php +++ b/src/validation/src/ValidationRuleParser.php @@ -89,7 +89,8 @@ protected function explodeExplicitRule(mixed $rule, string $attribute): array if (is_object($rule)) { if ($rule instanceof Date || $rule instanceof Numeric || $rule instanceof StringRule) { - return explode('|', (string) $rule); + // Composite rules already separate constraints; literal parameters may contain pipes. + return $rule->toArray(); } return Arr::wrap($this->prepareRule($rule, $attribute)); @@ -99,7 +100,7 @@ protected function explodeExplicitRule(mixed $rule, string $attribute): array foreach ($rule as $value) { if ($value instanceof Date || $value instanceof Numeric || $value instanceof StringRule) { - $rules = array_merge($rules, explode('|', (string) $value)); + $rules = array_merge($rules, $value->toArray()); } else { $rules[] = $this->prepareRule($value, $attribute); } @@ -421,7 +422,8 @@ protected static function parseStringRule(string|Stringable $rule): array */ protected static function parseParameters(string $rule, string $parameter): array { - return static::ruleIsRegex($rule) ? [$parameter] : str_getcsv($parameter, escape: '\\'); + // Builders use doubled quotes; a backslash escape corrupts trailing backslashes. + return static::ruleIsRegex($rule) ? [$parameter] : str_getcsv($parameter, escape: ''); } /** diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index 85d168e38..f33b03d6f 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -381,8 +381,6 @@ protected function replacePlaceholderInString(string $value): string */ protected function replaceDotPlaceholderInParameters(array $parameters): array { - // Inline date-comparison failures bypass validateAttribute(), so their raw - // scalar parameters need the same string normalization as delegated rules. return array_map( static fn (mixed $field): string => ValidationData::replacePlaceholderInString((string) $field), $parameters, @@ -851,7 +849,7 @@ private function extractPresenceRuleMeta( [$idColumn, $ignore] = $this->getUniqueIds($modelIdColumn, $parameters); if ($ignore !== null) { - $ignore = stripslashes((string) $ignore); + $ignore = (string) $ignore; } } if (isset($parameters[4])) { @@ -1307,6 +1305,8 @@ protected function hasNotFailedPreviousRuleIfPresenceRule(object|string $rule, s */ protected function validateUsingCustomRule(string $attribute, mixed $value, Rule $rule): void { + $attributeWithPlaceholders = $attribute; + $originalAttribute = $this->replacePlaceholderInString($attribute); $attribute = match (true) { @@ -1339,7 +1339,7 @@ protected function validateUsingCustomRule(string $attribute, mixed $value, Rule $this->failedRules[$originalAttribute][$ruleClass] = []; - $messages = $this->getFromLocalArray($originalAttribute, $ruleClass) ?? $rule->message(); + $messages = $this->getFromLocalArray($attributeWithPlaceholders, $ruleClass) ?? $rule->message(); $messages = $messages ? (array) $messages : [$ruleClass]; @@ -1348,7 +1348,7 @@ protected function validateUsingCustomRule(string $attribute, mixed $value, Rule $this->messages->add($key, $this->makeReplacements( $message, - $key, + $key === $originalAttribute ? $attributeWithPlaceholders : $key, $ruleClass, [] )); @@ -1401,17 +1401,21 @@ public function addFailure(string $attribute, string $rule, array $parameters = } if ($this->dependsOnOtherFields($rule)) { - $parameters = $this->replaceDotPlaceholderInParameters($parameters); + // Inline checks may supply scalar parameters; retain their encoded field paths. + $parameters = array_map(strval(...), $parameters); } + // Message lookups must distinguish literal dots and asterisks from path syntax. $this->messages->add($attribute, $this->makeReplacements( $this->getMessage($attributeWithPlaceholders, $rule), - $attribute, + $attributeWithPlaceholders, $rule, $parameters )); - $this->failedRules[$attribute][$rule] = $parameters; + $this->failedRules[$attribute][$rule] = $this->dependsOnOtherFields($rule) + ? $this->replaceDotPlaceholderInParameters($parameters) + : $parameters; } /** diff --git a/src/wayfinder/src/GenerateCommand.php b/src/wayfinder/src/GenerateCommand.php index b47568e1f..dfcf0b916 100644 --- a/src/wayfinder/src/GenerateCommand.php +++ b/src/wayfinder/src/GenerateCommand.php @@ -7,6 +7,7 @@ use BackedEnum; use Closure; use Hypervel\Console\Command; +use Hypervel\Contracts\Http\Kernel as HttpKernel; use Hypervel\Contracts\Routing\UrlRoutable; use Hypervel\Filesystem\Filesystem; use Hypervel\Routing\Route as BaseRoute; @@ -83,6 +84,10 @@ public function handle(): int throw new InvalidArgumentException('The --path option may not be empty.'); } + // Console bootstrap leaves the HTTP kernel unresolved. Resolving it installs + // the application's middleware groups, aliases, and priority on the router. + $this->hypervel->make(HttpKernel::class); + $this->view->replaceNamespace('wayfinder', __DIR__ . '/../resources'); $this->view->addExtension('blade.ts', 'blade'); diff --git a/tests/Cache/CacheArrayStoreTest.php b/tests/Cache/CacheArrayStoreTest.php index 3b67367ba..a62c014f9 100644 --- a/tests/Cache/CacheArrayStoreTest.php +++ b/tests/Cache/CacheArrayStoreTest.php @@ -143,13 +143,15 @@ public function testIncrementNonNumericValues(): void public function testNonExistingKeysCanBeIncremented(): void { + CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + $store = new ArrayStore; $result = $store->increment('foo'); $this->assertEquals(1, $result); $this->assertEquals(1, $store->get('foo')); // Will be there forever - CarbonImmutable::setTestNow(CarbonImmutable::now()->addYears(10)); + CarbonImmutable::setTestNow($now->addYears(10)); $this->assertEquals(1, $store->get('foo')); } diff --git a/tests/Cache/Redis/Support/SerializationTest.php b/tests/Cache/Redis/Support/SerializationTest.php index 1372af106..7f7ea8342 100644 --- a/tests/Cache/Redis/Support/SerializationTest.php +++ b/tests/Cache/Redis/Support/SerializationTest.php @@ -183,9 +183,9 @@ public function testSerializeForLuaAppliesCompressionWhenEnabled(): void $connection->shouldReceive('getOption') ->with(Redis::OPT_COMPRESSION) ->andReturn(Redis::COMPRESSION_LZF); - $connection->shouldReceive('_serialize') - ->with(serialize('test-value')) - ->andReturn('compressed-value'); + $connection->shouldReceive('pack') + ->with([serialize('test-value')]) + ->andReturn(['compressed-value']); $this->assertSame('compressed-value', $this->serialization->serializeForLua($connection, 'test-value')); } @@ -216,7 +216,7 @@ public function testSerializeForLuaCastsNumericValuesToString(): void $this->assertSame('45.67', $this->serialization->serializeForLua($connection, 45.67)); } - public function testSerializeForLuaCastsNumericToStringWithCompression(): void + public function testSerializeForLuaPreservesNumericTypesForPackingWithCompression(): void { if (! defined('Redis::COMPRESSION_LZF')) { $this->markTestSkipped('Redis::COMPRESSION_LZF not available (phpredis compiled without LZF support)'); @@ -228,10 +228,9 @@ public function testSerializeForLuaCastsNumericToStringWithCompression(): void $connection->shouldReceive('getOption') ->with(Redis::OPT_COMPRESSION) ->andReturn(Redis::COMPRESSION_LZF); - // When compression is enabled, numeric strings get passed through _serialize - $connection->shouldReceive('_serialize') - ->with('123') - ->andReturn('compressed-123'); + $connection->shouldReceive('pack') + ->with([123]) + ->andReturn(['compressed-123']); $this->assertSame('compressed-123', $this->serialization->serializeForLua($connection, 123)); } diff --git a/tests/Console/CommandSignatureTest.php b/tests/Console/CommandSignatureTest.php new file mode 100644 index 000000000..43d996bf2 --- /dev/null +++ b/tests/Console/CommandSignatureTest.php @@ -0,0 +1,100 @@ +assertTrue( + class_exists($class), + "Command class [{$class}] no longer exists. Update tests/Console/Fixtures/command_signatures.php." + ); + + $command = $this->makeCommandWithoutDependencies($class); + + $this->assertSame($expected['name'], $command->getName(), "Command name changed for [{$class}]."); + $this->assertSame($expected['aliases'], $command->getAliases(), "Command aliases changed for [{$class}]."); + $this->assertSame($expected['hidden'], $command->isHidden(), "Command visibility changed for [{$class}]."); + + $definition = $command->getDefinition(); + + $arguments = []; + + foreach ($definition->getArguments() as $argument) { + $arguments[] = [ + 'name' => $argument->getName(), + 'mode' => $argument->isRequired() ? 'required' : 'optional', + 'isArray' => $argument->isArray(), + 'default' => $argument->getDefault(), + 'description' => $argument->getDescription(), + ]; + } + + $options = []; + + foreach ($definition->getOptions() as $option) { + $options[] = [ + 'name' => $option->getName(), + 'shortcut' => $option->getShortcut(), + 'negatable' => $option->isNegatable(), + 'valueRequired' => $option->isValueRequired(), + 'valueOptional' => $option->isValueOptional(), + 'isArray' => $option->isArray(), + 'acceptValue' => $option->acceptValue(), + 'default' => $option->getDefault(), + 'description' => $option->getDescription(), + ]; + } + + $this->assertSame($expected['arguments'], $arguments, "Command arguments changed for [{$class}]."); + $this->assertSame($expected['options'], $options, "Command options changed for [{$class}]."); + } + + /** + * Provide the recorded command signatures. + */ + public static function commands(): array + { + $commands = require __DIR__ . '/Fixtures/command_signatures.php'; + + $cases = []; + + foreach ($commands as $class => $expected) { + $expected = [ + 'aliases' => $expected['aliases'] ?? [], + 'hidden' => $expected['hidden'] ?? false, + 'arguments' => $expected['arguments'] ?? [], + 'options' => $expected['options'] ?? [], + ...$expected, + ]; + + $cases[$class] = [$class, $expected]; + } + + return $cases; + } + + /** + * Create a command definition without resolving its dependencies. + */ + protected function makeCommandWithoutDependencies(string $class): Command + { + $reflection = new ReflectionClass($class); + + $instance = $reflection->newInstanceWithoutConstructor(); + + (new ReflectionMethod(Command::class, '__construct'))->invoke($instance); + + return $instance; + } +} diff --git a/tests/Console/ConsoleApplicationResolveTest.php b/tests/Console/ConsoleApplicationResolveTest.php index e36667d32..291f0dc56 100644 --- a/tests/Console/ConsoleApplicationResolveTest.php +++ b/tests/Console/ConsoleApplicationResolveTest.php @@ -98,7 +98,7 @@ public function testResolveRegistersAllPipeAliases() $this->assertArrayHasKey('test:alias', $map); } - public function testResolveEagerlyResolvesCommandWithoutStaticName() + public function testResolveEagerlyResolvesCommandWithoutStaticName(): void { $command = new SymfonyCommand('test:dynamic'); $container = $this->createMock(Application::class); @@ -107,11 +107,11 @@ public function testResolveEagerlyResolvesCommandWithoutStaticName() ->with(StubDynamicCommand::class) ->willReturn($command); - $app = $this->createApp($container); - $result = $app->resolve(StubDynamicCommand::class); + $artisan = $this->createApp($container); + $result = $artisan->resolve(StubDynamicCommand::class); - $this->assertInstanceOf(SymfonyCommand::class, $result); - $this->assertArrayNotHasKey('test:dynamic', $this->getCommandMap($app)); + $this->assertSame($command, $result); + $this->assertArrayNotHasKey('test:dynamic', $this->getCommandMap($artisan)); } public function testAsCommandAttributeTakesPriorityOverSignature() @@ -335,36 +335,42 @@ public function testAliasesAttributeOverridesSignatureAliasesInCommandMap(): voi $this->assertArrayNotHasKey('test:aliases-attribute-ignored', $map); } - public function testResolvingCommandsWithNoAliasViaAttribute() + public function testResolvingCommandsWithNoAliasViaAttribute(): void { - $app = $this->createApp($this->app); - $app->resolve(StubAttributedCommand::class); - $app->setContainerCommandLoader(); + $artisan = $this->createApp($this->app); + $artisan->resolve(StubAttributedCommand::class); + $artisan->setContainerCommandLoader(); - $this->assertInstanceOf(StubAttributedCommand::class, $app->get('test:attributed')); + $this->assertInstanceOf(StubAttributedCommand::class, $artisan->get('test:attributed')); try { - $app->get('some-nonexistent-alias'); + $artisan->get('some-nonexistent-alias'); $this->fail(); } catch (Throwable $e) { $this->assertInstanceOf(CommandNotFoundException::class, $e); } + + $this->assertArrayHasKey('test:attributed', $artisan->all()); + $this->assertArrayNotHasKey('some-nonexistent-alias', $artisan->all()); } - public function testResolvingCommandsWithNoAliasViaProperty() + public function testResolvingCommandsWithNoAliasViaProperty(): void { - $app = $this->createApp($this->app); - $app->resolve(StubCommandWithoutPropertyAlias::class); - $app->setContainerCommandLoader(); + $artisan = $this->createApp($this->app); + $artisan->resolve(StubCommandWithoutPropertyAlias::class); + $artisan->setContainerCommandLoader(); - $this->assertInstanceOf(StubCommandWithoutPropertyAlias::class, $app->get('alias-test:no-alias')); + $this->assertInstanceOf(StubCommandWithoutPropertyAlias::class, $artisan->get('alias-test:no-alias')); try { - $app->get('some-nonexistent-alias'); + $artisan->get('some-nonexistent-alias'); $this->fail(); } catch (Throwable $e) { $this->assertInstanceOf(CommandNotFoundException::class, $e); } + + $this->assertArrayHasKey('alias-test:no-alias', $artisan->all()); + $this->assertArrayNotHasKey('some-nonexistent-alias', $artisan->all()); } // --------------------------------------------------------------- @@ -403,7 +409,7 @@ public function testCallStringAndArrayInputProduceSameResult(): void // PromptsForMissingInput // --------------------------------------------------------------- - public function testCommandInputPromptsWhenRequiredArgumentIsMissing() + public function testCommandInputPromptsWhenRequiredArgumentIsMissing(): void { $artisan = $this->createApp($this->app); $output = new BufferedOutput; @@ -414,10 +420,10 @@ public function testCommandInputPromptsWhenRequiredArgumentIsMissing() $exitCode = $artisan->call('fake-command-for-testing', [], $output); $this->assertSame(0, $exitCode); - $this->assertSame("foo\n", $output->fetch()); + $this->assertSame(['prompted' => true, 'name' => 'foo'], json_decode($output->fetch(), true)); } - public function testCommandInputDoesntPromptWhenRequiredArgumentIsPassed() + public function testCommandInputDoesntPromptWhenRequiredArgumentIsPassed(): void { $artisan = $this->createApp($this->app); $output = new BufferedOutput; @@ -425,14 +431,14 @@ public function testCommandInputDoesntPromptWhenRequiredArgumentIsPassed() $artisan->addCommands([new FakeCommandWithInputPrompting]); $exitCode = $artisan->call('fake-command-for-testing', [ - 'name' => 'bar', + 'name' => 'foo', ], $output); $this->assertSame(0, $exitCode); - $this->assertSame("bar\n", $output->fetch()); + $this->assertSame(['prompted' => false, 'name' => 'foo'], json_decode($output->fetch(), true)); } - public function testCommandInputPromptsWhenRequiredArgumentsAreMissing() + public function testCommandInputPromptsWhenRequiredArgumentsAreMissing(): void { $artisan = $this->createApp($this->app); $output = new BufferedOutput; @@ -443,10 +449,10 @@ public function testCommandInputPromptsWhenRequiredArgumentsAreMissing() $exitCode = $artisan->call('fake-command-for-testing-array', [], $output); $this->assertSame(0, $exitCode); - $this->assertSame("foo\n", $output->fetch()); + $this->assertSame(['prompted' => true, 'names' => ['foo']], json_decode($output->fetch(), true)); } - public function testCommandInputDoesntPromptWhenRequiredArgumentsArePassed() + public function testCommandInputDoesntPromptWhenRequiredArgumentsArePassed(): void { $artisan = $this->createApp($this->app); $output = new BufferedOutput; @@ -454,14 +460,14 @@ public function testCommandInputDoesntPromptWhenRequiredArgumentsArePassed() $artisan->addCommands([new FakeCommandWithArrayInputPrompting]); $exitCode = $artisan->call('fake-command-for-testing-array', [ - 'names' => ['bar', 'baz'], + 'names' => ['foo', 'bar', 'baz'], ], $output); $this->assertSame(0, $exitCode); - $this->assertSame("bar,baz\n", $output->fetch()); + $this->assertSame(['prompted' => false, 'names' => ['foo', 'bar', 'baz']], json_decode($output->fetch(), true)); } - public function testCallMethodCanCallArtisanCommandUsingCommandClassObject() + public function testCallMethodCanCallArtisanCommandUsingCommandClassObject(): void { $artisan = $this->createApp($this->app); $output = new BufferedOutput; @@ -472,7 +478,7 @@ public function testCallMethodCanCallArtisanCommandUsingCommandClassObject() $exitCode = $artisan->call($command, [], $output); $this->assertSame(0, $exitCode); - $this->assertSame("foo\n", $output->fetch()); + $this->assertSame(['prompted' => true, 'name' => 'foo'], json_decode($output->fetch(), true)); } public function testSequentialCallsUseFreshCommandInstances(): void diff --git a/tests/Console/Fixtures/FakeCommandWithArrayInputPrompting.php b/tests/Console/Fixtures/FakeCommandWithArrayInputPrompting.php index c1d4b5824..b0369cc0a 100644 --- a/tests/Console/Fixtures/FakeCommandWithArrayInputPrompting.php +++ b/tests/Console/Fixtures/FakeCommandWithArrayInputPrompting.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Console\PromptsForMissingInput; use Hypervel\Prompts\Prompt; use Hypervel\Prompts\TextPrompt; +use Hypervel\Support\Json; use Symfony\Component\Console\Input\InputInterface; class FakeCommandWithArrayInputPrompting extends Command implements PromptsForMissingInput @@ -16,6 +17,9 @@ class FakeCommandWithArrayInputPrompting extends Command implements PromptsForMi public bool $prompted = false; + /** + * Configure the prompt fallback for missing input. + */ protected function configurePrompts(InputInterface $input): void { Prompt::interactive(true); @@ -28,9 +32,12 @@ protected function configurePrompts(InputInterface $input): void }); } + /** + * Report the prompt result from the executed command instance. + */ public function handle(): int { - $this->line(implode(',', $this->argument('names'))); + $this->line(Json::encode(['prompted' => $this->prompted, 'names' => $this->argument('names')])); return self::SUCCESS; } diff --git a/tests/Console/Fixtures/FakeCommandWithInputPrompting.php b/tests/Console/Fixtures/FakeCommandWithInputPrompting.php index b159cd28a..d8aff1577 100644 --- a/tests/Console/Fixtures/FakeCommandWithInputPrompting.php +++ b/tests/Console/Fixtures/FakeCommandWithInputPrompting.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Console\PromptsForMissingInput; use Hypervel\Prompts\Prompt; use Hypervel\Prompts\TextPrompt; +use Hypervel\Support\Json; use Symfony\Component\Console\Input\InputInterface; class FakeCommandWithInputPrompting extends Command implements PromptsForMissingInput @@ -16,6 +17,9 @@ class FakeCommandWithInputPrompting extends Command implements PromptsForMissing public bool $prompted = false; + /** + * Configure the prompt fallback for missing input. + */ protected function configurePrompts(InputInterface $input): void { Prompt::interactive(true); @@ -28,9 +32,12 @@ protected function configurePrompts(InputInterface $input): void }); } + /** + * Report the prompt result from the executed command instance. + */ public function handle(): int { - $this->line((string) $this->argument('name')); + $this->line(Json::encode(['prompted' => $this->prompted, 'name' => $this->argument('name')])); return self::SUCCESS; } diff --git a/tests/Console/Fixtures/command_signatures.php b/tests/Console/Fixtures/command_signatures.php new file mode 100644 index 000000000..c09734a8a --- /dev/null +++ b/tests/Console/Fixtures/command_signatures.php @@ -0,0 +1,90 @@ + [ + 'name' => 'queue:pause', + 'arguments' => [ + [ + 'name' => 'queue', + 'mode' => 'optional', + 'isArray' => false, + 'default' => null, + 'description' => 'The name of the queue to pause', + ], + ], + 'options' => [ + [ + 'name' => 'all', + 'shortcut' => null, + 'negatable' => false, + 'valueRequired' => false, + 'valueOptional' => false, + 'isArray' => false, + 'acceptValue' => false, + 'default' => false, + 'description' => 'Pause job processing for all queues on all connections', + ], + [ + 'name' => 'disable-event-dispatcher', + 'shortcut' => null, + 'negatable' => false, + 'valueRequired' => false, + 'valueOptional' => false, + 'isArray' => false, + 'acceptValue' => false, + 'default' => false, + 'description' => 'Whether disable event dispatcher.', + ], + ], + ], + \Hypervel\Queue\Console\ResumeCommand::class => [ + 'name' => 'queue:resume', + 'aliases' => [ + 'queue:continue', + ], + 'arguments' => [ + [ + 'name' => 'queue', + 'mode' => 'optional', + 'isArray' => false, + 'default' => null, + 'description' => 'The name of the queue that should resume processing', + ], + ], + 'options' => [ + [ + 'name' => 'all', + 'shortcut' => null, + 'negatable' => false, + 'valueRequired' => false, + 'valueOptional' => false, + 'isArray' => false, + 'acceptValue' => false, + 'default' => false, + 'description' => 'Resume job processing for all queues on all connections', + ], + [ + 'name' => 'disable-event-dispatcher', + 'shortcut' => null, + 'negatable' => false, + 'valueRequired' => false, + 'valueOptional' => false, + 'isArray' => false, + 'acceptValue' => false, + 'default' => false, + 'description' => 'Whether disable event dispatcher.', + ], + ], + ], +]; diff --git a/tests/Console/Scheduling/QueuePauseCommandTest.php b/tests/Console/Scheduling/QueuePauseCommandTest.php new file mode 100644 index 000000000..b95b37e4c --- /dev/null +++ b/tests/Console/Scheduling/QueuePauseCommandTest.php @@ -0,0 +1,131 @@ +make('config')->set('cache.default', 'array'); + } + + public function testDispatchesEvent(): void + { + Event::fake(); + + $this->artisan('queue:pause default')->assertSuccessful(); + + Event::assertDispatched(QueuePaused::class); + } + + public function testPauseAllDispatchesEvent(): void + { + Event::fake(); + + $this->artisan('queue:pause --all')->assertSuccessful(); + + Event::assertDispatched(QueuesPaused::class); + } + + public function testResumeAllDispatchesEvent(): void + { + Event::fake(); + + $this->artisan('queue:resume --all')->assertSuccessful(); + + Event::assertDispatched(QueuesResumed::class); + } + + public function testDisabledError(): void + { + Event::fake(); + + Worker::$pausable = false; + + $this->artisan('queue:pause default')->assertFailed(); + + Event::assertNotDispatched(QueuePaused::class); + } + + public function testContinueAliasResumesAllQueues(): void + { + Queue::pauseAll(); + $this->assertTrue(Queue::isPaused('redis', 'default')); + + $this->artisan('queue:continue --all')->assertSuccessful(); + + $this->assertFalse(Queue::isPaused('redis', 'default')); + } + + #[DataProvider('commandsWithoutQueue')] + public function testQueueNameIsRequiredWithoutAll(string $command, array $arguments): void + { + Event::fake(); + $connection = Queue::getDefaultDriver(); + Queue::pause($connection, 'emails'); + + $this->artisan($command, $arguments) + ->expectsOutputToContain('A queue name is required unless the --all option is used.') + ->assertFailed(); + + $this->assertTrue(Queue::isPaused($connection, 'emails')); + $this->assertFalse(Queue::isPaused($connection, 'default')); + Event::assertNotDispatched(QueuesPaused::class); + Event::assertNotDispatched(QueuesResumed::class); + } + + /** + * Provide missing and empty queue arguments for both commands. + */ + public static function commandsWithoutQueue(): array + { + return [ + ['queue:pause', []], + ['queue:pause', ['queue' => '']], + ['queue:resume', []], + ['queue:resume', ['queue' => '']], + ]; + } + + public function testDisabledErrorPreventsGlobalPause(): void + { + Event::fake(); + Worker::$pausable = false; + + $this->artisan('queue:pause --all') + ->expectsOutputToContain('Queue pausing is currently disabled.') + ->assertFailed(); + + $this->assertFalse(Queue::isPaused('redis', 'default')); + Event::assertNotDispatched(QueuesPaused::class); + } + + public function testZeroQueueNameCanBePausedAndResumed(): void + { + $connection = Queue::getDefaultDriver(); + + $this->artisan('queue:pause', ['queue' => '0'])->assertSuccessful(); + + $this->assertTrue(Queue::isPaused($connection, '0')); + $this->assertFalse(Queue::isPaused($connection, 'default')); + + $this->artisan('queue:resume', ['queue' => '0'])->assertSuccessful(); + + $this->assertFalse(Queue::isPaused($connection, '0')); + } +} diff --git a/tests/Data/Attributes/Validation/ValidationAttributeTest.php b/tests/Data/Attributes/Validation/ValidationAttributeTest.php index 67981922c..8a58d75cc 100644 --- a/tests/Data/Attributes/Validation/ValidationAttributeTest.php +++ b/tests/Data/Attributes/Validation/ValidationAttributeTest.php @@ -124,6 +124,8 @@ use Hypervel\Data\Support\Validation\ValidationPath; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; +use Hypervel\Translation\ArrayLoader; +use Hypervel\Translation\Translator; use Hypervel\Validation\Rules\AnyOf as AnyOfRule; use Hypervel\Validation\Rules\Can as CanRule; use Hypervel\Validation\Rules\Dimensions as DimensionsRule; @@ -132,6 +134,7 @@ use Hypervel\Validation\Rules\ProhibitedIf as ProhibitedIfRule; use Hypervel\Validation\Rules\RequiredIf as RequiredIfRule; use Hypervel\Validation\ValidationRuleParser; +use Hypervel\Validation\Validator; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; use ReflectionProperty; @@ -146,17 +149,14 @@ public function testGetsAStringRepresentationOfRules(): void $this->assertSame('string', (string) new StringType); } - /** - * Test rule parameters normalize to Validator string values. - */ #[DataProvider('normalizedValues')] - public function testNormalizesValues(mixed $input, string $output): void + public function testNormalizesValues(mixed $input, string $output, ?string $key = null): void { - $attribute = new class([$input]) extends StringValidationAttribute { + $attribute = new class($key === null ? [$input] : [$key => $input]) extends StringValidationAttribute { /** * Create a test validation attribute. * - * @param list $parameters + * @param array $parameters */ public function __construct(protected array $parameters) { @@ -202,6 +202,12 @@ public static function normalizedValues(): iterable yield [false, 'false']; yield [['a', 'b', 'c'], 'a,b,c']; yield [[null], 'null']; + yield ['last,first', '"last,first"']; + yield ['a"b', '"a""b"']; + yield ['path\\', 'path\\']; + yield [[['a,b'], 'c'], '"a,b",c']; + yield [new ValidationAttributeExternalReference(['a,b', 'c']), '"a,b",c']; + yield ['a,b', '"name=a,b"', 'name']; yield [ CarbonImmutable::create( 2020, @@ -221,6 +227,32 @@ public static function normalizedValues(): iterable ]; } + #[DataProvider('literalParameterRules')] + public function testValidatesLiteralAttributeParameters( + StringValidationAttribute $attribute, + array $data, + bool $passes, + ): void { + $rules = (new RuleDenormalizer)->execute($attribute, ValidationPath::create()); + $validator = new Validator(new Translator(new ArrayLoader, 'en'), $data, ['value' => $rules]); + + $this->assertSame($passes, $validator->passes()); + } + + /** + * Provide attributes whose literal parameters contain rule delimiters. + */ + public static function literalParameterRules(): iterable + { + yield 'RFC2822 date' => [new DateFormat(DATE_RFC2822), ['value' => 'Tue, 02 Jan 2024 12:00:00 +0000'], true]; + yield 'literal array key' => [new ArrayType('last,first'), ['value' => ['last,first' => 'Taylor']], true]; + yield 'split array key' => [new ArrayType('last,first'), ['value' => ['last' => 'Taylor']], false]; + yield 'matching dependent value' => [new RequiredIf('status', 'a,b'), ['status' => 'a,b'], false]; + yield 'partial dependent value' => [new RequiredIf('status', 'a,b'), ['status' => 'a'], true]; + yield 'raw regex' => [new Regex('/^a,"b"\|c$/'), ['value' => 'a,"b"|c'], true]; + yield 'raw negative regex' => [new NotRegex('/^a,"b"\|c$/'), ['value' => 'a,"b"|c'], false]; + } + /** * Test simple attributes compile from objects and parsed string parameters. */ diff --git a/tests/Foundation/Console/RouteListCommandMiddlewareTest.php b/tests/Foundation/Console/RouteListCommandMiddlewareTest.php new file mode 100644 index 000000000..49b909875 --- /dev/null +++ b/tests/Foundation/Console/RouteListCommandMiddlewareTest.php @@ -0,0 +1,106 @@ +app->make(Kernel::class); + $router = $this->app->make(Router::class); + $router->middlewareGroup('inspection', [RouteListCommandInspectionMiddleware::class]); + $route = $router->get('/middleware-inspection', static fn (): string => 'OK') + ->middleware('inspection'); + + if ($warm) { + $this->get('/middleware-inspection')->assertOk()->assertHeader('X-Route-Middleware', 'applied'); + } + + $groups = $router->getMiddlewareGroups(); + $resolvedMiddleware = $route->resolvedMiddleware; + $pipeline = $route->middlewarePipeline; + + Artisan::call('route:list', ['--json' => true, '-v' => true, '--path' => 'middleware-inspection']); + $routes = json_decode(Artisan::output(), true); + + $this->assertSame(['inspection'], $routes[0]['middleware']); + $this->assertSame($groups, $router->getMiddlewareGroups()); + $this->assertSame($resolvedMiddleware, $route->resolvedMiddleware); + $this->assertSame($pipeline, $route->middlewarePipeline); + + Artisan::call('route:list', ['--json' => true, '-vv' => true, '--path' => 'middleware-inspection']); + $routes = json_decode(Artisan::output(), true); + + $this->assertSame([RouteListCommandInspectionMiddleware::class], $routes[0]['middleware']); + $this->get('/middleware-inspection')->assertOk()->assertHeader('X-Route-Middleware', 'applied'); + $this->assertSame([RouteListCommandInspectionMiddleware::class], $route->resolvedMiddleware); + } + + /** + * Provide cold and previously dispatched routes. + */ + public static function middlewareCacheStates(): array + { + return [ + 'cold' => [false], + 'warm' => [true], + ]; + } + + public function testListingInitializesConfiguredMiddlewareBeforeTheFirstRequest(): void + { + $this->app->afterResolving(Kernel::class, static function (Kernel $kernel): void { + $kernel->setMiddlewareAliases([ + ...$kernel->getMiddlewareAliases(), + 'inspection.alias' => RouteListCommandInspectionMiddleware::class, + ]); + $kernel->setMiddlewareGroups([ + ...$kernel->getMiddlewareGroups(), + 'inspection' => ['inspection.alias'], + ]); + }); + + $this->app->make(Router::class)->get('/configured-middleware', static fn (): string => 'OK') + ->middleware('inspection'); + + $this->assertFalse($this->app->resolved(Kernel::class)); + + Artisan::call('route:list', [ + '--json' => true, + '-vv' => true, + '--middleware' => RouteListCommandInspectionMiddleware::class, + ]); + $routes = json_decode(Artisan::output(), true); + + $this->assertCount(1, $routes); + $this->assertSame('configured-middleware', $routes[0]['uri']); + $this->assertSame([RouteListCommandInspectionMiddleware::class], $routes[0]['middleware']); + $this->get('/configured-middleware')->assertOk()->assertHeader('X-Route-Middleware', 'applied'); + } +} + +class RouteListCommandInspectionMiddleware +{ + /** + * Mark responses that pass through the route middleware. + */ + public function handle(Request $request, Closure $next): Response + { + $response = $next($request); + $response->headers->set('X-Route-Middleware', 'applied'); + + return $response; + } +} diff --git a/tests/Foundation/Console/RouteListCommandTest.php b/tests/Foundation/Console/RouteListCommandTest.php index 9e77e43a5..5cc66e1cf 100644 --- a/tests/Foundation/Console/RouteListCommandTest.php +++ b/tests/Foundation/Console/RouteListCommandTest.php @@ -7,6 +7,7 @@ use Hypervel\Console\Application; use Hypervel\Console\Events\ArtisanStarting; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Contracts\Http\Kernel as KernelContract; use Hypervel\Foundation\Console\RouteListCommand; use Hypervel\Foundation\Http\Kernel; use Hypervel\Routing\Router; @@ -49,7 +50,7 @@ protected function setUp(): void $kernel->prependToMiddlewarePriority('Middleware 5'); - $hypervel->instance(Kernel::class, $kernel); + $hypervel->instance(KernelContract::class, $kernel); $router->get('/example', function () { return 'Hello World'; @@ -264,7 +265,7 @@ public function testControllerRoutePathIsNull(): void protected array $middlewareGroups = []; }; - $hypervel->instance(Kernel::class, $kernel); + $hypervel->instance(KernelContract::class, $kernel); $router->get('/controller-route', [RouteListCommandTestController::class, 'index']); diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index a21293d58..09558d166 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Foundation\FoundationApplicationTest; use Hypervel\Config\Repository; +use Hypervel\Contracts\Auth\PasswordBroker; +use Hypervel\Contracts\Auth\PasswordBrokerFactory; use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; use Hypervel\Contracts\Translation\Translator as TranslatorContract; use Hypervel\Events\Dispatcher as EventDispatcher; @@ -953,42 +955,68 @@ public function testRoutesAreCachedMemoizesFilesystemResult(): void $this->assertTrue($freshApp->routesAreCached()); } - public function testEventsAreCachedReturnsFalseWhenNoCacheFile() + public function testEventsAreCachedReturnsFalseWhenNoCacheFile(): void { - $app = new Application(sys_get_temp_dir() . '/hypervel-test-app-' . uniqid()); + $app = $this->makeCacheApplication(); $this->assertFalse($app->eventsAreCached()); } - public function testEventsAreCachedReturnsTrueWhenCacheFileExists() + public function testEventsAreCachedReturnsTrueWhenCacheFileExists(): void { - $basePath = sys_get_temp_dir() . '/hypervel-test-app-' . uniqid(); - $cachePath = $basePath . '/bootstrap/cache/events.php'; + $app = $this->makeCacheApplication(); + file_put_contents($app->getCachedEventsPath(), 'assertTrue($app->eventsAreCached()); + } + + public function testEventsAreCachedUsesContainerInstance(): void + { + $app = $this->makeCacheApplication(); + $app->instance('events.cached', true); + + $this->assertTrue($app->eventsAreCached()); + $this->assertFileDoesNotExist($app->getCachedEventsPath()); + + file_put_contents($app->getCachedEventsPath(), 'instance('events.cached', false); + + $this->assertFalse($app->eventsAreCached()); + } + + public function testEventsAreCachedChecksFilesystemIfNotSet(): void + { + $app = $this->makeCacheApplication(); + $cachePath = $app->getCachedEventsPath(); + + $this->assertFalse($app->eventsAreCached()); + $this->assertStringContainsString('events.php', $cachePath); + $this->assertTrue($app->bound('events.cached')); + $this->assertFalse($app->make('events.cached')); - mkdir(dirname($cachePath), 0755, true); file_put_contents($cachePath, 'assertTrue($app->eventsAreCached()); - } finally { - unlink($cachePath); - rmdir(dirname($cachePath)); - rmdir(dirname($cachePath, 2)); - rmdir($basePath); - } + $this->assertFalse($app->eventsAreCached()); + + $freshApp = new Application($this->cacheApplicationPath); + + $this->assertTrue($freshApp->eventsAreCached()); + + unlink($cachePath); + + $this->assertTrue($freshApp->eventsAreCached()); } - public function testCoreContainerAliasesAreRegisteredByDefault() + public function testCoreContainerAliasesAreRegisteredByDefault(): void { $app = new Application; - $this->assertTrue($app->isAlias(\Hypervel\Contracts\Translation\Translator::class)); - $this->assertSame('translator', $app->getAlias(\Hypervel\Contracts\Translation\Translator::class)); - $this->assertTrue($app->isAlias(\Hypervel\Contracts\Auth\PasswordBrokerFactory::class)); - $this->assertSame('auth.password', $app->getAlias(\Hypervel\Contracts\Auth\PasswordBrokerFactory::class)); - $this->assertTrue($app->isAlias(\Hypervel\Contracts\Auth\PasswordBroker::class)); - $this->assertSame('auth.password.broker', $app->getAlias(\Hypervel\Contracts\Auth\PasswordBroker::class)); + $this->assertTrue($app->isAlias(TranslatorContract::class)); + $this->assertSame('translator', $app->getAlias(TranslatorContract::class)); + $this->assertTrue($app->isAlias(PasswordBrokerFactory::class)); + $this->assertSame('auth.password', $app->getAlias(PasswordBrokerFactory::class)); + $this->assertTrue($app->isAlias(PasswordBroker::class)); + $this->assertSame('auth.password.broker', $app->getAlias(PasswordBroker::class)); } public function testAddAbsoluteCachePathPrefixReturnsSelf() diff --git a/tests/Foundation/Http/Middleware/ValidatePathEncodingTest.php b/tests/Foundation/Http/Middleware/ValidatePathEncodingTest.php index f56cb0b63..16d3089e4 100644 --- a/tests/Foundation/Http/Middleware/ValidatePathEncodingTest.php +++ b/tests/Foundation/Http/Middleware/ValidatePathEncodingTest.php @@ -18,8 +18,8 @@ class ValidatePathEncodingTest extends TestCase #[TestWith(['valid-path'])] #[TestWith(['ä'])] #[TestWith(['with%20space'])] - #[TestWith(['汉字字符集'])] - public function testValidPathsArePassing(string $path) + #[TestWith(['%E6%B1%89%E5%AD%97%E5%AD%97%E7%AC%A6%E9%9B%86'])] + public function testValidPathsArePassing(string $path): void { $middleware = new ValidatePathEncoding; $symfonyRequest = new SymfonyRequest; @@ -27,7 +27,7 @@ public function testValidPathsArePassing(string $path) $symfonyRequest->server->set('REQUEST_URI', $path); $request = Request::createFromBase($symfonyRequest); - $response = $middleware->handle($request, fn () => new Response('OK')); + $response = $middleware->handle($request, fn (): Response => new Response('OK')); $this->assertSame(200, $response->status()); $this->assertSame('OK', $response->content()); @@ -35,7 +35,7 @@ public function testValidPathsArePassing(string $path) #[TestWith(['%C0'])] #[TestWith(['%c0'])] - public function testInvalidPathsAreFailing(string $path) + public function testInvalidPathsAreFailing(string $path): void { $middleware = new ValidatePathEncoding; $symfonyRequest = new SymfonyRequest; @@ -44,7 +44,7 @@ public function testInvalidPathsAreFailing(string $path) $request = Request::createFromBase($symfonyRequest); try { - $middleware->handle($request, fn () => new Response('OK')); + $middleware->handle($request, fn (): Response => new Response('OK')); $this->fail('MalformedUrlExceptions should have been thrown.'); } catch (MalformedUrlException $e) { diff --git a/tests/Foundation/Support/Providers/EventServiceProviderTest.php b/tests/Foundation/Support/Providers/EventServiceProviderTest.php index ae5a79c78..d4406db8f 100644 --- a/tests/Foundation/Support/Providers/EventServiceProviderTest.php +++ b/tests/Foundation/Support/Providers/EventServiceProviderTest.php @@ -46,7 +46,7 @@ class_alias(ListenerInterface::class, 'Tests\Integration\Foundation\Fixtures\Eve $this->assertContains('App\Listeners\CustomListener', $events['App\Events\CustomEvent']); } - public function testGetEventsReadsFromCacheWhenCached() + public function testGetEventsReadsFromCacheWhenCached(): void { $cachePath = $this->app->getCachedEventsPath(); $cacheDir = dirname($cachePath); @@ -64,6 +64,8 @@ public function testGetEventsReadsFromCacheWhenCached() file_put_contents($cachePath, 'app->instance('events.cached', true); + $provider = new EventServiceProvider($this->app); $events = $provider->getEvents(); @@ -76,7 +78,7 @@ public function testGetEventsReadsFromCacheWhenCached() } } - public function testGetEventsReturnsEmptyWhenCachedButProviderNotInCache() + public function testGetEventsReturnsEmptyWhenCachedButProviderNotInCache(): void { $cachePath = $this->app->getCachedEventsPath(); $cacheDir = dirname($cachePath); @@ -88,6 +90,8 @@ public function testGetEventsReturnsEmptyWhenCachedButProviderNotInCache() file_put_contents($cachePath, 'app->instance('events.cached', true); + $provider = new EventServiceProvider($this->app); $events = $provider->getEvents(); diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index e90c09df7..a86790c8b 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -67,6 +67,7 @@ use Swoole\Coroutine\CanceledException; use Symfony\Component\VarDumper\VarDumper; use Throwable; +use WeakReference; use function Hypervel\Coroutine\parallel; use function Hypervel\Coroutine\run; @@ -4956,6 +4957,31 @@ public function testTooManyRedirectsWithFakedRedirectChain(): void $this->factory->maxRedirects(1)->get('https://1.example.com'); } + public function testPendingRequestsAreFreedOnceUnset(): void + { + $garbageCollectionEnabled = gc_enabled(); + gc_disable(); + + try { + $request = (new PendingRequest) + ->throwUnless(static fn (Response $response): bool => false) + ->stub(static fn (): PromiseInterface => Factory::response('ok')); + + $reference = WeakReference::create($request); + $response = $request->post('http://localhost/memory-test'); + + $this->assertSame('ok', $response->body()); + + unset($request, $response); + + $this->assertNull($reference->get()); + } finally { + if ($garbageCollectionEnabled) { + gc_enable(); + } + } + } + public function testRequestExceptionIsNotThrownIfThePendingRequestIsSetToThrowOnFailureButTheResponseIsSuccessful(): void { $this->factory->fake([ diff --git a/tests/Http/HttpRequestTest.php b/tests/Http/HttpRequestTest.php index 73089e504..503e8fa7b 100644 --- a/tests/Http/HttpRequestTest.php +++ b/tests/Http/HttpRequestTest.php @@ -791,6 +791,8 @@ public function testFluentMethod(): void $this->assertSame(['name' => 'Michael', 'role' => 'admin'], $request->fluent('user')->toArray()); $this->assertSame([], $request->fluent('users')->toArray()); $this->assertSame([], $request->fluent('not_found')->toArray()); + $this->assertSame(['name' => 'Guest'], $request->fluent('users', ['name' => 'Guest'])->toArray()); + $this->assertSame(['name' => 'Guest'], $request->fluent('not_found', ['name' => 'Guest'])->toArray()); } public function testStringMethod(): void diff --git a/tests/Integration/Auth/ForgotPasswordTest.php b/tests/Integration/Auth/ForgotPasswordTest.php index 32843b041..21c78c079 100644 --- a/tests/Integration/Auth/ForgotPasswordTest.php +++ b/tests/Integration/Auth/ForgotPasswordTest.php @@ -9,6 +9,8 @@ use Hypervel\Contracts\Auth\PasswordBroker as PasswordBrokerContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Notifications\Messages\MailMessage; +use Hypervel\Routing\Router; use Hypervel\Support\Facades\Event; use Hypervel\Support\Facades\Notification; use Hypervel\Support\Facades\Password; @@ -35,6 +37,112 @@ protected function defineEnvironment(ApplicationContract $app): void ]); } + /** + * Define the password reset routes. + */ + protected function defineRoutes(Router $router): void + { + $router->get('password/reset/{token}', function (string $token): string { + return 'Reset password!'; + })->name('password.reset'); + + $router->get('custom/password/reset/{token}', function (string $token): string { + return 'Custom reset password!'; + })->name('custom.password.reset'); + } + + public function testItCanSendForgotPasswordEmail(): void + { + Notification::fake(); + + $user = $this->createUser(); + + Password::broker()->sendResetLink([ + 'email' => $user->email, + ]); + + Notification::assertSentTo( + $user, + function (ResetPassword $notification, array $channels) use ($user): bool { + $message = $notification->toMail($user); + + return $notification->token !== '' + && $message->actionUrl === route('password.reset', ['token' => $notification->token, 'email' => $user->email]); + } + ); + } + + public function testItCanTriggerPasswordResetSentEvent(): void + { + Event::fake([PasswordResetLinkSent::class]); + + $user = $this->createUser(); + + Password::broker()->sendResetLink([ + 'email' => $user->email, + ]); + + Event::assertDispatched(PasswordResetLinkSent::class, function (PasswordResetLinkSent $event) use ($user): bool { + $this->assertSame($user->getAuthIdentifier(), $event->user->getAuthIdentifier()); + + return true; + }); + } + + public function testItCanSendForgotPasswordEmailViaCreateUrlUsing(): void + { + Notification::fake(); + + ResetPassword::createUrlUsing(function (mixed $user, string $token): string { + return route('custom.password.reset', $token); + }); + + $user = $this->createUser(); + + Password::broker()->sendResetLink([ + 'email' => $user->email, + ]); + + Notification::assertSentTo( + $user, + function (ResetPassword $notification, array $channels) use ($user): bool { + $message = $notification->toMail($user); + + return $notification->token !== '' + && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token]); + } + ); + } + + public function testItCanSendForgotPasswordEmailViaToMailUsing(): void + { + Notification::fake(); + + ResetPassword::toMailUsing(function (mixed $notifiable, string $token): MailMessage { + return (new MailMessage) + ->subject(__('Reset your password')) + ->line(__('You are receiving this email because we received a password reset request for your account.')) + ->action(__('Reset Password'), route('custom.password.reset', $token)) + ->line(__('If you did not request a password reset, no further action is required.')); + }); + + $user = $this->createUser(); + + Password::broker()->sendResetLink([ + 'email' => $user->email, + ]); + + Notification::assertSentTo( + $user, + function (ResetPassword $notification, array $channels) use ($user): bool { + $message = $notification->toMail($user); + + return $notification->token !== '' + && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token]); + } + ); + } + public function testResolvedBrokerFollowsEventFakesAndTheirRestoration(): void { Notification::fake(); diff --git a/tests/Integration/Auth/ForgotPasswordWithoutDefaultRoutesTest.php b/tests/Integration/Auth/ForgotPasswordWithoutDefaultRoutesTest.php new file mode 100644 index 000000000..bb3df2661 --- /dev/null +++ b/tests/Integration/Auth/ForgotPasswordWithoutDefaultRoutesTest.php @@ -0,0 +1,139 @@ +make('config'); + $config->set([ + 'app.key' => '12345678901234567890123456789012', + 'auth.providers.users.model' => AuthTestUser::class, + 'auth.passwords.users.throttle' => 0, + 'auth.timebox_duration' => 0, + 'hashing.bcrypt.rounds' => 4, + ]); + } + + /** + * Define the custom password reset route. + */ + protected function defineRoutes(Router $router): void + { + $router->get('custom/password/reset/{token}', function (string $token): string { + return 'Custom reset password!'; + })->name('custom.password.reset'); + } + + public function testItCannotSendForgotPasswordEmail(): void + { + $this->expectExceptionObject(new RouteNotFoundException('Route [password.reset] not defined.')); + + Notification::fake(); + + $user = $this->createUser(); + + Password::broker()->sendResetLink([ + 'email' => $user->email, + ]); + + Notification::assertSentTo( + $user, + function (ResetPassword $notification, array $channels) use ($user): bool { + $message = $notification->toMail($user); + + return $notification->token !== '' + && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token, 'email' => $user->email]); + } + ); + } + + public function testItCanSendForgotPasswordEmailViaCreateUrlUsing(): void + { + Notification::fake(); + + ResetPassword::createUrlUsing(function (mixed $user, string $token): string { + return route('custom.password.reset', $token); + }); + + $user = $this->createUser(); + + Password::broker()->sendResetLink([ + 'email' => $user->email, + ]); + + Notification::assertSentTo( + $user, + function (ResetPassword $notification, array $channels) use ($user): bool { + $message = $notification->toMail($user); + + return $notification->token !== '' + && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token]); + } + ); + } + + public function testItCanSendForgotPasswordEmailViaToMailUsing(): void + { + Notification::fake(); + + ResetPassword::toMailUsing(function (mixed $notifiable, string $token): MailMessage { + return (new MailMessage) + ->subject(__('Reset your password')) + ->line(__('You are receiving this email because we received a password reset request for your account.')) + ->action(__('Reset Password'), route('custom.password.reset', $token)) + ->line(__('If you did not request a password reset, no further action is required.')); + }); + + $user = $this->createUser(); + + Password::broker()->sendResetLink([ + 'email' => $user->email, + ]); + + Notification::assertSentTo( + $user, + function (ResetPassword $notification, array $channels) use ($user): bool { + $message = $notification->toMail($user); + + return $notification->token !== '' + && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token]); + } + ); + } + + /** + * Create a password-resettable user. + */ + private function createUser(): AuthTestUser + { + return AuthTestUser::forceCreate([ + 'name' => 'Auth User', + 'email' => 'auth@example.com', + 'password' => 'password', + ]); + } +} diff --git a/tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php b/tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php index 056d15b56..cabc4a02a 100644 --- a/tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php +++ b/tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php @@ -7,20 +7,13 @@ use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Support\Facades\Cache; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\TestWith; use Redis; /** - * Tests that Redis locks work correctly under various phpredis serializer - * and compression configurations. - * - * Validates the pack() + withConnection() fix on RedisLock::release() and - * refresh() — Lua ARGV values must be pre-packed when a serializer is - * configured, because phpredis does NOT auto-serialize eval() ARGV. - * - * Unlike Laravel (which sets serializer options on a live client instance), - * Hypervel uses connection pooling — serializer/compression options must be - * configured at the connection config level so the pool creates connections - * with the correct settings. + * Configure serialization and compression on the pool so every connection + * uses the same options. Lock release and refresh must pack the owner for + * Lua with those options, since phpredis does not serialize eval() ARGV. */ class PhpRedisCacheLockTest extends TestCase { @@ -93,7 +86,9 @@ public function testRedisLockCanBeAcquiredAndReleasedWithLzfCompression(): void $this->assertLockCanBeAcquiredAndReleased(); } - public function testRedisLockCanBeAcquiredAndReleasedWithZstdCompression(): void + #[TestWith(['COMPRESSION_ZSTD_DEFAULT'])] + #[TestWith(['COMPRESSION_ZSTD_MAX'])] + public function testRedisLockCanBeAcquiredAndReleasedWithZstdCompression(string $compressionLevel): void { if (! defined('Redis::COMPRESSION_ZSTD')) { $this->markTestSkipped('Redis extension is not configured to support the zstd compression.'); @@ -102,13 +97,16 @@ public function testRedisLockCanBeAcquiredAndReleasedWithZstdCompression(): void $this->configureLockConnection([ 'serializer' => Redis::SERIALIZER_NONE, 'compression' => Redis::COMPRESSION_ZSTD, - 'compression_level' => Redis::COMPRESSION_ZSTD_DEFAULT, + 'compression_level' => constant(Redis::class . '::' . $compressionLevel), ]); $this->assertLockCanBeAcquiredAndReleased(); } - public function testRedisLockCanBeAcquiredAndReleasedWithLz4Compression(): void + #[TestWith([1])] + #[TestWith([3])] + #[TestWith([12])] + public function testRedisLockCanBeAcquiredAndReleasedWithLz4Compression(int $compressionLevel): void { if (! defined('Redis::COMPRESSION_LZ4')) { $this->markTestSkipped('Redis extension is not configured to support the lz4 compression.'); @@ -117,7 +115,7 @@ public function testRedisLockCanBeAcquiredAndReleasedWithLz4Compression(): void $this->configureLockConnection([ 'serializer' => Redis::SERIALIZER_NONE, 'compression' => Redis::COMPRESSION_LZ4, - 'compression_level' => 1, + 'compression_level' => $compressionLevel, ]); $this->assertLockCanBeAcquiredAndReleased(); @@ -140,21 +138,13 @@ public function testRedisLockCanBeAcquiredAndReleasedWithSerializationAndCompres /** * Configure a dedicated Redis connection for lock testing with the given options. * - * Creates a 'lock-test' Redis connection with the specified serializer/compression - * options, points the cache store's lock_connection to it, and purges the cache - * store so it picks up the new configuration. + * @param array $options */ protected function configureLockConnection(array $options): void { $config = $this->app->make('config'); - $baseConfig = $config->array('database.redis.default'); - - $config->set('database.redis.lock-test', array_merge($baseConfig, [ - 'options' => $options, - ])); - $config->set('cache.stores.redis.connection', 'default'); - $config->set('cache.stores.redis.lock_connection', 'lock-test'); + $config->set('cache.stores.redis.lock_connection', $this->createRedisConnectionWithOptions('lock-test', $options)); Cache::forgetDriver('redis'); } @@ -168,6 +158,8 @@ protected function assertLockCanBeAcquiredAndReleased(): void $store = Cache::store('redis'); $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix() . 'foo')); + $lock = $store->lock('foo', 3); $this->assertTrue($lock->get()); $this->assertFalse($store->lock('foo', 3)->get()); @@ -184,8 +176,8 @@ protected function assertLockCanBeAcquiredAndReleased(): void $this->assertGreaterThan($decayedLifetime, $refreshedLifetime); $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix() . 'foo')); - // After release, lock should be acquirable again $lock = $store->lock('foo', 10); $this->assertTrue($lock->get()); $lock->forceRelease(); diff --git a/tests/Integration/Cache/Redis/RedisCacheIntegrationTest.php b/tests/Integration/Cache/Redis/RedisCacheIntegrationTest.php index e3ac06f1c..c574be746 100644 --- a/tests/Integration/Cache/Redis/RedisCacheIntegrationTest.php +++ b/tests/Integration/Cache/Redis/RedisCacheIntegrationTest.php @@ -19,6 +19,9 @@ public function testRedisCacheAddTwice() $this->assertGreaterThan(3500, $this->store()->connection()->ttl($this->store()->getPrefix() . 'k')); } + // REMOVED: the cache-backed testRedisCacheRateLimiter fixture. Its admission assertions + // run in RateLimiter/Redis/RedisStoreTest::testSerializerAndCompressionOptionsDoNotAffectLimiterState(). + /** * Breaking change. */ diff --git a/tests/Integration/Cache/Redis/RedisStoreTest.php b/tests/Integration/Cache/Redis/RedisStoreTest.php index a21d23a0e..284073873 100644 --- a/tests/Integration/Cache/Redis/RedisStoreTest.php +++ b/tests/Integration/Cache/Redis/RedisStoreTest.php @@ -130,6 +130,20 @@ public function testTagEntriesCanBeIncremented() $this->assertEquals(0, Cache::store('redis')->tags(['votes'])->get('person-1')); } + public function testTagEntriesCanBeDecrementedUsingEnumKeys(): void + { + Cache::store('redis')->clear(); + + Cache::store('redis')->tags(['votes'])->put(RedisTaggedCacheTestKey::Person1, 2, 5); + Cache::store('redis')->tags(['votes'])->decrement(RedisTaggedCacheTestKey::Person1); + + $this->assertEquals(1, Cache::store('redis')->tags(['votes'])->get(RedisTaggedCacheTestKey::Person1)); + + Cache::store('redis')->tags(['votes'])->flush(); + + $this->assertNull(Cache::store('redis')->tags(['votes'])->get(RedisTaggedCacheTestKey::Person1)); + } + public function testIncrementedTagEntriesProperlyTurnStale() { Cache::store('redis')->clear(); @@ -228,9 +242,25 @@ public function testMultipleItemsCanBeSetAndRetrieved() // PutMany operation class which has its own cluster fallback. This behavior is tested in // tests/Cache/Redis/Operations/PutManyTest.php (cluster mode tests). - public function testIncrementWithSerializationEnabled() + public function testIncrementWithSerializationEnabled(): void { - $this->markTestSkipped('Test makes no sense anymore. Application must explicitly wrap such code in runClean() when used with serialization/compression enabled.'); + if (! defined('Redis::OPT_PACK_IGNORE_NUMBERS')) { + $this->markTestSkipped('PhpRedis does not support OPT_PACK_IGNORE_NUMBERS.'); + } + + $connection = $this->createRedisConnectionWithOptions('cache_serialized', [ + 'serializer' => Redis::SERIALIZER_PHP, + 'pack_ignore_numbers' => true, + ]); + config(['cache.stores.redis.connection' => $connection]); + + $store = Cache::store('redis'); + $store->flush(); + $store->add('foo', 1, 10); + $this->assertSame(1, $store->get('foo')); + + $store->increment('foo'); + $this->assertSame(2, $store->get('foo')); } public function testTagsCanBeFlushedWithLargeNumberOfKeys() @@ -340,3 +370,8 @@ public function testStoreFlushLocksThrowsExceptionWhenLockConnectionIsSame() $store->flushLocks(); } } + +enum RedisTaggedCacheTestKey: string +{ + case Person1 = 'person-1'; +} diff --git a/tests/Integration/Cache/Redis/SerializationIntegrationTest.php b/tests/Integration/Cache/Redis/SerializationIntegrationTest.php new file mode 100644 index 000000000..0196d2b2c --- /dev/null +++ b/tests/Integration/Cache/Redis/SerializationIntegrationTest.php @@ -0,0 +1,84 @@ +configureCompression(); + + if ($operation !== 'putMany') { + $this->setTagMode(TagMode::Any); + } + + $cache = $operation === 'putMany' ? $this->cache() : $this->cache()->tags(['compressed']); + $value = str_repeat('cache-value', 100); + + $this->assertTrue(match ($operation) { + 'putMany' => $cache->putMany(['compressed' => $value], 60), + 'put' => $cache->put('compressed', $value, 60), + 'add' => $cache->add('compressed', $value, 60), + 'forever' => $cache->forever('compressed', $value), + }); + + $redis = $this->redis(); + $key = $this->getCachePrefix() . 'compressed'; + $expected = $redis->withConnection( + static fn (RedisConnection $connection): string => $connection->pack([serialize($value)])[0], + ); + + // Round trips also succeed for uncompressed bytes, so inspect the stored representation. + $this->assertSame($expected, $redis->withoutSerializationOrCompression(static fn (): mixed => $redis->get($key))); + $this->assertSame($value, $this->cache()->get('compressed')); + } + + public function testPutManyPreservesNumbersWhenPackingIgnoresThem(): void + { + if (! defined('Redis::OPT_PACK_IGNORE_NUMBERS')) { + $this->markTestSkipped('PhpRedis does not support OPT_PACK_IGNORE_NUMBERS.'); + } + + $this->configureCompression(ignoreNumbers: true); + + $this->assertTrue($this->cache()->putMany(['counter' => 5], 60)); + + $redis = $this->redis(); + $key = $this->getCachePrefix() . 'counter'; + + $this->assertSame('5', $redis->withoutSerializationOrCompression(static fn (): mixed => $redis->get($key))); + $this->assertSame(6, $this->cache()->increment('counter')); + } + + /** + * Configure compression on a fresh connection before resolving the cache store. + */ + protected function configureCompression(bool $ignoreNumbers = false): void + { + if (! defined('Redis::COMPRESSION_LZF')) { + $this->markTestSkipped('Redis extension is not configured to support the lzf compression.'); + } + + $options = [ + 'serializer' => Redis::SERIALIZER_NONE, + 'compression' => Redis::COMPRESSION_LZF, + ]; + + if ($ignoreNumbers) { + $options['pack_ignore_numbers'] = true; + } + + config(['cache.stores.redis.connection' => $this->createRedisConnectionWithOptions('cache_compressed', $options)]); + } +} diff --git a/tests/Integration/Generators/ResourceMakeCommandTest.php b/tests/Integration/Generators/ResourceMakeCommandTest.php index 2b5d3db09..2812f3415 100644 --- a/tests/Integration/Generators/ResourceMakeCommandTest.php +++ b/tests/Integration/Generators/ResourceMakeCommandTest.php @@ -6,12 +6,12 @@ class ResourceMakeCommandTest extends TestCase { - protected $files = [ + protected array $files = [ 'app/Http/Resources/FooResource.php', 'app/Http/Resources/FooResourceCollection.php', ]; - public function testItCanGenerateResourceFile() + public function testItCanGenerateResourceFile(): void { $this->artisan('make:resource', ['name' => 'FooResource']) ->assertExitCode(0); @@ -23,7 +23,7 @@ public function testItCanGenerateResourceFile() ], 'app/Http/Resources/FooResource.php'); } - public function testItCanGenerateResourceCollectionFile() + public function testItCanGenerateResourceCollectionFile(): void { $this->artisan('make:resource', ['name' => 'FooResourceCollection', '--collection' => true]) ->assertExitCode(0); @@ -34,4 +34,20 @@ public function testItCanGenerateResourceCollectionFile() 'class FooResourceCollection extends ResourceCollection', ], 'app/Http/Resources/FooResourceCollection.php'); } + + public function testItCanGenerateJsonApiResourceFile(): void + { + $this->artisan('make:resource', ['name' => 'FooResource', '--json-api' => true]) + ->assertExitCode(0); + + $this->assertFileContains([ + 'namespace App\Http\Resources;', + 'use Hypervel\Http\Resources\JsonApi\JsonApiResource;', + 'class FooResource extends JsonApiResource', + ], 'app/Http/Resources/FooResource.php'); + + $this->assertFileNotContains([ + 'use Hypervel\Http\Request;', + ], 'app/Http/Resources/FooResource.php'); + } } diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiRequestTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiRequestTest.php index ab06d4709..d6ce23075 100644 --- a/tests/Integration/Http/Resources/JsonApi/JsonApiRequestTest.php +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiRequestTest.php @@ -44,6 +44,18 @@ public function testItCanDetermineIfSparseFieldsetWasProvided(): void $this->assertFalse($request->hasSparseFieldset('posts')); } + public function testItIgnoresNonStringSparseFields(): void + { + $request = JsonApiRequest::create(uri: '/?' . http_build_query([ + 'fields' => [ + 'users' => ['name', 'email'], + ], + ])); + + $this->assertSame([], $request->sparseFields('users')); + $this->assertTrue($request->hasSparseFieldset('users')); + } + public function testItCanResolveSparseIncluded(): void { $request = JsonApiRequest::create(uri: '/?' . http_build_query([ @@ -70,10 +82,34 @@ public function testItCanResolveSparseIncludedWithMaxRelationshipNesting(): void $this->assertSame(['user'], $request->sparseIncluded('profile')); } + public function testItDropsNestedSparseIncludedWithZeroMaxRelationshipNesting(): void + { + JsonApiResource::maxRelationshipDepth(0); + + $request = JsonApiRequest::create(uri: '/?' . http_build_query([ + 'include' => 'teams,posts.author,profile.user.profile', + ])); + + $this->assertSame(['teams', 'posts', 'profile'], $request->sparseIncluded()); + $this->assertSame([], $request->sparseIncluded('teams')); + $this->assertSame([], $request->sparseIncluded('posts')); + $this->assertSame([], $request->sparseIncluded('profile')); + } + public function testItCanResolveEmptySparseIncluded(): void { $request = JsonApiRequest::create(uri: '/'); $this->assertSame([], $request->sparseIncluded()); } + + public function testItIgnoresNonStringSparseIncluded(): void + { + $request = JsonApiRequest::create(uri: '/?' . http_build_query([ + 'include' => ['teams', 'posts'], + ])); + + $this->assertSame([], $request->sparseIncluded()); + $this->assertSame([], $request->sparseIncluded('teams')); + } } diff --git a/tests/Integration/Mail/SendingMarkdownMailTest.php b/tests/Integration/Mail/SendingMarkdownMailTest.php index 94cc9e202..fb57321a1 100644 --- a/tests/Integration/Mail/SendingMarkdownMailTest.php +++ b/tests/Integration/Mail/SendingMarkdownMailTest.php @@ -85,9 +85,9 @@ public function testEmbed(): void $email = $this->app->make('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage()->toString(); - $cid = explode(' cid:', (new Stringable($email))->explode("\r\n") + $cid = rtrim(explode(' cid:', (new Stringable($email))->explode("\r\n") ->filter(fn (string $line): bool => str_contains($line, ' content: cid:')) - ->first())[1]; + ->first())[1], '='); $filename = explode('Embed file: ', (new Stringable($email))->explode("\r\n") ->filter(fn (string $line): bool => str_contains($line, ' file:')) diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php index 70d2e27db..fd3ff99cb 100644 --- a/tests/Integration/Queue/WorkCommandTest.php +++ b/tests/Integration/Queue/WorkCommandTest.php @@ -293,7 +293,7 @@ public function testMemoryExitCode() Worker::$memoryExceededExitCode = null; } - public function testDisableLastRestartCheck() + public function testDisableLastRestartCheck(): void { $this->markTestSkippedWhenUsingQueueDrivers(['redis', 'beanstalkd']); @@ -301,6 +301,7 @@ public function testDisableLastRestartCheck() $cache = m::mock(Repository::class); $cache->shouldNotReceive('get')->with(Worker::RESTART_SIGNAL_CACHE_KEY); + $cache->shouldReceive('get')->with('illuminate:queues:paused', false)->andReturn(false); $cache->shouldReceive('many') ->with(['illuminate:queue:paused:database:default']) ->andReturn(['illuminate:queue:paused:database:default' => false]); diff --git a/tests/Integration/RateLimiter/Redis/RedisStoreTest.php b/tests/Integration/RateLimiter/Redis/RedisStoreTest.php index 5913e9dbb..017029b51 100644 --- a/tests/Integration/RateLimiter/Redis/RedisStoreTest.php +++ b/tests/Integration/RateLimiter/Redis/RedisStoreTest.php @@ -424,7 +424,12 @@ public function testSerializerAndCompressionOptionsDoNotAffectLimiterState(): vo resetAfter: 5, )->by('encoded-backoff'); + // Vary cost rather than the limit so both admission checks inspect the same stored counter. + $this->assertFalse($limiter->inspect($fixed->cost(2))->denied()); $this->assertSame(1, $limiter->consume($fixed)->remaining()); + $this->assertTrue($limiter->inspect($fixed->cost(2))->denied()); + $this->assertFalse($limiter->inspect($fixed)->denied()); + $this->assertSame(1, $limiter->consume($sliding)->remaining()); $this->assertTrue($limiter->consume($leaky)->allowed()); $this->assertTrue($limiter->consume($leaky)->denied()); diff --git a/tests/Integration/Redis/RedisConnectorTest.php b/tests/Integration/Redis/RedisConnectorTest.php index 25b59ef38..e18de1f4f 100644 --- a/tests/Integration/Redis/RedisConnectorTest.php +++ b/tests/Integration/Redis/RedisConnectorTest.php @@ -10,6 +10,9 @@ use Hypervel\Redis\RedisConnection; use Hypervel\Support\Facades\Redis; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; +use Redis as PhpRedis; /** * Tests that Redis connection configuration is correctly applied to the @@ -20,6 +23,18 @@ class RedisConnectorTest extends TestCase { use InteractsWithRedis; + /** + * Set up the standalone Redis configuration tests. + */ + protected function setUp(): void + { + parent::setUp(); + + if ($this->usingRedisCluster()) { + $this->markTestSkipped('These connection options require standalone phpredis.'); + } + } + protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); @@ -33,7 +48,7 @@ public function testDefaultConfiguration(): void $host = $this->app->make('config')->get('database.redis.default.host'); $port = $this->app->make('config')->get('database.redis.default.port'); - $this->withClient('default', function (\Redis $client) use ($host, $port): void { + $this->withClient('default', function (PhpRedis $client) use ($host, $port): void { $this->assertSame($host, $client->getHost()); $this->assertSame($port, $client->getPort()); }); @@ -50,7 +65,7 @@ public function testUrl(): void 'database' => $this->getParallelRedisDb(), ]); - $this->withClient($name, function (\Redis $client) use ($host, $port): void { + $this->withClient($name, function (PhpRedis $client) use ($host, $port): void { // redis:// URL maps to tcp:// scheme via ConfigurationUrlParser driver aliases $this->assertSame("tcp://{$host}", $client->getHost()); $this->assertEquals($port, $client->getPort()); @@ -68,7 +83,7 @@ public function testUrlWithScheme(): void 'database' => $this->getParallelRedisDb(), ]); - $this->withClient($name, function (\Redis $client) use ($host, $port): void { + $this->withClient($name, function (PhpRedis $client) use ($host, $port): void { $this->assertSame("tcp://{$host}", $client->getHost()); $this->assertEquals($port, $client->getPort()); }); @@ -87,7 +102,7 @@ public function testScheme(): void 'database' => $this->getParallelRedisDb(), ]); - $this->withClient($name, function (\Redis $client) use ($host, $port): void { + $this->withClient($name, function (PhpRedis $client) use ($host, $port): void { $this->assertSame("tcp://{$host}", $client->getHost()); $this->assertEquals($port, $client->getPort()); }); @@ -111,8 +126,8 @@ public function testPerConnectionPrefixOverridesGlobalPrefix(): void // Must purge + re-resolve since config changed after initial resolution $this->app->make('redis')->purge($name); - $this->withClient($name, function (\Redis $client): void { - $this->assertSame('per_connection_', $client->getOption(\Redis::OPT_PREFIX)); + $this->withClient($name, function (PhpRedis $client): void { + $this->assertSame('per_connection_', $client->getOption(PhpRedis::OPT_PREFIX)); }); } @@ -132,8 +147,8 @@ public function testTopLevelConnectionPrefixOverridesGlobalAndLocalPrefix(): voi $this->app->make('config')->set('database.redis.options.prefix', 'global_'); $this->app->make('redis')->purge($name); - $this->withClient($name, function (\Redis $client): void { - $this->assertSame('top_level_', $client->getOption(\Redis::OPT_PREFIX)); + $this->withClient($name, function (PhpRedis $client): void { + $this->assertSame('top_level_', $client->getOption(PhpRedis::OPT_PREFIX)); }); } @@ -147,7 +162,7 @@ public function testClientNameIsApplied(): void 'name' => 'hypervel-connector-test', ]); - $this->withClient($name, function (\Redis $client): void { + $this->withClient($name, function (PhpRedis $client): void { $this->assertSame('hypervel-connector-test', $client->client('GETNAME')); }); } @@ -164,22 +179,77 @@ public function testTcpKeepaliveOptionIsApplied(): void ], ]); - $this->withClient($name, function (\Redis $client): void { - $this->assertSame(1, $client->getOption(\Redis::OPT_TCP_KEEPALIVE)); + $this->withClient($name, function (PhpRedis $client): void { + $this->assertSame(1, $client->getOption(PhpRedis::OPT_TCP_KEEPALIVE)); + }); + } + + #[DataProvider('phpRedisBackoffAlgorithmsProvider')] + public function testPhpRedisBackoffAlgorithmParsing(string $friendlyAlgorithmName, int $expectedAlgorithm): void + { + $name = $this->addTestConnection(['backoff_algorithm' => $friendlyAlgorithmName]); + + $this->withClient($name, function (PhpRedis $client) use ($expectedAlgorithm): void { + $this->assertSame($expectedAlgorithm, $client->getOption(PhpRedis::OPT_BACKOFF_ALGORITHM)); + }); + } + + #[DataProvider('phpRedisBackoffAlgorithmsProvider')] + public function testPhpRedisBackoffAlgorithm(string $friendlyAlgorithm, int $expectedAlgorithm): void + { + $name = $this->addTestConnection(['backoff_algorithm' => $expectedAlgorithm]); + + $this->withClient($name, function (PhpRedis $client) use ($expectedAlgorithm): void { + $this->assertSame($expectedAlgorithm, $client->getOption(PhpRedis::OPT_BACKOFF_ALGORITHM)); + }); + } + + /** + * Provide friendly backoff names and their native algorithms. + */ + public static function phpRedisBackoffAlgorithmsProvider(): array + { + return [ + ['default', PhpRedis::BACKOFF_ALGORITHM_DEFAULT], + ['decorrelated_jitter', PhpRedis::BACKOFF_ALGORITHM_DECORRELATED_JITTER], + ['equal_jitter', PhpRedis::BACKOFF_ALGORITHM_EQUAL_JITTER], + ['exponential', PhpRedis::BACKOFF_ALGORITHM_EXPONENTIAL], + ['uniform', PhpRedis::BACKOFF_ALGORITHM_UNIFORM], + ['constant', PhpRedis::BACKOFF_ALGORITHM_CONSTANT], + ]; + } + + public function testAnInvalidPhpRedisBackoffAlgorithmIsConvertedToDefault(): void + { + $name = $this->addTestConnection(['backoff_algorithm' => 7]); + + $this->withClient($name, function (PhpRedis $client): void { + $this->assertSame(PhpRedis::BACKOFF_ALGORITHM_DEFAULT, $client->getOption(PhpRedis::OPT_BACKOFF_ALGORITHM)); + }); + } + + public function testItFailsWithAnInvalidPhpRedisAlgorithm(): void + { + $this->expectExceptionObject(new InvalidArgumentException('Algorithm [foo] is not a valid PhpRedis backoff algorithm')); + + $name = $this->addTestConnection(['backoff_algorithm' => 'foo']); + + // Acquiring the pooled connection builds the native client and applies its options. + Redis::connection($name)->withConnection(static function (RedisConnection $connection): void { }); } /** * Execute a callback with the underlying phpredis client for a named connection. * - * @param Closure(\Redis): void $callback + * @param Closure(PhpRedis): void $callback */ private function withClient(string $name, Closure $callback): void { Redis::connection($name)->withConnection( function (RedisConnection $connection) use ($callback): void { $client = $connection->client(); - $this->assertInstanceOf(\Redis::class, $client); + $this->assertInstanceOf(PhpRedis::class, $client); $callback($client); }, diff --git a/tests/Integration/Redis/RedisProxyIntegrationTest.php b/tests/Integration/Redis/RedisProxyIntegrationTest.php index d0a22cb87..622ee90b4 100644 --- a/tests/Integration/Redis/RedisProxyIntegrationTest.php +++ b/tests/Integration/Redis/RedisProxyIntegrationTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Integration\Redis; +use Exception; use Hypervel\Context\CoroutineContext; use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; @@ -1095,6 +1096,148 @@ public function testConcurrentTransactionCallbacksWithLimitedConnectionPool(): v } } + public function testItKeepsTheConnectionUsableWhenATransactionFails(): void + { + foreach ($this->connections() as $redis) { + $redis->set('name', 'taylor'); + $exception = new Exception('Something went wrong.'); + + try { + $redis->transaction(function (PhpRedis $transaction) use ($exception): never { + $transaction->set('name', 'mohamed'); + + throw $exception; + }); + $this->fail('Expected the transaction callback exception to propagate.'); + } catch (Exception $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame('taylor', $redis->get('name')); + } + } + + public function testItKeepsTheConnectionUsableWhenAPipelineFails(): void + { + foreach ($this->connections() as $redis) { + $redis->set('name', 'taylor'); + $exception = new Exception('Something went wrong.'); + + try { + $redis->pipeline(function (PhpRedis $pipeline) use ($exception): never { + $pipeline->set('name', 'mohamed'); + + throw $exception; + }); + $this->fail('Expected the pipeline callback exception to propagate.'); + } catch (Exception $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame('taylor', $redis->get('name')); + } + } + + /** + * Get the configured PhpRedis connection variants. + * + * @return array + */ + public function connections(): array + { + $connections = ['phpredis' => Redis::connection()]; + $default = config('database.redis.default'); + $configurations = [ + 'url' => [ + 'url' => "redis://{$default['host']}:{$default['port']}", + 'host' => 'overwrittenByUrl', + 'port' => 'overwrittenByUrl', + 'options' => ['prefix' => 'hypervel:'], + ], + // The connection pool owns persistence instead of native pconnect(). + 'pooled' => [ + 'options' => ['prefix' => 'hypervel:'], + ], + 'serializer_json' => [ + 'options' => ['serializer' => PhpRedis::SERIALIZER_JSON], + ], + 'scan_retry' => [ + 'options' => ['scan' => PhpRedis::SCAN_RETRY], + ], + ]; + + if (defined('Redis::COMPRESSION_LZF')) { + $configurations['compression_lzf'] = [ + 'name' => 'compression_lzf', + 'options' => ['compression' => PhpRedis::COMPRESSION_LZF], + ]; + } + + if (defined('Redis::COMPRESSION_ZSTD')) { + $configurations['compression_zstd'] = [ + 'name' => 'compression_zstd', + 'options' => ['compression' => PhpRedis::COMPRESSION_ZSTD], + ]; + $configurations['compression_zstd_default'] = [ + 'name' => 'compression_zstd_default', + 'options' => [ + 'compression' => PhpRedis::COMPRESSION_ZSTD, + 'compression_level' => PhpRedis::COMPRESSION_ZSTD_DEFAULT, + ], + ]; + $configurations['compression_zstd_max'] = [ + 'name' => 'compression_zstd_max', + 'options' => [ + 'compression' => PhpRedis::COMPRESSION_ZSTD, + 'compression_level' => PhpRedis::COMPRESSION_ZSTD_MAX, + ], + ]; + } + + if (defined('Redis::COMPRESSION_LZ4')) { + $configurations['compression_lz4'] = [ + 'name' => 'compression_lz4', + 'options' => ['compression' => PhpRedis::COMPRESSION_LZ4], + ]; + $configurations['compression_lz4_default'] = [ + 'name' => 'compression_lz4_default', + 'options' => [ + 'compression' => PhpRedis::COMPRESSION_LZ4, + 'compression_level' => 0, + ], + ]; + $configurations['compression_lz4_min'] = [ + 'name' => 'compression_lz4_min', + 'options' => [ + 'compression' => PhpRedis::COMPRESSION_LZ4, + 'compression_level' => 1, + ], + ]; + $configurations['compression_lz4_max'] = [ + 'name' => 'compression_lz4_max', + 'options' => [ + 'compression' => PhpRedis::COMPRESSION_LZ4, + 'compression_level' => 12, + ], + ]; + } + + foreach ($configurations as $name => $configuration) { + $connectionName = $this->createRedisConnectionWithOptions('test_' . $name, $configuration['options']); + config([ + "database.redis.{$connectionName}" => array_replace( + config("database.redis.{$connectionName}"), + $configuration, + ['timeout' => 0.5], + ), + ]); + + $connections[$name] = Redis::connection($connectionName); + } + + return $connections; + } + /** * Get the exact native client held by a Redis proxy. */ diff --git a/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php b/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php index c42f2db10..4124d32aa 100644 --- a/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php +++ b/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php @@ -23,6 +23,7 @@ use Hypervel\Validation\Rules\Exists; use Hypervel\Validation\Rules\Unique; use Hypervel\Validation\Validator; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Stringable; @@ -997,10 +998,11 @@ function ($query) use (&$uniqueCallbackCalls): void { $this->assertSame(2, $uniqueCallbackCalls); } - public function testStringFormUniqueRuleUnescapesIgnoredValueBeforeBatching(): void + #[TestWith(['slash\id@example.com', false])] + #[TestWith(['quote"\id,@example.com\\', false])] + #[TestWith(['quote"\id,@example.com\\', true])] + public function testStringFormUniqueRulePreservesIgnoredValue(string $email, bool $stopOnFirstFailure): void { - $email = 'slash\id@example.com'; - $this->app->make('db')->table('batch_test_users')->insert([ 'external_id' => 3, 'email' => $email, @@ -1017,6 +1019,7 @@ public function testStringFormUniqueRuleUnescapesIgnoredValueBeforeBatching(): v ]], ['items.*.email' => ['required', $rule]], ); + $validator->stopOnFirstFailure($stopOnFirstFailure); DB::enableQueryLog(); @@ -1033,7 +1036,35 @@ public function testStringFormUniqueRuleUnescapesIgnoredValueBeforeBatching(): v return str_contains($entry['query'], 'batch_test_users'); }); - $this->assertCount(1, $uniqueQueries); + $this->assertCount($stopOnFirstFailure ? 2 : 1, $uniqueQueries); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testPresenceFiltersPreserveLiteralValues(bool $stopOnFirstFailure): void + { + $status = 'active,"quoted"\\'; + DB::table('batch_test_users')->where('email', 'user1@example.com')->update(['status' => $status]); + + $data = ['items' => [ + ['email' => 'user1@example.com'], + ['email' => 'user2@example.com'], + ]]; + $exists = $this->makeValidator($data, [ + 'items.*.email' => [(new Exists('batch_test_users', 'email'))->where('status', $status)], + ]); + $exists->stopOnFirstFailure($stopOnFirstFailure); + + $this->assertFalse($exists->passes()); + $this->assertSame(['items.1.email'], $exists->errors()->keys()); + + $unique = $this->makeValidator($data, [ + 'items.*.email' => [(new Unique('batch_test_users', 'email'))->where('status', $status)], + ]); + $unique->stopOnFirstFailure($stopOnFirstFailure); + + $this->assertFalse($unique->passes()); + $this->assertSame(['items.0.email'], $unique->errors()->keys()); } public function testArrayFormExistsRuleCanConsumeFactsFromIdenticalWildcardShape(): void diff --git a/tests/Mail/MailMailerTest.php b/tests/Mail/MailMailerTest.php index 02b053295..0c8785f38 100644 --- a/tests/Mail/MailMailerTest.php +++ b/tests/Mail/MailMailerTest.php @@ -19,7 +19,10 @@ use Hypervel\Support\HtmlString; use Hypervel\Support\Testing\Fakes\QueueFake; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; use Mockery as m; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Exception\InvalidArgumentException as MimeInvalidArgumentException; class MailMailerTest extends TestCase { @@ -235,6 +238,36 @@ public function testToAllowsEmailAndName(): void $this->assertSame('Taylor Otwell', $recipients[0]->getName()); } + public function testMailerRejectsAddressesContainingLineBreaks(): void + { + $renderedView = m::mock(ViewContract::class); + $renderedView->expects('render')->andReturn('rendered.view'); + $view = m::mock(ViewFactory::class); + $view->expects('make')->andReturn($renderedView); + $mailer = new Mailer('array', $view, new ArrayTransport); + + $this->expectExceptionObject(new InvalidArgumentException('Email addresses may not contain line break characters.')); + + $mailer->send('foo', ['data'], function (Message $message): void { + $message->to("\"foo\r\nBcc: victim@example.com\"@example.com")->from('hello@hypervel.org'); + }); + } + + public function testMailerRejectsSymfonyAddressesContainingLineBreaks(): void + { + $renderedView = m::mock(ViewContract::class); + $renderedView->expects('render')->andReturn('rendered.view'); + $view = m::mock(ViewFactory::class); + $view->expects('make')->andReturn($renderedView); + $mailer = new Mailer('array', $view, new ArrayTransport); + + $this->expectExceptionObject(new MimeInvalidArgumentException('Email address contains control characters.')); + + $mailer->send('foo', ['data'], function (Message $message): void { + $message->to(new Address("\"foo\r\nBcc: victim@example.com\"@example.com"))->from('hello@hypervel.org'); + }); + } + public function testGlobalFromIsRespectedOnAllMessages(): void { $view = $this->mockView(); diff --git a/tests/Mail/MailMessageTest.php b/tests/Mail/MailMessageTest.php index cbdcc9a71..7e0e9b678 100644 --- a/tests/Mail/MailMessageTest.php +++ b/tests/Mail/MailMessageTest.php @@ -11,6 +11,8 @@ use Hypervel\Support\Str; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; @@ -55,6 +57,11 @@ public function testReturnPathMethod(): void { $this->assertInstanceOf(Message::class, $message = $this->message->returnPath('foo@bar.baz')); $this->assertEquals(new Address('foo@bar.baz'), $message->getSymfonyMessage()->getReturnPath()); + + $address = new Address('person@example.test'); + $this->message->returnPath($address); + + $this->assertSame($address, $this->message->getSymfonyMessage()->getReturnPath()); } public function testToMethod(): void @@ -64,6 +71,12 @@ public function testToMethod(): void $this->assertInstanceOf(Message::class, $message = $this->message->to(['bar@bar.baz' => 'Bar'])); $this->assertEquals(new Address('bar@bar.baz', 'Bar'), $message->getSymfonyMessage()->getTo()[0]); + + $this->message->to([['email' => 'person@example.test']]); + $this->assertEquals(new Address('person@example.test'), $this->message->getSymfonyMessage()->getTo()[0]); + + $this->message->to([['address' => 'another@example.test', 'name' => null]]); + $this->assertEquals(new Address('another@example.test'), $this->message->getSymfonyMessage()->getTo()[0]); } public function testToMethodWithOverride(): void @@ -90,6 +103,33 @@ public function testReplyToMethod(): void $this->assertEquals(new Address('foo@bar.baz', 'Foo'), $message->getSymfonyMessage()->getReplyTo()[0]); } + #[DataProvider('addressesContainingLineBreaks')] + public function testAddressEntryPointsRejectLineBreaks(string $method, array $arguments): void + { + $this->expectExceptionObject(new InvalidArgumentException('Email addresses may not contain line break characters.')); + + $this->message->{$method}(...$arguments); + } + + /** + * Provide the independently normalized address forms. + */ + public static function addressesContainingLineBreaks(): iterable + { + $address = "person@example.test\n"; + + yield 'from list' => ['from', [[$address]]]; + yield 'sender list' => ['sender', [[$address]]]; + yield 'to override' => ['to', [[$address], null, true]]; + yield 'cc override' => ['cc', [[$address], null, true]]; + yield 'bcc override' => ['bcc', [[$address], null, true]]; + yield 'mapped name' => ['to', [[$address => 'Person']]]; + yield 'nested address' => ['to', [[['email' => $address]]]]; + yield 'mapped null name' => ['to', [[$address => null]]]; + yield 'reply-to list' => ['replyTo', [[$address]]]; + yield 'return path' => ['returnPath', [$address]]; + } + public function testSubjectMethod(): void { $this->assertInstanceOf(Message::class, $message = $this->message->subject('foo')); diff --git a/tests/Mail/MailableAlternativeSyntaxTest.php b/tests/Mail/MailableAlternativeSyntaxTest.php index c20dfe7f1..5a3b68d58 100644 --- a/tests/Mail/MailableAlternativeSyntaxTest.php +++ b/tests/Mail/MailableAlternativeSyntaxTest.php @@ -9,6 +9,8 @@ use Hypervel\Mail\Mailables\Content; use Hypervel\Mail\Mailables\Envelope; use Hypervel\Tests\TestCase; +use InvalidArgumentException; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; class MailableAlternativeSyntaxTest extends TestCase @@ -42,6 +44,15 @@ public function testBasicMailableInspection(): void $this->assertEquals(1, count($mailable->bcc)); } + #[TestWith(["person@example.test\r"])] + #[TestWith(["person@example.test\n"])] + public function testAddressRejectsLineBreaks(string $address): void + { + $this->expectExceptionObject(new InvalidArgumentException('Email addresses may not contain line break characters.')); + + new Address($address); + } + public function testEnvelopesCanReceiveAdditionalRecipients(): void { $envelope = new Envelope(to: ['taylor@example.com']); diff --git a/tests/Queue/QueuePauseResumeTest.php b/tests/Queue/QueuePauseResumeTest.php index 34b0c9bb1..5c1e38bbd 100644 --- a/tests/Queue/QueuePauseResumeTest.php +++ b/tests/Queue/QueuePauseResumeTest.php @@ -13,9 +13,12 @@ use Hypervel\Queue\Console\Concerns\ParsesQueue; use Hypervel\Queue\Events\QueuePaused; use Hypervel\Queue\Events\QueueResumed; +use Hypervel\Queue\Events\QueuesPaused; +use Hypervel\Queue\Events\QueuesResumed; use Hypervel\Queue\QueueManager; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; +use RuntimeException; class QueuePauseResumeTest extends TestCase { @@ -25,12 +28,24 @@ class QueuePauseResumeTest extends TestCase protected Dispatcher $events; + /** + * Set up the test environment. + */ protected function setUp(): void { parent::setUp(); - $container = new Container; $this->cache = new CacheRepository(new ArrayStore); + + $this->manager = $this->createManager($this->cache); + } + + /** + * Create a queue manager using the given cache repository. + */ + protected function createManager(CacheRepository $cache): QueueManager + { + $container = new Container; $this->events = new Dispatcher($container); $container->instance('config', new ConfigRepository([ @@ -42,12 +57,18 @@ protected function setUp(): void ], ], ])); - $container->instance('cache', new class($this->cache) { + $container->instance('cache', new class($cache) { + /** + * Create a cache manager fixture. + */ public function __construct( private readonly CacheRepository $repository, ) { } + /** + * Get the cache repository. + */ public function store(?string $name = null): CacheRepository { return $this->repository; @@ -56,7 +77,7 @@ public function store(?string $name = null): CacheRepository $container->instance('events', $this->events); $container->instance(DispatcherContract::class, $this->events); - $this->manager = new QueueManager($container); + return new QueueManager($container); } public function testPauseQueueWithConnection() @@ -177,7 +198,7 @@ public function testPassiveObserversDoNotCauseQueueStateEventsToDispatch(): void { $observed = []; $this->events->observe( - [QueuePaused::class, QueueResumed::class], + [QueuePaused::class, QueueResumed::class, QueuesPaused::class, QueuesResumed::class], static function (string $event) use (&$observed): void { $observed[] = $event; }, @@ -187,6 +208,9 @@ static function (string $event) use (&$observed): void { $this->manager->pauseFor('redis', 'emails', 60); $this->manager->resume('redis', 'default'); + $this->manager->pauseAll(); + $this->manager->resumeAll(); + $this->assertSame([], $observed); } @@ -203,6 +227,87 @@ public function testGetPausedQueues(): void ); } + public function testPauseAllPausesEveryQueueAndResumeAllResumesThem(): void + { + $this->manager->pauseAll(); + + $this->assertTrue($this->manager->isPaused('redis', 'default')); + $this->assertTrue($this->manager->isPaused('database', 'emails')); + $this->assertSame( + ['default', 'emails'], + $this->manager->getPausedQueues('redis', ['default', 'emails']) + ); + + $this->manager->resumeAll(); + + $this->assertFalse($this->manager->isPaused('redis', 'default')); + $this->assertSame([], $this->manager->getPausedQueues('redis', ['default', 'emails'])); + } + + public function testResumeAllPreservesIndividuallyPausedQueues(): void + { + $this->manager->pause('redis', 'emails'); + $this->manager->pauseAll(); + $this->manager->resumeAll(); + + $this->assertTrue($this->manager->isPaused('redis', 'emails')); + $this->assertFalse($this->manager->isPaused('database', 'emails')); + $this->assertSame(['emails'], $this->manager->getPausedQueues('redis', ['default', 'emails'])); + } + + public function testPauseChecksDoNotBatchTheGlobalKeyWithQueueKeys(): void + { + $store = new class extends ArrayStore { + /** + * Retrieve multiple keys without crossing the global pause key's slot. + */ + public function many(array $keys): array + { + if (count($keys) > 1 && in_array('illuminate:queues:paused', $keys, true)) { + throw new RuntimeException("CROSSSLOT Keys in request don't hash to the same slot"); + } + + return parent::many($keys); + } + }; + + $manager = $this->createManager(new CacheRepository($store)); + + $this->assertFalse($manager->isPaused('redis', 'default')); + $this->assertSame([], $manager->getPausedQueues('redis', ['default'])); + + $manager->pauseAll(); + + $this->assertTrue($manager->isPaused('redis', 'default')); + $this->assertSame(['default'], $manager->getPausedQueues('redis', ['default'])); + } + + public function testPauseAllDispatchesQueuesPausedEvent(): void + { + $dispatchedEvent = null; + + $this->events->listen(QueuesPaused::class, function (QueuesPaused $event) use (&$dispatchedEvent): void { + $dispatchedEvent = $event; + }); + + $this->manager->pauseAll(); + + $this->assertInstanceOf(QueuesPaused::class, $dispatchedEvent); + } + + public function testResumeAllDispatchesQueuesResumedEvent(): void + { + $dispatchedEvent = null; + + $this->events->listen(QueuesResumed::class, function (QueuesResumed $event) use (&$dispatchedEvent): void { + $dispatchedEvent = $event; + }); + + $this->manager->resumeAll(); + + $this->assertInstanceOf(QueuesResumed::class, $dispatchedEvent); + } + public function testParsingQueueString() { $parser = new class { diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index 8fb4242bd..88858b6ca 100644 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -41,6 +41,8 @@ use Hypervel\Queue\Events\WorkerIdle; use Hypervel\Queue\Events\WorkerInterrupted; use Hypervel\Queue\Events\WorkerPausing; +use Hypervel\Queue\Events\WorkerQueuePaused; +use Hypervel\Queue\Events\WorkerQueueResumed; use Hypervel\Queue\Events\WorkerResuming; use Hypervel\Queue\Events\WorkerStarting; use Hypervel\Queue\Events\WorkerStopping; @@ -293,7 +295,7 @@ public function testInvalidPayloadIsNotReportedWhenJobExceptionReportingIsDisabl $this->assertTrue($job->isDeleted()); } - public function testWorkerOptionsCoroutineContextIsScopedToJob() + public function testWorkerOptionsCoroutineContextIsScopedToJob(): void { CoroutineContext::set('queue.worker.test.previous', 'previous'); @@ -304,6 +306,16 @@ public function testWorkerOptionsCoroutineContextIsScopedToJob() 'queue.worker.test.new' => 'fresh', ]; + $seenDuringPop = []; + $this->events->shouldReceive('dispatch')->andReturnUsing(function (object $event) use (&$seenDuringPop): void { + if ($event instanceof JobPopping || $event instanceof JobPopped) { + $seenDuringPop[$event::class] = [ + CoroutineContext::get('queue.worker.test.previous'), + CoroutineContext::get('queue.worker.test.new'), + ]; + } + }); + $worker = $this->getWorker('default', ['queue' => [ new WorkerFakeJob(function () use (&$seen) { $seen = [ @@ -316,6 +328,10 @@ public function testWorkerOptionsCoroutineContextIsScopedToJob() $worker->runNextJob('default', 'queue', $options); $this->assertSame(['seeded', 'fresh'], $seen); + $this->assertSame([ + JobPopping::class => ['seeded', 'fresh'], + JobPopped::class => ['seeded', 'fresh'], + ], $seenDuringPop); $this->assertSame('previous', CoroutineContext::get('queue.worker.test.previous')); $this->assertFalse(CoroutineContext::has('queue.worker.test.new')); } @@ -970,6 +986,108 @@ public function testLoopingEventCarriesWorkerOptions(): void $this->assertFalse($worker->daemonShouldRunForTest($options, 'default', 'queue')); } + public function testQueuePauseEventsTrackOnlyTheCurrentSelection(): void + { + $paused = ['emails']; + $manager = m::mock(QueueManager::class); + $manager->shouldReceive('connection')->with('first')->andReturn( + new WorkerFakeConnection('first', ['emails' => [], 'default' => []]), + ); + $manager->shouldReceive('connection')->with('second')->andReturn( + new WorkerFakeConnection('second', ['emails' => []]), + ); + $manager->shouldReceive('getPausedQueues')->andReturnUsing( + static function (string $connection, array $queues) use (&$paused): array { + return $connection === 'first' + ? array_values(array_intersect($queues, $paused)) + : []; + }, + ); + $worker = new InsomniacWorker($manager, $this->events, $this->exceptionHandler, static fn (): bool => false); + $worker->setCache(m::mock(CacheContract::class)); + $options = new WorkerOptions(sleep: 0); + $observed = []; + $this->events->shouldReceive('dispatch')->andReturnUsing(function (object $event) use (&$observed): void { + if ($event instanceof WorkerQueuePaused || $event instanceof WorkerQueueResumed) { + $observed[] = [$event::class, $event->connectionName, $event->queue]; + } + }); + + $worker->runNextJob('first', 'emails', $options); + $worker->runNextJob('first', 'emails', $options); + $worker->runNextJob('second', 'emails', $options); + $worker->runNextJob('first', 'emails', $options); + $worker->runNextJob('first', 'default', $options); + $worker->runNextJob('first', 'emails', $options); + + // Returning to a selection reports its current pause state without retaining other selections. + $this->assertSame([ + [WorkerQueuePaused::class, 'first', 'emails'], + [WorkerQueuePaused::class, 'first', 'emails'], + [WorkerQueuePaused::class, 'first', 'emails'], + ], $observed); + + $paused = []; + $worker->runNextJob('first', 'emails', $options); + $worker->runNextJob('first', 'emails', $options); + + $this->assertCount(4, $observed); + $this->assertSame([WorkerQueueResumed::class, 'first', 'emails'], $observed[3]); + } + + public function testPauseHistoryIsRetainedWithoutEventListeners(): void + { + $manager = m::mock(QueueManager::class); + $manager->shouldReceive('connection')->with('default')->andReturn( + new WorkerFakeConnection('default', ['queue' => []]), + ); + $manager->shouldReceive('getPausedQueues')->with('default', ['queue'])->andReturn(['queue'], []); + $listening = false; + $this->events->shouldReceive('hasListeners')->andReturnUsing( + static function (string $event) use (&$listening): bool { + return $listening && $event === WorkerQueueResumed::class; + }, + ); + $worker = new InsomniacWorker($manager, $this->events, $this->exceptionHandler, static fn (): bool => false); + $worker->setCache(m::mock(CacheContract::class)); + + $worker->runNextJob('default', 'queue', new WorkerOptions(sleep: 0)); + $this->events->shouldNotHaveReceived('dispatch'); + + $listening = true; + $worker->runNextJob('default', 'queue', new WorkerOptions(sleep: 0)); + + $this->events->shouldHaveReceived('dispatch')->with(m::on( + static fn (object $event): bool => $event instanceof WorkerQueueResumed + && $event->connectionName === 'default' + && $event->queue === 'queue', + ))->once(); + } + + public function testEachDaemonRunReportsInitiallyPausedQueues(): void + { + $manager = m::mock(QueueManager::class); + $manager->shouldReceive('connection')->with('default')->andReturn( + new WorkerFakeConnection('default', ['queue' => []]), + ); + $manager->shouldReceive('getPausedQueues')->with('default', ['queue'])->andReturn(['queue']); + $cache = m::mock(CacheContract::class); + $cache->shouldReceive('get')->with(Worker::RESTART_SIGNAL_CACHE_KEY)->andReturn(null); + $worker = new InsomniacWorker($manager, $this->events, $this->exceptionHandler, static fn (): bool => false); + $worker->setCache($cache); + $options = new WorkerOptions(sleep: 0, stopWhenEmpty: true, memory: 1024); + + $this->assertSame(Worker::EXIT_SUCCESS, $worker->daemon('default', 'queue', $options)); + $this->assertSame(Worker::EXIT_SUCCESS, $worker->daemon('default', 'queue', $options)); + + $this->events->shouldHaveReceived('dispatch')->with(m::on( + static fn (object $event): bool => $event instanceof WorkerQueuePaused + && $event->connectionName === 'default' + && $event->queue === 'queue', + ))->twice(); + $this->events->shouldNotHaveReceived('dispatch', [m::type(WorkerQueueResumed::class)]); + } + public function testJobCanBeFiredBasedOnPriority() { $worker = $this->getWorker('default', [ diff --git a/tests/Queue/WorkCommandTest.php b/tests/Queue/WorkCommandTest.php index 564511530..6b4174258 100644 --- a/tests/Queue/WorkCommandTest.php +++ b/tests/Queue/WorkCommandTest.php @@ -12,6 +12,8 @@ use Hypervel\Queue\WorkerOptions; use Hypervel\Queue\WorkerStopReason; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Facades\Artisan; +use Hypervel\Support\Facades\Queue; use Hypervel\Testbench\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; @@ -31,6 +33,82 @@ protected function defineEnvironment(Application $app): void $config->set('cache.default', 'array'); } + #[DataProvider('queueStatusOutputProvider')] + public function testQueueStatusOutputUsesTheCurrentCommand(bool $json): void + { + $this->travelTo(CarbonImmutable::create(2023, 1, 18, 10, 10, 11)); + $arguments = ['--once' => true, '--sleep' => 0, '--json' => $json]; + + Queue::pause('sync', 'default'); + $firstOutput = new BufferedOutput; + $this->assertSame(0, Artisan::call('queue:work', $arguments, $firstOutput)); + + if ($json) { + $this->assertSame([ + 'level' => 'warning', + 'queue' => 'default', + 'status' => 'paused', + 'timestamp' => '2023-01-18T10:10:11.000000+00:00', + ], json_decode($firstOutput->fetch(), true, 512, JSON_THROW_ON_ERROR)); + } else { + $this->assertSame(" 2023-01-18 10:10:11 Queue default PAUSED\n", $firstOutput->fetch()); + } + + Queue::resume('sync', 'default'); + $secondOutput = new BufferedOutput; + $this->assertSame(0, Artisan::call('queue:work', $arguments, $secondOutput)); + + $this->assertSame('', $firstOutput->fetch()); + + if ($json) { + $this->assertSame([ + 'level' => 'warning', + 'queue' => 'default', + 'status' => 'resumed', + 'timestamp' => '2023-01-18T10:10:11.000000+00:00', + ], json_decode($secondOutput->fetch(), true, 512, JSON_THROW_ON_ERROR)); + } else { + $this->assertSame(" 2023-01-18 10:10:11 Queue default RESUMED\n", $secondOutput->fetch()); + } + } + + /** + * Provide the queue status output formats. + */ + public static function queueStatusOutputProvider(): array + { + return [ + 'CLI' => [false], + 'JSON' => [true], + ]; + } + + #[DataProvider('suppressedQueueStatusOutputProvider')] + public function testQueueStatusOutputIsSuppressed(string $option): void + { + $output = new BufferedOutput; + $arguments = ['--once' => true, '--sleep' => 0, '--json' => true, $option => true]; + + Queue::pause('sync', 'default'); + $this->assertSame(0, Artisan::call('queue:work', $arguments, $output)); + + Queue::resume('sync', 'default'); + $this->assertSame(0, Artisan::call('queue:work', $arguments, $output)); + + $this->assertSame('', $output->fetch()); + } + + /** + * Provide verbosity options that suppress queue status output. + */ + public static function suppressedQueueStatusOutputProvider(): array + { + return [ + 'quiet' => ['--quiet'], + 'silent' => ['--silent'], + ]; + } + public function testStopOutputUsesTheCurrentCommand(): void { $this->travelTo(CarbonImmutable::create(2023, 1, 18, 10, 10, 11)); diff --git a/tests/Redis/PhpRedisClusterConnectionTest.php b/tests/Redis/PhpRedisClusterConnectionTest.php index 35223a3a1..3ddcf0f17 100644 --- a/tests/Redis/PhpRedisClusterConnectionTest.php +++ b/tests/Redis/PhpRedisClusterConnectionTest.php @@ -660,6 +660,56 @@ public function testDefaultNodeThrowsWhenNoMasters(): void $connection->scan($cursor, ['match' => '*']); } + public function testConnectionRebuildsItsClientOnNextAcquisitionWithoutReplayingCommand(): void + { + $exception = new RedisException('Connection lost'); + $failedClient = m::mock(RedisCluster::class); + $healthyClient = m::mock(RedisCluster::class); + $this->expectDefaultConnectionOptions($failedClient); + $this->expectDefaultConnectionOptions($healthyClient); + $failedClient->expects('get')->once()->with('foo')->andThrow($exception); + $failedClient->expects('getLastError')->andReturnNull(); + $healthyClient->expects('get')->once()->with('foo')->andReturn('bar'); + + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->clusterConfig(), [$failedClient, $healthyClient]) extends PhpRedisClusterConnection { + /** + * Create a connection with replacement native clients. + * + * @param RedisCluster[] $clients + */ + public function __construct( + ContainerContract $container, + PoolInterface $pool, + array $config, + private array $clients, + ) { + parent::__construct($container, $pool, $config); + } + + /** + * Return the next native client. + */ + protected function createRedisCluster(): RedisCluster + { + return array_shift($this->clients); + } + }; + + try { + $connection->__call('get', ['foo']); + $this->fail('Expected the command failure to propagate.'); + } catch (RedisException $throwable) { + $this->assertSame($exception, $throwable); + } + + $this->assertFalse($connection->check()); + $this->assertSame($failedClient, $connection->client()); + $this->assertSame($connection, $connection->getActiveConnection()); + $this->assertSame($healthyClient, $connection->client()); + $this->assertTrue($connection->check()); + $this->assertSame('bar', $connection->__call('get', ['foo'])); + } + public function testReconnectClearsCachedDefaultNode(): void { $pool = m::mock(PoolInterface::class); diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index 25d088862..69ee7a961 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -183,6 +183,24 @@ public function testReleaseDiscardsAConnectionInMultiMode(): void $connection->release(); } + public function testReleaseDiscardsAnInvalidatedTransactionWithoutReportingAbandonment(): void + { + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldNotReceive('log'); + $container = $this->getContainer(); + $container->instance(StdoutLoggerInterface::class, $logger); + $pool = $this->getMockedPool(); + $pool->expects('discard')->with(m::type(RedisConnection::class)); + $pool->shouldNotReceive('release'); + $redis = m::mock(Redis::class); + $redis->expects('getMode')->andReturn(Redis::MULTI); + $connection = $this->mockRedisConnection(container: $container, pool: $pool); + $connection->setActiveConnection($redis); + $connection->invalidate(); + + $connection->release(); + } + public function testReleaseDiscardsAConnectionInPipelineMode(): void { $pool = $this->getMockedPool(); @@ -1461,10 +1479,80 @@ public function testClusterTransportFailureInvalidatesWithoutReplayingCommand(): $this->assertTrue($connection->isInvalidForTest()); } + // REMOVED: Automatic read/write retries and configured command retries can replay committed commands. + #[DataProvider('connectionFailureProvider')] + public function testConnectionRebuildsItsClientOnNextAcquisitionWithoutReplayingCommand( + RedisException|RedisClusterException $exception, + string $command, + array $arguments, + bool $synchronized, + ): void { + $failedClient = m::mock(Redis::class); + $healthyClient = m::mock(Redis::class); + $this->expectDefaultConnectionOptions($failedClient); + $this->expectDefaultConnectionOptions($healthyClient); + $failedClient->expects($command)->once()->with(...$arguments)->andThrow($exception); + $failedClient->expects('getLastError')->andReturn($synchronized ? $exception->getMessage() : null); + $failedClient->shouldNotReceive('isConnected'); + $healthyClient->expects('get')->once()->with('foo')->andReturn('bar'); + + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->standaloneConfig(), [$failedClient, $healthyClient]) extends PhpRedisConnection { + /** + * Create a connection with replacement native clients. + * + * @param Redis[] $clients + */ + public function __construct( + ContainerContract $container, + PoolInterface $pool, + array $config, + private array $clients, + ) { + parent::__construct($container, $pool, $config); + } + + /** + * Return the next native client. + */ + protected function createRedis(array $config): Redis + { + return array_shift($this->clients); + } + }; + + try { + $connection->__call($command, $arguments); + $this->fail('Expected the command failure to propagate.'); + } catch (RedisException|RedisClusterException $throwable) { + $this->assertSame($exception, $throwable); + } + + $this->assertFalse($connection->check()); + $this->assertSame($failedClient, $connection->client()); + $this->assertSame($connection, $connection->getActiveConnection()); + $this->assertSame($healthyClient, $connection->client()); + $this->assertTrue($connection->check()); + $this->assertSame('bar', $connection->__call('get', ['foo'])); + } + + /** + * Provide failover and transport errors across command types. + */ + public static function connectionFailureProvider(): array + { + return [ + 'read-only replica' => [new RedisException('READONLY replica is read-only'), 'set', ['foo', 'bar'], true], + 'disconnected replica' => [new RedisException('MASTERDOWN link is down'), 'get', ['foo'], true], + 'read failure' => [new RedisException('Connection lost'), 'get', ['foo'], false], + 'non-idempotent write' => [new RedisException('Connection lost'), 'incr', ['foo'], false], + 'write with options' => [new RedisException('Connection lost'), 'set', ['foo', 'bar', ['ex' => 60]], false], + 'cluster response error' => [new RedisClusterException('Error processing response from Redis node!'), 'get', ['foo'], false], + ]; + } + #[DataProvider('synchronizedServerErrorDispositionProvider')] public function testSynchronizedServerErrorDispositionDoesNotReplayCommand( string $message, - bool $sentinel, bool $invalid, ): void { $exception = new RedisException($message); @@ -1474,7 +1562,6 @@ public function testSynchronizedServerErrorDispositionDoesNotReplayCommand( $connection = new PhpRedisConnectionStub( $this->getContainer(), $this->getMockedPool(), - ['sentinel' => ['enabled' => $sentinel]], ); $connection->setActiveConnection($redis); @@ -1488,19 +1575,19 @@ public function testSynchronizedServerErrorDispositionDoesNotReplayCommand( $this->assertSame($invalid, $connection->isInvalidForTest()); } + /** + * Provide server errors that invalidate or retain a synchronized connection. + */ public static function synchronizedServerErrorDispositionProvider(): array { return [ - 'standalone READONLY' => ['READONLY replica is read-only', false, false], - 'standalone MASTERDOWN' => ['MASTERDOWN link is down', false, false], - 'standalone LOADING' => ['LOADING data is loading', false, false], - 'standalone OOM' => ['OOM command not allowed', false, false], - 'standalone MISCONF' => ['MISCONF persistence error', false, false], - 'standalone CROSSSLOT' => ["CROSSSLOT Keys in request don't hash to the same slot", false, false], - 'Sentinel READONLY' => ['READONLY replica is read-only', true, true], - 'Sentinel MASTERDOWN' => ['MASTERDOWN link is down', true, true], - 'Sentinel LOADING' => ['LOADING data is loading', true, false], - 'Sentinel non-exact READONLY prefix' => ['READONLY_STATE custom error', true, false], + 'READONLY' => ['READONLY replica is read-only', true], + 'MASTERDOWN' => ['MASTERDOWN link is down', true], + 'LOADING' => ['LOADING data is loading', false], + 'OOM' => ['OOM command not allowed', false], + 'MISCONF' => ['MISCONF persistence error', false], + 'CROSSSLOT' => ["CROSSSLOT Keys in request don't hash to the same slot", false], + 'non-exact READONLY prefix' => ['READONLY_STATE custom error', false], ]; } diff --git a/tests/Support/SleepTest.php b/tests/Support/SleepTest.php index ad194e4ef..758c8a40a 100644 --- a/tests/Support/SleepTest.php +++ b/tests/Support/SleepTest.php @@ -68,13 +68,15 @@ public function testItCanFakeSleeping() $this->assertEqualsWithDelta(0, $end - $start, 0.03); } - public function testItCanSpecifyMinutes() + #[TestWith([1.5, 90_000_000.0])] + #[TestWith([0.000001, 60.0])] + public function testItCanSpecifyMinutes(float $duration, float $microseconds): void { Sleep::fake(); - $sleep = Sleep::for(1.5)->minutes(); + $sleep = Sleep::for($duration)->minutes(); - $this->assertSame((float) $sleep->duration->totalMicroseconds, 90_000_000.0); + $this->assertSame($microseconds, $sleep->duration->totalMicroseconds); } public function testItCanSpecifyMinute() @@ -86,13 +88,15 @@ public function testItCanSpecifyMinute() $this->assertSame((float) $sleep->duration->totalMicroseconds, 60_000_000.0); } - public function testItCanSpecifySeconds() + #[TestWith([1.5, 1_500_000.0])] + #[TestWith([0.000001, 1.0])] + public function testItCanSpecifySeconds(float $duration, float $microseconds): void { Sleep::fake(); - $sleep = Sleep::for(1.5)->seconds(); + $sleep = Sleep::for($duration)->seconds(); - $this->assertSame((float) $sleep->duration->totalMicroseconds, 1_500_000.0); + $this->assertSame($microseconds, $sleep->duration->totalMicroseconds); } public function testItCanSpecifySecond() @@ -104,13 +108,16 @@ public function testItCanSpecifySecond() $this->assertSame((float) $sleep->duration->totalMicroseconds, 1_000_000.0); } - public function testItCanSpecifyMilliseconds() + #[TestWith([1.5, 1_500.0])] + #[TestWith([0.0015, 2.0])] + #[TestWith([0.000001, 0.0])] + public function testItCanSpecifyMilliseconds(float $duration, float $microseconds): void { Sleep::fake(); - $sleep = Sleep::for(1.5)->milliseconds(); + $sleep = Sleep::for($duration)->milliseconds(); - $this->assertSame((float) $sleep->duration->totalMicroseconds, 1_500.0); + $this->assertSame($microseconds, $sleep->duration->totalMicroseconds); } public function testItCanSpecifyMillisecond() @@ -122,14 +129,16 @@ public function testItCanSpecifyMillisecond() $this->assertSame((float) $sleep->duration->totalMicroseconds, 1_000.0); } - public function testItCanSpecifyMicroseconds() + #[TestWith([1.5, 1.0])] + #[TestWith([0.000001, 0.0])] + public function testItCanSpecifyMicroseconds(float $duration, float $microseconds): void { Sleep::fake(); - $sleep = Sleep::for(1.5)->microseconds(); + $sleep = Sleep::for($duration)->microseconds(); - // rounded as microseconds is the smallest unit supported... - $this->assertSame((float) $sleep->duration->totalMicroseconds, 1.0); + // Truncated as microseconds is the smallest unit supported... + $this->assertSame($microseconds, $sleep->duration->totalMicroseconds); } public function testItCanSpecifyMicrosecond() diff --git a/tests/Support/SupportCarbonImmutableTest.php b/tests/Support/SupportCarbonImmutableTest.php index 5ec52ae22..c751ce68c 100644 --- a/tests/Support/SupportCarbonImmutableTest.php +++ b/tests/Support/SupportCarbonImmutableTest.php @@ -112,6 +112,41 @@ public static function dateUnitProvider(): array ]; } + #[DataProvider('overflowProvider')] + public function testPlusAndMinusRespectOverflowSettings( + string $method, + string $unit, + string $original, + string $clamped, + string $overflowed, + ): void { + $date = CarbonImmutable::parse($original)->settings(['monthOverflow' => false, 'yearOverflow' => false]); + + $this->assertSame($clamped, $date->{$method}(...[$unit => 1])->toDateString()); + $this->assertSame($overflowed, $date->{$method}(...[$unit => 1], overflow: true)->toDateString()); + $this->assertSame($original, $date->toDateString()); + } + + /** + * Provide month and year overflow boundaries for both operations. + */ + public static function overflowProvider(): array + { + return [ + 'add month' => ['plus', 'months', '2026-01-31', '2026-02-28', '2026-03-03'], + 'subtract month' => ['minus', 'months', '2026-05-31', '2026-04-30', '2026-05-01'], + 'add year' => ['plus', 'years', '2024-02-29', '2025-02-28', '2025-03-01'], + 'subtract year' => ['minus', 'years', '2024-02-29', '2023-02-28', '2023-03-01'], + ]; + } + + public function testPlusAppliesYearsBeforeMonths(): void + { + $date = CarbonImmutable::parse('2024-02-29'); + + $this->assertSame('2025-03-28', $date->plus(years: 1, months: 1, overflow: false)->toDateString()); + } + public function testConversionsPreserveHypervelClassesAndDateState(): void { $immutable = CarbonImmutable::parse('2026-07-22 12:34:56.123456', 'Pacific/Auckland') diff --git a/tests/Support/SupportCarbonTest.php b/tests/Support/SupportCarbonTest.php index 2dcc78b59..71d26f08e 100644 --- a/tests/Support/SupportCarbonTest.php +++ b/tests/Support/SupportCarbonTest.php @@ -132,6 +132,24 @@ public function testCreateFromId(): void $this->assertEquals('2023-05-12 03:21:18.117185', $uuidv7->toDateTimeString('microsecond')); } + public function testPlus(): void + { + $carbon = Carbon::parse('2026-01-31'); + $this->assertSame('2026-03-03', $carbon->plus(months: 1, overflow: true)->toDateString()); + + $carbon = Carbon::parse('2026-01-31'); + $this->assertSame('2026-02-28', $carbon->plus(months: 1, overflow: false)->toDateString()); + } + + public function testMinus(): void + { + $carbon = Carbon::parse('2026-05-31'); + $this->assertSame('2026-05-01', $carbon->minus(months: 1, overflow: true)->toDateString()); + + $carbon = Carbon::parse('2026-05-31'); + $this->assertSame('2026-04-30', $carbon->minus(months: 1, overflow: false)->toDateString()); + } + public function testCreateFromIdRejectsNonTimeBasedUuid(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Support/SupportIntervalFunctionsTest.php b/tests/Support/SupportIntervalFunctionsTest.php new file mode 100644 index 000000000..058a6584c --- /dev/null +++ b/tests/Support/SupportIntervalFunctionsTest.php @@ -0,0 +1,84 @@ +assertSame($microseconds, $interval->totalMicroseconds); + $this->assertSame($fields, [$interval->d, $interval->h, $interval->i, $interval->s, $interval->microseconds]); + } + + /** + * Provide whole, fractional, and rounded interval values. + */ + public static function intervalProvider(): array + { + return [ + 'fractional seconds' => [seconds(...), 1.4, 1400000, [0, 0, 0, 1, 400000]], + 'fractional minutes' => [minutes(...), 1.4, 84000000, [0, 0, 1, 24, 0]], + 'fractional hours' => [hours(...), 1.4, 5040000000, [0, 1, 24, 0, 0]], + 'fractional days' => [days(...), 1.4, 120960000000, [1, 9, 36, 0, 0]], + 'negative seconds' => [seconds(...), -1.4, -1400000, [0, 0, 0, -1, -400000]], + 'negative minutes' => [minutes(...), -1.4, -84000000, [0, 0, -1, -24, 0]], + 'negative hours' => [hours(...), -1.4, -5040000000, [0, -1, -24, 0, 0]], + 'negative days' => [days(...), -1.4, -120960000000, [-1, -9, -36, 0, 0]], + 'tiny seconds' => [seconds(...), 0.000001, 1, [0, 0, 0, 0, 1]], + 'tiny minutes' => [minutes(...), 0.000001, 60, [0, 0, 0, 0, 60]], + 'tiny hours' => [hours(...), 0.000001, 3600, [0, 0, 0, 0, 3600]], + 'tiny days' => [days(...), 0.000001, 86400, [0, 0, 0, 0, 86400]], + 'integer seconds' => [seconds(...), 2, 2000000, [0, 0, 0, 2, 0]], + 'integer minutes' => [minutes(...), 2, 120000000, [0, 0, 2, 0, 0]], + 'integer hours' => [hours(...), 2, 7200000000, [0, 2, 0, 0, 0]], + 'integer days' => [days(...), 2, 172800000000, [2, 0, 0, 0, 0]], + 'rounded second' => [seconds(...), 0.9999999, 1000000, [0, 0, 0, 1, 0]], + 'rounded day' => [days(...), 1.9999999999999, 172800000000, [2, 0, 0, 0, 0]], + 'days remain days' => [days(...), 31.5, 2721600000000, [31, 12, 0, 0, 0]], + ]; + } + + public function testFractionalUnitsAreIncludedInIntervalFormatting(): void + { + $this->assertSame('1 minute 24 seconds', minutes(1.4)->forHumans()); + $this->assertSame('P31DT12H', days(31.5)->spec()); + } + + #[DataProvider('calendarDayProvider')] + public function testDayIntervalsPreserveCalendarArithmetic(float $amount, string $expected): void + { + $date = CarbonImmutable::parse('2026-03-06 20:00:00', 'America/New_York'); + + $this->assertSame($expected, $date->add(days($amount))->format('Y-m-d H:i:sP')); + } + + /** + * Provide calendar-day intervals crossing daylight saving time. + */ + public static function calendarDayProvider(): array + { + return [ + 'fractional day' => [1.5, '2026-03-08 09:00:00-04:00'], + 'rounding carries into days' => [1.9999999999999, '2026-03-08 20:00:00-04:00'], + 'days do not cascade into months' => [31.5, '2026-04-07 08:00:00-04:00'], + ]; + } +} diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index a238edbb6..dcf720b81 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -319,6 +319,15 @@ public function testEndsWith(): void $this->assertTrue(Str::endsWith(0.27, '0.27')); $this->assertFalse(Str::endsWith(0.27, '8')); $this->assertFalse(Str::endsWith(null, 'Marc')); + $this->assertTrue(Str::endsWith('foobar', new class { + /** + * Return the suffix. + */ + public function __toString(): string + { + return 'bar'; + } + })); // Test for multibyte string support $this->assertTrue(Str::endsWith('Jönköping', 'öping')); $this->assertTrue(Str::endsWith('Malmö', 'mö')); @@ -352,6 +361,15 @@ public function testDoesntEndWith(): void $this->assertFalse(Str::doesntEndWith(0.27, '0.27')); $this->assertTrue(Str::doesntEndWith(0.27, '8')); $this->assertTrue(Str::doesntEndWith(null, 'Marc')); + $this->assertFalse(Str::doesntEndWith('foobar', new class { + /** + * Return the suffix. + */ + public function __toString(): string + { + return 'bar'; + } + })); // Test for multibyte string support $this->assertFalse(Str::doesntEndWith('Jönköping', 'öping')); $this->assertFalse(Str::doesntEndWith('Malmö', 'mö')); @@ -547,6 +565,9 @@ public function testStrContainsAll(string $haystack, iterable $needles, bool $ex $this->assertEquals($expected, Str::containsAll($haystack, $needles, $ignoreCase)); } + /** + * Provide strings and needles for complete substring matching. + */ public static function strContainsAllProvider(): array { return [ @@ -556,6 +577,7 @@ public static function strContainsAllProvider(): array ['Taylor Otwell', ['taylor'], true, true], ['Taylor Otwell', ['taylor', 'xxx'], false, false], ['Taylor Otwell', ['taylor', 'xxx'], false, true], + ['Taylor Otwell', [], false, false], ]; } @@ -1120,6 +1142,25 @@ public function testReplace(): void $this->assertSame('foo/bar/baz', Str::replace(' ', '/', 'foo bar baz')); $this->assertSame('foo bar baz', Str::replace(['?1', '?2', '?3'], ['foo', 'bar', 'baz'], '?1 ?2 ?3')); $this->assertSame(['foo', 'bar', 'baz'], Str::replace(collect(['?1', '?2', '?3']), collect(['foo', 'bar', 'baz']), collect(['?1', '?2', '?3']))); + + $this->assertSame('Xltý kôň', Str::replace('ž', 'X', 'Žltý kôň', false)); + $this->assertSame('žltý pes', Str::replace('KÔŇ', 'pes', 'žltý kôň', false)); + $this->assertSame('Xltý pes', Str::replace(['ž', 'KÔŇ'], ['X', 'pes'], 'Žltý kôň', false)); + $this->assertSame(['Xltý', 'kôň'], Str::replace('ž', 'X', ['Žltý', 'kôň'], false)); + $this->assertSame('ſ Yito X', Str::replace(['s', 'ž'], ['X', 'Y'], 'ſ žito s', false)); + $this->assertSame("caf\xC3 X", Str::replace('ž', 'X', "caf\xC3 ž", false)); + $this->assertSame('É', Str::replace(["\xFF", 'é'], ['X', 'Y'], 'É', false)); + $this->assertSame("\xFFÉ", Str::replace(['ž', 'é'], ["\xFF", 'x'], 'žÉ', false)); + $this->assertSame('$1\X', Str::replace('ž.+?', '$1\X', 'Ž.+?', false)); + $this->assertSame(['label' => 'Xltý pes'], Str::replace(['first' => 'ž', 'second' => 'KÔŇ'], [10 => 'X', 20 => 'pes'], ['label' => 'Žltý kôň'], false)); + $this->assertSame('Xltý kň', Str::replace(['ž', 'ô'], ['X'], 'Žltý kôň', false)); + } + + public function testReplaceThrowsForScalarSearchAndArrayReplacement(): void + { + $this->expectException(TypeError::class); + + Str::replace('ž', ['X'], 'Ž', false); } public function testReplaceArray(): void @@ -1224,6 +1265,9 @@ public function testRemove(): void $this->assertSame('Fooar', Str::remove(['f', 'b'], 'Foobar')); $this->assertSame('ooar', Str::remove(['f', 'b'], 'Foobar', false)); $this->assertSame('Foobar', Str::remove(['f', '|'], 'Foo|bar')); + + $this->assertSame('ltý', Str::remove('ž', 'Žltý', false)); + $this->assertSame('žltý ', Str::remove('KÔŇ', 'žltý kôň', false)); } public function testReverse(): void @@ -1552,6 +1596,9 @@ public function testPosition(): void $this->assertFalse(Str::position('Hello, World!', 'X', 0, 'UTF-8')); $this->assertFalse(Str::position('', 'test')); $this->assertFalse(Str::position('Hello, World!', 'X')); + $this->assertSame(0, Str::position('Taylor', '')); + $this->assertSame(3, Str::position('Taylor', '', 3)); + $this->assertSame(0, Str::position('', '')); } public function testSubstrReplace(): void @@ -1770,8 +1817,11 @@ public function testWordCount(): void $this->assertEquals(2, Str::wordCount('Hello, world!')); $this->assertEquals(10, Str::wordCount('Hi, this is my first contribution to the Hypervel framework.')); - $this->assertEquals(0, Str::wordCount('мама')); - $this->assertEquals(0, Str::wordCount('мама мыла раму')); + // str_word_count() without $characters does not reliably handle multibyte + // strings — results depend on the system locale's isalpha() behavior + // (e.g. macOS 15+ changed LC_CTYPE defaults). See php/php-src#19828. + $this->assertEquals(str_word_count('мама'), Str::wordCount('мама')); + $this->assertEquals(str_word_count('мама мыла раму'), Str::wordCount('мама мыла раму')); $this->assertEquals(1, Str::wordCount('мама', 'абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ')); $this->assertEquals(3, Str::wordCount('мама мыла раму', 'абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ')); diff --git a/tests/Validation/ValidationArrayKeysRuleTest.php b/tests/Validation/ValidationArrayKeysRuleTest.php new file mode 100644 index 000000000..ecb53792c --- /dev/null +++ b/tests/Validation/ValidationArrayKeysRuleTest.php @@ -0,0 +1,199 @@ +assertSame('array_keys:"key_1","key_2","key_3"', (string) $rule); + + $rule = Rule::arrayKeys(['key_1', 'key_2', 'key_3']); + + $this->assertSame('array_keys:"key_1","key_2","key_3"', (string) $rule); + + $rule = Rule::arrayKeys(collect(['key_1', 'key_2', 'key_3'])); + + $this->assertSame('array_keys:"key_1","key_2","key_3"', (string) $rule); + + $rule = Rule::arrayKeys([ArrayKeys::key_1, ArrayKeys::key_2, ArrayKeys::key_3]); + + $this->assertSame('array_keys:"key_1","key_2","key_3"', (string) $rule); + + $rule = Rule::arrayKeys([ArrayKeysBacked::Key1, ArrayKeysBacked::Key2, ArrayKeysBacked::Key3]); + + $this->assertSame('array_keys:"key_1","key_2","key_3"', (string) $rule); + + $rule = Rule::arrayKeys([1, 2, 3]); + + $this->assertSame('array_keys:"1","2","3"', (string) $rule); + } + + public function testArrayKeysValidation(): void + { + $trans = new Translator(new ArrayLoader, 'en'); + + $v = new Validator($trans, ['foo' => ['key_1' => 'bar', 'key_3' => 'baz']], ['foo' => Rule::arrayKeys(['key_1', 'key_2'])]); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => ['bar', 'baz']], ['foo' => Rule::arrayKeys(['key_1'])]); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => 'not an array'], ['foo' => Rule::arrayKeys(['key_1'])]); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => (object) ['key_1' => 'bar']], ['foo' => Rule::arrayKeys(['key_1'])]); + $this->assertTrue($v->fails()); + + $v = new Validator($trans, ['foo' => ['key_1' => 'bar', 'key_2' => '']], ['foo' => Rule::arrayKeys(['key_1', 'key_2'])]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => ['key_1' => 'bar']], ['foo' => Rule::arrayKeys(['key_1', 'key_2'])]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => ['key_1' => []]], ['foo' => Rule::arrayKeys(['key_1'])]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => []], ['foo' => Rule::arrayKeys(['key_1', 'key_2'])]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => ['bar', 'baz']], ['foo' => Rule::arrayKeys([0, 1])]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => ['key_1' => 'bar']], ['foo' => (string) Rule::arrayKeys(['key_1'])]); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['foo' => null], ['foo' => ['nullable', Rule::arrayKeys(['key_1'])]]); + $this->assertTrue($v->passes()); + } + + public function testArrayKeysValidationRequiresAtLeastOneKey(): void + { + $trans = new Translator(new ArrayLoader, 'en'); + + $v = new Validator($trans, ['foo' => ['key_1' => 'bar']], ['foo' => 'array_keys']); + + $this->expectExceptionObject(new InvalidArgumentException('Validation rule array_keys requires at least 1 parameters.')); + + $v->passes(); + } + + public function testArrayKeysValidationErrorMessage(): void + { + $trans = new Translator(new ArrayLoader, 'en'); + + $trans->addLines([ + 'validation.array_keys' => 'The :attribute field must only contain the following keys: :values.', + ], 'en'); + + $v = new Validator($trans, ['foo' => ['key_1' => 'bar', 'key_3' => 'baz']], ['foo' => Rule::arrayKeys(['key_1', 'key_2'])]); + + $this->assertTrue($v->fails()); + $this->assertSame( + 'The foo field must only contain the following keys: key_1, key_2.', + $v->messages()->first('foo') + ); + $this->assertSame(['ArrayKeys' => ['key_1', 'key_2']], $v->failed()['foo']); + } + + public function testArrayKeysValidationErrorMessageCanReferenceTheUnexpectedKeys(): void + { + $trans = new Translator(new ArrayLoader, 'en'); + + $v = new Validator( + $trans, + ['foo' => ['key_3' => 'bar', 'key_1' => 'baz', 'key_4' => 'qux']], + ['foo' => Rule::arrayKeys(['key_1', 'key_2'])], + ['foo.array_keys' => 'The :attribute field does not accept :unexpected. Accepted keys: :values.'] + ); + + $this->assertTrue($v->fails()); + $this->assertSame( + 'The foo field does not accept key_3, key_4. Accepted keys: key_1, key_2.', + $v->messages()->first('foo') + ); + } + + public function testUnexpectedKeysArePlaceholderSafeAndEmptyForNonArrays(): void + { + $trans = new Translator(new ArrayLoader, 'en'); + + $v = new Validator( + $trans, + ['foo' => ['key_1' => 'a', ':values' => 'b', ':attribute' => 'c']], + ['foo' => Rule::arrayKeys(['key_1'])], + ['foo.array_keys' => 'Unexpected keys: :unexpected. Accepted: :values.'] + ); + + $this->assertTrue($v->fails()); + $this->assertSame('Unexpected keys: :values, :attribute. Accepted: key_1.', $v->messages()->first('foo')); + + $v = new Validator( + $trans, + ['foo' => 'not an array'], + ['foo' => Rule::arrayKeys(['key_1'])], + ['foo.array_keys' => 'Unexpected keys: :unexpected.'] + ); + + $this->assertTrue($v->fails()); + $this->assertSame('Unexpected keys: .', $v->messages()->first('foo')); + } + + #[TestWith(['a.b'])] + #[TestWith(['a*b'])] + #[TestWith(['a,b'])] + #[TestWith(['a"b'])] + #[TestWith(['a\\'])] + #[TestWith(['a\"b'])] + public function testArrayKeysAcceptsLiteralKeys(string $key): void + { + $validator = new Validator( + new Translator(new ArrayLoader, 'en'), + ['options' => [$key => 'value']], + ['options' => Rule::arrayKeys($key)], + ); + + $this->assertTrue($validator->passes()); + } + + public function testArrayKeysDoesNotAcceptPartsOfCommaSeparatedLiteralKeys(): void + { + $validator = new Validator( + new Translator(new ArrayLoader, 'en'), + ['options' => ['a' => 1, 'b' => 2]], + ['options' => Rule::arrayKeys('a,b')], + ); + + $this->assertTrue($validator->fails()); + } + + #[TestWith(['options', 'options'])] + #[TestWith(['options.group', 'options\.group'])] + #[TestWith(['options*group', 'options\*group'])] + public function testUnexpectedKeysUseTheirLiteralNames(string $attribute, string $ruleAttribute): void + { + $validator = new Validator( + new Translator(new ArrayLoader, 'en'), + [$attribute => ['allowed.key' => 1, 'extra.key' => 2, 'extra*key' => 3]], + [$ruleAttribute => Rule::arrayKeys('allowed.key')], + ['array_keys' => 'Unexpected: :unexpected. Accepted: :values.'], + ); + + $this->assertTrue($validator->fails()); + $this->assertSame('Unexpected: extra.key, extra*key. Accepted: allowed.key.', $validator->errors()->first($attribute)); + } +} diff --git a/tests/Validation/ValidationArrayRuleTest.php b/tests/Validation/ValidationArrayRuleTest.php index dcb96673f..9b851c754 100644 --- a/tests/Validation/ValidationArrayRuleTest.php +++ b/tests/Validation/ValidationArrayRuleTest.php @@ -9,12 +9,13 @@ use Hypervel\Translation\Translator; use Hypervel\Validation\Rule; use Hypervel\Validation\Validator; +use PHPUnit\Framework\Attributes\TestWith; include_once 'Enums.php'; class ValidationArrayRuleTest extends TestCase { - public function testItCorrectlyFormatsAStringVersionOfTheRule() + public function testItCorrectlyFormatsAStringVersionOfTheRule(): void { $rule = Rule::array(); @@ -25,29 +26,48 @@ public function testItCorrectlyFormatsAStringVersionOfTheRule() $rule = Rule::array('key_1', 'key_2', 'key_3'); - $this->assertSame('array:key_1,key_2,key_3', (string) $rule); + $this->assertSame('array:"key_1","key_2","key_3"', (string) $rule); $rule = Rule::array(['key_1', 'key_2', 'key_3']); - $this->assertSame('array:key_1,key_2,key_3', (string) $rule); + $this->assertSame('array:"key_1","key_2","key_3"', (string) $rule); $rule = Rule::array(collect(['key_1', 'key_2', 'key_3'])); - $this->assertSame('array:key_1,key_2,key_3', (string) $rule); + $this->assertSame('array:"key_1","key_2","key_3"', (string) $rule); $rule = Rule::array([ArrayKeys::key_1, ArrayKeys::key_2, ArrayKeys::key_3]); - $this->assertSame('array:key_1,key_2,key_3', (string) $rule); + $this->assertSame('array:"key_1","key_2","key_3"', (string) $rule); $rule = Rule::array([ArrayKeysBacked::Key1, ArrayKeysBacked::Key2, ArrayKeysBacked::Key3]); - $this->assertSame('array:key_1,key_2,key_3', (string) $rule); + $this->assertSame('array:"key_1","key_2","key_3"', (string) $rule); $rule = Rule::array(['key_1', 'key_1']); - $this->assertSame('array:key_1,key_1', (string) $rule); + $this->assertSame('array:"key_1","key_1"', (string) $rule); $rule = Rule::array([1, 2, 3]); - $this->assertSame('array:1,2,3', (string) $rule); + $this->assertSame('array:"1","2","3"', (string) $rule); + } + + #[TestWith(['a,b'])] + #[TestWith(['a"b'])] + #[TestWith(['a\\'])] + #[TestWith(['a\"b'])] + public function testArrayRulePreservesLiteralKeys(string $key): void + { + $validator = new Validator( + new Translator(new ArrayLoader, 'en'), + ['options' => [$key => 'value']], + ['options' => Rule::array($key)], + ); + + $this->assertTrue($validator->passes()); + + $validator->setData(['options' => ['a' => 'value']]); + + $this->assertTrue($validator->fails()); } public function testArrayValidation() diff --git a/tests/Validation/ValidationDateRuleTest.php b/tests/Validation/ValidationDateRuleTest.php index abaca620e..f0a39d4b4 100644 --- a/tests/Validation/ValidationDateRuleTest.php +++ b/tests/Validation/ValidationDateRuleTest.php @@ -11,6 +11,7 @@ use Hypervel\Validation\Rule; use Hypervel\Validation\Rules\Date; use Hypervel\Validation\Validator; +use PHPUnit\Framework\Attributes\TestWith; class ValidationDateRuleTest extends TestCase { @@ -26,76 +27,76 @@ public function testDefaultDateRule(): void public function testDateFormatRule(): void { $rule = Rule::date()->format('d/m/Y'); - $this->assertEquals('date_format:d/m/Y', (string) $rule); + $this->assertEquals('date_format:"d/m/Y"', (string) $rule); } public function testAfterTodayRule(): void { $rule = Rule::date()->afterToday(); - $this->assertEquals('date|after:today', (string) $rule); + $this->assertEquals('date|after:"today"', (string) $rule); $rule = Rule::date()->todayOrAfter(); - $this->assertEquals('date|after_or_equal:today', (string) $rule); + $this->assertEquals('date|after_or_equal:"today"', (string) $rule); } public function testBeforeTodayRule(): void { $rule = Rule::date()->beforeToday(); - $this->assertEquals('date|before:today', (string) $rule); + $this->assertEquals('date|before:"today"', (string) $rule); $rule = Rule::date()->todayOrBefore(); - $this->assertEquals('date|before_or_equal:today', (string) $rule); + $this->assertEquals('date|before_or_equal:"today"', (string) $rule); } public function testAfterSpecificDateRule(): void { $rule = Rule::date()->after(CarbonImmutable::parse('2024-01-01')); - $this->assertEquals('date|after:2024-01-01', (string) $rule); + $this->assertEquals('date|after:"2024-01-01"', (string) $rule); $rule = Rule::date()->format('d/m/Y')->after(CarbonImmutable::parse('2024-01-01')); - $this->assertEquals('date_format:d/m/Y|after:01/01/2024', (string) $rule); + $this->assertEquals('date_format:"d/m/Y"|after:"01/01/2024"', (string) $rule); } public function testBeforeSpecificDateRule(): void { $rule = Rule::date()->before(CarbonImmutable::parse('2024-01-01')); - $this->assertEquals('date|before:2024-01-01', (string) $rule); + $this->assertEquals('date|before:"2024-01-01"', (string) $rule); $rule = Rule::date()->format('d/m/Y')->before(CarbonImmutable::parse('2024-01-01')); - $this->assertEquals('date_format:d/m/Y|before:01/01/2024', (string) $rule); + $this->assertEquals('date_format:"d/m/Y"|before:"01/01/2024"', (string) $rule); } public function testAfterOrEqualSpecificDateRule(): void { $rule = Rule::date()->afterOrEqual(CarbonImmutable::parse('2024-01-01')); - $this->assertEquals('date|after_or_equal:2024-01-01', (string) $rule); + $this->assertEquals('date|after_or_equal:"2024-01-01"', (string) $rule); $rule = Rule::date()->format('d/m/Y')->afterOrEqual(CarbonImmutable::parse('2024-01-01')); - $this->assertEquals('date_format:d/m/Y|after_or_equal:01/01/2024', (string) $rule); + $this->assertEquals('date_format:"d/m/Y"|after_or_equal:"01/01/2024"', (string) $rule); } public function testBeforeOrEqualSpecificDateRule(): void { $rule = Rule::date()->beforeOrEqual(CarbonImmutable::parse('2024-01-01')); - $this->assertEquals('date|before_or_equal:2024-01-01', (string) $rule); + $this->assertEquals('date|before_or_equal:"2024-01-01"', (string) $rule); $rule = Rule::date()->format('d/m/Y')->beforeOrEqual(CarbonImmutable::parse('2024-01-01')); - $this->assertEquals('date_format:d/m/Y|before_or_equal:01/01/2024', (string) $rule); + $this->assertEquals('date_format:"d/m/Y"|before_or_equal:"01/01/2024"', (string) $rule); } public function testBetweenDatesRule(): void { $rule = Rule::date()->between(CarbonImmutable::parse('2024-01-01'), CarbonImmutable::parse('2024-02-01')); - $this->assertEquals('date|after:2024-01-01|before:2024-02-01', (string) $rule); + $this->assertEquals('date|after:"2024-01-01"|before:"2024-02-01"', (string) $rule); $rule = Rule::date()->format('d/m/Y')->between(CarbonImmutable::parse('2024-01-01'), CarbonImmutable::parse('2024-02-01')); - $this->assertEquals('date_format:d/m/Y|after:01/01/2024|before:01/02/2024', (string) $rule); + $this->assertEquals('date_format:"d/m/Y"|after:"01/01/2024"|before:"01/02/2024"', (string) $rule); } public function testBetweenOrEqualDatesRule(): void { $rule = Rule::date()->betweenOrEqual('2024-01-01', '2024-02-01'); - $this->assertEquals('date|after_or_equal:2024-01-01|before_or_equal:2024-02-01', (string) $rule); + $this->assertEquals('date|after_or_equal:"2024-01-01"|before_or_equal:"2024-02-01"', (string) $rule); } public function testChainedRules(): void @@ -104,7 +105,7 @@ public function testChainedRules(): void ->format('Y-m-d') ->after('2024-01-01 00:00:00') ->before('2025-01-01 00:00:00'); - $this->assertEquals('date_format:Y-m-d|after:2024-01-01 00:00:00|before:2025-01-01 00:00:00', (string) $rule); + $this->assertEquals('date_format:"Y-m-d"|after:"2024-01-01 00:00:00"|before:"2025-01-01 00:00:00"', (string) $rule); $rule = Rule::date() ->format('Y-m-d') @@ -114,7 +115,27 @@ public function testChainedRules(): void ->unless(true, function ($rule) { $rule->before('2025-01-01'); }); - $this->assertSame('date_format:Y-m-d|after:2024-01-01', (string) $rule); + $this->assertSame('date_format:"Y-m-d"|after:"2024-01-01"', (string) $rule); + } + + #[TestWith([DATE_RFC2822])] + #[TestWith(['Y-m-d"H:i:s'])] + #[TestWith(['Y-m-d\|H:i:s'])] + public function testDateFormatsAndBoundsPreserveLiteralSeparators(string $format): void + { + $date = CarbonImmutable::parse('2024-01-02 12:00:00', 'UTC'); + $rule = Rule::date()->format($format)->after($date->subDay())->before($date->addDay()); + $validator = new Validator( + new Translator(new ArrayLoader, 'en'), + ['date' => $date->format($format)], + ['date' => $rule], + ); + + $this->assertTrue($validator->passes()); + + $validator->setData(['date' => $date->addDays(2)->format($format)]); + + $this->assertTrue($validator->fails()); } public function testDateValidation(): void diff --git a/tests/Validation/ValidationNumericRuleTest.php b/tests/Validation/ValidationNumericRuleTest.php index d962f1017..b360e6ce0 100644 --- a/tests/Validation/ValidationNumericRuleTest.php +++ b/tests/Validation/ValidationNumericRuleTest.php @@ -10,6 +10,7 @@ use Hypervel\Validation\Rule; use Hypervel\Validation\Rules\Numeric; use Hypervel\Validation\Validator; +use PHPUnit\Framework\Attributes\TestWith; class ValidationNumericRuleTest extends TestCase { @@ -40,10 +41,10 @@ public function testDecimalRule() $this->assertEquals('numeric|decimal:2', (string) $rule); } - public function testDifferentRule() + public function testDifferentRule(): void { $rule = Rule::numeric()->different('some_field'); - $this->assertEquals('numeric|different:some_field', (string) $rule); + $this->assertEquals('numeric|different:"some_field"', (string) $rule); } public function testDigitsRule() @@ -58,16 +59,16 @@ public function testDigitsBetweenRule() $this->assertEquals('numeric|integer|digits_between:2,10', (string) $rule); } - public function testGreaterThanRule() + public function testGreaterThanRule(): void { $rule = Rule::numeric()->greaterThan('some_field'); - $this->assertEquals('numeric|gt:some_field', (string) $rule); + $this->assertEquals('numeric|gt:"some_field"', (string) $rule); } - public function testGreaterThanOrEqualRule() + public function testGreaterThanOrEqualRule(): void { $rule = Rule::numeric()->greaterThanOrEqualTo('some_field'); - $this->assertEquals('numeric|gte:some_field', (string) $rule); + $this->assertEquals('numeric|gte:"some_field"', (string) $rule); } public function testIntegerRule() @@ -76,16 +77,16 @@ public function testIntegerRule() $this->assertEquals('numeric|integer', (string) $rule); } - public function testLessThanRule() + public function testLessThanRule(): void { $rule = Rule::numeric()->lessThan('some_field'); - $this->assertEquals('numeric|lt:some_field', (string) $rule); + $this->assertEquals('numeric|lt:"some_field"', (string) $rule); } - public function testLessThanOrEqualRule() + public function testLessThanOrEqualRule(): void { $rule = Rule::numeric()->lessThanOrEqualTo('some_field'); - $this->assertEquals('numeric|lte:some_field', (string) $rule); + $this->assertEquals('numeric|lte:"some_field"', (string) $rule); } public function testMaxRule() @@ -124,10 +125,10 @@ public function testMultipleOfRule() $this->assertEquals('numeric|multiple_of:10', (string) $rule); } - public function testSameRule() + public function testSameRule(): void { $rule = Rule::numeric()->same('some_field'); - $this->assertEquals('numeric|same:some_field', (string) $rule); + $this->assertEquals('numeric|same:"some_field"', (string) $rule); } public function testSizeRule() @@ -136,14 +137,14 @@ public function testSizeRule() $this->assertEquals('numeric|integer|size:10', (string) $rule); } - public function testChainedRules() + public function testChainedRules(): void { $rule = Rule::numeric() ->integer() ->multipleOf(10) ->lessThanOrEqualTo('some_field') ->max(100); - $this->assertEquals('numeric|integer|multiple_of:10|lte:some_field|max:100', (string) $rule); + $this->assertEquals('numeric|integer|multiple_of:10|lte:"some_field"|max:100', (string) $rule); $rule = Rule::numeric() ->decimal(2) @@ -153,7 +154,29 @@ public function testChainedRules() ->unless(true, function ($rule) { $rule->different('some_field_2'); }); - $this->assertSame('numeric|decimal:2|same:some_field', (string) $rule); + $this->assertSame('numeric|decimal:2|same:"some_field"', (string) $rule); + } + + #[TestWith(['different', 4, 5, 4])] + #[TestWith(['greaterThan', 6, 5, 7])] + #[TestWith(['greaterThanOrEqualTo', 5, 5, 6])] + #[TestWith(['lessThan', 4, 5, 3])] + #[TestWith(['lessThanOrEqualTo', 5, 5, 4])] + #[TestWith(['same', 5, 5, 6])] + public function testFieldReferencesPreserveLiteralSeparators(string $method, int $value, int $other, int $invalidOther): void + { + $field = 'other,value|"quoted"\\'; + $validator = new Validator( + new Translator(new ArrayLoader, 'en'), + ['value' => $value, $field => $other], + ['value' => [Rule::numeric()->{$method}($field)]], + ); + + $this->assertTrue($validator->passes()); + + $validator->setData(['value' => $value, $field => $invalidOther, 'other' => $other]); + + $this->assertTrue($validator->fails()); } public function testNumericValidation() diff --git a/tests/Validation/ValidationRuleParserTest.php b/tests/Validation/ValidationRuleParserTest.php index 49816d28f..5976186e7 100644 --- a/tests/Validation/ValidationRuleParserTest.php +++ b/tests/Validation/ValidationRuleParserTest.php @@ -10,6 +10,7 @@ use Hypervel\Validation\Rule; use Hypervel\Validation\ValidationRuleParser; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; class ValidationRuleParserTest extends TestCase { @@ -410,7 +411,7 @@ public function testExplodeHandlesDateRuleWithAdditionalRules(): void $this->assertEquals([ 'date' => [ 'date', - 'after:today', + 'after:"today"', ], ], $results->rules); } @@ -555,6 +556,53 @@ public function testExplodeCanonicalizesStringableFluentRules(): void ], $results->rules['value']); } + #[TestWith(['a,b'])] + #[TestWith(['a"b'])] + #[TestWith(['directory\\'])] + #[TestWith(['a\"b'])] + public function testLiteralRuleParametersRoundTripThroughCsv(string $value): void + { + foreach ([Rule::in([$value]), Rule::notIn([$value]), Rule::contains([$value]), Rule::doesntContain([$value])] as $rule) { + $this->assertSame([$value], ValidationRuleParser::parse((string) $rule)[1]); + } + } + + public function testStringifiedEnumValuesRoundTripThroughCsv(): void + { + $this->assertSame( + ['In', [CsvRuleValue::Literal->value]], + ValidationRuleParser::parse((string) Rule::enum(CsvRuleValue::class)), + ); + } + + #[TestWith(['direct'])] + #[TestWith(['array'])] + #[TestWith(['wildcard'])] + public function testCompositeRulesPreserveLiteralPipesDuringExpansion(string $form): void + { + $rules = [ + [Rule::date()->format('Y-m-d\|H:i:s'), ['date_format:"Y-m-d\|H:i:s"']], + [Rule::numeric()->same('other|value'), ['numeric', 'same:"other|value"']], + [Rule::string()->startsWith('INFO|'), ['string', 'starts_with:"INFO|"']], + ]; + + foreach ($rules as [$rule, $expected]) { + $parser = new ValidationRuleParser(['items' => [['value' => 'value']]]); + $attribute = $form === 'wildcard' ? 'items.*.value' : 'items.0.value'; + $result = $parser->explode([$attribute => $form === 'direct' ? $rule : [$rule]]); + + $this->assertSame($expected, $result->rules['items.0.value']); + } + } + + public function testCsvParsingDoesNotAlterRegexParameters(): void + { + $pattern = '/^[a,b"\\\|]+$/'; + + $this->assertSame(['Regex', [$pattern]], ValidationRuleParser::parse('regex:' . $pattern)); + $this->assertSame(['NotRegex', [$pattern]], ValidationRuleParser::parse('not_regex:' . $pattern)); + } + public function testExplodePreservesCallbackBearingPresenceRules(): void { $exists = Rule::exists('users', 'email')->where(static fn ($query) => $query); @@ -772,3 +820,8 @@ public static function dateFieldReferenceProvider(): array ]; } } + +enum CsvRuleValue: string +{ + case Literal = 'a,"b"\\'; +} diff --git a/tests/Validation/ValidationStringRuleTest.php b/tests/Validation/ValidationStringRuleTest.php index 0fd7eb4a5..149140266 100644 --- a/tests/Validation/ValidationStringRuleTest.php +++ b/tests/Validation/ValidationStringRuleTest.php @@ -10,6 +10,7 @@ use Hypervel\Validation\Rule; use Hypervel\Validation\Rules\StringRule; use Hypervel\Validation\Validator; +use PHPUnit\Framework\Attributes\TestWith; class ValidationStringRuleTest extends TestCase { @@ -94,37 +95,37 @@ public function testLowercaseRule(): void public function testStartsWithRule(): void { $rule = Rule::string()->startsWith('foo'); - $this->assertSame('string|starts_with:foo', (string) $rule); + $this->assertSame('string|starts_with:"foo"', (string) $rule); $rule = Rule::string()->startsWith('foo', 'bar'); - $this->assertSame('string|starts_with:foo,bar', (string) $rule); + $this->assertSame('string|starts_with:"foo","bar"', (string) $rule); } public function testEndsWithRule(): void { $rule = Rule::string()->endsWith('.com'); - $this->assertSame('string|ends_with:.com', (string) $rule); + $this->assertSame('string|ends_with:".com"', (string) $rule); $rule = Rule::string()->endsWith('.com', '.org'); - $this->assertSame('string|ends_with:.com,.org', (string) $rule); + $this->assertSame('string|ends_with:".com",".org"', (string) $rule); } public function testDoesntStartWithRule(): void { $rule = Rule::string()->doesntStartWith('foo'); - $this->assertSame('string|doesnt_start_with:foo', (string) $rule); + $this->assertSame('string|doesnt_start_with:"foo"', (string) $rule); $rule = Rule::string()->doesntStartWith('foo', 'bar'); - $this->assertSame('string|doesnt_start_with:foo,bar', (string) $rule); + $this->assertSame('string|doesnt_start_with:"foo","bar"', (string) $rule); } public function testDoesntEndWithRule(): void { $rule = Rule::string()->doesntEndWith('.exe'); - $this->assertSame('string|doesnt_end_with:.exe', (string) $rule); + $this->assertSame('string|doesnt_end_with:".exe"', (string) $rule); $rule = Rule::string()->doesntEndWith('.exe', '.bat'); - $this->assertSame('string|doesnt_end_with:.exe,.bat', (string) $rule); + $this->assertSame('string|doesnt_end_with:".exe",".bat"', (string) $rule); } public function testChainedRules(): void @@ -144,7 +145,28 @@ public function testChainedRules(): void ->unless(true, function ($rule) { $rule->endsWith('suffix'); }); - $this->assertSame('string|between:1,100|starts_with:prefix', (string) $rule); + $this->assertSame('string|between:1,100|starts_with:"prefix"', (string) $rule); + } + + #[TestWith(['startsWith', 'a,b', 'a,b rest', 'a rest'])] + #[TestWith(['endsWith', 'a,b', 'rest a,b', 'rest b'])] + #[TestWith(['doesntStartWith', 'a,b', 'a rest', 'a,b rest'])] + #[TestWith(['doesntEndWith', 'a,b', 'rest b', 'rest a,b'])] + #[TestWith(['startsWith', 'INFO|', 'INFO|record', 'INFOrecord'])] + #[TestWith(['startsWith', 'a\"b\\', 'a\"b\rest', 'a rest'])] + public function testLiteralPrefixesAndSuffixes(string $method, string $parameter, string $valid, string $invalid): void + { + $validator = new Validator( + new Translator(new ArrayLoader, 'en'), + ['field' => $valid], + ['field' => [Rule::string()->{$method}($parameter)]], + ); + + $this->assertTrue($validator->passes()); + + $validator->setData(['field' => $invalid]); + + $this->assertTrue($validator->fails()); } public function testStringValidation(): void diff --git a/tests/Validation/ValidationUniqueRuleTest.php b/tests/Validation/ValidationUniqueRuleTest.php index 0d9c62427..9b8b6e251 100644 --- a/tests/Validation/ValidationUniqueRuleTest.php +++ b/tests/Validation/ValidationUniqueRuleTest.php @@ -30,7 +30,7 @@ protected function migrateFreshUsing(): array ]; } - public function testItCorrectlyFormatsAStringVersionOfTheRule() + public function testItCorrectlyFormatsAStringVersionOfTheRule(): void { $rule = new Unique('table'); $rule->where('foo', 'bar'); @@ -78,9 +78,9 @@ public function testItCorrectlyFormatsAStringVersionOfTheRule() $rule = new Unique('table', 'column'); $rule->ignore('Taylor, Otwell"\'..-"', 'id_column'); $rule->where('foo', 'bar'); - $this->assertSame('unique:table,column,"Taylor, Otwell\"\\\'..-\"",id_column,foo,"bar"', (string) $rule); - $this->assertSame('Taylor, Otwell"\'..-"', stripslashes(str_getcsv('table,column,"Taylor, Otwell\"\\\'..-\"",id_column,foo,"bar"', escape: '\\')[2])); - $this->assertSame('id_column', stripslashes(str_getcsv('table,column,"Taylor, Otwell\"\\\'..-\"",id_column,foo,"bar"', escape: '\\')[3])); + $this->assertSame('unique:table,column,"Taylor, Otwell""\'..-""",id_column,foo,"bar"', (string) $rule); + $this->assertSame('Taylor, Otwell"\'..-"', str_getcsv('table,column,"Taylor, Otwell""\'..-""",id_column,foo,"bar"', escape: '')[2]); + $this->assertSame('id_column', str_getcsv('table,column,"Taylor, Otwell""\'..-""",id_column,foo,"bar"', escape: '')[3]); $rule = new Unique('table', 'column'); $rule->ignore(null, 'id_column'); diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index 4ca8538a5..acee2c49f 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -46,6 +46,7 @@ use RuntimeException; use SplFileInfo; use stdClass; +use Stringable as StringableInterface; use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; use UnitEnum; @@ -943,6 +944,72 @@ public function testCapitalizedDisplayableValuesAreReplaced() $this->assertSame('The url must start with one of the following values hTtp, hTtps', $v->messages()->first('url')); } + #[TestWith(['declined_if', ['foo' => 'yes', 'bar' => 'aAa']])] + #[TestWith(['missing_if', ['foo' => 'yes', 'bar' => 'aAa']])] + #[TestWith(['present_if', ['bar' => 'aAa']])] + #[TestWith(['required_if', ['bar' => 'aAa']])] + public function testConditionalRulePlaceholdersPreserveCasingVariants(string $rule, array $data): void + { + $validator = new Validator( + $this->getArrayTranslator(), + $data, + ['foo' => $rule . ':bar,aAa'], + [$rule => ':other|:OTHER|:Other|:value|:VALUE|:Value'], + ['bar' => 'otherField'], + ); + + $this->assertFalse($validator->passes()); + $this->assertSame('otherField|OTHERFIELD|OtherField|aAa|AAA|AAa', $validator->errors()->first('foo')); + } + + public function testRequiredIfDeclinedPlaceholdersPreserveCasingVariants(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['bar' => 'no'], + ['foo' => 'required_if_declined:bar'], + ['required_if_declined' => ':other|:OTHER|:Other'], + ['bar' => 'otherField'], + ); + + $this->assertFalse($validator->passes()); + $this->assertSame('otherField|OTHERFIELD|OtherField', $validator->errors()->first('foo')); + } + + public function testProhibitedUnlessPlaceholdersPreserveCasingVariants(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['foo' => 'yes', 'bar' => 'aAa'], + ['foo' => 'prohibited_unless:bar,tAylor,sVen'], + ['prohibited_unless' => ':other|:OTHER|:Other|:values|:VALUES|:Values'], + ['bar' => 'otherField'], + ); + + $this->assertFalse($validator->passes()); + $this->assertSame( + 'otherField|OTHERFIELD|OtherField|tAylor, sVen|TAYLOR, SVEN|TAylor, SVen', + $validator->errors()->first('foo'), + ); + } + + #[TestWith(['required_array_keys', []])] + #[TestWith(['ends_with', 'other'])] + #[TestWith(['doesnt_end_with', 'tAylor'])] + #[TestWith(['doesnt_start_with', 'sVen'])] + public function testValueListRulePlaceholdersPreserveCasingVariants(string $rule, array|string $value): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['foo' => $value], + ['foo' => $rule . ':tAylor,sVen'], + [$rule => ':values|:VALUES|:Values'], + ); + + $this->assertFalse($validator->passes()); + $this->assertSame('tAylor, sVen|TAYLOR, SVEN|TAylor, SVen', $validator->errors()->first('foo')); + } + public function testDisplayableAttributesAreReplacedInCustomReplacers() { $trans = $this->getArrayTranslator(); @@ -1406,6 +1473,30 @@ public function testValidateArrayKeys() $this->assertFalse($v->passes()); } + #[TestWith(['array', 'a.b'])] + #[TestWith(['array', 'a*b'])] + #[TestWith(['required_array_keys', 'a.b'])] + #[TestWith(['required_array_keys', 'a*b'])] + #[TestWith(['in_array_keys', 'a.b'])] + #[TestWith(['in_array_keys', 'a*b'])] + public function testArrayRulesAcceptLiteralKeys(string $rule, string $key): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['options' => [$key => 'value']], + ['options' => $rule . ':' . $key], + ); + + $this->assertTrue($validator->passes()); + } + + public function testArrayValidationAcceptsLiteralKeysWhenCalledDirectly(): void + { + $validator = new Validator($this->getArrayTranslator(), [], []); + + $this->assertTrue($validator->validateArray('options', ['a.b' => 1, 'a*b' => 2], ['a.b', 'a*b'])); + } + public function testValidateCurrentPassword(): void { // Fails when user is not logged in. @@ -4935,7 +5026,7 @@ public function testValidateMacAddress() $this->assertTrue($v->passes()); } - public function testValidateEmail() + public function testValidateEmail(): void { $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['x' => 'aslsdlks'], ['x' => 'Email']); @@ -4945,8 +5036,8 @@ public function testValidateEmail() $this->assertFalse($v->passes()); $v = new Validator($trans, [ - 'x' => new class implements \Stringable { - public function __toString() + 'x' => new class implements StringableInterface { + public function __toString(): string { return 'aslsdlks'; } @@ -4955,8 +5046,8 @@ public function __toString() $this->assertFalse($v->passes()); $v = new Validator($trans, [ - 'x' => new class implements \Stringable { - public function __toString() + 'x' => new class implements StringableInterface { + public function __toString(): string { return 'foo@gmail.com'; } @@ -4966,6 +5057,9 @@ public function __toString() $v = new Validator($trans, ['x' => 'foo@gmail.com'], ['x' => 'Email']); $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['x' => "\"foo\r\nBcc: victim@example.com\"@example.com"], ['x' => 'Email']); + $this->assertFalse($v->passes()); } public function testValidateEmailWithInternationalCharacters() @@ -5786,6 +5880,32 @@ public function testNumericKeys() $this->assertTrue($v->passes()); } + public function testNumericKeysUseCustomMessageArrays(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['Taylor', ''], + ['*' => 'required'], + ['1' => ['required' => 'Second item required.']], + ); + + $this->assertSame('Second item required.', $validator->errors()->first('1')); + } + + public function testNumericKeysUseExactAndWildcardAttributeNames(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['', ''], + ['*' => 'required'], + ['required' => 'Required :attribute.'], + ['0' => 'First item', '*' => 'Other item'], + ); + + $this->assertSame('Required First item.', $validator->errors()->first('0')); + $this->assertSame('Required Other item.', $validator->errors()->first('1')); + } + public function testMergeRules() { $trans = $this->getArrayTranslator(); @@ -7839,6 +7959,268 @@ public function testAsteriskPlaceholdersInParametersAreReplaced(): void $this->assertSame('The name field is required when user.role* is not present.', $validator->messages()->first()); } + #[TestWith(['settings.version', 'settings\.version'])] + #[TestWith(['settings*version', 'settings\*version'])] + public function testLiteralFieldMessagesUseTheCorrectInput(string $attribute, string $ruleAttribute): void + { + $validator = new Validator( + $this->getArrayTranslator(), + [$attribute => 'invalid', 'settings' => ['version' => 'nested']], + [$ruleAttribute => 'integer'], + ['integer' => ':attribute: :input'], + [$attribute => 'Version'], + ); + $validator->addCustomValues([$attribute => ['invalid' => 'Invalid version']]); + + $this->assertSame('Version: Invalid version', $validator->errors()->first($attribute)); + } + + #[TestWith(['inline'])] + #[TestWith(['fallback'])] + #[TestWith(['translation'])] + #[TestWith(['flat_translation'])] + public function testWildcardMessagesDoNotSplitLiteralKeys(string $source): void + { + $translator = new Translator(new ArrayLoader, 'en'); + $messages = ['foo.*.required' => 'Nested message.', 'required' => 'Default message.']; + + if ($source !== 'fallback') { + $translator->addLines(['validation.required' => 'Default message.'], 'en'); + } + + if ($source === 'translation') { + $translator->addLines(['validation.custom.foo.*.required' => 'Nested message.'], 'en'); + } elseif ($source === 'flat_translation') { + $translator->addLines(['validation.custom' => ['foo.*.required' => 'Nested message.']], 'en'); + } + + foreach ([true, false] as $literal) { + $validator = new Validator( + $translator, + $literal ? ['foo.bar' => ''] : ['foo' => ['bar' => '']], + [$literal ? 'foo\.bar' : 'foo.bar' => 'required'], + $source === 'inline' ? $messages : [], + ); + + if ($source === 'fallback') { + $validator->setFallbackMessages($messages); + } + + $this->assertSame( + $literal ? 'Default message.' : 'Nested message.', + $validator->errors()->first(), + ); + } + } + + #[TestWith(['inline'])] + #[TestWith(['translation'])] + public function testWildcardAttributesDoNotSplitLiteralKeys(string $source): void + { + $translator = new Translator(new ArrayLoader, 'en'); + $translator->addLines(['validation.required' => 'Required :attribute.'], 'en'); + + if ($source === 'translation') { + $translator->addLines(['validation.attributes.foo.*' => 'Nested label'], 'en'); + } + + foreach ([true, false] as $literal) { + $validator = new Validator( + $translator, + $literal ? ['foo.bar' => ''] : ['foo' => ['bar' => '']], + [$literal ? 'foo\.bar' : 'foo.bar' => 'required'], + attributes: $source === 'inline' ? ['foo.*' => 'Nested label'] : [], + ); + + $this->assertSame( + $literal ? 'Required foo.bar.' : 'Required Nested label.', + $validator->errors()->first(), + ); + } + } + + #[TestWith(['items.list.*', ['items.list' => ['']], 'items\.list.*'])] + #[TestWith(['items.list.*', ['items' => ['list' => ['']]], 'items.list.*'])] + #[TestWith(['*', ['foo.bar' => ''], 'foo\.bar'])] + #[TestWith(['foo*bar', ['foo.bar' => ''], 'foo\.bar'])] + #[TestWith(['user*', ['username' => ''], 'username'])] + #[TestWith(['*name', ['username' => ''], 'username'])] + #[TestWith(['user*.email', ['user1' => ['email' => '']], 'user1.email'])] + #[TestWith(['settings*version', ['settings*version' => ''], 'settings\*version'])] + public function testWildcardMessagesAndLabelsPreserveLiteralSegments(string $pattern, array $data, string $attribute): void + { + $validator = new Validator( + new Translator(new ArrayLoader, 'en'), + $data, + [$attribute => 'required'], + [$pattern . '.required' => 'Required :attribute.'], + [$pattern => 'Custom label'], + ); + + $this->assertSame('Required Custom label.', $validator->errors()->first()); + } + + #[TestWith(['items.list.*.required', ['items.list' => ['']], 'items\.list.*'])] + #[TestWith(['a.*.required', ['a' => ['b' => ['c' => '']]], 'a.b.c'])] + #[TestWith(['a.*.required', ['a' => ["line\nbreak" => '']], "a.line\nbreak"])] + public function testTranslatedWildcardMessagesPreserveLiteralAndNestedSegments(string $pattern, array $data, string $attribute): void + { + $translator = new Translator(new ArrayLoader, 'en'); + $translator->addLines(['validation.custom' => [$pattern => 'Custom message.']], 'en'); + + $validator = new Validator($translator, $data, [$attribute => 'required']); + + $this->assertSame('Custom message.', $validator->errors()->first()); + } + + public function testLiteralWildcardSegmentsPreserveLabelsAndPositions(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['versions' => ['1.2' => [3 => 'invalid']]], + ['versions.*.*' => 'integer'], + ['versions.*.*.integer' => ':attribute: :index / :position / :second-index'], + ['versions.*.*' => 'Version'], + ); + + $this->assertSame('Version: 3 / 4 / :second-index', $validator->errors()->first()); + + $validator->setAttributeNames([]); + $validator->setImplicitAttributesFormatter(static fn (string $attribute): string => "Field {$attribute}"); + $validator->passes(); + + $this->assertSame('Field versions.1.2.3: 3 / 4 / :second-index', $validator->errors()->first()); + } + + #[TestWith(['settings.version', 'settings\.version'])] + #[TestWith(['settings*version', 'settings\*version'])] + public function testDependentRuleMessagesReadLiteralFieldValues(string $attribute, string $ruleAttribute): void + { + $validator = new Validator( + $this->getArrayTranslator(), + [$attribute => 'yes', 'settings' => ['version' => 'no']], + ['name' => 'required_if:' . $ruleAttribute . ',yes'], + ['required_if' => ':other: :value'], + [$attribute => 'Version'], + ); + $validator->addCustomValues([$attribute => ['yes' => 'Enabled']]); + + $this->assertSame('Version: Enabled', $validator->errors()->first('name')); + $this->assertSame(['RequiredIf' => [$attribute, 'yes']], $validator->failed()['name']); + } + + public function testComparisonMessagesPreserveBothLiteralFieldPaths(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['current.value' => 1, 'other.value' => 50, 'other' => ['value' => 100]], + ['current\.value' => 'numeric|gt:other\.value'], + ['gt' => ':attribute must exceed :value.'], + ); + + $this->assertSame('current.value must exceed 50.', $validator->errors()->first()); + } + + #[TestWith(['inline'])] + #[TestWith(['translation'])] + #[TestWith(['flat_translation'])] + public function testLiteralFieldMessagesRetainTheirNumericType(string $source): void + { + $translator = $this->getArrayTranslator(); + $messages = ['value.amount.min' => ['numeric' => 'Numeric minimum.', 'string' => 'String minimum.']]; + + if ($source === 'translation') { + $translator->addLines(['validation.custom.value.amount.min.numeric' => 'Numeric minimum.'], 'en'); + } elseif ($source === 'flat_translation') { + $translator->addLines(['validation.custom' => ['value.amount.min.numeric' => 'Numeric minimum.']], 'en'); + } + + $validator = new Validator( + $translator, + ['value.amount' => 1], + ['value\.amount' => 'numeric|min:5'], + $source === 'inline' ? $messages : [], + ); + + $this->assertSame('Numeric minimum.', $validator->errors()->first()); + } + + public function testLiteralFieldMessagesUseFallbackMessageKeys(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['value.amount' => 'invalid'], + ['value\.amount' => 'integer'], + ); + $validator->setFallbackMessages(['value.amount.integer' => 'Integer required.']); + + $this->assertSame('Integer required.', $validator->errors()->first()); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testCustomReplacersReceiveDecodedFieldPaths(bool $classBased): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['settings.version' => 'invalid', 'other.value' => 'yes'], + ['settings\.version' => 'accepted_if:other\.value,yes'], + ['accepted_if' => ':input'], + ); + + $callback = function (string $message, string $attribute, string $rule, array $parameters, Validator $instance) use ($validator): string { + $this->assertSame('invalid', $message); + $this->assertSame('settings.version', $attribute); + $this->assertSame('accepted_if', $rule); + $this->assertSame(['other.value', 'yes'], $parameters); + $this->assertSame($validator, $instance); + + return 'Custom message.'; + }; + + if ($classBased) { + $validator->setContainer($container = m::mock(ContainerContract::class)); + $container->shouldReceive('make')->once()->with('LiteralFieldReplacer')->andReturn($replacer = m::mock(stdClass::class)); + $replacer->shouldReceive('replace')->once()->andReturnUsing($callback); + $validator->addReplacer('accepted_if', 'LiteralFieldReplacer'); + } else { + $validator->addReplacer('accepted_if', $callback); + } + + $this->assertSame('Custom message.', $validator->errors()->first('settings.version')); + } + + public function testCustomRuleMessagesPreserveLiteralFieldIdentity(): void + { + $rule = new class implements Rule { + /** + * Determine if the validation rule passes. + */ + public function passes(string $attribute, mixed $value): bool + { + return $attribute !== 'settings.version' || $value !== 'invalid'; + } + + /** + * Get the validation error messages. + */ + public function message(): array + { + return [':attribute: :input', 'other' => ':attribute: :input']; + } + }; + $validator = new Validator( + $this->getArrayTranslator(), + ['settings.version' => 'invalid', 'settings' => ['version' => 'nested'], 'other' => 'other input'], + ['settings\.version' => $rule], + ); + + $this->assertSame([ + 'settings.version' => ['settings.version: invalid'], + 'other' => ['other: other input'], + ], $validator->errors()->getMessages()); + } + public function testCoveringEmptyKeys() { $trans = $this->getArrayTranslator(); diff --git a/tests/Wayfinder/GenerateCommandTest.php b/tests/Wayfinder/GenerateCommandTest.php index 24bd2cb1c..6fd177444 100644 --- a/tests/Wayfinder/GenerateCommandTest.php +++ b/tests/Wayfinder/GenerateCommandTest.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Contracts\Http\Kernel as HttpKernel; use Hypervel\Filesystem\Filesystem; use Hypervel\Routing\RouteCollection; use Hypervel\Routing\Router; @@ -202,8 +203,16 @@ public function testSkipRoutesIgnoresDuplicateNamesWhenGeneratingActions(): void public function testParameterizedMiddlewareUsesItsResolvedClassForUrlDefaults(): void { - $router = $this->app->make(Router::class); - $router->aliasMiddleware('wayfinder.defaults', ParameterizedWayfinderDefaultsMiddleware::class); + $this->app->afterResolving(HttpKernel::class, static function (HttpKernel $kernel): void { + $kernel->setMiddlewareAliases([ + ...$kernel->getMiddlewareAliases(), + 'wayfinder.defaults' => ParameterizedWayfinderDefaultsMiddleware::class, + ]); + $kernel->setMiddlewareGroups([ + ...$kernel->getMiddlewareGroups(), + 'wayfinder' => ['wayfinder.defaults:tenant'], + ]); + }); Route::get('/direct/{tenant}', [ParameterizedWayfinderDefaultsController::class, 'direct']) ->middleware(ParameterizedWayfinderDefaultsMiddleware::class . ':tenant'); @@ -211,6 +220,8 @@ public function testParameterizedMiddlewareUsesItsResolvedClassForUrlDefaults(): ->middleware('wayfinder.defaults:tenant'); Route::get('/plain/{tenant}', [ParameterizedWayfinderDefaultsController::class, 'plain']) ->middleware(ParameterizedWayfinderDefaultsMiddleware::class); + Route::get('/group/{tenant}', [ParameterizedWayfinderDefaultsController::class, 'group']) + ->middleware('wayfinder'); // This class is intentionally undefined to exercise the absent-middleware guard. Route::get('/missing/{tenant}', [ParameterizedWayfinderDefaultsController::class, 'missing']) ->middleware(MissingWayfinderDefaultsMiddleware::class . ':tenant'); @@ -232,6 +243,7 @@ public function testParameterizedMiddlewareUsesItsResolvedClassForUrlDefaults(): $this->assertStringContainsString("url: '/direct/{tenant?}'", $content); $this->assertStringContainsString("url: '/alias/{tenant?}'", $content); $this->assertStringContainsString("url: '/plain/{tenant?}'", $content); + $this->assertStringContainsString("url: '/group/{tenant?}'", $content); $this->assertStringContainsString("url: '/missing/{tenant}'", $content); } @@ -306,18 +318,37 @@ public function handle(mixed $request, Closure $next): mixed class ParameterizedWayfinderDefaultsController { + /** + * Handle the route with parameterized middleware defaults. + */ public function direct(): void { } + /** + * Handle the route with aliased middleware defaults. + */ public function alias(): void { } + /** + * Handle the route with unparameterized middleware defaults. + */ public function plain(): void { } + /** + * Handle the route with grouped middleware defaults. + */ + public function group(): void + { + } + + /** + * Handle the route with an undefined middleware class. + */ public function missing(): void { } diff --git a/types/Support/Str.php b/types/Support/Str.php new file mode 100644 index 000000000..d7cb03fd7 --- /dev/null +++ b/types/Support/Str.php @@ -0,0 +1,172 @@ +', Str::replace($search, $replace, [$subject])); + +assertType('\'\'', Str::camel('')); +assertType('string', Str::camel('Taylor Otwell')); + +assertType('false', Str::contains('Taylor Otwell', [])); +assertType('false', Str::contains('', 'Taylor')); +assertType('bool', Str::contains('Taylor Otwell', 'Taylor')); + +assertType('false', Str::containsAll('Taylor Otwell', [])); +assertType('bool', Str::containsAll('Taylor Otwell', ['Taylor'])); + +assertType('true', Str::doesntContain('Taylor Otwell', [])); +assertType('true', Str::doesntContain('', 'Taylor')); +assertType('bool', Str::doesntContain('Taylor Otwell', 'Taylor')); + +assertType('\'\'', Str::convertCase('')); +assertType('string', Str::convertCase('Taylor Otwell')); + +assertType('\'\'', Str::deduplicate('')); +assertType('string', Str::deduplicate('Taylor Otwell')); + +assertType('false', Str::endsWith('Taylor Otwell', [])); +assertType('false', Str::endsWith('', 'Taylor')); +assertType('bool', Str::endsWith('Taylor Otwell', 'Taylor')); +assertType('bool', Str::endsWith(123, '3')); +assertType('bool', Str::endsWith('123', 3)); +assertType('bool', Str::of('123')->endsWith(3)); + +assertType('true', Str::doesntEndWith('Taylor Otwell', [])); +assertType('true', Str::doesntEndWith('', 'Taylor')); +assertType('bool', Str::doesntEndWith('Taylor Otwell', 'Taylor')); +assertType('bool', Str::doesntEndWith(123, '3')); +assertType('bool', Str::of('123')->doesntEndWith(3)); + +assertType('\'\'', Str::kebab('')); +assertType('string', Str::kebab('Taylor Otwell')); + +assertType('\'\'', Str::lower('')); +assertType('lowercase-string&non-empty-string', Str::lower('Taylor')); +assertType('\'\'', Str::upper('')); +assertType('non-empty-string&uppercase-string', Str::upper('Taylor')); + +assertType('\'\'', Str::markdown('')); +assertType('string', Str::markdown('Taylor Otwell')); + +assertType('\'\'', Str::inlineMarkdown('')); +assertType('string', Str::inlineMarkdown('Taylor Otwell')); + +assertType('false', Str::isMatch([], 'Taylor Otwell')); +assertType('bool', Str::isMatch(['Taylor'], 'Taylor Otwell')); + +assertType('string', Str::numbers('(555) 123-4567')); +assertType('array', Str::numbers(['(555) 123-4567'])); + +assertType('numeric-string', Str::password(letters: false, symbols: false, spaces: false)); +assertType('string', Str::password()); + +assertType('int', Str::position('Taylor Otwell', '')); +assertType('int', Str::position('', '')); +assertType('false', Str::position('', 'Taylor')); +assertType('int|false', Str::position('Taylor Otwell', 'Taylor')); + +assertType('string|null', Str::replaceMatches('', '', 'Taylor Otwell')); +assertType('array|null', Str::replaceMatches('', '', ['Taylor', 'Otwell'])); + +assertType('false', Str::startsWith('Taylor Otwell', [])); +assertType('false', Str::startsWith('', 'Taylor')); +assertType('bool', Str::startsWith('Taylor Otwell', 'Taylor')); +assertType('bool', Str::startsWith(123, '1')); +assertType('bool', Str::startsWith('123', 1)); +assertType('bool', Str::of('123')->startsWith(1)); + +/** @var Stringable $stringable */ +assertType('bool', Str::startsWith($stringable, '1')); +assertType('bool', Str::endsWith('123', $stringable)); + +assertType('true', Str::doesntStartWith('Taylor Otwell', [])); +assertType('true', Str::doesntStartWith('', 'Taylor')); +assertType('bool', Str::doesntStartWith('Taylor Otwell', 'Taylor')); +assertType('bool', Str::doesntStartWith(123, '1')); +assertType('bool', Str::of('123')->doesntStartWith(1)); + +assertType('\'\'', Str::studly('')); +assertType('string', Str::studly('Taylor Otwell')); + +assertType('\'\'', Str::pascal('')); +assertType('string', Str::pascal('Taylor Otwell')); + +assertType('\'\'', Str::toBase64('')); +assertType('string', Str::toBase64('Taylor Otwell')); + +assertType('\'\'', Str::fromBase64('')); +assertType('string', Str::fromBase64('Taylor Otwell')); + +assertType('\'\'', Str::lcfirst('')); +assertType('non-empty-string', Str::lcfirst('Taylor Otwell')); + +assertType('\'\'', Str::ucfirst('')); +assertType('non-empty-string', Str::ucfirst('Taylor Otwell')); + +assertType('\'\'', Str::ucwords('')); +assertType('non-empty-string', Str::ucwords('Taylor Otwell')); + +assertType('array{}', Str::ucsplit('')); +assertType('array', Str::ucsplit('Taylor Otwell'));