diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml index 067c60d0a..3f0f19060 100644 --- a/.github/workflows/redis.yml +++ b/.github/workflows/redis.yml @@ -69,13 +69,11 @@ jobs: run: | vendor/bin/paratest --max-processes=15 tests/Integration/Auth/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Broadcasting/Redis - vendor/bin/paratest --max-processes=15 tests/Integration/Cache/Redis + CACHE_STORE=redis vendor/bin/paratest --max-processes=15 tests/Integration/Cache vendor/bin/paratest --max-processes=15 tests/Integration/Horizon vendor/bin/paratest --max-processes=15 tests/Integration/Http/Redis vendor/bin/paratest --max-processes=15 tests/Integration/OpenTelemetry/Redis - vendor/bin/paratest --max-processes=15 tests/Integration/Queue/Redis - QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobChainingTest.php - QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobDispatchingTest.php + QUEUE_CONNECTION=redis vendor/bin/paratest --max-processes=15 tests/Integration/Queue vendor/bin/paratest --max-processes=15 tests/Integration/RateLimiter/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Session/Redis @@ -225,13 +223,11 @@ jobs: run: | vendor/bin/phpunit --no-progress tests/Integration/Auth/Redis vendor/bin/phpunit --no-progress tests/Integration/Broadcasting/Redis - vendor/bin/phpunit --no-progress tests/Integration/Cache/Redis + CACHE_STORE=redis vendor/bin/phpunit --no-progress tests/Integration/Cache vendor/bin/phpunit --no-progress tests/Integration/Horizon vendor/bin/phpunit --no-progress tests/Integration/Http/Redis vendor/bin/phpunit --no-progress tests/Integration/OpenTelemetry/Redis - vendor/bin/phpunit --no-progress tests/Integration/Queue/Redis - QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobChainingTest.php - QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobDispatchingTest.php + QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue vendor/bin/phpunit --no-progress tests/Integration/RateLimiter/Redis vendor/bin/phpunit --no-progress tests/Integration/Session/Redis # This explicit list contains the topology-neutral Redis tests; the remaining files require standalone-only behavior. @@ -306,13 +302,11 @@ jobs: run: | vendor/bin/paratest --max-processes=15 tests/Integration/Auth/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Broadcasting/Redis - vendor/bin/paratest --max-processes=15 tests/Integration/Cache/Redis + CACHE_STORE=redis vendor/bin/paratest --max-processes=15 tests/Integration/Cache vendor/bin/paratest --max-processes=15 tests/Integration/Horizon vendor/bin/paratest --max-processes=15 tests/Integration/Http/Redis vendor/bin/paratest --max-processes=15 tests/Integration/OpenTelemetry/Redis - vendor/bin/paratest --max-processes=15 tests/Integration/Queue/Redis - QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobChainingTest.php - QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue/JobDispatchingTest.php + QUEUE_CONNECTION=redis vendor/bin/paratest --max-processes=15 tests/Integration/Queue vendor/bin/paratest --max-processes=15 tests/Integration/RateLimiter/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Redis vendor/bin/phpunit --no-progress tests/Integration/Reverb/ClearStateCommandTest.php diff --git a/composer.json b/composer.json index b3ec1853f..954253254 100644 --- a/composer.json +++ b/composer.json @@ -308,6 +308,7 @@ "ably/ably-php": "^1.0", "algolia/algoliasearch-client-php": "^4.0", "brianium/paratest": "^7.24", + "composer/composer": "^2.10.3", "composer/semver": "^3.4", "fakerphp/faker": "^1.24", "friendsofphp/php-cs-fixer": "^3.57.2", diff --git a/docs/todo.md b/docs/todo.md index d4d23aa48..542bf0858 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -31,7 +31,7 @@ ## Testing -- Replace PHPUnit 13's deprecated `expectExceptionMessage()` calls across the test suite. Use `expectExceptionMessageIs()` for complete messages and `expectExceptionMessageIsOrContains()` for fragments, auditing each assertion's intent and running its owning test file as it is changed. +- Replace PHPUnit 13's [soft-deprecated `expectExceptionMessage()`](https://github.com/sebastianbergmann/phpunit/issues/6560) calls across the test suite. Preserve intended matching semantics: use `expectExceptionObject()` for combined class/message/code expectations, `expectExceptionMessageIs()` for exact messages, and `expectExceptionMessageIsOrContains()` for substring matching. Audit each assertion's intent and run its owning test file as it is changed. ## HTTP Server diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 4a1cca4c8..eec673130 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -31,7 +31,6 @@ parameters: - %currentWorkingDirectory%/src/*/node_modules/* - %currentWorkingDirectory%/src/*/resources/views/* - %currentWorkingDirectory%/src/*/publish/* - - %currentWorkingDirectory%/src/foundation/src/ComposerScripts.php ignoreErrors: # CreatesApplication is an open trait consumed by both PHPUnit test cases and the # standalone Testbench application. PHPStan reports this shared capability guard diff --git a/src/cache/src/Redis/Operations/AllTag/GetEntries.php b/src/cache/src/Redis/Operations/AllTag/GetEntries.php index 28834e4d8..677320216 100644 --- a/src/cache/src/Redis/Operations/AllTag/GetEntries.php +++ b/src/cache/src/Redis/Operations/AllTag/GetEntries.php @@ -38,8 +38,10 @@ public function execute(array $tagIds): LazyCollection do { $entries = $context->withConnection( - function (RedisConnection $connection) use ($prefix, $tagId, &$cursor) { - return $connection->zscan($prefix . $tagId, $cursor, '*', 1000); + function (RedisConnection $connection) use ($prefix, $tagId, &$cursor): mixed { + return $connection->withoutScanPrefix(function () use ($connection, $prefix, $tagId, &$cursor): mixed { + return $connection->zscan($prefix . $tagId, $cursor, '*', 1000); + }); } ); diff --git a/src/cache/src/Redis/Operations/AllTag/Prune.php b/src/cache/src/Redis/Operations/AllTag/Prune.php index 6bdba4555..b433af34c 100644 --- a/src/cache/src/Redis/Operations/AllTag/Prune.php +++ b/src/cache/src/Redis/Operations/AllTag/Prune.php @@ -100,8 +100,10 @@ private function removeOrphanedEntries( $iterator = PhpRedis::initialScanCursor(); do { - // ZSCAN returns [member => score, ...] array - $members = $connection->zScan($tagKey, $iterator, '*', $scanCount); + // Tag members omit OPT_PREFIX, so scan every member without prefixing the pattern. + $members = $connection->withoutScanPrefix(function () use ($connection, $tagKey, &$iterator, $scanCount): mixed { + return $connection->zScan($tagKey, $iterator, '*', $scanCount); + }); if ($members === false || ! is_array($members)) { break; diff --git a/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php b/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php index 897b4d7d2..e61333647 100644 --- a/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php +++ b/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php @@ -90,8 +90,10 @@ private function hscanGenerator(string $tagKey, int $count): Generator do { // Acquire connection just for this HSCAN batch $fields = $this->context->withConnection( - function (RedisConnection $connection) use ($tagKey, &$iterator, $count) { - return $connection->hscan($tagKey, $iterator, null, $count); + function (RedisConnection $connection) use ($tagKey, &$iterator, $count): mixed { + return $connection->withoutScanPrefix(function () use ($connection, $tagKey, &$iterator, $count): mixed { + return $connection->hscan($tagKey, $iterator, null, $count); + }); } ); diff --git a/src/cache/src/Redis/Operations/AnyTag/Prune.php b/src/cache/src/Redis/Operations/AnyTag/Prune.php index ab8417d68..5cd1c2eb5 100644 --- a/src/cache/src/Redis/Operations/AnyTag/Prune.php +++ b/src/cache/src/Redis/Operations/AnyTag/Prune.php @@ -169,8 +169,10 @@ private function cleanupTagHashUsingLua( $iterator = PhpRedis::initialScanCursor(); do { - // HSCAN returns [field => value, ...] array - $fields = $connection->hScan($tagHash, $iterator, '*', $scanCount); + // Tag fields omit OPT_PREFIX, so scan every field without prefixing the pattern. + $fields = $connection->withoutScanPrefix(function () use ($connection, $tagHash, &$iterator, $scanCount): mixed { + return $connection->hScan($tagHash, $iterator, '*', $scanCount); + }); if ($fields === false || ! is_array($fields)) { break; @@ -230,8 +232,10 @@ private function cleanupTagHashCluster( $iterator = PhpRedis::initialScanCursor(); do { - // HSCAN returns [field => value, ...] array - $fields = $connection->hScan($tagHash, $iterator, '*', $scanCount); + // Tag fields omit OPT_PREFIX, so scan every field without prefixing the pattern. + $fields = $connection->withoutScanPrefix(function () use ($connection, $tagHash, &$iterator, $scanCount): mixed { + return $connection->hScan($tagHash, $iterator, '*', $scanCount); + }); if ($fields === false || ! is_array($fields)) { break; diff --git a/src/concurrency/composer.json b/src/concurrency/composer.json index c092be10c..7aad3c708 100644 --- a/src/concurrency/composer.json +++ b/src/concurrency/composer.json @@ -37,6 +37,7 @@ "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", "hypervel/coroutine": "^0.4", + "hypervel/log": "^0.4", "hypervel/process": "^0.4", "hypervel/support": "^0.4", "nesbot/carbon": "^3.13.1", diff --git a/src/concurrency/src/ProcessDriver.php b/src/concurrency/src/ProcessDriver.php index 929bfe85a..7399dff06 100644 --- a/src/concurrency/src/ProcessDriver.php +++ b/src/concurrency/src/ProcessDriver.php @@ -13,6 +13,7 @@ use Hypervel\Process\Pool; use Hypervel\Support\Arr; use Hypervel\Support\Defer\DeferredCallback; +use Hypervel\Support\Facades\Context; use Laravel\SerializableClosure\SerializableClosure; use function Hypervel\Support\defer; @@ -37,6 +38,8 @@ public function run(Closure|array $tasks, CarbonInterval|int|null $timeout = nul $results = $this->processFactory->pool(function (Pool $pool) use ($tasks, $command, $timeout) { foreach (Arr::wrap($tasks) as $key => $task) { $process = $pool->as((string) $key)->path(base_path())->env([ + /* @phpstan-ignore staticMethod.notFound */ + '__HYPERVEL_CONTEXT' => base64_encode(serialize(Context::dehydrate())), 'HYPERVEL_INVOKABLE_CLOSURE' => base64_encode( serialize(new SerializableClosure($task)) ), @@ -67,6 +70,8 @@ public function defer(Closure|array $tasks): DeferredCallback return defer(function () use ($tasks, $command) { foreach (Arr::wrap($tasks) as $task) { $this->processFactory->path(base_path())->env([ + /* @phpstan-ignore staticMethod.notFound */ + '__HYPERVEL_CONTEXT' => base64_encode(serialize(Context::dehydrate())), 'HYPERVEL_INVOKABLE_CLOSURE' => base64_encode( serialize(new SerializableClosure($task)) ), diff --git a/src/console/src/ConsoleServiceProvider.php b/src/console/src/ConsoleServiceProvider.php index 1a1b26ffe..23e76b59c 100644 --- a/src/console/src/ConsoleServiceProvider.php +++ b/src/console/src/ConsoleServiceProvider.php @@ -11,6 +11,10 @@ use Hypervel\Console\Commands\ScheduleResumeCommand; use Hypervel\Console\Commands\ScheduleRunCommand; use Hypervel\Console\Commands\ScheduleTestCommand; +use Hypervel\Console\Events\BeforeHandle; +use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Log\Context\Repository; +use Hypervel\Support\Env; use Hypervel\Support\ServiceProvider; class ConsoleServiceProvider extends ServiceProvider @@ -30,4 +34,33 @@ public function register(): void ScheduleTestCommand::class, ]); } + + /** + * Bootstrap the service provider. + */ + public function boot(Dispatcher $events): void + { + if (! $this->app->runningInConsole() + || ($encoded = Env::get('__HYPERVEL_CONTEXT')) === null) { + return; + } + + // Consume startup transport at boot so child processes cannot inherit stale context. + Env::deleteMany(['__HYPERVEL_CONTEXT']); + // The native environment also needs clearing when Env's putenv adapter is disabled. + putenv('__HYPERVEL_CONTEXT'); + + if (is_string($encoded) + && is_array($context = unserialize(base64_decode($encoded, true), ['allowed_classes' => false]))) { + $events->listen(BeforeHandle::class, static function () use (&$context): void { + if ($context !== null) { + // Hydration callbacks may invoke another command, so claim the startup context first. + $payload = $context; + $context = null; + + Repository::getInstance()->hydrate($payload); + } + }); + } + } } diff --git a/src/console/src/Scheduling/Event.php b/src/console/src/Scheduling/Event.php index b1c5c49c0..20ba49f3f 100644 --- a/src/console/src/Scheduling/Event.php +++ b/src/console/src/Scheduling/Event.php @@ -20,6 +20,7 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Mail\Mailer; use Hypervel\Filesystem\Filesystem; +use Hypervel\Log\Context\Repository; use Hypervel\Support\Arr; use Hypervel\Support\Facades\Date; use Hypervel\Support\Stringable; @@ -265,10 +266,13 @@ protected function execute(Container $container): int */ protected function runProcess(Container $container): int { + $context = base64_encode(serialize(Repository::getInstance()->dehydrate())); + /** @var \Hypervel\Contracts\Foundation\Application $container */ $process = Process::fromShellCommandline( $this->command, - $container->basePath() + $container->basePath(), + ['__HYPERVEL_CONTEXT' => $context] ); CoroutineContext::set($this->processContextKey(), $process); diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 167c6c5ee..e4ca35ec4 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -1027,10 +1027,15 @@ public function getErrorCount(): int /** * Register a database query listener with the connection. + * + * Boot-only. Registers a listener on the worker-global event dispatcher; + * per-request registration persists and affects subsequent requests. + * + * @param Closure(QueryExecuted): mixed $callback */ public function listen(Closure $callback): void { - $this->events?->listen(Events\QueryExecuted::class, $callback); + $this->events?->listen(QueryExecuted::class, $callback); } /** diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 695973352..567f6c612 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -482,10 +482,10 @@ public function fillForInsert(array $values): array $this->model->unguarded(function () use (&$values) { foreach ($values as $key => $rowValues) { - $values[$key] = tap( - $this->newModelInstance($rowValues), - fn ($model) => $model->setUniqueIds() - )->getAttributes(); + $model = $this->newModelInstance($rowValues); + $model->setUniqueIds(); + + $values[$key] = $model->prepareBinaryAttributesForDatabase($model->getAttributes()); } }); diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index 9bf4a6587..fac63cc9d 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -1920,7 +1920,7 @@ private function isBinaryCast(?string $cast): bool * @param array $attributes * @return array */ - protected function prepareBinaryAttributesForDatabase(array $attributes): array + public function prepareBinaryAttributesForDatabase(array $attributes): array { $casts = $this->getCasts(); diff --git a/src/database/src/Eloquent/Factories/Factory.php b/src/database/src/Eloquent/Factories/Factory.php index 22fb5deb2..c17583d4d 100644 --- a/src/database/src/Eloquent/Factories/Factory.php +++ b/src/database/src/Eloquent/Factories/Factory.php @@ -323,7 +323,7 @@ public function lazy(array $attributes = [], ?Model $parent = null): Closure /** * Set the connection name on the results and store them. * - * @param \Hypervel\Support\Collection $results + * @param \Hypervel\Support\Collection $results */ protected function store(Collection $results): void { @@ -346,6 +346,8 @@ protected function store(Collection $results): void /** * Create the children for the given model. + * + * @param TModel $model */ protected function createChildren(Model $model): void { @@ -444,6 +446,10 @@ public function insert(array $attributes = [], ?Model $parent = null): void ? $made : $this->newModel()->newCollection([$made]); + if ($madeCollection->isEmpty()) { + return; + } + $model = $madeCollection->first(); if (isset($this->connection)) { @@ -452,12 +458,25 @@ public function insert(array $attributes = [], ?Model $parent = null): void $query = $model->newQueryWithoutScopes(); - $query->fillAndInsert( - $madeCollection->withoutAppends() - ->setHidden([]) - ->map(static fn (Model $model) => $model->attributesToArray()) - ->all() - ); + $timestamps = []; + + if ($model->usesTimestamps()) { + $timestamp = $model->freshTimestampString(); + + foreach (array_filter([$model->getCreatedAtColumn(), $model->getUpdatedAtColumn()]) as $column) { + $timestamps[$column] = $timestamp; + } + } + + // These models have already applied their casts and mutators. Serializing or + // filling them again can drop attributes or transform stored values twice. + $values = $madeCollection->map(static function (Model $model) use ($timestamps): array { + $model->setUniqueIds(); + + return array_merge($timestamps, $model->prepareBinaryAttributesForDatabase($model->getAttributes())); + })->all(); + + $query->insert($values); } /** diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index d4f5bc737..fe99ae563 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -858,6 +858,11 @@ public function where(Closure|self|EloquentBuilder|Relation|ExpressionContract|a $type = 'Bitwise'; } + // JSON booleans need their driver's casts before applying null-safe equality. + if ($operator === '<=>' && $type !== 'JsonBoolean') { + $type = 'NullSafeEquals'; + } + // Now that we are working with just a simple query we can put the elements // in our array and add the query binding to our array of bindings that // will be bound to each SQL statements when it is finally executed. @@ -1143,6 +1148,10 @@ public function orWhereNotLike(ExpressionContract|string $column, string $value, */ public function whereNullSafeEquals(ExpressionContract|string $column, mixed $value, string $boolean = 'and'): static { + if (is_bool($value) && is_string($column) && str_contains($column, '->')) { + return $this->where($column, '<=>', $value, $boolean); + } + $type = 'NullSafeEquals'; $this->wheres[] = compact('type', 'column', 'value', 'boolean'); diff --git a/src/database/src/Query/Grammars/Grammar.php b/src/database/src/Query/Grammars/Grammar.php index 39602b8e0..94776b4e1 100755 --- a/src/database/src/Query/Grammars/Grammar.php +++ b/src/database/src/Query/Grammars/Grammar.php @@ -9,6 +9,7 @@ use Hypervel\Database\Concerns\CompilesJsonPaths; use Hypervel\Database\Grammar as BaseGrammar; use Hypervel\Database\Query\Builder; +use Hypervel\Database\Query\Expression as QueryExpression; use Hypervel\Database\Query\JoinClause; use Hypervel\Database\Query\JoinLateralClause; use Hypervel\Support\Arr; @@ -512,6 +513,12 @@ protected function whereSub(Builder $query, array $where): string $subquery = $where['query']; $select = $subquery->getGrammar()->compileSelectQuery($subquery); + if ($where['operator'] === '<=>') { + $where['value'] = new QueryExpression("({$select})"); + + return $this->whereNullSafeEquals($query, $where); + } + return $this->wrap($where['column']) . ' ' . $where['operator'] . " ({$select})"; } @@ -558,6 +565,13 @@ protected function whereJsonBoolean(Builder $query, array $where): string $this->parameter($where['value']) ); + if ($where['operator'] === '<=>') { + $where['column'] = new QueryExpression($column); + $where['value'] = new QueryExpression($value); + + return $this->whereNullSafeEquals($query, $where); + } + return $column . ' ' . $where['operator'] . ' ' . $value; } diff --git a/src/database/src/Query/Grammars/SQLiteGrammar.php b/src/database/src/Query/Grammars/SQLiteGrammar.php index f42acf39f..af075f253 100755 --- a/src/database/src/Query/Grammars/SQLiteGrammar.php +++ b/src/database/src/Query/Grammars/SQLiteGrammar.php @@ -41,29 +41,6 @@ protected function wrapUnion(string $sql): string return 'select * from (' . $sql . ')'; } - /** - * Compile a basic where clause. - */ - protected function whereBasic(Builder $query, array $where): string - { - if ($where['operator'] === '<=>') { - $column = $this->wrap($where['column']); - $value = $this->parameter($where['value']); - - return "{$column} IS {$value}"; - } - - return parent::whereBasic($query, $where); - } - - /** - * Compile a "where null safe equals" clause. - */ - protected function whereNullSafeEquals(Builder $query, array $where): string - { - return $this->wrap($where['column']) . ' is ' . $this->parameter($where['value']); - } - /** * Compile a "where like" clause. */ @@ -89,6 +66,23 @@ public function prepareWhereLikeBinding(string $value, bool $caseSensitive): str ); } + /** + * Compile a "where null safe equals" clause. + */ + protected function whereNullSafeEquals(Builder $query, array $where): string + { + $value = $this->parameter($where['value']); + + // SQLite's IS TRUE and IS FALSE test truthiness instead of equality. + $value = match ($value) { + 'true' => '1', + 'false' => '0', + default => $value, + }; + + return $this->wrap($where['column']) . ' is ' . $value; + } + /** * Compile a "where date" clause. */ diff --git a/src/docs/concurrency.md b/src/docs/concurrency.md index c503c5b0e..c301c6230 100644 --- a/src/docs/concurrency.md +++ b/src/docs/concurrency.md @@ -110,6 +110,8 @@ The `coroutine` driver is the correct choice for almost all application code. It The `process` driver should be reserved for tasks that need OS-level process isolation. This includes work that must run outside the current framework lifecycle, work that should not share long-lived worker memory, or work that calls extensions Swoole cannot hook. Since process tasks are serialized before being sent to the child process, the closure and any captured values must be serializable. +When using the `process` driver, each task receives the visible and hidden [context](/docs/{{version}}/context) captured when the process is started. This applies to both `run` and `defer`. + The `sync` driver executes tasks one after another. This is useful in tests and simple scripts where you want deterministic execution without concurrency. diff --git a/src/docs/context.md b/src/docs/context.md index dd01e72ec..6857a7f20 100644 --- a/src/docs/context.md +++ b/src/docs/context.md @@ -109,7 +109,7 @@ The resulting log entry would contain the information that was added to the cont Processing podcast. {"podcast_id":95} {"url":"https://example.com/login","trace_id":"e04e1a11-e75c-4db3-b5b5-cfef4ef56697"} ``` -Although we have focused on the built-in logging related features of Hypervel's context, the following documentation will illustrate how context allows you to share information across the HTTP request / queued job boundary and even how to add [hidden context data](#hidden-context) that is not written with log entries. +In addition to its logging features, context allows you to share information with queued jobs, [concurrent processes](/docs/{{version}}/concurrency), and [scheduled commands](/docs/{{version}}/scheduling). You may also add [hidden context data](#hidden-context) that is not written with log entries. ## Capturing Context diff --git a/src/docs/eloquent-factories.md b/src/docs/eloquent-factories.md index 611d6030c..d5cd8e8ce 100644 --- a/src/docs/eloquent-factories.md +++ b/src/docs/eloquent-factories.md @@ -302,6 +302,14 @@ $user = User::factory()->create([ ]); ``` +To insert multiple models in a single query, you may use the `insert` method: + +```php +User::factory()->count(100)->insert(); +``` + +The `insert` method applies attribute casts, generates unique IDs, and adds timestamps. It does not return model instances, dispatch model events, run `afterCreating` callbacks, or create child relationships. + ### Sequences diff --git a/src/docs/eloquent-mutators.md b/src/docs/eloquent-mutators.md index ce49dc2ac..d49afced7 100644 --- a/src/docs/eloquent-mutators.md +++ b/src/docs/eloquent-mutators.md @@ -626,7 +626,7 @@ return $user->uuid; // "6e8cdeed-2f32-40bd-b109-1e4405be2140" ``` -Normal model saves apply the cast automatically. Direct query builder operations, including `where`, bulk `update`, and `upsert` calls, do not apply model casts. When those operations receive already-encoded binary strings, wrap them in a [binary parameter](/docs/{{version}}/database#binding-binary-values). +Model saves, `fillAndInsert`, `fillAndInsertOrIgnore`, `fillAndInsertGetId`, and [factory inserts](/docs/{{version}}/eloquent-factories#persisting-models) apply the cast automatically. Direct query builder operations, including `where`, bulk `update`, and `upsert` calls, do not apply model casts. When those operations receive already-encoded binary strings, wrap them in a [binary parameter](/docs/{{version}}/database#binding-binary-values). A non-incrementing primary key may also use `AsBinary` for model-instance writes and reloads. Lookups from already-encoded key bytes must use a binary parameter with `find`, `whereKey`, or `whereKeyNot`. Canonical UUID / ULID text passed directly to `find`, route or queue model binding, and relationship queries is not encoded automatically. diff --git a/src/docs/packages.md b/src/docs/packages.md index 0c576400b..a79959c95 100644 --- a/src/docs/packages.md +++ b/src/docs/packages.md @@ -6,6 +6,7 @@ - [Generating Facade Docblocks](#generating-facade-docblocks) - [Package Discovery](#package-discovery) - [Test State Cleanup](#test-state-cleanup) +- [Package Uninstallation](#package-uninstallation) - [Inspecting Installed Packages](#inspecting-installed-packages) - [Service Providers](#service-providers) - [Provider Priority](#provider-priority) @@ -179,6 +180,38 @@ Use your Composer package name as the callback name. Registrar classes are disco Test-state callbacks run after the test application has been destroyed. Use them for process-local state that can be reset directly, not cleanup that resolves container services. Use the appropriate testing trait to clean up external resources. + +## Package Uninstallation + +Packages may listen for an event before Composer removes them. To enable these events, add the following script to your application's `composer.json` file: + +```json +"scripts": { + "pre-package-uninstall": [ + "Hypervel\\Foundation\\ComposerScripts::prePackageUninstall" + ] +} +``` + +Register a listener in your package's service provider using the event name `composer_package.vendor/package:pre_uninstall`, replacing `vendor/package` with your Composer package name. For example, you may remove a manually registered provider from the application's `bootstrap/providers.php` file: + +```php +use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Support\ServiceProvider; + +/** + * Bootstrap any package services. + */ +public function boot(Dispatcher $events): void +{ + $events->listen('composer_package.vendor/courier:pre_uninstall', static function (): void { + ServiceProvider::removeProviderFromBootstrapFile(CourierServiceProvider::class); + }); +} +``` + +These events do not run when Composer's `--no-dev` option is used. If an event cannot be dispatched or a listener fails, Hypervel displays a warning and allows the package removal to continue. Run Composer with `--verbose` to see the exception message. + ## Inspecting Installed Packages diff --git a/src/docs/queues.md b/src/docs/queues.md index 25187b5fc..bba6ea549 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -204,6 +204,8 @@ If your Redis queue connection uses a [Redis Cluster](https://redis.io/docs/late You may still include your own hash tag, such as `{mail}:high`, when you need several queue names to share a specific Redis Cluster slot. Hypervel leaves explicit hash tags unchanged. +On standalone Redis, or when supplying an explicit Cluster hash tag, avoid queue names ending in `:delayed`, `:reserved`, or `:notify`, which overlap with the queue driver's storage keys. Automatically tagged Cluster queue names do not have this restriction. + ##### Blocking @@ -2607,6 +2609,12 @@ You may include the `-v` flag when invoking the `queue:work` command if you woul php artisan queue:work -v ``` +The command also reports worker stop reasons, such as reaching the memory limit. To output job updates and stop information as JSON, use the `--json` option: + +```shell +php artisan queue:work --json +``` + Remember, queue workers are long-lived processes and store the booted application state in memory. As a result, they will not notice changes in your code base after they have been started. So, during your deployment process, be sure to [restart your queue workers](#queue-workers-and-deployment). In addition, remember that any static state created or modified by your application will not be automatically reset between jobs. Request or job specific state should be stored in `CoroutineContext` instead of static properties or mutable singletons. Alternatively, you may run the `queue:listen` command. When using the `queue:listen` command, you don't have to manually restart the worker when you want to reload your updated code or reset the application state; however, this command is significantly less efficient than the `queue:work` command: @@ -3298,6 +3306,17 @@ $reserved = $queue->allReservedJobs(); These methods load every matching job into memory. Avoid using them against very large backlogs in latency-sensitive code. +To count jobs across every queue on a database, Redis, or Beanstalkd connection, use the `totalSize`, `totalPendingSize`, `totalDelayedSize`, and `totalReservedSize` methods: + +```php +$total = $queue->totalSize(); +$pending = $queue->totalPendingSize(); +$delayed = $queue->totalDelayedSize(); +$reserved = $queue->totalReservedSize(); +``` + +The total includes pending, delayed, and reserved jobs. Beanstalkd's buried jobs are excluded. + ## Monitoring Your Queues diff --git a/src/docs/redis.md b/src/docs/redis.md index dc11900b6..06961e831 100644 --- a/src/docs/redis.md +++ b/src/docs/redis.md @@ -120,6 +120,9 @@ A non-zero `read_timeout` is applied both when the Redis socket is opened and as The `context` option accepts stream options directly or nested under an `ssl` or `stream` key. If you need to configure PhpRedis options such as `prefix`, `scan`, `serializer`, `compression`, `compression_level`, `tcp_keepalive`, or `pack_ignore_numbers`, add them to the `options` array. The `pack_ignore_numbers` option requires PhpRedis 6.2 or later and applies to standalone and Cluster connections. Connection options override shared options, while a non-null top-level connection `prefix` takes final precedence. +> [!WARNING] +> PhpRedis 6.3.0 crashes when `tcp_keepalive` is applied to a Redis Cluster connection; leave this option unset for Cluster connections when using that version. + #### Retry and Backoff Configuration @@ -608,7 +611,7 @@ PhpRedis does not support pipelining on Redis Cluster connections. Pipelining re ### Advanced Helpers -If you need to stream keys with Redis' `SCAN` command, use `safeScan` while holding a pooled connection. The `safeScan` method handles PhpRedis' `OPT_PREFIX` behavior by adding the prefix to the scan pattern and removing it from returned keys: +If you need to stream keys with Redis' `SCAN` command, use `safeScan` while holding a pooled connection. Pass a logical key pattern without adding the connection prefix, just as you would pass a key to `get`. The `safeScan` method adds the connection prefix, including when PhpRedis' `SCAN_PREFIX` option is enabled, and removes it from returned keys: ```php use Hypervel\Redis\RedisConnection; @@ -619,6 +622,8 @@ $keys = Redis::withConnection(function (RedisConnection $connection): array { }, transform: false); ``` +The `SCAN_PREFIX` option also prefixes hash-field and set-member patterns. To scan these without prefixing the pattern, wrap the scan calls in `$connection->withoutScanPrefix($callback)` inside `withConnection`. Key prefixing is unchanged, and scan options are restored when the callback finishes. + The `evalWithShaCache` method executes a Lua script using `evalSha` and automatically falls back to `eval` when Redis has not cached the script yet: ```php diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md index ec1718e21..72981e67a 100644 --- a/src/docs/scheduling.md +++ b/src/docs/scheduling.md @@ -145,6 +145,8 @@ use Hypervel\Support\Facades\Schedule; Schedule::exec('node /path/to/script.js')->daily(); ``` +If the shell command launches a Hypervel Artisan command, the child command receives the task's visible and hidden [context](/docs/{{version}}/context). + ### Schedule Frequency Options diff --git a/src/docs/strings.md b/src/docs/strings.md index 618ca9735..70dabfcf2 100644 --- a/src/docs/strings.md +++ b/src/docs/strings.md @@ -1453,6 +1453,12 @@ To instruct the `random` method to return to generating random strings normally, Str::createRandomStringsNormally(); ``` +You may reset the random string, UUID, and ULID factories together using the `resetFactoryState` method: + +```php +Str::resetFactoryState(); +``` + #### `Str::remove()` {.collection-method} @@ -1943,6 +1949,14 @@ $string = Str::ucwords('hypervel framework'); // Hypervel Framework ``` +You may pass custom word separators as the second argument: + +```php +$string = Str::ucwords('hypervel-framework', '-'); + +// Hypervel-Framework +``` + #### `Str::upper()` {.collection-method} @@ -2110,7 +2124,7 @@ The `Str::wordWrap` method wraps a string to a given number of characters: ```php use Hypervel\Support\Str; -$text = "The quick brown fox jumped over the lazy dog." +$text = "The quick brown fox jumped over the lazy dog."; Str::wordWrap($text, characters: 20, break: "
\n"); @@ -4087,6 +4101,14 @@ $string = Str::of('hypervel framework')->ucwords(); // Hypervel Framework ``` +You may pass custom word separators to the method: + +```php +$string = Str::of('hypervel-framework')->ucwords('-'); + +// Hypervel-Framework +``` + #### `unwrap` {.collection-method} diff --git a/src/docs/validation.md b/src/docs/validation.md index 34f293fa9..9addbe387 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -3156,7 +3156,7 @@ Validator::validate($input, [ #### Validating Image Dimensions -You may also validate the dimensions of an image. For example, to validate that an uploaded image is at least 1000 pixels wide and 500 pixels tall, you may use the `dimensions` rule: +You may also validate the dimensions of an image. For example, to validate that an uploaded image is at most 1000 pixels wide and 500 pixels tall, you may use the `dimensions` rule: ```php use Hypervel\Validation\Rule; diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index 5b4e89098..18a8119bf 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -659,7 +659,11 @@ public function environmentFilePath(): string */ public function configurationIsCached(): bool { - return is_file($this->getCachedConfigPath()); + if ($this->bound('config_loaded_from_cache')) { + return (bool) $this->make('config_loaded_from_cache'); + } + + return $this->instance('config_loaded_from_cache', is_file($this->getCachedConfigPath())); } /** @@ -687,7 +691,7 @@ public function routesAreCached(): bool return (bool) $this->make('routes.cached'); } - return is_file($this->getCachedRoutesPath()); + return $this->instance('routes.cached', is_file($this->getCachedRoutesPath())); } /** diff --git a/src/foundation/src/ComposerScripts.php b/src/foundation/src/ComposerScripts.php index 6a58ba8f0..56d42ce0b 100644 --- a/src/foundation/src/ComposerScripts.php +++ b/src/foundation/src/ComposerScripts.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation; +use Composer\DependencyResolver\Operation\UninstallOperation; use Composer\Installer\PackageEvent; use Composer\IO\IOInterface; use Composer\Script\Event; @@ -70,16 +71,18 @@ public static function prePackageUninstall(PackageEvent $event): void // Ensure we can encrypt our serializable closure... (new EncryptionServiceProvider($hypervel))->register(); - $name = $event->getOperation()->getPackage()->getName(); + /** @var UninstallOperation $operation */ + $operation = $event->getOperation(); + $name = $operation->getPackage()->getName(); $eventName = "composer_package.{$name}:pre_uninstall"; $hypervel->make(ProcessDriver::class)->run( - static fn () => app()['events']->dispatch($eventName) + static fn (): mixed => app()->make('events')->dispatch($eventName) ); } catch (Throwable $e) { // Ignore any errors to allow the package removal to complete... $event->getIO()->write('There was an error dispatching or handling the [' . ($eventName ?? 'unknown') . '] event. Continuing with package removal...'); - $event->getIO()->writeError('Exception message: ' . $e->getMessage(), verbosity: IOInterface::VERBOSE); // @phpstan-ignore class.notFound (Composer exists if this is running) + $event->getIO()->writeError('Exception message: ' . $e->getMessage(), verbosity: IOInterface::VERBOSE); } } diff --git a/src/foundation/src/Configuration/Exceptions.php b/src/foundation/src/Configuration/Exceptions.php index 71fffceec..fad89028c 100644 --- a/src/foundation/src/Configuration/Exceptions.php +++ b/src/foundation/src/Configuration/Exceptions.php @@ -201,6 +201,9 @@ public function shouldRenderJsonWhen(callable $callback): static /** * Indicate that the given exception class should not be ignored. * + * Boot-only. The exception lists persist on the shared handler and affect + * exception reporting for every subsequent request and job in the worker. + * * @param array>|class-string $class */ public function stopIgnoring(array|string $class): static diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index 3b8bddb1d..af0a06a4b 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -641,18 +641,21 @@ public function throttleUsing(callable $throttleUsing): static /** * Remove the given exception class from the list of exceptions that should be ignored. + * + * Boot-only. The exception lists persist on the shared handler and affect + * exception reporting for every subsequent request and job in the worker. */ public function stopIgnoring(array|string $exceptions): static { $exceptions = Arr::wrap($exceptions); $this->dontReport = (new Collection($this->dontReport)) - ->reject(fn ($ignored) => in_array($ignored, $exceptions)) + ->diff($exceptions) ->values() ->all(); $this->internalDontReport = (new Collection($this->internalDontReport)) - ->reject(fn ($ignored) => in_array($ignored, $exceptions)) + ->diff($exceptions) ->values() ->all(); diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php index 4db3a3afc..a1e2fcb57 100644 --- a/src/foundation/src/helpers.php +++ b/src/foundation/src/helpers.php @@ -177,7 +177,13 @@ function app_id(): string */ function app_path(string $path = ''): string { - return join_paths(base_path('app'), $path); + if (! Container::getInstance()->has(Application::class)) { + return defined('BASE_PATH') + ? join_paths(BASE_PATH, 'app', $path) + : throw new RuntimeException('BASE_PATH constant is not defined.'); + } + + return app()->path($path); } } diff --git a/src/log/src/Context/ContextServiceProvider.php b/src/log/src/Context/ContextServiceProvider.php index cffa5b3d5..5a8f8f662 100644 --- a/src/log/src/Context/ContextServiceProvider.php +++ b/src/log/src/Context/ContextServiceProvider.php @@ -5,6 +5,7 @@ namespace Hypervel\Log\Context; use Hypervel\Contracts\Log\ContextLogProcessor as ContextLogProcessorContract; +use Hypervel\Log\Context\Events\ContextDehydrating; use Hypervel\Queue\Events\JobProcessing; use Hypervel\Queue\Queue; use Hypervel\Support\Facades\Context; @@ -25,8 +26,10 @@ public function register(): void */ public function boot(): void { - Queue::createPayloadUsing(function (string $connection, ?string $queue, array $payload): array { - if (! Repository::hasInstance()) { + $events = $this->app->make('events'); + + Queue::createPayloadUsing(function (string $connection, ?string $queue, array $payload) use ($events): array { + if (! Repository::hasInstance() && ! $events->hasListeners(ContextDehydrating::class)) { return []; } @@ -40,7 +43,7 @@ public function boot(): void }); // IMPORTANT: Uses Laravel's payload key for cross-framework queue interoperability. - $this->app->make('events')->listen(JobProcessing::class, function (JobProcessing $event): void { + $events->listen(JobProcessing::class, function (JobProcessing $event): void { $context = $event->job->payload()['illuminate:log:context'] ?? null; if ($context !== null || Repository::hasInstance()) { diff --git a/src/log/src/Context/Repository.php b/src/log/src/Context/Repository.php index cd1708d4f..09793e04d 100644 --- a/src/log/src/Context/Repository.php +++ b/src/log/src/Context/Repository.php @@ -546,7 +546,7 @@ public function replicate(): static // --- Transport hooks --- /** - * Register a callback to execute before context is dehydrated for a job. + * Register a callback to execute before context is dehydrated. * * Boot-only. Registers a listener on the worker-global event dispatcher; * per-request registration persists and affects subsequent requests. @@ -562,7 +562,7 @@ public function dehydrating(callable $callback): static } /** - * Register a callback to execute after context has been hydrated from a job. + * Register a callback to execute after context has been hydrated. * * Boot-only. Registers a listener on the worker-global event dispatcher; * per-request registration persists and affects subsequent requests. @@ -603,7 +603,7 @@ public static function flushState(): void static::flushMacros(); } - // --- Internal transport (called by queue infrastructure) --- + // --- Internal transport --- /** * Dehydrate the context into a serializable payload. diff --git a/src/queue/README.md b/src/queue/README.md index 552ba2baa..207046744 100644 --- a/src/queue/README.md +++ b/src/queue/README.md @@ -10,6 +10,7 @@ Documentation: https://hypervel.org/docs/queues - The protected `enqueueUsing()` callback receives the queue that owns the operation as its first argument. This lets deferred work borrow a fresh pooled queue connection after a database transaction commits instead of retaining a connection for the lifetime of the transaction. - Positive per-message delays on SQS FIFO queues throw `LogicException`. Laravel silently omits the delay and sends the job immediately. - `RateLimited::releaseAfter(0)` requests an immediate retry. Laravel treats zero as absent and uses the limiter's computed retry delay. -- Redis bulk dispatch uses one same-slot Lua call, keeping Cluster dispatch to one round trip and giving every Redis topology the same exact completion count. Laravel uses a nested transaction and pipeline. +- `RedisQueue::scanQueueKeys()` is intentionally omitted. Queue discovery streams keys on a held connection instead of materializing a physical-key array; override `allQueueNames()` to customize discovery. +- Redis bulk dispatch uses `enqueueBatch()` and `LuaScripts::bulk()` for both standalone Redis and Cluster, storing immediate and delayed jobs together with an exact completion count. Laravel's `RedisQueue::bulkOnClusterConnection()` and `LuaScripts::bulkPush()` helpers are intentionally omitted. Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Queue diff --git a/src/queue/src/BeanstalkdQueue.php b/src/queue/src/BeanstalkdQueue.php index 54d0ca5bb..bdde992b5 100644 --- a/src/queue/src/BeanstalkdQueue.php +++ b/src/queue/src/BeanstalkdQueue.php @@ -73,6 +73,42 @@ public function reservedSize(?string $queue = null): int return $this->pheanstalk->statsTube(new TubeName($this->getQueue($queue)))->currentJobsReserved; } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + $stats = $this->pheanstalk->stats(); + + return $stats->currentJobsReady + + $stats->currentJobsDelayed + + $stats->currentJobsReserved; + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return $this->pheanstalk->stats()->currentJobsReady; + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return $this->pheanstalk->stats()->currentJobsDelayed; + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return $this->pheanstalk->stats()->currentJobsReserved; + } + /** * Get the pending jobs for the given queue. */ diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php index 034b42f89..2754b5835 100644 --- a/src/queue/src/Console/WorkCommand.php +++ b/src/queue/src/Console/WorkCommand.php @@ -14,6 +14,7 @@ use Hypervel\Queue\Events\JobProcessed; use Hypervel\Queue\Events\JobProcessing; use Hypervel\Queue\Events\JobReleasedAfterException; +use Hypervel\Queue\Events\WorkerStopping; use Hypervel\Queue\Failed\FailedJobProviderInterface; use Hypervel\Queue\InvalidPayloadException; use Hypervel\Queue\Worker; @@ -197,6 +198,15 @@ protected function listenForEvents(): void $command?->writeOutput($event->job, 'failed', $event->exception); }); + $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; + + if ($command instanceof self) { + $command->writeStopReason($event); + } + }); + static::$hasRegisteredListeners = true; } @@ -214,6 +224,36 @@ protected function writeOutput(Job $job, string $status, ?Throwable $exception = : $this->writeOutputForCli($job, $status); } + /** + * Write the status output for a queue worker that is stopping. + */ + protected function writeStopReason(WorkerStopping $event): void + { + if ($this->output->isQuiet() || $this->output->isSilent() || is_null($event->reason)) { + return; + } + + if ($this->outputUsingJson()) { + $this->output->writeln(json_encode([ + 'level' => $event->status === 0 ? 'info' : 'warning', + 'status' => 'stopped', + 'reason' => $event->reason->value, + 'exit_code' => $event->status, + 'jobs_processed' => $event->jobsProcessed, + 'memory' => is_null($event->memoryUsage) ? null : round($event->memoryUsage, 1), + 'timestamp' => $this->now()->format('Y-m-d\TH:i:s.uP'), + ])); + + return; + } + + $this->output->writeln(sprintf( + ' %s Worker STOPPED %s', + $this->now()->format('Y-m-d H:i:s'), + $event->reason->description(), + )); + } + /** * Format the status output for the queue worker. */ diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index 77ceef0c7..946c7bb6f 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -93,6 +93,46 @@ public function reservedSize(?string $queue = null): int ->count(); } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + return $this->getDatabase()->table($this->table)->count(); + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return $this->getDatabase()->table($this->table) + ->whereNull('reserved_at') + ->where('available_at', '<=', $this->currentTime()) + ->count(); + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return $this->getDatabase()->table($this->table) + ->whereNull('reserved_at') + ->where('available_at', '>', $this->currentTime()) + ->count(); + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return $this->getDatabase()->table($this->table) + ->whereNotNull('reserved_at') + ->count(); + } + /** * Get the pending jobs for the given queue. * diff --git a/src/queue/src/FailoverQueue.php b/src/queue/src/FailoverQueue.php index 378e7d900..3f99aa415 100644 --- a/src/queue/src/FailoverQueue.php +++ b/src/queue/src/FailoverQueue.php @@ -77,6 +77,39 @@ public function reservedSize(?string $queue = null): int return $this->manager->connection($this->connections[0])->reservedSize($queue); } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + // Aggregate counts are an optional driver capability outside the core Queue contract. + return $this->manager->connection($this->connections[0])->totalSize(); // @phpstan-ignore method.notFound + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return $this->manager->connection($this->connections[0])->totalPendingSize(); // @phpstan-ignore method.notFound + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return $this->manager->connection($this->connections[0])->totalDelayedSize(); // @phpstan-ignore method.notFound + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return $this->manager->connection($this->connections[0])->totalReservedSize(); // @phpstan-ignore method.notFound + } + /** * Get the pending jobs for the given queue. */ diff --git a/src/queue/src/LuaScripts.php b/src/queue/src/LuaScripts.php index 016aed7f8..95a65a79e 100644 --- a/src/queue/src/LuaScripts.php +++ b/src/queue/src/LuaScripts.php @@ -37,6 +37,9 @@ public static function push(): string LUA; } + // REMOVED: Laravel's bulkPush(). bulk() stores immediate and delayed jobs + // together and returns the count required for dispatch confirmation. + /** * Get the Lua script for pushing delayed jobs onto the queue. * diff --git a/src/queue/src/NullQueue.php b/src/queue/src/NullQueue.php index deb105dfd..7bf849f45 100644 --- a/src/queue/src/NullQueue.php +++ b/src/queue/src/NullQueue.php @@ -44,6 +44,38 @@ public function reservedSize(?string $queue = null): int return 0; } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + return 0; + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return 0; + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return 0; + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return 0; + } + /** * Get the pending jobs for the given queue. */ diff --git a/src/queue/src/QueuePoolProxy.php b/src/queue/src/QueuePoolProxy.php index 364fc0e4b..14c5a391d 100644 --- a/src/queue/src/QueuePoolProxy.php +++ b/src/queue/src/QueuePoolProxy.php @@ -84,6 +84,38 @@ public function reservedSize(?string $queue = null): int return $this->invoke(__FUNCTION__, func_get_args()); } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + return $this->invoke(__FUNCTION__, func_get_args()); + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return $this->invoke(__FUNCTION__, func_get_args()); + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return $this->invoke(__FUNCTION__, func_get_args()); + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return $this->invoke(__FUNCTION__, func_get_args()); + } + /** * Get the pending jobs for the given queue. */ diff --git a/src/queue/src/RedisQueue.php b/src/queue/src/RedisQueue.php index 4371e25db..5a0ed1414 100644 --- a/src/queue/src/RedisQueue.php +++ b/src/queue/src/RedisQueue.php @@ -101,6 +101,46 @@ public function reservedSize(?string $queue = null): int return $this->getConnection()->zcard($this->getQueueRedisKey($queue) . ':reserved'); } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + return $this->getConnection()->withPinnedConnection( + fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->size($name)), + ); + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return $this->getConnection()->withPinnedConnection( + fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->pendingSize($name)), + ); + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return $this->getConnection()->withPinnedConnection( + fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->delayedSize($name)), + ); + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return $this->getConnection()->withPinnedConnection( + fn (): int => $this->allQueueNames()->sum(fn (string $name): int => $this->reservedSize($name)), + ); + } + /** * Get the pending jobs for the given queue. * @@ -161,6 +201,55 @@ public function allReservedJobs(): Collection return $this->inspectAllQueues(':reserved'); } + /** + * Get the unique queue names. + * + * @return Collection + */ + protected function allQueueNames(): Collection + { + return $this->getConnection()->withConnection( + fn (RedisConnection $connection): Collection => $this->allQueueNamesUsing($connection), + transform: false, + ); + } + + // REMOVED: Laravel's scanQueueKeys(). allQueueNamesUsing() streams keys on + // a held connection instead of materializing a physical-key array. + + /** + * Get the unique queue names using an already-held raw connection. + * + * @return Collection + */ + protected function allQueueNamesUsing(RedisConnection $connection): Collection + { + $this->isCluster ??= $connection->isCluster(); + $names = []; + + foreach ($connection->safeScan('queues:*') as $key) { + $name = substr($key, strlen('queues:')); + + foreach ([':delayed', ':reserved', ':notify'] as $storageSuffix) { + if (str_ends_with($name, $storageSuffix)) { + $name = substr($name, 0, -strlen($storageSuffix)); + break; + } + } + + // Cluster hash tags are routing syntax, so discovery reports the + // canonical queue name. On standalone Redis, braces remain identity. + if ($this->isCluster && preg_match('/^\{([^{}]+)\}$/', $name, $matches) === 1) { + $name = $matches[1]; + } + + // Keep the string value because PHP converts numeric array keys to integers. + $names[$name] = $name; + } + + return Collection::make(array_values($names)); + } + /** * Inspect jobs from one queue while holding one Redis connection. * @@ -188,32 +277,8 @@ function (RedisConnection $connection) use ($name, $suffix): Collection { protected function inspectAllQueues(string $suffix = ''): Collection { return $this->getConnection()->withConnection( - function (RedisConnection $connection) use ($suffix): Collection { - $this->isCluster ??= $connection->isCluster(); - $names = []; - - foreach ($connection->safeScan('queues:*') as $key) { - $name = substr($key, strlen('queues:')); - - foreach ([':delayed', ':reserved', ':notify'] as $storageSuffix) { - if (str_ends_with($name, $storageSuffix)) { - $name = substr($name, 0, -strlen($storageSuffix)); - break; - } - } - - // Cluster hash tags are routing syntax, so discovery reports the - // canonical queue name. On standalone Redis, braces remain identity. - if ($this->isCluster && preg_match('/^\{([^{}]+)\}$/', $name, $matches) === 1) { - $name = $matches[1]; - } - - $names[$name] = true; - } - - return Collection::make(array_keys($names)) - ->flatMap(fn (string $name): Collection => $this->inspectJobsUsing($connection, $name, $suffix)); - }, + fn (RedisConnection $connection): Collection => $this->allQueueNamesUsing($connection) + ->flatMap(fn (string $name): Collection => $this->inspectJobsUsing($connection, $name, $suffix)), transform: false, ); } @@ -292,6 +357,9 @@ static function (Queue $owner) use ($preparedJobs, $queue): void { return null; } + // REMOVED: Laravel's bulkOnClusterConnection(). enqueueBatch() owns Lua + // bulk dispatch for both standalone Redis and Cluster. + /** * Prepare the payload and delay for each of the given jobs. * diff --git a/src/queue/src/SqsQueue.php b/src/queue/src/SqsQueue.php index 7fd5af91d..a7c4392c9 100644 --- a/src/queue/src/SqsQueue.php +++ b/src/queue/src/SqsQueue.php @@ -129,6 +129,38 @@ public function reservedSize(?string $queue = null): int return (int) ($response['Attributes']['ApproximateNumberOfMessagesNotVisible'] ?? 0); } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + return 0; + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return 0; + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return 0; + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return 0; + } + /** * Get the pending jobs for the given queue. */ diff --git a/src/queue/src/SyncQueue.php b/src/queue/src/SyncQueue.php index 7edbca6b6..a216aeefb 100644 --- a/src/queue/src/SyncQueue.php +++ b/src/queue/src/SyncQueue.php @@ -66,6 +66,38 @@ public function reservedSize(?string $queue = null): int return 0; } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + return 0; + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return 0; + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return 0; + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return 0; + } + /** * Get the pending jobs for the given queue. */ diff --git a/src/queue/src/WorkerStopReason.php b/src/queue/src/WorkerStopReason.php index 41e2ccc97..e33f57ced 100644 --- a/src/queue/src/WorkerStopReason.php +++ b/src/queue/src/WorkerStopReason.php @@ -15,4 +15,22 @@ enum WorkerStopReason: string case QueueEmptyFor = 'empty_for'; case ReceivedRestartSignal = 'restart_signal'; case TimedOut = 'timed_out'; + + /** + * Get the description of the worker stop reason. + */ + public function description(): string + { + return match ($this) { + self::Interrupted => 'Interrupted', + self::LostConnection => 'Lost connection', + self::MaxJobsExceeded => 'Maximum jobs exceeded', + self::MaxMemoryExceeded => 'Memory limit exceeded', + self::MaxTimeExceeded => 'Maximum run time exceeded', + self::QueueEmpty => 'Queue empty', + self::QueueEmptyFor => 'Queue empty for the configured duration', + self::ReceivedRestartSignal => 'Received restart signal', + self::TimedOut => 'Job timed out', + }; + } } diff --git a/src/redis/src/Operations/SafeScan.php b/src/redis/src/Operations/SafeScan.php index fdab04131..9f35b7089 100644 --- a/src/redis/src/Operations/SafeScan.php +++ b/src/redis/src/Operations/SafeScan.php @@ -9,6 +9,7 @@ use Hypervel\Redis\PhpRedis; use Hypervel\Redis\PhpRedisClusterConnection; use Hypervel\Redis\RedisConnection; +use Redis; /** * Safely scan the Redis keyspace for keys matching a pattern. @@ -21,8 +22,8 @@ * phpredis has an OPT_PREFIX option that automatically prepends a prefix to keys * for most commands (GET, SET, DEL, etc.). However, this creates complexity: * - * 1. **SCAN does NOT auto-prefix the pattern** - You must manually include OPT_PREFIX - * in your SCAN pattern to match keys that were stored with auto-prefixing. + * 1. **SCAN prefixing is configurable** - SCAN_PREFIX adds OPT_PREFIX to the pattern. + * Without it, the pattern must include OPT_PREFIX to match auto-prefixed keys. * * 2. **SCAN returns full keys** - Keys returned include the OPT_PREFIX as stored in Redis. * @@ -31,6 +32,8 @@ * * ## Example of the Bug This Class Prevents * + * With SCAN_PREFIX disabled: + * * ``` * OPT_PREFIX = "myapp:" * Stored key in Redis = "myapp:cache:user:1" @@ -92,8 +95,8 @@ public function __construct( /** * Execute the scan operation. * - * @param string $pattern The pattern to match (e.g., "cache:users:*"). - * Should NOT include OPT_PREFIX - it will be added automatically. + * @param string $pattern The logical key pattern (e.g., "cache:users:*"). OPT_PREFIX is added automatically; + * the pattern is preserved even when it starts with the same bytes as OPT_PREFIX. * @param int $count The COUNT hint for SCAN (not a limit, just a hint to Redis) * @return Generator yields keys with OPT_PREFIX stripped, safe for use with * other phpredis commands that auto-add the prefix @@ -102,10 +105,11 @@ public function execute(string $pattern, int $count = 1000): Generator { $prefixLen = strlen($this->optPrefix); - // SCAN does not automatically apply OPT_PREFIX to the pattern, - // so we must prepend it manually to match keys stored with auto-prefixing. + // Patterns are logical keys, even when they start with OPT_PREFIX. + // Prepend the connection prefix only when phpredis will not add it. $scanPattern = $pattern; - if ($prefixLen > 0 && ! str_starts_with($pattern, $this->optPrefix)) { + if ($prefixLen > 0 + && ($this->connection->getOption(Redis::OPT_SCAN) & Redis::SCAN_PREFIX) === 0) { $scanPattern = $this->optPrefix . $pattern; } diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index cb4ed75fc..3f9569568 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -1651,6 +1651,33 @@ public function compressed(): bool return $this->connection->getOption(Redis::OPT_COMPRESSION) !== Redis::COMPRESSION_NONE; } + /** + * Execute the given callback without prefixing scan patterns. + * + * Key prefixing remains unchanged. Hold this connection until the callback + * finishes so its scan options are restored before returning it to the pool. + * + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn + */ + public function withoutScanPrefix(callable $callback): mixed + { + if (($this->connection->getOption(Redis::OPT_SCAN) & Redis::SCAN_PREFIX) === 0) { + return $callback(); + } + + $this->connection->setOption(Redis::OPT_SCAN, Redis::SCAN_NOPREFIX); + + try { + return $callback(); + } finally { + // OPT_SCAN setters toggle individual flags; passing the saved bitmask can disable prefixing. + $this->connection->setOption(Redis::OPT_SCAN, Redis::SCAN_PREFIX); + } + } + /** * Execute the given callback without serialization or compression. * @@ -1799,7 +1826,7 @@ protected function normalizeNullReplies(mixed $result): mixed * Safely scan the Redis keyspace for keys matching a pattern. * * This method handles the phpredis OPT_PREFIX complexity correctly: - * - Automatically prepends OPT_PREFIX to the scan pattern + * - Applies OPT_PREFIX to the scan pattern exactly once, respecting SCAN_PREFIX * - Strips OPT_PREFIX from returned keys so they work with other commands * * The connection must be held with transform disabled so SCAN retains its diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php index a7b5be86a..10ab18ca2 100644 --- a/src/redis/src/RedisProxy.php +++ b/src/redis/src/RedisProxy.php @@ -80,6 +80,7 @@ class RedisProxy implements ConnectionContract 'safescan', 'setoption', 'shouldtransform', + 'withoutscanprefix', ]; /** diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index 7b1c9e0e3..3bcfd1b4f 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -96,6 +96,10 @@ * @method static \Hypervel\Support\Collection reservedJobs(\UnitEnum|string|null $queue = null) * @method static \Hypervel\Support\Testing\Fakes\QueueFake serializeAndRestore(bool $serializeAndRestore = true) * @method static bool shouldFakeJob(object|string $job) + * @method static int totalDelayedSize() + * @method static int totalPendingSize() + * @method static int totalReservedSize() + * @method static int totalSize() * * @see \Hypervel\Queue\QueueManager * @see \Hypervel\Queue\Queue diff --git a/src/support/src/Facades/Redis.php b/src/support/src/Facades/Redis.php index 9eda33cb1..7edc2d2a9 100644 --- a/src/support/src/Facades/Redis.php +++ b/src/support/src/Facades/Redis.php @@ -344,6 +344,7 @@ protected static function ignoredFacadeDocumenterMethods(): array 'setOption', 'shouldTransform', 'ssubscribe', + 'withoutScanPrefix', ]; } diff --git a/src/support/src/ServiceProvider.php b/src/support/src/ServiceProvider.php index 41aea538a..895abf61a 100644 --- a/src/support/src/ServiceProvider.php +++ b/src/support/src/ServiceProvider.php @@ -505,10 +505,10 @@ public static function removeProviderFromBootstrapFile(string|array $providersTo ->values() ->when( $strict, - static fn (Collection $providerCollection) => $providerCollection->reject(fn (string $p) => in_array($p, $providersToRemove, true)), - static fn (Collection $providerCollection) => $providerCollection->reject(fn (string $p) => Str::contains($p, $providersToRemove)) + static fn (Collection $providerCollection): Collection => $providerCollection->diff($providersToRemove), + static fn (Collection $providerCollection): Collection => $providerCollection->reject(fn (string $p): bool => Str::contains($p, $providersToRemove)) ) - ->map(fn ($p) => ' ' . $p . '::class,') + ->map(fn (string $p): string => ' ' . $p . '::class,') ->implode(PHP_EOL); $content = 'reservedJobs($queue)->count(); } + /** + * Get the number of jobs across every queue. + */ + public function totalSize(): int + { + return $this->totalPendingSize() + + $this->totalDelayedSize() + + $this->totalReservedSize(); + } + + /** + * Get the number of pending jobs across every queue. + */ + public function totalPendingSize(): int + { + return $this->allPendingJobs()->count(); + } + + /** + * Get the number of delayed jobs across every queue. + */ + public function totalDelayedSize(): int + { + return $this->allDelayedJobs()->count(); + } + + /** + * Get the number of reserved jobs across every queue. + */ + public function totalReservedSize(): int + { + return $this->allReservedJobs()->count(); + } + /** * Get the pending jobs for the given queue. */ @@ -685,9 +722,13 @@ public function pop(UnitEnum|string|null $queue = null): ?Job public function bulk(array $jobs, mixed $data = '', UnitEnum|string|null $queue = null): mixed { foreach ($jobs as $job) { - is_object($job) && isset($job->delay) - ? $this->later($job->delay, $job, $data, $queue) - : $this->push($job, $data, $queue); + $delay = is_object($job) ? $this->getAttributeValue($job, Delay::class, 'delay') : null; + + if ($delay !== null) { + $this->later($delay, $job, $data, $queue); + } else { + $this->push($job, $data, $queue); + } } return null; diff --git a/src/testbench/src/Foundation/Console/ServeCommand.php b/src/testbench/src/Foundation/Console/ServeCommand.php index 22f06a6a3..3686309f1 100644 --- a/src/testbench/src/Foundation/Console/ServeCommand.php +++ b/src/testbench/src/Foundation/Console/ServeCommand.php @@ -22,13 +22,13 @@ #[AsCommand(name: 'serve', description: 'Start Hypervel servers.')] class ServeCommand extends Command { + /** + * Execute the console command. + */ #[Override] protected function execute(InputInterface $input, OutputInterface $output): int { - if ( - class_exists(ComposerConfig::class, false) - && method_exists(ComposerConfig::class, 'disableProcessTimeout') // @phpstan-ignore function.impossibleType - ) { + if (class_exists(ComposerConfig::class, false)) { ComposerConfig::disableProcessTimeout(); } diff --git a/src/validation/src/Concerns/FormatsMessages.php b/src/validation/src/Concerns/FormatsMessages.php index 5be34cb2e..b1926bfaf 100644 --- a/src/validation/src/Concerns/FormatsMessages.php +++ b/src/validation/src/Concerns/FormatsMessages.php @@ -6,10 +6,10 @@ use Closure; use Hypervel\Contracts\Validation\Validator; -use Hypervel\Http\UploadedFile; use Hypervel\Support\Arr; use Hypervel\Support\Number; use Hypervel\Support\Str; +use Symfony\Component\HttpFoundation\File\File; trait FormatsMessages { @@ -212,13 +212,11 @@ protected function getSizeMessage(string $attribute, string $rule): string */ protected function getAttributeType(string $attribute): string { - // We assume that the attributes present in the file array are files so that - // means that if the attribute does not have a numeric rule and the files - // list doesn't have it we'll just consider it a string by elimination. + // Keep file-message selection consistent with sizeOf(). return match (true) { $this->hasRule($attribute, $this->numericRules) => 'numeric', $this->hasRule($attribute, ['Array', 'List']) => 'array', - $this->getValue($attribute) instanceof UploadedFile => 'file', + $this->getValue($attribute) instanceof File => 'file', default => 'string', }; } diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index 77a7b7fc4..e2b968e00 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -19,7 +19,6 @@ use Exception; use Hypervel\Container\Container; use Hypervel\Database\Eloquent\Model; -use Hypervel\Http\UploadedFile; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Exceptions\MathException; @@ -32,9 +31,9 @@ use Hypervel\Validation\ValidationData; use Hypervel\Validation\ValidationRuleParser; use InvalidArgumentException; -use SplFileInfo; use Stringable; use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile; use ValueError; use function with; @@ -2013,7 +2012,7 @@ public function validateRequired(string $attribute, mixed $value): bool if (is_countable($value) && count($value) < 1) { return false; } - if ($value instanceof SplFileInfo) { + if ($value instanceof File) { return (string) $value->getPath() !== ''; } @@ -2565,7 +2564,7 @@ protected function sizeOf(string $attribute, mixed $value, bool $numeric): float return count($value); } - if ($value instanceof SplFileInfo) { + if ($value instanceof File) { return $value->getSize() / 1024; } @@ -2581,7 +2580,7 @@ public function isValidFileInstance(mixed $value): bool return false; } - return $value instanceof SplFileInfo; + return $value instanceof File; } /** diff --git a/src/validation/src/PlanExecutor.php b/src/validation/src/PlanExecutor.php index 47d12e122..a635677f1 100644 --- a/src/validation/src/PlanExecutor.php +++ b/src/validation/src/PlanExecutor.php @@ -6,14 +6,14 @@ use Brick\Math\BigNumber; use Brick\Math\Exception\MathException as BrickMathException; -use Hypervel\Http\UploadedFile; use Hypervel\Support\Arr; use Hypervel\Support\Exceptions\MathException; use Hypervel\Support\Str; use Hypervel\Validation\Enums\CheckType; use InvalidArgumentException; -use SplFileInfo; use Stringable; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile; /** * Execute compiled AttributePlans against validation data. @@ -433,7 +433,7 @@ private function compareSizeBetween( */ private function usesNativeSizeComparison(mixed $value, bool $numeric): bool { - return ! ($numeric && is_numeric($value)) && ! $value instanceof SplFileInfo; + return ! ($numeric && is_numeric($value)) && ! $value instanceof File; } /** diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index a2ac37e98..85d168e38 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -14,7 +14,6 @@ use Hypervel\Contracts\Validation\Rule as RuleContract; use Hypervel\Contracts\Validation\Validator as ValidatorContract; use Hypervel\Contracts\Validation\ValidatorAwareRule; -use Hypervel\Http\UploadedFile; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Fluent; @@ -26,6 +25,7 @@ use LogicException; use RuntimeException; use stdClass; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Throwable; use ValueError; diff --git a/tests/Cache/Redis/RedisCacheTestCase.php b/tests/Cache/Redis/RedisCacheTestCase.php index 7d1bf5ea4..8bf52b5f3 100644 --- a/tests/Cache/Redis/RedisCacheTestCase.php +++ b/tests/Cache/Redis/RedisCacheTestCase.php @@ -89,6 +89,9 @@ protected function mockConnection(): m\MockInterface|PhpRedisConnection $connection = m::mock(PhpRedisConnection::class); $connection->shouldReceive('release')->zeroOrMoreTimes(); + $connection->shouldReceive('withoutScanPrefix') + ->andReturnUsing(fn (callable $callback): mixed => $callback()) + ->byDefault(); $connection->shouldReceive('serialized')->andReturn(false)->byDefault(); $connection->shouldReceive('client')->andReturn($client)->byDefault(); $connection->shouldReceive('getOption') @@ -104,7 +107,7 @@ protected function mockConnection(): m\MockInterface|PhpRedisConnection $connection->shouldReceive('pipeline')->andReturn($connection)->byDefault(); $connection->shouldReceive('exec')->andReturn([])->byDefault(); - // Store client reference for backward compatibility during migration + // Expose the client for expectation setup. $connection->_mockClient = $client; return $connection; @@ -134,6 +137,9 @@ protected function mockClusterConnection(): m\MockInterface|PhpRedisClusterConne $connection = m::mock(PhpRedisClusterConnection::class); $connection->shouldReceive('release')->zeroOrMoreTimes(); + $connection->shouldReceive('withoutScanPrefix') + ->andReturnUsing(fn (callable $callback): mixed => $callback()) + ->byDefault(); $connection->shouldReceive('serialized')->andReturn(false)->byDefault(); $connection->shouldReceive('client')->andReturn($client)->byDefault(); $connection->shouldReceive('getOption') @@ -145,7 +151,7 @@ protected function mockClusterConnection(): m\MockInterface|PhpRedisClusterConne ->andReturn('') ->byDefault(); - // Store client reference for backward compatibility during migration + // Expose the client for expectation setup. $connection->_mockClient = $client; return $connection; diff --git a/tests/Concurrency/ConcurrencyTest.php b/tests/Concurrency/ConcurrencyTest.php index 2d71f0f4a..eb805e850 100644 --- a/tests/Concurrency/ConcurrencyTest.php +++ b/tests/Concurrency/ConcurrencyTest.php @@ -14,9 +14,12 @@ use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; use Hypervel\Process\Factory as ProcessFactory; +use Hypervel\Process\PendingProcess; use Hypervel\Support\Defer\DeferredCallback; use Hypervel\Support\Defer\DeferredCallbackCollection; use Hypervel\Support\Facades\Concurrency as ConcurrencyFacade; +use Hypervel\Support\Facades\Context; +use Hypervel\Testbench\Attributes\UsesVendor; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Concurrency\Fixtures\ConcurrentProcessExceptionFixtures; use Hypervel\Tests\Context\Fixtures\ThrowingReplicableContext; @@ -508,6 +511,48 @@ public function testProcessDriverReportsFailedChildProcessesBeforeDecoding(): vo $driver->run(static fn () => null); } + #[UsesVendor] + public function testContextIsPropagatedToConcurrentProcesses(): void + { + Context::add('task', 'concurrency'); + Context::addHidden('token', 'secret'); + + [$task, $token] = ConcurrencyFacade::driver('process')->run([ + static fn (): mixed => Context::get('task'), + static fn (): mixed => Context::getHidden('token'), + ]); + + $this->assertSame('concurrency', $task); + $this->assertSame('secret', $token); + } + + public function testContextIsPropagatedToDeferredConcurrentProcesses(): void + { + $this->withoutDefer(); + + Context::add('task', 'concurrency'); + + $factory = $this->app->make(ProcessFactory::class); + $factory->fake(); + + (new ProcessDriver($factory))->defer([static fn (): string => 'result']); + + $factory->assertRan(static fn (PendingProcess $process): bool => ($process->environment['__HYPERVEL_CONTEXT'] ?? null) === base64_encode(serialize(Context::dehydrate()))); + } + + #[UsesVendor] + public function testBinaryContextIsPropagatedToConcurrentProcesses(): void + { + Context::add('task', 'concurrency'); + Context::addHidden('token', "binary-\xFF\x00\x8B"); + + [$context] = ConcurrencyFacade::driver('process')->run([ + static fn (): array => [Context::get('task'), Context::getHidden('token')], + ]); + + $this->assertSame(['concurrency', "binary-\xFF\x00\x8B"], $context); + } + public function testProcessDriverAppliesCustomTimeouts(): void { $factory = $this->app->make(ProcessFactory::class); diff --git a/tests/Console/ConsoleServiceProviderTest.php b/tests/Console/ConsoleServiceProviderTest.php index 121c3ceac..f351bd928 100644 --- a/tests/Console/ConsoleServiceProviderTest.php +++ b/tests/Console/ConsoleServiceProviderTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Console; +use Hypervel\Console\Command; use Hypervel\Console\Commands\ScheduleClearCacheCommand; use Hypervel\Console\Commands\ScheduleInterruptCommand; use Hypervel\Console\Commands\ScheduleListCommand; @@ -11,8 +12,20 @@ use Hypervel\Console\Commands\ScheduleResumeCommand; use Hypervel\Console\Commands\ScheduleRunCommand; use Hypervel\Console\Commands\ScheduleTestCommand; +use Hypervel\Console\ConsoleServiceProvider; +use Hypervel\Console\Events\BeforeHandle; use Hypervel\Contracts\Console\Kernel as KernelContract; +use Hypervel\Coroutine\Coroutine; +use Hypervel\Engine\Channel; +use Hypervel\Log\Context\Events\ContextHydrated; +use Hypervel\Log\Context\Repository; +use Hypervel\Support\Env; +use Hypervel\Support\Facades\Context; +use Hypervel\Testbench\Attributes\WithEnv; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Process\Process; class ConsoleServiceProviderTest extends TestCase { @@ -37,4 +50,122 @@ public function testScheduleCommandsAreRegistered() $this->assertInstanceOf($class, $artisan->find($name)); } } + + #[DataProvider('putenvAdapters')] + public function testProcessContextIsHydratedOnlyForTheInitialCommand(bool $putenvEnabled): void + { + $payload = [ + 'data' => ['task' => serialize('concurrency')], + 'hidden' => ['token' => serialize('secret')], + ]; + $restoreEnvironment = (new WithEnv('__HYPERVEL_CONTEXT', base64_encode(serialize($payload))))($this->app); + + try { + if (! $putenvEnabled) { + Env::disablePutenv(); + } + + $events = $this->app->make('events'); + (new ConsoleServiceProvider($this->app))->boot($events); + + $this->assertNull(Env::get('__HYPERVEL_CONTEXT')); + $this->assertFalse(getenv('__HYPERVEL_CONTEXT')); + $this->assertArrayNotHasKey('__HYPERVEL_CONTEXT', $_ENV); + $this->assertArrayNotHasKey('__HYPERVEL_CONTEXT', $_SERVER); + + $process = new Process([PHP_BINARY, '-r', 'echo json_encode(getenv("__HYPERVEL_CONTEXT"));']); + $process->mustRun(); + $this->assertSame('false', $process->getOutput()); + + $command = new BeforeHandle(new Command('context:test'), new ArrayInput([])); + $hydrations = 0; + $received = null; + + Context::hydrated(static function (Repository $context) use ($events, $command, &$hydrations, &$received): void { + ++$hydrations; + $received = [$context->get('task'), $context->getHidden('token')]; + $context->add('task', 'updated'); + + if ($hydrations === 1) { + $events->dispatch($command); + } + }); + + $events->dispatch($command); + + $this->assertSame(['concurrency', 'secret'], $received); + $this->assertSame(1, $hydrations); + $this->assertSame('updated', Context::get('task')); + + Context::add('task', 'later'); + $events->dispatch($command); + + $this->assertSame('later', Context::get('task')); + + $result = new Channel(1); + Coroutine::create(static function () use ($events, $command, $result): void { + $events->dispatch($command); + $result->push([Repository::hasInstance()]); + }); + + $this->assertSame([false], $result->pop(1)); + $this->assertSame(1, $hydrations); + } finally { + Env::enablePutenv(); + $restoreEnvironment(); + } + } + + /** + * Provide environment adapter configurations. + */ + public static function putenvAdapters(): array + { + return [ + 'putenv enabled' => [true], + 'putenv disabled' => [false], + ]; + } + + #[DataProvider('contextsWithoutProcessHydration')] + public function testProcessContextIsNotHydratedWithoutAConsolePayload(bool $runningInConsole, ?array $payload): void + { + $restoreEnvironment = (new WithEnv('__HYPERVEL_CONTEXT', base64_encode(serialize($payload))))($this->app); + $previousRunningInConsole = $this->app->runningInConsole(); + + try { + $this->app->setRunningInConsole($runningInConsole); + $events = $this->app->make('events'); + (new ConsoleServiceProvider($this->app))->boot($events); + + if ($runningInConsole) { + $this->assertNull(Env::get('__HYPERVEL_CONTEXT')); + $this->assertFalse(getenv('__HYPERVEL_CONTEXT')); + } + + $hydrations = 0; + $events->listen(ContextHydrated::class, static function () use (&$hydrations): void { + ++$hydrations; + }); + + $events->dispatch(new BeforeHandle(new Command('context:test'), new ArrayInput([]))); + + $this->assertSame(0, $hydrations); + $this->assertFalse(Repository::hasInstance()); + } finally { + $this->app->setRunningInConsole($previousRunningInConsole); + $restoreEnvironment(); + } + } + + /** + * Provide startup contexts that must not hydrate command context. + */ + public static function contextsWithoutProcessHydration(): array + { + return [ + 'empty context' => [true, null], + 'HTTP server' => [false, ['data' => ['task' => serialize('concurrency')]]], + ]; + } } diff --git a/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php b/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php index 8588adaee..81fa79648 100644 --- a/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php +++ b/tests/Console/Scheduling/ScheduleRunContextPropagationTest.php @@ -15,9 +15,11 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Coroutine\Concurrent; use Hypervel\Engine\Channel; +use Hypervel\Filesystem\Filesystem; use Hypervel\Log\Context\Repository as ContextRepository; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; +use Hypervel\Support\Stringable; use Hypervel\Testbench\TestCase; use Mockery as m; use ReflectionMethod; @@ -30,6 +32,8 @@ class ScheduleRunContextPropagationTest extends TestCase protected ExceptionHandler $handler; + protected ?string $outputFile = null; + protected function setUp(): void { parent::setUp(); @@ -41,6 +45,20 @@ protected function setUp(): void $this->handler = m::mock(ExceptionHandler::class); } + /** + * Remove captured process output. + */ + protected function tearDown(): void + { + try { + if ($this->outputFile !== null) { + (new Filesystem)->delete($this->outputFile); + } + } finally { + parent::tearDown(); + } + } + public function testBackgroundTaskReceivesParentContext() { ContextRepository::getInstance()->add('trace_id', 'parent-trace-123'); @@ -141,6 +159,28 @@ public function testForegroundTaskReceivesIndependentCopyOfParentLogContext(): v $this->assertNull(ContextRepository::getInstance()->get('child_only')); } + public function testSystemTaskReceivesContextThroughItsEnvironment(): void + { + ContextRepository::getInstance() + ->add('trace_id', 'parent-trace-123') + ->addHidden('token', 'secret'); + + $context = null; + $event = new Event(m::mock(EventMutex::class), 'printf %s "$__HYPERVEL_CONTEXT"', isSystem: true); + $event->thenWithOutput(static function (Stringable $output) use (&$context): void { + $context = (string) $output; + }); + $this->outputFile = $event->output; + + $event->run($this->app); + + $this->assertSame(0, $event->exitCode()); + $this->assertSame([ + 'data' => ['trace_id' => serialize('parent-trace-123')], + 'hidden' => ['token' => serialize('secret')], + ], unserialize(base64_decode($context, true), ['allowed_classes' => false])); + } + /** * Create a background Event mock that executes a callback inside run(). */ diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index 22de23af5..a08104d15 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -2567,6 +2567,8 @@ public function testWhereNotMorphedToClassUsesPostgresNullSafeEquality(): void $this->assertSame([ModelCloseRelatedStub::class], $builder->getBindings()); } + // REMOVED: SQL Server whereNotMorphedTo tests; SQL Server is not supported. + public function testWhereMorphedToAlias(): void { $model = new ModelParentStub; diff --git a/tests/Database/DatabaseEloquentFactoryTest.php b/tests/Database/DatabaseEloquentFactoryTest.php index 18b2991d5..6e3a793fd 100644 --- a/tests/Database/DatabaseEloquentFactoryTest.php +++ b/tests/Database/DatabaseEloquentFactoryTest.php @@ -5,13 +5,17 @@ namespace Hypervel\Tests\Database\DatabaseEloquentFactoryTest; use BadMethodCallException; +use Faker\Factory as FakerFactory; use Faker\Generator; use Hypervel\Container\Container; use Hypervel\Contracts\Foundation\Application; use Hypervel\Database\Capsule\Manager as DB; +use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder; use Hypervel\Database\Eloquent\Attributes\UseFactory; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Casts\Attribute; use Hypervel\Database\Eloquent\Collection; +use Hypervel\Database\Eloquent\Concerns\HasUuids; use Hypervel\Database\Eloquent\Factories\Attributes\UseModel; use Hypervel\Database\Eloquent\Factories\CrossJoinSequence; use Hypervel\Database\Eloquent\Factories\Factory; @@ -20,6 +24,7 @@ use Hypervel\Database\Eloquent\Model as Eloquent; use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Database\Eloquent\SoftDeletes; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Str; use Hypervel\Tests\Database\Fixtures\Models\Money\Price; @@ -31,9 +36,11 @@ class DatabaseEloquentFactoryTest extends TestCase { protected function setUp(): void { + parent::setUp(); + $container = Container::getInstance(); $container->singleton(Generator::class, function ($app, $parameters) { - return \Faker\Factory::create('en_US'); + return FakerFactory::create('en_US'); }); $container->instance(Application::class, $app = m::mock(Application::class)); $app->shouldReceive('getNamespace')->andReturn('App\\'); @@ -1155,7 +1162,7 @@ public function testFactoryModelMorphManyRelationshipHasPendingAttributesOverrid $this->assertEquals('other body', Comment::first()->body); } - public function testFactoryCanInsert() + public function testFactoryCanInsert(): void { (new PostFactory) ->count(5) @@ -1167,24 +1174,31 @@ public function testFactoryCanInsert() ->insert(); $this->assertCount(5, $posts = Post::query()->where('title', 'hello')->get()); $this->assertEquals(strtoupper($posts[0]->user->name), $posts[0]->upper_case_name); - $this->assertEquals( + $this->assertCount( 2, - ($users = User::query()->get())->count() + $users = User::query()->get() ); $this->assertCount(1, $users->where('name', 'totwell')); $this->assertCount(1, $users->where('name', 'shaedrich')); } - public function testFactoryCanInsertWithHidden() + public function testFactoryCanInsertZeroModels(): void + { + (new PostFactory)->count(0)->insert(); + + $this->assertCount(0, Post::all()); + } + + public function testFactoryCanInsertWithHidden(): void { (new UserFactory)->forEachSequence(['name' => Name::Taylor, 'options' => 'abc'])->insert(); $user = DB::table('users')->sole(); - $this->assertEquals('abc', $user->options); + $this->assertSame('abc', $user->options); $userModel = User::query()->sole(); - $this->assertEquals('abc', $userModel->options); + $this->assertSame('abc', $userModel->options); } - public function testFactoryCanInsertWithArrayCasts() + public function testFactoryCanInsertWithArrayCasts(): void { (new UserWithArrayFactory)->count(2)->insert(); $users = DB::table('users')->get(); @@ -1196,6 +1210,80 @@ public function testFactoryCanInsertWithArrayCasts() } } + public function testFactoryInsertDoesNotApplyMutatorsTwice(): void + { + (new UserFactory)->useModel(UserWithMutator::class)->insert(['name' => 'Taylor']); + + $user = DB::table('users')->sole(); + + $this->assertSame('prefix:Taylor', $user->name); + $this->assertNull($user->created_at); + $this->assertNull($user->updated_at); + } + + public function testFactoryInsertPreservesAttributesExcludedFromSerialization(): void + { + (new UserFactory) + ->afterMaking(fn (User $user) => $user->setVisible(['options'])) + ->insert(['name' => 'Taylor', 'options' => 'abc']); + + $user = DB::table('users')->sole(); + + $this->assertSame('Taylor', $user->name); + $this->assertSame('abc', $user->options); + } + + public function testFactoryInsertGeneratesUniqueIdsAndPreservesTimestampValues(): void + { + $this->schema()->table('users', function (Blueprint $table): void { + $table->timestamp('joined_at')->nullable(); + $table->timestamp('changed_at')->nullable(); + }); + + CarbonImmutable::setTestNow('2026-09-06 12:00:00'); + $suppliedId = '11111111-0000-7000-8000-000000000000'; + $factory = (new UserFactory)->useModel(UserWithUniqueIds::class); + + $factory->forEachSequence( + ['name' => 'generated'], + ['name' => 'supplied', 'options' => $suppliedId, 'joined_at' => '2020-01-01 00:00:00', 'changed_at' => null], + ['name' => 'another generated'], + )->insert(); + + $users = DB::table('users')->get()->keyBy('name'); + + $this->assertCount(3, $users); + foreach (['generated', 'another generated'] as $name) { + $this->assertTrue(Str::isUuid($users[$name]->options)); + $this->assertSame('2026-09-06 12:00:00', $users[$name]->joined_at); + $this->assertSame($users[$name]->joined_at, $users[$name]->changed_at); + } + $this->assertNotSame($users['generated']->options, $users['another generated']->options); + $this->assertSame($suppliedId, $users['supplied']->options); + $this->assertSame('2020-01-01 00:00:00', $users['supplied']->joined_at); + $this->assertNull($users['supplied']->changed_at); + + // Values equal to model defaults are clean, but bulk insertion must retain them. + $factory->useModel(UserWithTimestampDefaults::class)->insert([ + 'name' => 'defaults', 'joined_at' => null, 'changed_at' => '2020-01-01 00:00:00', + ]); + + $defaults = DB::table('users')->where('name', 'defaults')->sole(); + + $this->assertNull($defaults->joined_at); + $this->assertSame('2020-01-01 00:00:00', $defaults->changed_at); + } + + public function testFactoryInsertUsesTheCustomEloquentBuilder(): void + { + DB::enableQueryLog(); + + (new UserFactory)->useModel(UserWithInsertBuilder::class)->count(2)->insert(); + + $this->assertCount(1, DB::getQueryLog()); + $this->assertSame(['custom builder', 'custom builder'], DB::table('users')->pluck('options')->all()); + } + /** * Get a database connection instance. * @@ -1503,6 +1591,61 @@ public function definition(): array } } +class UserWithMutator extends User +{ + public bool $timestamps = false; + + /** + * Prefix the stored name. + */ + protected function name(): Attribute + { + return Attribute::set(fn (string $value): string => 'prefix:' . $value); + } +} + +class UserWithUniqueIds extends User +{ + use HasUuids; + + public const ?string CREATED_AT = 'joined_at'; + + public const ?string UPDATED_AT = 'changed_at'; + + /** + * Get the columns that should receive a unique identifier. + */ + public function uniqueIds(): array + { + return ['options']; + } +} + +class UserWithTimestampDefaults extends UserWithUniqueIds +{ + protected array $attributes = ['joined_at' => null, 'changed_at' => '2020-01-01 00:00:00']; +} + +#[UseEloquentBuilder(FactoryInsertBuilder::class)] +class UserWithInsertBuilder extends User +{ +} + +class FactoryInsertBuilder extends Builder +{ + /** + * Apply custom attributes to the inserted rows. + */ + public function insert(array $values): bool + { + foreach ($values as &$row) { + $row['options'] = 'custom builder'; + } + + return $this->toBase()->insert($values); + } +} + class UserWithCallbacksFactory extends Factory { protected ?string $model = User::class; diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 4decaa72c..14a98b641 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -932,11 +932,29 @@ public function testWhereTimeOperatorOptionalSqlite() public function testWhereNullSafeEquals(): void { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar'); + $this->assertSame('select * from "users" where "foo" is not distinct from ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar')->whereNullSafeEquals('baz', 'qux'); + $this->assertSame('select * from "users" where "foo" is not distinct from ? and "baz" is not distinct from ?', $builder->toSql()); + $this->assertSame(['bar', 'qux'], $builder->getBindings()); + $builder = $this->getBuilder(); $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar')->whereNullSafeEquals('baz', null); $this->assertSame('select * from "users" where "foo" is not distinct from ? and "baz" is not distinct from ?', $builder->toSql()); $this->assertSame(['bar', null], $builder->getBindings()); + } + + public function testOrWhereNullSafeEquals(): void + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('foo', 'bar')->orWhereNullSafeEquals('baz', 'qux'); + $this->assertSame('select * from "users" where "foo" = ? or "baz" is not distinct from ?', $builder->toSql()); + $this->assertSame(['bar', 'qux'], $builder->getBindings()); $builder = $this->getBuilder(); $builder->select('*')->from('users')->where('foo', 'bar')->orWhereNullSafeEquals('baz', new Raw('qux')); @@ -945,19 +963,126 @@ public function testWhereNullSafeEquals(): void $this->assertSame(['bar'], $builder->getBindings()); } - public function testWhereNullSafeEqualsUsesSupportedDriverSyntax(): void + public function testWhereNullSafeEqualsViaNullSafeOperator(): void + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('foo', '<=>', 'bar'); + $this->assertSame('select * from "users" where "foo" is not distinct from ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsWithNullViaOperator(): void + { + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('foo', '<=>', null); + $this->assertSame('select * from "users" where "foo" is null', $builder->toSql()); + } + + public function testWhereNullSafeEqualsMySql(): void { $builder = $this->getMySqlBuilder(); $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar'); $this->assertSame('select * from `users` where `foo` <=> ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('users')->where('foo', '<=>', 'bar'); + $this->assertSame('select * from `users` where `foo` <=> ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + } + public function testWhereNullSafeEqualsSQLite(): void + { $builder = $this->getSQLiteBuilder(); $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar'); $this->assertSame('select * from "users" where "foo" is ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + $builder = $this->getSQLiteBuilder(); + $builder->select('*')->from('users')->where('foo', '<=>', 'bar'); + $this->assertSame('select * from "users" where "foo" is ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsPostgres(): void + { $builder = $this->getPostgresBuilder(); $builder->select('*')->from('users')->whereNullSafeEquals('foo', 'bar'); $this->assertSame('select * from "users" where "foo" is not distinct from ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('users')->where('foo', '<=>', 'bar'); + $this->assertSame('select * from "users" where "foo" is not distinct from ?', $builder->toSql()); + $this->assertSame(['bar'], $builder->getBindings()); + } + + // REMOVED: SQL Server null-safe equality tests; SQL Server is not supported. + + public function testWhereNullSafeEqualsWithJsonBooleansMySql(): void + { + $builder = $this->getMySqlBuilder(); + $builder->from('users')->where('id', 1)->whereNullSafeEquals('options->enabled', true)->orWhereNullSafeEquals('options->archived', false); + $this->assertSame('select * from `users` where `id` = ? and json_extract(`options`, \'$."enabled"\') <=> true or json_extract(`options`, \'$."archived"\') <=> false', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + + $builder = $this->getMySqlBuilder(); + $builder->from('users')->where('id', 1)->where('options->enabled', '<=>', true)->orWhere('options->archived', '<=>', false); + $this->assertSame('select * from `users` where `id` = ? and json_extract(`options`, \'$."enabled"\') <=> true or json_extract(`options`, \'$."archived"\') <=> false', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsWithJsonBooleansSQLite(): void + { + $builder = $this->getSQLiteBuilder(); + $builder->from('users')->where('id', 1)->whereNullSafeEquals('options->enabled', true)->orWhereNullSafeEquals('options->archived', false); + $this->assertSame('select * from "users" where "id" = ? and json_extract("options", \'$."enabled"\') is 1 or json_extract("options", \'$."archived"\') is 0', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + + $builder = $this->getSQLiteBuilder(); + $builder->from('users')->where('id', 1)->where('options->enabled', '<=>', true)->orWhere('options->archived', '<=>', false); + $this->assertSame('select * from "users" where "id" = ? and json_extract("options", \'$."enabled"\') is 1 or json_extract("options", \'$."archived"\') is 0', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsWithJsonBooleansPostgres(): void + { + $builder = $this->getPostgresBuilder(); + $builder->from('users')->where('id', 1)->whereNullSafeEquals('options->enabled', true)->orWhereNullSafeEquals('options->archived', false); + $this->assertSame('select * from "users" where "id" = ? and ("options"->\'enabled\')::jsonb is not distinct from \'true\'::jsonb or ("options"->\'archived\')::jsonb is not distinct from \'false\'::jsonb', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + + $builder = $this->getPostgresBuilder(); + $builder->from('users')->where('id', 1)->where('options->enabled', '<=>', true)->orWhere('options->archived', '<=>', false); + $this->assertSame('select * from "users" where "id" = ? and ("options"->\'enabled\')::jsonb is not distinct from \'true\'::jsonb or ("options"->\'archived\')::jsonb is not distinct from \'false\'::jsonb', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsWithSubqueryMySql(): void + { + $builder = $this->getMySqlBuilder(); + $builder->from('users')->where('id', 1)->orWhere('foo', '<=>', fn (Builder $query) => $query->selectRaw('?', ['bar'])); + + $this->assertSame('select * from `users` where `id` = ? or `foo` <=> (select ?)', $builder->toSql()); + $this->assertSame([1, 'bar'], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsWithSubquerySQLite(): void + { + $builder = $this->getSQLiteBuilder(); + $builder->from('users')->where('id', 1)->orWhere('foo', '<=>', fn (Builder $query) => $query->selectRaw('?', ['bar'])); + + $this->assertSame('select * from "users" where "id" = ? or "foo" is (select ?)', $builder->toSql()); + $this->assertSame([1, 'bar'], $builder->getBindings()); + } + + public function testWhereNullSafeEqualsWithSubqueryPostgres(): void + { + $builder = $this->getPostgresBuilder(); + $builder->from('users')->where('id', 1)->orWhere('foo', '<=>', fn (Builder $query) => $query->selectRaw('?', ['bar'])); + + $this->assertSame('select * from "users" where "id" = ? or "foo" is not distinct from (select ?)', $builder->toSql()); + $this->assertSame([1, 'bar'], $builder->getBindings()); } public function testWhereBetweens() diff --git a/tests/Database/PruneCommandTest.php b/tests/Database/PruneCommandTest.php index 07d033445..7c8c027b1 100644 --- a/tests/Database/PruneCommandTest.php +++ b/tests/Database/PruneCommandTest.php @@ -49,10 +49,9 @@ protected function setUp(): void $container->instance('env', 'development'); } - public function testPrunableModelAndExceptWithEachOther() + public function testPrunableModelAndExceptWithEachOther(): void { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('The --model and --except options cannot be combined.'); + $this->expectExceptionObject(new InvalidArgumentException('The --model and --except options cannot be combined.')); $this->artisan([ '--model' => Pruning\Models\PrunableTestModelWithPrunableRecords::class, @@ -159,12 +158,23 @@ public function testNonPrunableTestWithATrait() ); } - public function testNonModelFilesAreIgnoredTest() + public function testNonModelFilesAreIgnoredTest(): void { - $output = $this->artisan(['--path' => 'Models']); + $output = $this->artisan([ + '--path' => 'Models', + // The soft-delete fixture needs a database; its dedicated tests set one up. + '--except' => [Pruning\Models\PrunableTestSoftDeletedModelWithPrunableRecords::class], + ]); $output = $output->fetch(); + $this->assertStringContainsString( + 'Hypervel\Tests\Database\Pruning\Models\PrunableTestModelWithPrunableRecords', + $output, + ); + + $this->assertStringContainsString('20 records', $output); + $this->assertStringNotContainsString( 'No prunable [Hypervel\Tests\Database\Pruning\Models\AbstractPrunableModel] records found.', $output, diff --git a/tests/Foundation/ComposerScriptsUninstallTest.php b/tests/Foundation/ComposerScriptsUninstallTest.php new file mode 100644 index 000000000..3dbc921d4 --- /dev/null +++ b/tests/Foundation/ComposerScriptsUninstallTest.php @@ -0,0 +1,157 @@ +files = new Filesystem; + $this->providersPath = $this->app->getBootstrapProvidersPath(); + $this->originalProviders = $this->files->get($this->providersPath); + $this->marker = $this->app->storagePath('composer-uninstall-events.log'); + $this->previousDirectory = getcwd(); + + $this->composer = new Composer; + $config = new Config(false); + $config->merge(['config' => ['vendor-dir' => $this->app->basePath('vendor')]]); + $this->composer->setConfig($config); + + // Only the child application boots this provider. + $this->files->replace($this->providersPath, 'app->basePath()); + } + + /** + * Restore the shared application files and working directory. + */ + protected function tearDown(): void + { + CleanupActions::run( + fn (): bool => chdir($this->previousDirectory), + function (): void { + $this->files->replace($this->providersPath, $this->originalProviders); + }, + fn (): bool => $this->files->delete($this->marker), + function (): void { + parent::tearDown(); + }, + ); + } + + public function testPrePackageUninstallDispatchesEachPackageEvent(): void + { + $io = new BufferIO; + + ComposerScripts::prePackageUninstall($this->createPackageEvent('example/first', $io)); + ComposerScripts::prePackageUninstall($this->createPackageEvent('example/second', $io)); + + $this->assertSame('', $io->getOutput()); + $this->assertSame( + 'composer_package.example/first:pre_uninstall' . PHP_EOL . 'composer_package.example/second:pre_uninstall' . PHP_EOL, + $this->files->get($this->marker), + ); + } + + public function testPrePackageUninstallDoesNotDispatchOutsideDevMode(): void + { + $io = new BufferIO; + + ComposerScripts::prePackageUninstall($this->createPackageEvent('example/first', $io, devMode: false)); + + $this->assertSame('', $io->getOutput()); + $this->assertFileDoesNotExist($this->marker); + } + + #[DataProvider('failureVerbosityProvider')] + public function testPrePackageUninstallContinuesAfterListenerFailure(int $verbosity, string $exceptionOutput): void + { + $io = new BufferIO(verbosity: $verbosity); + + ComposerScripts::prePackageUninstall($this->createPackageEvent('example/failing', $io)); + + $this->assertSame('composer_package.example/failing:pre_uninstall' . PHP_EOL, $this->files->get($this->marker)); + $this->assertSame( + 'There was an error dispatching or handling the [composer_package.example/failing:pre_uninstall] event. Continuing with package removal...' . PHP_EOL . $exceptionOutput, + $io->getOutput(), + ); + + $logPath = $this->app->storagePath('logs/hypervel.log'); + + $this->assertStringNotContainsString( + 'Package cleanup failed.', + $this->files->exists($logPath) ? $this->files->get($logPath) : '', + ); + } + + /** + * Provide the exception detail shown at each output level. + * + * @return array + */ + public static function failureVerbosityProvider(): array + { + return [ + 'normal' => [OutputInterface::VERBOSITY_NORMAL, ''], + 'verbose' => [OutputInterface::VERBOSITY_VERBOSE, 'Exception message: Package cleanup failed.' . PHP_EOL], + ]; + } + + /** + * Create the Composer event for a package removal. + */ + protected function createPackageEvent(string $package, BufferIO $io, bool $devMode = true): PackageEvent + { + $operation = new UninstallOperation(new Package($package, '1.0.0.0', '1.0.0')); + + return new PackageEvent( + PackageEvents::PRE_PACKAGE_UNINSTALL, + $this->composer, + $io, + $devMode, + new ArrayRepository, + [$operation], + $operation, + ); + } +} diff --git a/tests/Foundation/Fixtures/ComposerUninstallServiceProvider.php b/tests/Foundation/Fixtures/ComposerUninstallServiceProvider.php new file mode 100644 index 000000000..aac8a376e --- /dev/null +++ b/tests/Foundation/Fixtures/ComposerUninstallServiceProvider.php @@ -0,0 +1,33 @@ +dontReport(RuntimeException::class); + + $marker = $this->app->storagePath('composer-uninstall-events.log'); + $events->listen('composer_package.example/*:pre_uninstall', static function (string $event) use ($files, $marker): void { + $files->append($marker, $event . PHP_EOL); + + if ($event === 'composer_package.example/failing:pre_uninstall') { + throw new RuntimeException('Package cleanup failed.'); + } + }); + } +} diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index 008ef69a0..a21293d58 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -32,12 +32,18 @@ class FoundationApplicationTest extends TestCase { protected ?string $namespaceApplicationPath = null; + protected ?string $cacheApplicationPath = null; + protected function tearDown(): void { try { if ($this->namespaceApplicationPath !== null) { (new Filesystem)->deleteDirectory($this->namespaceApplicationPath); } + + if ($this->cacheApplicationPath !== null) { + (new Filesystem)->deleteDirectory($this->cacheApplicationPath); + } } finally { parent::tearDown(); } @@ -523,8 +529,7 @@ public function testGetNamespaceRejectsMissingComposerFile(): void { $app = $this->makeNamespaceApplication(null); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to detect application namespace.'); + $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.')); $app->getNamespace(); } @@ -535,8 +540,7 @@ public function testGetNamespaceRejectsUnreadableComposerPath(): void unlink($this->namespaceApplicationPath . '/composer.json'); mkdir($this->namespaceApplicationPath . '/composer.json'); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to detect application namespace.'); + $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.')); $app->getNamespace(); } @@ -559,8 +563,7 @@ public function testGetNamespaceRejectsNonArrayComposerJson(): void { $app = $this->makeNamespaceApplication('null'); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to detect application namespace.'); + $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.')); $app->getNamespace(); } @@ -571,8 +574,7 @@ public function testGetNamespaceRejectsInvalidPsrFourMap(): void 'autoload' => ['psr-4' => 'app/'], ], JSON_THROW_ON_ERROR)); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to detect application namespace.'); + $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.')); $app->getNamespace(); } @@ -583,8 +585,7 @@ public function testGetNamespaceRejectsInvalidPsrFourPath(): void 'autoload' => ['psr-4' => ['App\\' => [123]]], ], JSON_THROW_ON_ERROR)); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to detect application namespace.'); + $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.')); $app->getNamespace(); } @@ -595,8 +596,7 @@ public function testGetNamespaceDoesNotMatchTwoMissingPaths(): void 'autoload' => ['psr-4' => ['App\\' => 'missing/']], ], JSON_THROW_ON_ERROR), createAppPath: false); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to detect application namespace.'); + $this->expectExceptionObject(new RuntimeException('Unable to detect application namespace.')); $app->getNamespace(); } @@ -817,19 +817,17 @@ protected function assertExpectationCount(int $times): void $this->assertSame($times, m::getContainer()->mockery_getExpectationCount()); } - public function testAbortThrowsNotFoundHttpException() + public function testAbortThrowsNotFoundHttpException(): void { - $this->expectException(NotFoundHttpException::class); - $this->expectExceptionMessage('Page was not found'); + $this->expectExceptionObject(new NotFoundHttpException('Page was not found')); $app = new Application; $app->abort(404, 'Page was not found'); } - public function testAbortThrowsHttpException() + public function testAbortThrowsHttpException(): void { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Request is bad'); + $this->expectExceptionObject(new HttpException(400, 'Request is bad')); $app = new Application; $app->abort(400, 'Request is bad'); @@ -859,56 +857,100 @@ public function testMethodAfterLoadingEnvironmentAddsClosure(): void $this->assertArrayHasKey(0, $listeners); } - public function testConfigurationIsCachedReturnsFalseWhenNoCacheFile() + public function testConfigurationIsCachedReturnsFalseWhenNoCacheFile(): void { - $app = new Application(sys_get_temp_dir() . '/hypervel-test-app-' . uniqid()); + $app = $this->makeCacheApplication(); $this->assertFalse($app->configurationIsCached()); } - public function testConfigurationIsCachedReturnsTrueWhenCacheFileExists() + public function testConfigurationIsCachedReturnsTrueWhenCacheFileExists(): void { - $basePath = sys_get_temp_dir() . '/hypervel-test-app-' . uniqid(); - $cachePath = $basePath . '/bootstrap/cache/config.php'; + $app = $this->makeCacheApplication(); + file_put_contents($app->getCachedConfigPath(), 'assertTrue($app->configurationIsCached()); + } + + public function testConfigurationIsCachedUsesBoundState(): void + { + $app = $this->makeCacheApplication(); + $app->instance('config_loaded_from_cache', true); + + $this->assertTrue($app->configurationIsCached()); + + file_put_contents($app->getCachedConfigPath(), 'instance('config_loaded_from_cache', false); + + $this->assertFalse($app->configurationIsCached()); + } + + public function testConfigurationIsCachedMemoizesFilesystemResult(): void + { + $app = $this->makeCacheApplication(); + $cachePath = $app->getCachedConfigPath(); + + $this->assertFalse($app->configurationIsCached()); - mkdir(dirname($cachePath), 0755, true); file_put_contents($cachePath, 'assertTrue($app->configurationIsCached()); - } finally { - unlink($cachePath); - rmdir(dirname($cachePath)); - rmdir(dirname($cachePath, 2)); - rmdir($basePath); - } + $this->assertFalse($app->configurationIsCached()); + + $freshApp = new Application($this->cacheApplicationPath); + + $this->assertTrue($freshApp->configurationIsCached()); + + unlink($cachePath); + + $this->assertTrue($freshApp->configurationIsCached()); } - public function testRoutesAreCachedReturnsFalseWhenNoCacheFile() + public function testRoutesAreCachedReturnsFalseWhenNoCacheFile(): void { - $app = new Application(sys_get_temp_dir() . '/hypervel-test-app-' . uniqid()); + $app = $this->makeCacheApplication(); $this->assertFalse($app->routesAreCached()); } - public function testRoutesAreCachedReturnsTrueWhenCacheFileExists() + public function testRoutesAreCachedReturnsTrueWhenCacheFileExists(): void { - $basePath = sys_get_temp_dir() . '/hypervel-test-app-' . uniqid(); - $cachePath = $basePath . '/bootstrap/cache/routes-v7.php'; + $app = $this->makeCacheApplication(); + file_put_contents($this->cacheApplicationPath . '/bootstrap/cache/routes-v7.php', 'assertTrue($app->routesAreCached()); + } + + public function testRoutesAreCachedUsesBoundState(): void + { + $app = $this->makeCacheApplication(); + $app->instance('routes.cached', true); + + $this->assertTrue($app->routesAreCached()); + + file_put_contents($app->getCachedRoutesPath(), 'instance('routes.cached', false); + + $this->assertFalse($app->routesAreCached()); + } + + public function testRoutesAreCachedMemoizesFilesystemResult(): void + { + $app = $this->makeCacheApplication(); + $cachePath = $app->getCachedRoutesPath(); + + $this->assertFalse($app->routesAreCached()); - mkdir(dirname($cachePath), 0755, true); file_put_contents($cachePath, 'assertTrue($app->routesAreCached()); - } finally { - unlink($cachePath); - rmdir(dirname($cachePath)); - rmdir(dirname($cachePath, 2)); - rmdir($basePath); - } + $this->assertFalse($app->routesAreCached()); + + $freshApp = new Application($this->cacheApplicationPath); + + $this->assertTrue($freshApp->routesAreCached()); + + unlink($cachePath); + + $this->assertTrue($freshApp->routesAreCached()); } public function testEventsAreCachedReturnsFalseWhenNoCacheFile() @@ -956,6 +998,20 @@ public function testAddAbsoluteCachePathPrefixReturnsSelf() $this->assertSame($app, $app->addAbsoluteCachePathPrefix('s3:')); } + /** + * Create an application with an isolated cache directory. + */ + private function makeCacheApplication(): Application + { + $this->cacheApplicationPath = ParallelTesting::tempDir('FoundationApplicationCacheTest'); + + $files = new Filesystem; + $files->deleteDirectory($this->cacheApplicationPath); + $files->makeDirectory($this->cacheApplicationPath . '/bootstrap/cache', 0755, true); + + return new Application($this->cacheApplicationPath); + } + private function makeNamespaceApplication(?string $composerContents, bool $createAppPath = true): Application { $this->namespaceApplicationPath = ParallelTesting::tempDir('FoundationApplicationNamespaceTest'); diff --git a/tests/Foundation/FoundationHelpersTest.php b/tests/Foundation/FoundationHelpersTest.php index c421ccd86..4174f9074 100644 --- a/tests/Foundation/FoundationHelpersTest.php +++ b/tests/Foundation/FoundationHelpersTest.php @@ -8,6 +8,7 @@ use Hypervel\Broadcasting\FakePendingBroadcast; use Hypervel\Broadcasting\PendingBroadcast; use Hypervel\Cache\CacheManager; +use Hypervel\Container\Container; use Hypervel\Context\RequestContext; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Support\Responsable; @@ -45,6 +46,30 @@ enum UnitEnum class FoundationHelpersTest extends TestCase { + public function testAppPathUsesTheConfiguredApplicationDirectory(): void + { + $this->assertSame($this->app->basePath('app'), app_path()); + $this->assertSame($this->app->basePath('app/Models'), app_path('Models')); + + $path = $this->app->basePath('custom-app'); + $this->app->useAppPath($path); + + $this->assertSame($path, app_path()); + $this->assertSame($path . '/Models', app_path('Models')); + } + + public function testAppPathUsesBasePathBeforeApplicationBootstrap(): void + { + Container::setInstance(new Container); + + try { + $this->assertSame(BASE_PATH . '/app', app_path()); + $this->assertSame(BASE_PATH . '/app/Models', app_path('Models')); + } finally { + Container::setInstance($this->app); + } + } + public function testNowReturnsCarbonImmutableByDefault(): void { $result = now(); diff --git a/tests/Foundation/Testing/WithCachedStateTest.php b/tests/Foundation/Testing/WithCachedStateTest.php index 1ec7b5c55..e6923f6ae 100644 --- a/tests/Foundation/Testing/WithCachedStateTest.php +++ b/tests/Foundation/Testing/WithCachedStateTest.php @@ -115,6 +115,7 @@ public function testCachedStateIsRearmedBeforeSuccessiveFoundationApplicationBoo $second->runSetUp(); $this->assertTrue($second->configLoadedFromCache()); + $this->assertTrue($second->application()->configurationIsCached()); $this->assertTrue($second->application()->routesAreCached()); $this->assertInstanceOf(CompiledRouteCollection::class, $second->routeCollection()); $this->assertNotNull($second->routeCollection()->getByName('cached-state')); diff --git a/tests/Grpc/GrpcServiceProviderTest.php b/tests/Grpc/GrpcServiceProviderTest.php index 597d194c4..a3a27ef51 100644 --- a/tests/Grpc/GrpcServiceProviderTest.php +++ b/tests/Grpc/GrpcServiceProviderTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Grpc; -use Hypervel\Filesystem\Filesystem; use Hypervel\Grpc\GrpcServiceProvider; use Hypervel\Grpc\Health\HealthStatusProvider; use Hypervel\Grpc\Health\ServingHealthStatusProvider; @@ -23,7 +22,6 @@ use Hypervel\Server\ServerInterface; use Hypervel\Support\ServiceProvider; use Hypervel\Testbench\TestCase; -use Hypervel\Testing\ParallelTesting; use InvalidArgumentException; use PHPUnit\Framework\Attributes\DataProvider; @@ -206,36 +204,31 @@ public function testPublishesConfigurationAndCanonicalRoutes(): void public function testLoadsIsolatedRoutesDuringServerBootstrapEvenWhenApplicationRoutesAreCached(): void { - $cacheDirectory = ParallelTesting::tempDir('GrpcServiceProviderTest-route-cache'); - $cachePath = $cacheDirectory . '/routes.php'; - $files = new Filesystem; - $files->deleteDirectory($cacheDirectory); - $files->ensureDirectoryExists($cacheDirectory); - $files->put($cachePath, 'registerEnabledProvider(); - - $this->assertTrue($this->app->routesAreCached()); - - $provider->boot(); - - $this->assertCount(0, $this->app->make(GrpcRouter::class)->getRoutes()->getRoutes()); - - $this->app->make(Server::class)->bootstrapForServer('grpc'); - - $routes = $this->app->make(GrpcRouter::class)->getRoutes()->getRoutes(); - $this->assertCount(3, $routes); - $this->assertSame([ - 'grpc.health.v1.Health/Check', - 'grpc.health.v1.Health/List', - 'grpc.health.v1.Health/Watch', - ], array_map(static fn ($route): string => $route->uri(), $routes)); - } finally { - unset($_SERVER['APP_ROUTES_CACHE']); - $files->deleteDirectory($cacheDirectory); - } + $this->defineCacheRoutes(<<<'PHP' + 'cached HTTP'); +PHP); + + $provider = $this->registerEnabledProvider(); + + $this->assertTrue($this->app->routesAreCached()); + + $provider->boot(); + + $this->assertCount(0, $this->app->make(GrpcRouter::class)->getRoutes()->getRoutes()); + + $this->app->make(Server::class)->bootstrapForServer('grpc'); + + $routes = $this->app->make(GrpcRouter::class)->getRoutes()->getRoutes(); + $this->assertCount(3, $routes); + $this->assertSame([ + 'grpc.health.v1.Health/Check', + 'grpc.health.v1.Health/List', + 'grpc.health.v1.Health/Watch', + ], array_map(static fn ($route): string => $route->uri(), $routes)); + + $this->get('/cached-http')->assertOk()->assertContent('cached HTTP'); } public function testFinalServerConfigurationRejectsAListenerNameAddedByAnotherProvider(): void diff --git a/tests/Integration/Cache/Redis/FlushOperationsIntegrationTest.php b/tests/Integration/Cache/Redis/FlushOperationsIntegrationTest.php index 59080da32..f723b470d 100644 --- a/tests/Integration/Cache/Redis/FlushOperationsIntegrationTest.php +++ b/tests/Integration/Cache/Redis/FlushOperationsIntegrationTest.php @@ -6,6 +6,8 @@ use Hypervel\Cache\TagMode; use Hypervel\Support\Facades\Cache; +use Hypervel\Testbench\Attributes\WithConfig; +use Redis; use Throwable; /** @@ -248,6 +250,8 @@ public function testFlushNonExistentTagGracefullyInAnyMode(): void $this->assertSame('value', Cache::get('item')); } + #[WithConfig('database.redis.options.prefix', 'scan-prefix:')] + #[WithConfig('database.redis.options.scan', Redis::SCAN_PREFIX)] public function testFlushLargeTagSetInAllMode(): void { $this->setTagMode(TagMode::All); @@ -271,25 +275,29 @@ public function testFlushLargeTagSetInAllMode(): void } } + #[WithConfig('database.redis.options.prefix', 'scan-prefix:')] + #[WithConfig('database.redis.options.scan', Redis::SCAN_PREFIX)] public function testFlushLargeTagSetInAnyMode(): void { $this->setTagMode(TagMode::Any); - // Create many items with the same tag - for ($i = 0; $i < 100; ++$i) { - Cache::tags(['bulk'])->put("item.{$i}", "value.{$i}", 60); + // Exceed the HSCAN threshold to exercise paged tag enumeration. + $values = []; + for ($i = 0; $i < 1001; ++$i) { + $values["item.{$i}"] = "value.{$i}"; } + Cache::tags(['bulk'])->putMany($values, 60); // Verify some items exist $this->assertSame('value.0', Cache::get('item.0')); $this->assertSame('value.50', Cache::get('item.50')); - $this->assertSame('value.99', Cache::get('item.99')); + $this->assertSame('value.1000', Cache::get('item.1000')); // Flush all at once Cache::tags(['bulk'])->flush(); // Verify all items are gone - for ($i = 0; $i < 100; ++$i) { + for ($i = 0; $i < 1001; ++$i) { $this->assertNull(Cache::get("item.{$i}")); } } diff --git a/tests/Integration/Cache/Redis/PruneIntegrationTest.php b/tests/Integration/Cache/Redis/PruneIntegrationTest.php index c26da5b67..347ed4d96 100644 --- a/tests/Integration/Cache/Redis/PruneIntegrationTest.php +++ b/tests/Integration/Cache/Redis/PruneIntegrationTest.php @@ -6,6 +6,8 @@ use Hypervel\Cache\TagMode; use Hypervel\Support\Facades\Cache; +use Hypervel\Testbench\Attributes\WithConfig; +use Redis; /** * Integration tests for prune (cleanup) operations. @@ -80,6 +82,8 @@ public function testAnyModeForgetRemovesTagMembership(): void // ANY MODE - PRUNE COMMAND // ========================================================================= + #[WithConfig('database.redis.options.prefix', 'scan-prefix:')] + #[WithConfig('database.redis.options.scan', Redis::SCAN_PREFIX)] public function testAnyModePruneRemovesOrphanedFields(): void { $this->setTagMode(TagMode::Any); @@ -287,6 +291,8 @@ public function testAllModeFlushLeavesOrphanedEntriesInOtherTags(): void // ALL MODE - PRUNE COMMAND // ========================================================================= + #[WithConfig('database.redis.options.prefix', 'scan-prefix:')] + #[WithConfig('database.redis.options.scan', Redis::SCAN_PREFIX)] public function testAllModePruneRemovesOrphanedEntries(): void { $this->setTagMode(TagMode::All); diff --git a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php index 870bd9180..64b2cb1b2 100644 --- a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php +++ b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php @@ -6,12 +6,16 @@ use Hypervel\Database\BinaryParameter; use Hypervel\Database\Eloquent\Casts\AsBinary; +use Hypervel\Database\Eloquent\Concerns\HasUuids; +use Hypervel\Database\Eloquent\Factories\Factory; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Schema\Blueprint; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Support\Facades\Schema; +use Hypervel\Support\Str; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Uid\Ulid; use Symfony\Component\Uid\Uuid; @@ -148,6 +152,60 @@ public function testBinaryIdentifiersRoundTripAcrossModelAndQueryBuilderWritePat $this->assertSame($upsertUlid, $upserted->ulid); } + #[DataProvider('fillAndInsertMethods')] + public function testBinaryIdentifiersRoundTripThroughFillAndInsert(string $method): void + { + $uuid = '00ff7f80-4048-43c2-b80b-40491d165946'; + $ulid = '2WBHE5RQ2WBHE5RQ2WBHE5RQ2W'; + $attributes = ['uuid' => $uuid, 'ulid' => $ulid]; + + AsBinaryIdentifierModel::query()->{$method}( + $method === 'fillAndInsertGetId' ? $attributes : [$attributes] + ); + + $model = AsBinaryIdentifierModel::query() + ->where('uuid', new BinaryParameter(Uuid::fromString($uuid)->toBinary())) + ->where('ulid', new BinaryParameter(Ulid::fromString($ulid)->toBinary())) + ->sole(); + + $this->assertSame($uuid, $model->uuid); + $this->assertSame($ulid, $model->ulid); + } + + /** + * Provide the insert methods that prepare model attributes. + */ + public static function fillAndInsertMethods(): array + { + return [ + 'insert' => ['fillAndInsert'], + 'insert or ignore' => ['fillAndInsertOrIgnore'], + 'insert and get ID' => ['fillAndInsertGetId'], + ]; + } + + public function testFactoryInsertPreparesGeneratedAndSuppliedBinaryPrimaryKeys(): void + { + $suppliedId = '00ff7f80-4048-43c2-b80b-40491d165946'; + + (new AsBinaryPrimaryKeyFactory)->forEachSequence( + ['name' => 'generated'], + ['name' => 'supplied', 'id' => $suppliedId], + )->insert(); + + $models = AsBinaryFactoryPrimaryKeyModel::query()->get()->keyBy('name'); + + $this->assertCount(2, $models); + $this->assertTrue(Str::isUuid($models['generated']->id)); + $this->assertSame($suppliedId, $models['supplied']->id); + + foreach ($models as $name => $model) { + $key = new BinaryParameter(Uuid::fromString($model->id)->toBinary()); + + $this->assertSame($name, AsBinaryFactoryPrimaryKeyModel::query()->whereKey($key)->sole()->name); + } + } + public function testBinaryPrimaryKeysPreserveBindingIntentAcrossModelOperations(): void { $primaryId = '21107c1e-6448-43c2-b80b-40491d165946'; @@ -297,6 +355,24 @@ protected function casts(): array } } +class AsBinaryFactoryPrimaryKeyModel extends AsBinaryPrimaryKeyModel +{ + use HasUuids; +} + +class AsBinaryPrimaryKeyFactory extends Factory +{ + protected ?string $model = AsBinaryFactoryPrimaryKeyModel::class; + + /** + * Define the model's default state. + */ + public function definition(): array + { + return []; + } +} + class SoftDeletingAsBinaryPrimaryKeyModel extends AsBinaryPrimaryKeyModel { use SoftDeletes; diff --git a/tests/Integration/Database/Sqlite/NullSafeEqualityTest.php b/tests/Integration/Database/Sqlite/NullSafeEqualityTest.php new file mode 100644 index 000000000..8f821a5df --- /dev/null +++ b/tests/Integration/Database/Sqlite/NullSafeEqualityTest.php @@ -0,0 +1,66 @@ +make('config'); + $config->set('database.default', 'null_safe'); + $config->set('database.connections.null_safe', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } + + public function testJsonBooleansUseNullSafeEqualityInsteadOfTruthiness(): void + { + Schema::create('null_safe_values', function (Blueprint $table): void { + $table->integer('id')->primary(); + $table->jsonb('options'); + }); + + DB::table('null_safe_values')->insert([ + ['id' => 1, 'options' => '{"enabled":true}'], + ['id' => 2, 'options' => '{"enabled":false}'], + ['id' => 3, 'options' => '{"enabled":1}'], + ['id' => 4, 'options' => '{"enabled":0}'], + ['id' => 5, 'options' => '{"enabled":2}'], + ['id' => 6, 'options' => '{"enabled":"hello"}'], + ['id' => 7, 'options' => '{"enabled":null}'], + ['id' => 8, 'options' => '{}'], + ]); + + foreach ([true, false] as $value) { + // SQLite exposes JSON booleans as the integers 1 and 0. + $expected = $value ? [1, 3] : [2, 4]; + + foreach (['options->enabled', new Expression("options->>'enabled'")] as $column) { + $this->assertSame($expected, DB::table('null_safe_values') + ->whereNullSafeEquals($column, $value)->orderBy('id')->pluck('id')->all()); + + $this->assertSame($expected, DB::table('null_safe_values') + ->where($column, '<=>', $value)->orderBy('id')->pluck('id')->all()); + } + } + } +} diff --git a/tests/Integration/Queue/DebouncedJobTest.php b/tests/Integration/Queue/DebouncedJobTest.php index ecfa1b2a7..f5601f22a 100644 --- a/tests/Integration/Queue/DebouncedJobTest.php +++ b/tests/Integration/Queue/DebouncedJobTest.php @@ -32,17 +32,22 @@ #[WithMigration('queue')] class DebouncedJobTest extends QueueTestCase { + /** + * Define the test environment. + */ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); $config = $app->make('config'); $config->set('cache.default', 'database'); - $config->set('queue.default', 'database'); + $config->set('queue.default', env('QUEUE_CONNECTION', 'database')); } public function testDebouncedJobDispatchesAndExecutes(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']); + DebouncedTestJob::resetState(); dispatch(new DebouncedTestJob('entity-1')); @@ -54,6 +59,8 @@ public function testDebouncedJobDispatchesAndExecutes(): void public function testSupersededDebouncedJobIsSkipped(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']); + DebouncedTestJob::resetState(); dispatch(new DebouncedTestJob('entity-1')); @@ -67,6 +74,8 @@ public function testSupersededDebouncedJobIsSkipped(): void public function testTokenPersistsAfterSuccessfulExecution(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']); + DebouncedTestJob::resetState(); dispatch($job = new DebouncedTestJob('entity-1')); @@ -92,6 +101,8 @@ public function testFailedDebouncedJobStillCallsHandler(): void public function testJobDebouncedEventFiresForSupersededJob(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']); + $firedCount = 0; Event::listen(JobDebounced::class, function () use (&$firedCount): void { @@ -148,6 +159,8 @@ public function testDebounceOwnerSurvivesSerialization(): void public function testDifferentDebounceIdsDoNotInterfere(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']); + DebouncedTestJob::resetState(); dispatch(new DebouncedTestJob('entity-1')); @@ -195,6 +208,8 @@ public function testBusFakeRetainsDebounceMaximumWaitState(): void public function testJobExecutesWhenCacheTokenIsEvicted(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']); + DebouncedTestJob::resetState(); dispatch($job = new DebouncedTestJob('entity-1')); @@ -246,6 +261,8 @@ public function testReleaseClearsMaxWaitTimestamp(): void public function testSupersededDebouncedJobDoesNotDispatchChain(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']); + DebouncedTestJob::resetState(); ChainReceiverJob::resetState(); @@ -257,11 +274,13 @@ public function testSupersededDebouncedJobDoesNotDispatchChain(): void $this->assertSame(1, DebouncedTestJob::$handleCount); $this->assertFalse(ChainReceiverJob::$handled); - $this->assertDatabaseCount('jobs', 0); + $this->assertSame(0, Queue::size()); } public function testDebounceViaUsesCustomCacheStore(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']); + DebouncedWithCustomCacheJob::resetState(); dispatch(new DebouncedWithCustomCacheJob('entity-1')); @@ -274,6 +293,8 @@ public function testDebounceViaUsesCustomCacheStore(): void public function testMaxDebounceWaitForcesImmediateExecution(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']); + DebouncedWithMaxWaitJob::resetState(); dispatch(new DebouncedWithMaxWaitJob('entity-1')); @@ -291,6 +312,8 @@ public function testMaxDebounceWaitForcesImmediateExecution(): void public function testMaxDebounceWaitStartsOverAfterTheDebouncedJobRuns(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']); + DebouncedWithMaxWaitJob::resetState(); dispatch(new DebouncedWithMaxWaitJob('entity-1')); @@ -311,6 +334,8 @@ public function testMaxDebounceWaitStartsOverAfterTheDebouncedJobRuns(): void public function testMaxDebounceWaitIsNotReleasedWhenMiddlewareReleasesTheJob(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']); + dispatch(new DebouncedWithReleasingMiddlewareJob('entity-1')); $this->travelDebounceTo(CarbonImmutable::now()->addSeconds(31)); @@ -327,6 +352,8 @@ public function testMaxDebounceWaitIsNotReleasedWhenMiddlewareReleasesTheJob(): public function testDebounceWithoutMaxWaitAllowsIndefiniteDelay(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['beanstalkd']); + $job1 = new DebouncedTestJob('entity-1'); $pending = dispatch($job1); unset($pending); @@ -350,6 +377,8 @@ public function testDebounceLockReadsMaxWaitFromAttribute(): void public function testChildDebouncedJobInheritsFromParent(): void { + $this->markTestSkippedWhenUsingQueueDrivers(['sync', 'beanstalkd']); + ChildOfDebouncedTestJob::resetState(); dispatch(new ChildOfDebouncedTestJob('entity-1')); diff --git a/tests/Integration/Queue/DebouncedListenerTest.php b/tests/Integration/Queue/DebouncedListenerTest.php index 3ba4ec013..96ed9a97e 100644 --- a/tests/Integration/Queue/DebouncedListenerTest.php +++ b/tests/Integration/Queue/DebouncedListenerTest.php @@ -17,13 +17,16 @@ #[WithMigration('queue')] class DebouncedListenerTest extends QueueTestCase { + /** + * Define the test environment. + */ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); $config = $app->make('config'); $config->set('cache.default', 'database'); - $config->set('queue.default', 'database'); + $config->set('queue.default', env('QUEUE_CONNECTION', 'database')); } public function testSupersededDebouncedListenerIsSkipped(): void diff --git a/tests/Integration/Queue/Redis/RedisQueueTest.php b/tests/Integration/Queue/Redis/RedisQueueTest.php index 2e7a0cc90..8322ffa80 100644 --- a/tests/Integration/Queue/Redis/RedisQueueTest.php +++ b/tests/Integration/Queue/Redis/RedisQueueTest.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Redis\Factory as RedisFactory; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; +use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Events\JobPayloadFinalizing; use Hypervel\Queue\Events\JobQueued; use Hypervel\Queue\Events\JobQueueing; @@ -26,6 +27,7 @@ use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use Redis as PhpRedis; use RedisCluster; use ReflectionMethod; @@ -670,6 +672,8 @@ public function testAllPendingJobsReportExplicitHashTaggedNamesByTopology(): voi $this->usingRedisCluster() ? 'orders' : '{orders}', 0, ); + $this->assertSame(1, $this->queue->totalSize()); + $this->assertSame(1, $this->queue->totalPendingSize()); } public function testAllDelayedJobs(): void @@ -702,6 +706,178 @@ public function testAllReservedJobs(): void $jobs->each(fn (InspectedJob $job) => $this->assertInspectedJob($job, $job->queue, 1)); } + public function testTotalSize(): void + { + $this->setQueue($this->defaultQueueName()); + + $this->queue->push(new RedisQueueIntegrationTestJob(1)); + $this->queue->pushOn('emails', new RedisQueueIntegrationTestJob(2)); + $this->queue->later(60, new RedisQueueIntegrationTestJob(3)); + + $this->assertSame(3, $this->queue->totalSize()); + } + + public function testTotalPendingSize(): void + { + $this->setQueue($this->defaultQueueName()); + + $this->queue->push(new RedisQueueIntegrationTestJob(1)); + $this->queue->pushOn('emails', new RedisQueueIntegrationTestJob(2)); + + $this->assertSame(2, $this->queue->totalPendingSize()); + } + + public function testTotalDelayedSize(): void + { + $this->setQueue($this->defaultQueueName()); + + $this->queue->later(60, new RedisQueueIntegrationTestJob(1)); + $this->queue->laterOn('emails', 60, new RedisQueueIntegrationTestJob(2)); + + $this->assertSame(2, $this->queue->totalDelayedSize()); + } + + public function testTotalReservedSize(): void + { + $this->setQueue($this->defaultQueueName()); + + $this->queue->push(new RedisQueueIntegrationTestJob(1)); + $this->queue->pushOn('emails', new RedisQueueIntegrationTestJob(2)); + $this->queue->pop(); + $this->queue->pop('emails'); + + $this->assertSame(2, $this->queue->totalReservedSize()); + } + + public function testBulkPushesAllJobsOntoQueue(): void + { + $this->setQueue('default'); + + $this->queue->bulk([ + new RedisQueueIntegrationTestJob(1), + new RedisQueueIntegrationTestJob(2), + new RedisQueueIntegrationTestJob(3), + ], '', 'bulk-test'); + + $this->assertSame(3, $this->queue->size('bulk-test')); + + $seen = []; + + for ($i = 0; $i < 3; ++$i) { + $seen[] = unserialize(json_decode($this->queue->pop('bulk-test')->getRawBody())->data->command)->i; + } + + sort($seen); + + $this->assertSame([1, 2, 3], $seen); + $this->assertNull($this->queue->pop('bulk-test')); + } + + public function testBulkPushesDelayedJobsOntoDelayedQueue(): void + { + $this->setQueue('default'); + + $this->queue->bulk([ + new RedisQueueIntegrationTestJob(1), + new RedisQueueIntegrationTestDelayedJob(2), + ], '', 'bulk-delay'); + + $redisKey = $this->getQueueRedisKey('bulk-delay'); + + $this->assertSame(1, $this->redisConnection()->llen($redisKey)); + $this->assertSame(1, $this->redisConnection()->zcard("{$redisKey}:delayed")); + } + + public function testBulkPushesManyJobsOntoQueue(): void + { + $this->setQueue('default'); + + $jobs = []; + + for ($i = 0; $i < 1050; ++$i) { + $jobs[] = new RedisQueueIntegrationTestJob($i); + } + + $this->queue->bulk($jobs, '', 'bulk-many'); + + $redisKey = $this->getQueueRedisKey('bulk-many'); + + $this->assertSame(1050, $this->queue->size('bulk-many')); + $this->assertSame(1050, $this->redisConnection()->llen("{$redisKey}:notify")); + } + + public function testAllQueueNamesReturnsQueuesAcrossMultipleQueues(): void + { + $default = $this->defaultQueueName(); + $this->setQueue($default); + + $this->queue->push(new RedisQueueIntegrationTestJob(1)); + $this->queue->pushOn('emails', new RedisQueueIntegrationTestJob(2)); + $this->queue->pushOn('notifications', new RedisQueueIntegrationTestJob(3)); + + $names = (new ReflectionMethod($this->queue, 'allQueueNames')) + ->invoke($this->queue) + ->sort() + ->values() + ->all(); + + $this->assertSame([$default, 'emails', 'notifications'], $names); + } + + #[DataProvider('scanRetryOptions')] + public function testScanningQueueNamesDoesNotDoublePrefixTheMatchPattern(bool $retryScan): void + { + $connectionName = $this->createRedisConnectionWithOptions('queue-scan-prefix', [ + 'prefix' => 'test_', + 'scan' => PhpRedis::SCAN_PREFIX, + ]); + $this->setQueue('default', $connectionName); + $redis = Redis::connection($connectionName); + + $redis->withPinnedConnection(function () use ($redis, $retryScan): void { + if ($retryScan) { + $redis->withConnection(function (RedisConnection $connection): void { + $connection->setOption(PhpRedis::OPT_SCAN, PhpRedis::SCAN_RETRY); + }); + } + + $this->queue->push(new RedisQueueIntegrationTestJob(1)); + + $this->assertSame( + ['default'], + (new ReflectionMethod($this->queue, 'allQueueNames'))->invoke($this->queue)->all(), + ); + }); + } + + /** + * Provide the retry settings used alongside scan prefixing. + */ + public static function scanRetryOptions(): array + { + return [ + 'prefix only' => [false], + 'prefix and retry' => [true], + ]; + } + + public function testTotalSizesPreserveQueueNamesAcrossEveryState(): void + { + $this->setQueue(); + + foreach (['reports:high', '0'] as $name) { + $this->queue->pushOn($name, new RedisQueueIntegrationTestJob(1)); + $this->queue->pop($name); + $this->queue->pushOn($name, new RedisQueueIntegrationTestJob(2)); + $this->queue->laterOn($name, 60, new RedisQueueIntegrationTestJob(3)); + } + + $this->assertSame(6, $this->queue->totalSize()); + $this->assertSame(2, $this->queue->totalPendingSize()); + $this->assertSame(2, $this->queue->totalDelayedSize()); + $this->assertSame(2, $this->queue->totalReservedSize()); + } + public function testInvalidInspectedPayloadRetainsItsRedisRemovalMember(): void { $this->setQueue('poison'); @@ -772,6 +948,25 @@ public function handle(): void } } +#[Delay(60)] +class RedisQueueIntegrationTestDelayedJob +{ + /** + * Create a delayed test job. + */ + public function __construct( + public int $i, + ) { + } + + /** + * Handle the job. + */ + public function handle(): void + { + } +} + class RedisQueueIntegrationDelayedJob extends RedisQueueIntegrationTestJob { public function __construct(int $i, public int $delay) diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index 8c3df090f..6e0e9931a 100644 --- a/tests/Integration/Queue/UniqueJobTest.php +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -29,13 +29,16 @@ #[WithMigration('queue')] class UniqueJobTest extends QueueTestCase { + /** + * Define the test environment. + */ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); $config = $app->make('config'); $config->set('cache.default', 'database'); - $config->set('queue.default', 'database'); + $config->set('queue.default', env('QUEUE_CONNECTION', 'database')); } public function testUniqueJobsAreNotDispatched() diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php index 4edbbbf5d..70d2e27db 100644 --- a/tests/Integration/Queue/WorkCommandTest.php +++ b/tests/Integration/Queue/WorkCommandTest.php @@ -28,11 +28,14 @@ class WorkCommandTest extends QueueTestCase { use DatabaseMigrations; + /** + * Define the test environment. + */ protected function defineEnvironment(ApplicationContract $app): void { parent::defineEnvironment($app); - $app->make('config')->set('queue.default', 'database'); + $app->make('config')->set('queue.default', env('QUEUE_CONNECTION', 'database')); } protected function setUp(): void @@ -365,6 +368,33 @@ public function testFailedJobListenerOnlyRunsOnce() Exceptions::assertNotReported(UniqueConstraintViolationException::class); $this->assertSame(2, substr_count(Artisan::output(), JobWillFail::class)); } + + public function testStopReasonIsWritten(): void + { + Queue::push(new FirstJob); + Queue::push(new SecondJob); + + $this->artisan('queue:work', [ + '--daemon' => true, + '--stop-when-empty' => true, + '--memory' => 1024, + ])->expectsOutputToContain('Queue empty') + ->assertExitCode(0); + } + + public function testStopReasonIsWrittenAsJson(): void + { + Queue::push(new FirstJob); + Queue::push(new SecondJob); + + $this->artisan('queue:work', [ + '--daemon' => true, + '--stop-when-empty' => true, + '--memory' => 1, + '--json' => true, + ])->expectsOutputToContain('"status":"stopped","reason":"memory","exit_code":12') + ->assertExitCode(12); + } } class FirstJob implements ShouldQueue diff --git a/tests/Integration/Redis/SafeScanIntegrationTest.php b/tests/Integration/Redis/SafeScanIntegrationTest.php index 5bc1b4f45..4ad0a3b65 100644 --- a/tests/Integration/Redis/SafeScanIntegrationTest.php +++ b/tests/Integration/Redis/SafeScanIntegrationTest.php @@ -8,6 +8,8 @@ use Hypervel\Redis\RedisConnection; use Hypervel\Support\Facades\Redis; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use Redis as PhpRedis; /** * Integration tests for SafeScan and FlushByPattern operations. @@ -20,7 +22,8 @@ class SafeScanIntegrationTest extends TestCase { use InteractsWithRedis; - public function testSafeScanYieldsKeysWithoutPrefix() + #[DataProvider('scanPrefixOptions')] + public function testSafeScanYieldsKeysWithoutPrefix(bool $prefixScan, bool $retryScan): void { $prefix = 'safescan_test:'; $connectionName = $this->createRedisConnectionWithPrefix($prefix); @@ -31,10 +34,19 @@ public function testSafeScanYieldsKeysWithoutPrefix() $redis->set('key1', 'val1'); $redis->set('key2', 'val2'); $redis->set('key3', 'val3'); + $redis->set($prefix . 'key4', 'val4'); // safeScan should yield keys WITHOUT the prefix - $keys = $redis->withConnection(function (RedisConnection $connection) { - return iterator_to_array($connection->safeScan('key*')); + $keys = $redis->withConnection(function (RedisConnection $connection) use ($prefix, $prefixScan, $retryScan): array { + $connection->setOption(PhpRedis::OPT_SCAN, $retryScan ? PhpRedis::SCAN_RETRY : PhpRedis::SCAN_NORETRY); + $connection->setOption(PhpRedis::OPT_SCAN, $prefixScan ? PhpRedis::SCAN_PREFIX : PhpRedis::SCAN_NOPREFIX); + $options = $connection->getOption(PhpRedis::OPT_SCAN); + $keys = iterator_to_array($connection->safeScan('key*')); + + $this->assertSame([$prefix . 'key4'], iterator_to_array($connection->safeScan($prefix . 'key*'))); + $this->assertSame($options, $connection->getOption(PhpRedis::OPT_SCAN)); + + return $keys; }, transform: false); sort($keys); @@ -45,6 +57,18 @@ public function testSafeScanYieldsKeysWithoutPrefix() $this->assertSame('val1', $redis->get('key1')); } + /** + * Provide native scan prefix and retry settings. + */ + public static function scanPrefixOptions(): array + { + return [ + 'no prefixing' => [false, false], + 'prefixing' => [true, false], + 'prefixing and retry' => [true, true], + ]; + } + public function testSafeScanWithoutPrefix() { $connectionName = $this->createRedisConnectionWithPrefix(''); @@ -144,20 +168,23 @@ public function testFlushByPatternReturnsZeroWhenNoKeysMatch() $this->assertSame(0, $deleted); } - public function testFlushByPatternWithPrefixHandlesDoublePrefix() + #[DataProvider('scanPrefixOptions')] + public function testFlushByPatternPreservesOverlappingLogicalPrefixes(bool $prefixScan, bool $retryScan): void { - $prefix = 'flushprefix:'; + $prefix = 'cache:'; $connectionName = $this->createRedisConnectionWithPrefix($prefix); $redis = Redis::connection($connectionName); $redis->flushdb(); - // Create keys via prefixed connection (stored as "flushprefix:cache:1" in Redis) + // The logical cache prefix and the connection prefix both belong in the stored keys. $redis->set('cache:1', 'a'); $redis->set('cache:2', 'b'); $redis->set('other:1', 'c'); - // flushByPattern should handle OPT_PREFIX correctly — no double prefix - $deleted = $redis->withConnection(function (RedisConnection $connection) { + $deleted = $redis->withConnection(function (RedisConnection $connection) use ($prefixScan, $retryScan): int { + $connection->setOption(PhpRedis::OPT_SCAN, $retryScan ? PhpRedis::SCAN_RETRY : PhpRedis::SCAN_NORETRY); + $connection->setOption(PhpRedis::OPT_SCAN, $prefixScan ? PhpRedis::SCAN_PREFIX : PhpRedis::SCAN_NOPREFIX); + return $connection->flushByPattern('cache:*'); }, transform: false); diff --git a/tests/Integration/Validation/Rules/FileValidationTest.php b/tests/Integration/Validation/Rules/FileValidationTest.php index 8092a82d0..a30ac6b88 100644 --- a/tests/Integration/Validation/Rules/FileValidationTest.php +++ b/tests/Integration/Validation/Rules/FileValidationTest.php @@ -10,6 +10,8 @@ use Hypervel\Validation\Rule; use Hypervel\Validation\Rules\File; use PHPUnit\Framework\Attributes\TestWith; +use Symfony\Component\HttpFoundation\File\File as SymfonyFile; +use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; class FileValidationTest extends TestCase { @@ -55,11 +57,21 @@ public function testItCanValidateAttributeAsArrayWhenValidationShouldFails(strin ], $validator->messages()->all()); } - public function testFileCustomValidationMessages() + #[TestWith([UploadedFile::class])] + #[TestWith([SymfonyUploadedFile::class])] + #[TestWith([SymfonyFile::class])] + public function testFileCustomValidationMessages(string $fileClass): void { + $upload = UploadedFile::fake()->createWithContent('photo', str_repeat('x', 1000 * 1024)); + $file = match ($fileClass) { + UploadedFile::class => $upload, + SymfonyUploadedFile::class => new SymfonyUploadedFile($upload->getPathname(), 'photo', test: true), + SymfonyFile::class => new SymfonyFile($upload->getPathname()), + }; + $validator = Validator::make( [ - 'one' => UploadedFile::fake()->create('photo', 1000), + 'one' => $file, 'two' => 'not-a-file', ], [ diff --git a/tests/Log/ContextQueueTest.php b/tests/Log/ContextQueueTest.php index fd951c425..342442dde 100644 --- a/tests/Log/ContextQueueTest.php +++ b/tests/Log/ContextQueueTest.php @@ -11,6 +11,7 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Contracts\Queue\ShouldQueue; +use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; use Hypervel\Foundation\Bus\Dispatchable; use Hypervel\Foundation\Queue\Queueable; @@ -255,6 +256,24 @@ public function testDehydratingHookFiresBeforeJobDispatch(): void $this->assertArrayHasKey('dehydrated_at', $payload['illuminate:log:context']['data']); } + public function testDehydratingHookContributesContextInAFreshCoroutine(): void + { + Repository::getInstance()->dehydrating(static function (Repository $context): void { + $context->addHidden('locale', 'en'); + }); + + $queue = $this->createSyncQueue(); + $result = new Channel(1); + + Coroutine::create(static function () use ($queue, $result): void { + $result->push($queue->testCreatePayload('SomeJob', null)); + }); + + $payload = $result->pop(1); + + $this->assertSame(serialize('en'), $payload['illuminate:log:context']['hidden']['locale']); + } + public function testHydratedHookFiresWhenJobProcesses(): void { $called = false; diff --git a/tests/Notifications/NotificationSenderTest.php b/tests/Notifications/NotificationSenderTest.php index 6fc4bc349..7676d99be 100644 --- a/tests/Notifications/NotificationSenderTest.php +++ b/tests/Notifications/NotificationSenderTest.php @@ -19,6 +19,7 @@ use Hypervel\Notifications\Notifiable; use Hypervel\Notifications\Notification; use Hypervel\Notifications\NotificationSender; +use Hypervel\Notifications\SendQueuedNotifications; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Attributes\Queue; use Hypervel\Tests\TestCase; @@ -230,16 +231,16 @@ public function testItCanSendQueuedNotificationsWithAnArrayVia(): void { $notifiable = m::mock(Notifiable::class); $manager = m::mock(ChannelManager::class); - $manager->shouldReceive('getContainer')->andReturn(app()); + $manager->shouldReceive('getContainer')->twice()->andReturn(app()); $bus = m::mock(BusDispatcherContract::class); $bus->shouldReceive('dispatch') ->once() - ->withArgs(function ($job) { + ->withArgs(function (SendQueuedNotifications $job): bool { return $job->queue === 'dummy' && $job->channels === ['database'] && $job->connection === 'redis'; }); $bus->shouldReceive('dispatch') ->once() - ->withArgs(function ($job) { + ->withArgs(function (SendQueuedNotifications $job): bool { return $job->queue === 'dummy' && $job->channels === ['mail'] && $job->connection === 'redis'; }); @@ -331,16 +332,16 @@ public function testItCanSendQueuedWithViaConnectionsNotifications(): void { $notifiable = new AnonymousNotifiable; $manager = m::mock(ChannelManager::class); - $manager->shouldReceive('getContainer')->andReturn(app()); + $manager->shouldReceive('getContainer')->twice()->andReturn(app()); $bus = m::mock(BusDispatcherContract::class); $bus->shouldReceive('dispatch') ->once() - ->withArgs(function ($job) { + ->withArgs(function (SendQueuedNotifications $job): bool { return $job->connection === 'sync' && $job->channels === ['database'] && $job->queue === 'dummy'; }); $bus->shouldReceive('dispatch') ->once() - ->withArgs(function ($job) { + ->withArgs(function (SendQueuedNotifications $job): bool { return $job->connection === 'redis' && $job->channels === ['mail'] && $job->queue === 'dummy'; }); @@ -355,16 +356,16 @@ public function testItCanSendQueuedWithViaQueuesNotifications(): void { $notifiable = new AnonymousNotifiable; $manager = m::mock(ChannelManager::class); - $manager->shouldReceive('getContainer')->andReturn(app()); + $manager->shouldReceive('getContainer')->twice()->andReturn(app()); $bus = m::mock(BusDispatcherContract::class); $bus->shouldReceive('dispatch') ->once() - ->withArgs(function ($job) { + ->withArgs(function (SendQueuedNotifications $job): bool { return $job->queue === 'dummy' && $job->channels === ['database'] && $job->connection === 'redis'; }); $bus->shouldReceive('dispatch') ->once() - ->withArgs(function ($job) { + ->withArgs(function (SendQueuedNotifications $job): bool { return $job->queue === 'admin_notifications' && $job->channels === ['mail'] && $job->connection === 'redis'; }); @@ -709,6 +710,9 @@ class DummyQueuedNotificationWithArrayVia extends Notification implements Should { use Queueable; + /** + * Create a new notification instance. + */ public function __construct() { $this->connection = 'redis'; @@ -717,9 +721,8 @@ public function __construct() /** * Get the notification channels. - * @param mixed $notifiable */ - public function via($notifiable) + public function via(mixed $notifiable): array { return ['mail', 'database']; } @@ -777,18 +780,27 @@ class DummyNotificationWithViaConnections extends Notification implements Should { use Queueable; + /** + * Create a new notification instance. + */ public function __construct() { $this->connection = 'redis'; $this->queue = 'dummy'; } - public function via($notifiable) + /** + * Get the notification channels. + */ + public function via(mixed $notifiable): array { return ['mail', 'database']; } - public function viaConnections() + /** + * Determine which connections should be used for each notification channel. + */ + public function viaConnections(): array { return [ 'database' => 'sync', @@ -800,18 +812,27 @@ class DummyNotificationWithViaQueues extends Notification implements ShouldQueue { use Queueable; + /** + * Create a new notification instance. + */ public function __construct() { $this->connection = 'redis'; $this->queue = 'dummy'; } - public function via($notifiable) + /** + * Get the notification channels. + */ + public function via(mixed $notifiable): array { return ['mail', 'database']; } - public function viaQueues() + /** + * Determine which queues should be used for each notification channel. + */ + public function viaQueues(): array { return [ 'mail' => 'admin_notifications', diff --git a/tests/Queue/FailoverQueueTest.php b/tests/Queue/FailoverQueueTest.php index 11b057790..4bfc87fdb 100644 --- a/tests/Queue/FailoverQueueTest.php +++ b/tests/Queue/FailoverQueueTest.php @@ -537,7 +537,11 @@ public function testInspectionDelegatesToTheFirstConnection(): void $redis = m::mock(RedisQueue::class); $queue = new FailoverQueue($manager, $events, ['redis', 'sync']); - $manager->shouldReceive('connection')->times(6)->with('redis')->andReturn($redis); + $manager->shouldReceive('connection')->times(10)->with('redis')->andReturn($redis); + $redis->shouldReceive('totalSize')->once()->withNoArgs()->andReturn(9); + $redis->shouldReceive('totalPendingSize')->once()->withNoArgs()->andReturn(2); + $redis->shouldReceive('totalDelayedSize')->once()->withNoArgs()->andReturn(3); + $redis->shouldReceive('totalReservedSize')->once()->withNoArgs()->andReturn(4); $redis->shouldReceive('pendingJobs')->once()->with('emails')->andReturn($pending = new Collection(['pending'])); $redis->shouldReceive('delayedJobs')->once()->with('emails')->andReturn($delayed = new Collection(['delayed'])); $redis->shouldReceive('reservedJobs')->once()->with('emails')->andReturn($reserved = new Collection(['reserved'])); @@ -545,6 +549,10 @@ public function testInspectionDelegatesToTheFirstConnection(): void $redis->shouldReceive('allDelayedJobs')->once()->andReturn($allDelayed = new Collection(['all-delayed'])); $redis->shouldReceive('allReservedJobs')->once()->andReturn($allReserved = new Collection(['all-reserved'])); + $this->assertSame(9, $queue->totalSize()); + $this->assertSame(2, $queue->totalPendingSize()); + $this->assertSame(3, $queue->totalDelayedSize()); + $this->assertSame(4, $queue->totalReservedSize()); $this->assertSame($pending, $queue->pendingJobs('emails')); $this->assertSame($delayed, $queue->delayedJobs('emails')); $this->assertSame($reserved, $queue->reservedJobs('emails')); diff --git a/tests/Queue/QueueBeanstalkdQueueTest.php b/tests/Queue/QueueBeanstalkdQueueTest.php index 49a763bb5..122b4b940 100644 --- a/tests/Queue/QueueBeanstalkdQueueTest.php +++ b/tests/Queue/QueueBeanstalkdQueueTest.php @@ -20,6 +20,7 @@ use Pheanstalk\Contract\PheanstalkSubscriberInterface; use Pheanstalk\Pheanstalk; use Pheanstalk\Values\Job; +use Pheanstalk\Values\ServerStats; use Pheanstalk\Values\TubeList; use Pheanstalk\Values\TubeName; use Pheanstalk\Values\TubeStats; @@ -73,6 +74,74 @@ public function testSizeIncludesPendingDelayedAndReservedJobsWithOneStatsRequest $this->assertSame(12, $this->queue->size('stack')); } + public function testTotalSizesUseOneServerStatsRequestPerCount(): void + { + $this->setQueue('default', 60); + + $this->queue->getPheanstalk() + ->shouldReceive('stats') + ->times(4) + ->withNoArgs() + ->andReturn(new ServerStats( + currentJobsUrgent: 1, + currentJobsReady: 3, + currentJobsReserved: 5, + currentJobsDelayed: 4, + currentJobsBuried: 6, + cmdPut: 0, + cmdPeek: 0, + cmdPeekReady: 0, + cmdPeekDelayed: 0, + cmdReserveWithTimeout: 0, + cmdPeekBuried: 0, + cmdReserve: 0, + cmdUse: 0, + cmdWatch: 0, + cmdIgnore: 0, + cmdDelete: 0, + cmdRelease: 0, + cmdBury: 0, + cmdKick: 0, + cmdStats: 0, + cmdStatsJob: 0, + cmdStatsTube: 0, + cmdListTubes: 0, + cmdListTubeUsed: 0, + cmdListTubesWatched: 0, + cmdPauseTube: 0, + jobTimeouts: 0, + totalJobs: 18, + maxJobSize: 65535, + currentTubes: 2, + currentConnections: 1, + currentProducers: 0, + currentWorkers: 0, + currentWaiting: 0, + totalConnections: 1, + pid: 1, + version: '1.13', + rusageUtime: 0.0, + rusageStime: 0.0, + binlogOldestIndex: 0, + binlogCurrentIndex: 0, + binlogMaxSize: 0, + binlogRecordsWritten: 0, + draining: false, + id: 'test-server', + hostname: 'localhost', + os: 'Linux', + platform: 'x86_64', + cmdTouch: 0, + uptime: 0, + binlogRecordsMigrated: 0, + )); + + $this->assertSame(12, $this->queue->totalSize()); + $this->assertSame(3, $this->queue->totalPendingSize()); + $this->assertSame(4, $this->queue->totalDelayedSize()); + $this->assertSame(5, $this->queue->totalReservedSize()); + } + public function testInspectionReturnsEmptyCollections(): void { $this->setQueue('default', 60); diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index 1ef287161..85cd25777 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -365,8 +365,7 @@ public function testBulkTreatsAnExplicitFalseInsertAsFailure(): void $connection->shouldReceive('table')->once()->with('table')->andReturn($query = m::mock(Builder::class)); $query->shouldReceive('insert')->once()->andReturnFalse(); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to insert queued jobs into the database.'); + $this->expectExceptionObject(new RuntimeException('Unable to insert queued jobs into the database.')); $queue->bulk(['job']); } @@ -867,6 +866,71 @@ public function testAllReservedJobs(): void $this->assertInspectedJob($jobs->last(), 'SecondReservedJob', 'emails', 2, 42); } + public function testTotalSize(): void + { + $resolver = m::mock(ConnectionResolverInterface::class); + $database = m::mock(ConnectionInterface::class); + $resolver->expects('connection')->with(null)->andReturn($database); + $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704); + $queue->setContainer(m::spy(Container::class)); + + $query = m::mock(Builder::class); + $database->expects('table')->with('table')->andReturn($query); + $query->expects('count')->andReturn(9); + + $this->assertSame(9, $queue->totalSize()); + } + + public function testTotalPendingSize(): void + { + $resolver = m::mock(ConnectionResolverInterface::class); + $database = m::mock(ConnectionInterface::class); + $resolver->expects('connection')->with(null)->andReturn($database); + $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704); + $queue->setContainer(m::spy(Container::class)); + + $query = m::mock(Builder::class); + $database->expects('table')->with('table')->andReturn($query); + $query->expects('whereNull')->with('reserved_at')->andReturnSelf(); + $query->expects('where')->with('available_at', '<=', 1732502704)->andReturnSelf(); + $query->expects('count')->andReturn(2); + + $this->assertSame(2, $queue->totalPendingSize()); + } + + public function testTotalDelayedSize(): void + { + $resolver = m::mock(ConnectionResolverInterface::class); + $database = m::mock(ConnectionInterface::class); + $resolver->expects('connection')->with(null)->andReturn($database); + $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704); + $queue->setContainer(m::spy(Container::class)); + + $query = m::mock(Builder::class); + $database->expects('table')->with('table')->andReturn($query); + $query->expects('whereNull')->with('reserved_at')->andReturnSelf(); + $query->expects('where')->with('available_at', '>', 1732502704)->andReturnSelf(); + $query->expects('count')->andReturn(3); + + $this->assertSame(3, $queue->totalDelayedSize()); + } + + public function testTotalReservedSize(): void + { + $resolver = m::mock(ConnectionResolverInterface::class); + $database = m::mock(ConnectionInterface::class); + $resolver->expects('connection')->with(null)->andReturn($database); + $queue = new TestDatabaseQueue($resolver, null, 'table', 'default', 1732502704); + $queue->setContainer(m::spy(Container::class)); + + $query = m::mock(Builder::class); + $database->expects('table')->with('table')->andReturn($query); + $query->expects('whereNotNull')->with('reserved_at')->andReturnSelf(); + $query->expects('count')->andReturn(4); + + $this->assertSame(4, $queue->totalReservedSize()); + } + public function testInvalidInspectedPayloadIdentifiesItsQueueAndRecord(): void { [$queue, $query] = $this->createInspectionQueue(); diff --git a/tests/Queue/QueuePoolProxyTest.php b/tests/Queue/QueuePoolProxyTest.php index 1419d1edc..4328d4ba0 100644 --- a/tests/Queue/QueuePoolProxyTest.php +++ b/tests/Queue/QueuePoolProxyTest.php @@ -59,6 +59,10 @@ public function testEnumeratedSynchronousSurfaceUsesBorrowScopedInvocation(): vo $queue->shouldReceive('pendingSize')->once()->with('queue')->andReturn(2); $queue->shouldReceive('delayedSize')->once()->with('queue')->andReturn(3); $queue->shouldReceive('reservedSize')->once()->with('queue')->andReturn(4); + $queue->shouldReceive('totalSize')->once()->withNoArgs()->andReturn(18); + $queue->shouldReceive('totalPendingSize')->once()->withNoArgs()->andReturn(5); + $queue->shouldReceive('totalDelayedSize')->once()->withNoArgs()->andReturn(6); + $queue->shouldReceive('totalReservedSize')->once()->withNoArgs()->andReturn(7); $queue->shouldReceive('pendingJobs')->once()->with('queue')->andReturn($pending = new Collection(['pending'])); $queue->shouldReceive('delayedJobs')->once()->with('queue')->andReturn($delayed = new Collection(['delayed'])); $queue->shouldReceive('reservedJobs')->once()->with('queue')->andReturn($reserved = new Collection(['reserved'])); @@ -78,6 +82,10 @@ public function testEnumeratedSynchronousSurfaceUsesBorrowScopedInvocation(): vo $this->assertSame(2, $proxy->pendingSize('queue')); $this->assertSame(3, $proxy->delayedSize('queue')); $this->assertSame(4, $proxy->reservedSize('queue')); + $this->assertSame(18, $proxy->totalSize()); + $this->assertSame(5, $proxy->totalPendingSize()); + $this->assertSame(6, $proxy->totalDelayedSize()); + $this->assertSame(7, $proxy->totalReservedSize()); $this->assertSame($pending, $proxy->pendingJobs('queue')); $this->assertSame($delayed, $proxy->delayedJobs('queue')); $this->assertSame($reserved, $proxy->reservedJobs('queue')); diff --git a/tests/Queue/QueueRedisQueueTest.php b/tests/Queue/QueueRedisQueueTest.php index f6c0eb615..5df64a76b 100644 --- a/tests/Queue/QueueRedisQueueTest.php +++ b/tests/Queue/QueueRedisQueueTest.php @@ -24,14 +24,65 @@ use Hypervel\Queue\RedisQueue; use Hypervel\Redis\RedisProxy; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Collection; use Hypervel\Support\Str; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use Symfony\Component\Uid\Uuid; class QueueRedisQueueTest extends TestCase { + #[DataProvider('totalSizeMethods')] + public function testTotalsUseQueueSizeOverridesInsidePinnedConnection(string $totalMethod, string $sizeMethod): void + { + $pinned = false; + $connection = m::mock(RedisProxy::class); + $connection->expects('withPinnedConnection')->andReturnUsing(function (callable $callback) use (&$pinned): int { + $pinned = true; + + try { + return $callback(); + } finally { + $pinned = false; + } + }); + $redis = m::mock(Redis::class); + $redis->expects('connection')->with(null)->andReturn($connection); + $queue = m::mock(RedisQueue::class, [$redis, 'default']) + ->makePartial() + ->shouldAllowMockingProtectedMethods(); + $queue->expects('allQueueNames')->andReturnUsing(function () use (&$pinned): Collection { + $this->assertTrue($pinned); + + return new Collection(['emails', 'reports:high']); + }); + $queue->shouldReceive($sizeMethod)->twice()->andReturnUsing(function (string $name) use (&$pinned): int { + $this->assertTrue($pinned); + + return match ($name) { + 'emails' => 5, + 'reports:high' => 7, + }; + }); + + $this->assertSame(12, $queue->{$totalMethod}()); + } + + /** + * Provide aggregate methods and their per-queue extension points. + */ + public static function totalSizeMethods(): array + { + return [ + 'all jobs' => ['totalSize', 'size'], + 'pending jobs' => ['totalPendingSize', 'pendingSize'], + 'delayed jobs' => ['totalDelayedSize', 'delayedSize'], + 'reserved jobs' => ['totalReservedSize', 'reservedSize'], + ]; + } + public function testBulkUsesOneLuaCallAndHonorsJobDelays(): void { CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); diff --git a/tests/Queue/WorkCommandTest.php b/tests/Queue/WorkCommandTest.php new file mode 100644 index 000000000..564511530 --- /dev/null +++ b/tests/Queue/WorkCommandTest.php @@ -0,0 +1,142 @@ +make('config'); + $config->set('queue.default', 'sync'); + $config->set('cache.default', 'array'); + } + + public function testStopOutputUsesTheCurrentCommand(): void + { + $this->travelTo(CarbonImmutable::create(2023, 1, 18, 10, 10, 11)); + + $firstOutput = new BufferedOutput; + $this->runWorkerCommand(new WorkerStopping(reason: WorkerStopReason::QueueEmpty), $firstOutput); + + $this->assertSame(" 2023-01-18 10:10:11 Worker STOPPED Queue empty\n", $firstOutput->fetch()); + + $secondOutput = new BufferedOutput; + $this->runWorkerCommand(new WorkerStopping( + status: Worker::EXIT_MEMORY_LIMIT, + reason: WorkerStopReason::MaxMemoryExceeded, + jobsProcessed: 0, + memoryUsage: 64.25, + ), $secondOutput, ['--json' => true]); + + $this->assertSame('', $firstOutput->fetch()); + $this->assertSame([ + 'level' => 'warning', + 'status' => 'stopped', + 'reason' => 'memory', + 'exit_code' => 12, + 'jobs_processed' => 0, + 'memory' => 64.3, + 'timestamp' => '2023-01-18T10:10:11.000000+00:00', + ], json_decode($secondOutput->fetch(), true, 512, JSON_THROW_ON_ERROR)); + } + + public function testStopOutputPreservesMissingMetrics(): void + { + $this->travelTo(CarbonImmutable::create(2023, 1, 18, 10, 10, 11)); + + $output = new BufferedOutput; + $this->runWorkerCommand(new WorkerStopping(reason: WorkerStopReason::QueueEmpty), $output, ['--json' => true]); + + $this->assertSame([ + 'level' => 'info', + 'status' => 'stopped', + 'reason' => 'empty', + 'exit_code' => 0, + 'jobs_processed' => null, + 'memory' => null, + 'timestamp' => '2023-01-18T10:10:11.000000+00:00', + ], json_decode($output->fetch(), true, 512, JSON_THROW_ON_ERROR)); + } + + #[DataProvider('suppressedStopOutputProvider')] + public function testStopOutputIsSuppressed(int $verbosity, ?WorkerStopReason $reason): void + { + $output = new BufferedOutput($verbosity); + $this->runWorkerCommand(new WorkerStopping(reason: $reason), $output, ['--json' => true]); + + $this->assertSame('', $output->fetch()); + } + + /** + * Provide stop events that should not produce output. + */ + public static function suppressedStopOutputProvider(): array + { + return [ + 'quiet' => [OutputInterface::VERBOSITY_QUIET, WorkerStopReason::QueueEmpty], + 'silent' => [OutputInterface::VERBOSITY_SILENT, WorkerStopReason::QueueEmpty], + 'no reason' => [OutputInterface::VERBOSITY_NORMAL, null], + ]; + } + + public function testStopEventsWithoutCommandOptionsDoNotWriteOutput(): void + { + $output = new BufferedOutput; + $this->runWorkerCommand(new WorkerStopping(reason: WorkerStopReason::QueueEmpty), $output, ['--json' => true]); + $output->fetch(); + + $this->app->make('events')->dispatch(new WorkerStopping(reason: WorkerStopReason::QueueEmpty)); + + $this->assertSame('', $output->fetch()); + } + + /** + * Run a distinct command instance that dispatches the given stop event. + * + * @param array $arguments + */ + private function runWorkerCommand(WorkerStopping $event, BufferedOutput $output, array $arguments = []): void + { + $worker = m::mock(Worker::class); + $worker->shouldReceive('setName')->once()->with('default')->andReturnSelf(); + $worker->shouldReceive('setCache')->once()->with(m::type(Repository::class))->andReturnSelf(); + $worker->shouldReceive('daemon') + ->once() + ->with('sync', 'default', m::type(WorkerOptions::class)) + ->andReturnUsing(function (string $connection, string $queue, WorkerOptions $options) use ($event): int { + $event->workerOptions = $options; + $this->app->make('events')->dispatch($event); + + return $event->status; + }); + + $command = new WorkCommand( + $this->app, + $this->app->make('config'), + $worker, + $this->app->make('cache'), + ); + $command->setHypervel($this->app); + $command->run(new ArrayInput($arguments), $output); + } +} diff --git a/tests/Redis/Operations/SafeScanTest.php b/tests/Redis/Operations/SafeScanTest.php index 1f257d8af..8e2c93747 100644 --- a/tests/Redis/Operations/SafeScanTest.php +++ b/tests/Redis/Operations/SafeScanTest.php @@ -35,7 +35,7 @@ public function testScanRejectsTransformedConnections(): void $connection->shouldTransform(); $this->expectException(InvalidRedisConnectionException::class); - $this->expectExceptionMessage('SafeScan requires a raw Redis connection.'); + $this->expectExceptionMessageIsOrContains('SafeScan requires a raw Redis connection.'); new SafeScan($connection, ''); } @@ -136,25 +136,22 @@ public function testScanIteratesMultipleBatches(): void $this->assertSame(2, $client->getScanCallCount()); } - public function testScanDoesNotDoublePrefixWhenPatternAlreadyHasPrefix(): void + public function testScanPreservesLogicalPatternsStartingWithTheConnectionPrefix(): void { $client = new FakeRedisClient( scanResults: [ - ['keys' => ['myapp:cache:key1'], 'iterator' => 0], + ['keys' => ['myapp:myapp:cache:key1'], 'iterator' => 0], ], optPrefix: 'myapp:', ); $safeScan = new SafeScan($this->createConnection($client), 'myapp:'); - // Pattern already has prefix - should NOT add it again $keys = iterator_to_array($safeScan->execute('myapp:cache:*')); - // Should strip prefix from result - $this->assertSame(['cache:key1'], $keys); + $this->assertSame(['myapp:cache:key1'], $keys); - // Pattern should NOT be double-prefixed - $this->assertSame('myapp:cache:*', $client->getScanCalls()[0]['pattern']); + $this->assertSame('myapp:myapp:cache:*', $client->getScanCalls()[0]['pattern']); } public function testScanReturnsKeyAsIsWhenItDoesNotHavePrefix(): void diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index cc1f59218..25d088862 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -2355,6 +2355,63 @@ public function testCompressedReturnsFalseWhenNoCompression(): void $this->assertFalse($connection->compressed()); } + #[DataProvider('scanPrefixOptions')] + public function testWithoutScanPrefixPreservesOtherOptionsAndRestores(bool $retry, bool $prefix): void + { + $redis = new Redis; + $redis->setOption(Redis::OPT_PREFIX, 'app:'); + $redis->setOption(Redis::OPT_SCAN, $retry ? Redis::SCAN_RETRY : Redis::SCAN_NORETRY); + $redis->setOption(Redis::OPT_SCAN, $prefix ? Redis::SCAN_PREFIX : Redis::SCAN_NOPREFIX); + $originalOptions = $redis->getOption(Redis::OPT_SCAN); + $connection = (new PhpRedisConnectionStub)->setActiveConnection($redis); + + $result = $connection->withoutScanPrefix(function () use ($redis, $retry): string { + $this->assertSame($retry ? Redis::SCAN_RETRY : Redis::SCAN_NORETRY, $redis->getOption(Redis::OPT_SCAN)); + $this->assertSame('app:', $redis->getOption(Redis::OPT_PREFIX)); + + return 'callback-result'; + }); + + $this->assertSame('callback-result', $result); + $this->assertSame($originalOptions, $redis->getOption(Redis::OPT_SCAN)); + } + + /** + * Provide independent retry and prefix settings. + */ + public static function scanPrefixOptions(): array + { + return [ + 'neither' => [false, false], + 'retry' => [true, false], + 'prefix' => [false, true], + 'retry and prefix' => [true, true], + ]; + } + + public function testWithoutScanPrefixRestoresOptionsWhenCallbackThrows(): void + { + $redis = new Redis; + $redis->setOption(Redis::OPT_SCAN, Redis::SCAN_RETRY); + $redis->setOption(Redis::OPT_SCAN, Redis::SCAN_PREFIX); + $originalOptions = $redis->getOption(Redis::OPT_SCAN); + $connection = (new PhpRedisConnectionStub)->setActiveConnection($redis); + $failure = new RuntimeException('Callback failed'); + + try { + $connection->withoutScanPrefix(function () use ($redis, $failure): never { + $this->assertSame(Redis::SCAN_RETRY, $redis->getOption(Redis::OPT_SCAN)); + + throw $failure; + }); + $this->fail('Expected the callback exception.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame($originalOptions, $redis->getOption(Redis::OPT_SCAN)); + } + public function testWithoutSerializationOrCompressionDisablesSerializerAndRestores(): void { $connection = $this->mockRedisConnection(); diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 9d28fbc1b..a238edbb6 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -113,6 +113,8 @@ public function testStringHeadline(): void $this->assertSame('Orwell 1984', Str::headline('-orwell-1984 -')); $this->assertSame('Orwell 1984', Str::headline(' orwell_- 1984 ')); + $this->assertSame('❤ Multi Byte ☆', Str::headline('❤_multiByte-☆')); + $nbsp = chr(0xC2) . chr(0xA0); $this->assertSame('Hypervel Rocks!', Str::headline('hypervel' . $nbsp . 'rocks!')); @@ -162,6 +164,10 @@ public function testStringApa(): void $this->assertSame('Self-Report', Str::apa('Self-report')); $this->assertSame('Self-Report', Str::apa('SELF-REPORT')); + $this->assertSame('On-Call Work', Str::apa('on-call work')); + $this->assertSame('A Guide: On-Call Work', Str::apa('a guide: on-call work')); + $this->assertSame('A Guide to on-Call Work', Str::apa('a guide to on-call work')); + $this->assertSame('As the World Turns, So Are the Days of Our Lives', Str::apa('as the world turns, so are the days of our lives')); $this->assertSame('As the World Turns, So Are the Days of Our Lives', Str::apa('AS THE WORLD TURNS, SO ARE THE DAYS OF OUR LIVES')); $this->assertSame('As the World Turns, So Are the Days of Our Lives', Str::apa('As The World Turns, So Are The Days Of Our Lives')); @@ -899,6 +905,17 @@ public static function uuidVersionList(): array ]; } + public function testIsUlid(): void + { + $this->assertTrue(Str::isUlid((string) Str::ulid())); + $this->assertTrue(Str::isUlid('01ARZ3NDEKTSV4RRFFQ69G5FAV')); + + $this->assertFalse(Str::isUlid('not-a-ulid')); + $this->assertFalse(Str::isUlid('01ARZ3NDEKTSV4RRFFQ69G5FA')); + $this->assertFalse(Str::isUlid(null)); + $this->assertFalse(Str::isUlid(['not', 'a', 'ulid'])); + } + public function testIsJson(): void { $this->assertTrue(Str::isJson('1')); @@ -1381,6 +1398,8 @@ public function testStudly(): void $this->assertSame('ÖffentlicheÜberraschungen', Str::studly('öffentliche-überraschungen')); + $this->assertSame('❤MultiByte☆', Str::studly('❤ multi-byte☆')); + $nbsp = chr(0xC2) . chr(0xA0); $this->assertSame('HypervelRocks!', Str::studly('hypervel' . $nbsp . 'rocks!')); @@ -1633,6 +1652,8 @@ public function testUcfirst(): void $this->assertSame('Hypervel framework', Str::ucfirst('hypervel framework')); $this->assertSame('Мама', Str::ucfirst('мама')); $this->assertSame('Мама мыла раму', Str::ucfirst('мама мыла раму')); + $this->assertSame('Džungla', Str::ucfirst('džungla')); + $this->assertSame('Sseta', Str::ucfirst('ßeta')); } public function testUcwords(): void @@ -1643,6 +1664,8 @@ public function testUcwords(): void $this->assertSame('Мама', Str::ucwords('мама')); $this->assertSame('Мама Мыла Раму', Str::ucwords('мама мыла раму')); $this->assertSame('JJ Watt', Str::ucwords('JJ watt')); + $this->assertSame('Мама мыла раму', Str::ucwords('мама мыла раму', '')); + $this->assertSame('', Str::ucwords('', '')); } public function testUcsplit(): void @@ -1759,10 +1782,22 @@ public function testWordCount(): void public function testWordWrap(): void { - $this->assertEquals('Hello
World', Str::wordWrap('Hello World', 3, '
')); - $this->assertEquals('Hel
lo
Wor
ld', Str::wordWrap('Hello World', 3, '
', true)); + $this->assertSame('Hello
World', Str::wordWrap('Hello World', 3, '
')); + $this->assertSame('Hel
lo
Wor
ld', Str::wordWrap('Hello World', 3, '
', true)); + + $this->assertSame('❤Multi
Byte☆❤☆❤☆❤', Str::wordWrap('❤Multi Byte☆❤☆❤☆❤', 3, '
')); + + $this->assertSame('žltý kôň', Str::wordWrap('žltý kôň', 8, "\n")); + $this->assertSame("žltý\nkôň", Str::wordWrap('žltý kôň', 4, "\n", true)); + $this->assertSame("žl\ntý", Str::wordWrap('žltý', 2, "\n", true)); + $this->assertSame("😀😀\n😀😀", Str::wordWrap('😀😀😀😀', 2, "\n", true)); + $this->assertSame("éA\x1ABé", Str::wordWrap('é é', 1, "A\x1AB")); + $this->assertSame('❤Mu
lti
Byt
e☆❤
☆❤☆
❤', Str::wordWrap('❤Multi Byte☆❤☆❤☆❤', 3, '
', true)); - $this->assertEquals('❤Multi
Byte☆❤☆❤☆❤', Str::wordWrap('❤Multi Byte☆❤☆❤☆❤', 3, '
')); + $this->assertSame("éé\néé éé", Str::wordWrap("éé\néé éé", 5, "\n")); + $this->assertSame('éé
éé éé', Str::wordWrap('éé
éé éé', 5, '
', true)); + $this->assertSame('éé☆éé éé', Str::wordWrap('éé☆éé éé', 5, '☆')); + $this->assertSame("é\0\n\x1Aé", Str::wordWrap("é\0\x1Aé", 2, "\n", true)); } public function testMarkdown(): void @@ -2088,6 +2123,28 @@ public function testUlidSequenceIsRestoredWhenNormalGenerationThrows(): void ThrowingSequenceStr::createUlidsNormally(); } + public function testResetFactoryState(): void + { + $uuid = Uuid::fromString('00000000-0000-0000-0000-000000000000'); + $ulid = new Ulid('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + + Str::macro('factoryResetMacro', fn (): bool => true); + Str::createRandomStringsUsing(fn (int $length): string => 'random:' . $length); + Str::createUuidsUsing(fn (): Uuid => $uuid); + Str::createUlidsUsing(fn (): Ulid => $ulid); + + $this->assertSame('random:7', Str::random(7)); + $this->assertSame((string) $uuid, (string) Str::uuid()); + $this->assertSame((string) $ulid, (string) Str::ulid()); + + Str::resetFactoryState(); + + $this->assertNotSame('random:7', Str::random(7)); + $this->assertNotSame((string) $uuid, (string) Str::uuid()); + $this->assertNotSame((string) $ulid, (string) Str::ulid()); + $this->assertTrue(Str::hasMacro('factoryResetMacro')); + } + public function testPasswordCreation(): void { $this->assertTrue(strlen(Str::password()) === 32); @@ -2263,6 +2320,21 @@ public function count(): int $this->assertSame('UserGroups', Str::pluralPascal('UserGroup', $countable)); } + + public function testPluralStudly(): void + { + $this->assertSame('VerifiedHumans', Str::pluralStudly('VerifiedHuman')); + $this->assertSame('UserFeedback', Str::pluralStudly('UserFeedback')); + $this->assertSame('VerifiedHuman', Str::pluralStudly('VerifiedHuman', 1)); + $this->assertSame('VerifiedHumans', Str::pluralStudly('VerifiedHuman', 2)); + } + + public function testSingular(): void + { + $this->assertSame('child', Str::singular('children')); + $this->assertSame('mouse', Str::singular('mice')); + $this->assertSame('Laracon', Str::singular('Laracons')); + } } class ThrowingSequenceStr extends Str diff --git a/tests/Support/SupportStringableTest.php b/tests/Support/SupportStringableTest.php index 63d56d781..ea86fdf2c 100644 --- a/tests/Support/SupportStringableTest.php +++ b/tests/Support/SupportStringableTest.php @@ -18,6 +18,7 @@ use Hypervel\Tests\TestCase; use League\CommonMark\Environment\EnvironmentBuilderInterface; use League\CommonMark\Extension\ExtensionInterface; +use Symfony\Component\VarDumper\VarDumper; class SupportStringableTest extends TestCase { @@ -218,7 +219,7 @@ public function testCanBeLimitedByWords() $this->assertSame('Taylor Otwell', (string) $this->stringable('Taylor Otwell')->words(3)); } - public function testUcwords() + public function testUcwords(): void { $this->assertSame('Hypervel', (string) $this->stringable('hypervel')->ucwords()); $this->assertSame('Hypervel Framework', (string) $this->stringable('hypervel framework')->ucwords()); @@ -226,6 +227,8 @@ public function testUcwords() $this->assertSame('Мама', (string) $this->stringable('мама')->ucwords()); $this->assertSame('Мама Мыла Раму', (string) $this->stringable('мама мыла раму')->ucwords()); $this->assertSame('JJ Watt', (string) $this->stringable('JJ watt')->ucwords()); + $this->assertSame('Мама мыла раму', (string) $this->stringable('мама мыла раму')->ucwords('')); + $this->assertSame('', (string) $this->stringable('')->ucwords('')); } public function testUnless() @@ -299,6 +302,13 @@ public function testDirname() $this->assertSame(DIRECTORY_SEPARATOR, (string) $this->stringable('/')->dirname()); } + public function testBasename(): void + { + $this->assertSame('Support', (string) $this->stringable('/framework/tests/Support')->basename()); + $this->assertSame('Str.php', (string) $this->stringable('/framework/src/Str.php')->basename()); + $this->assertSame('Str', (string) $this->stringable('/framework/src/Str.php')->basename('.php')); + } + public function testUcsplitOnStringable() { $this->assertSame(['Taylor', 'Otwell'], $this->stringable('TaylorOtwell')->ucsplit()->toArray()); @@ -646,6 +656,55 @@ public function testTitle() $this->assertSame('Jefferson Costella', (string) $this->stringable('jefFErson coSTella')->title()); } + public function testHeadline(): void + { + $this->assertSame('Jefferson Costella', (string) $this->stringable('jefferson costella')->headline()); + $this->assertSame('Hypervel Php Framework', (string) $this->stringable('hypervel_php_framework')->headline()); + $this->assertSame('Foo Bar Baz', (string) $this->stringable('foo-barBaz')->headline()); + } + + public function testApa(): void + { + $this->assertSame('Back to the Future', (string) $this->stringable('back to the future')->apa()); + $this->assertSame('Self-Report', (string) $this->stringable('self-report')->apa()); + } + + public function testLcfirst(): void + { + $this->assertSame('hypervel', (string) $this->stringable('Hypervel')->lcfirst()); + $this->assertSame('hypervel framework', (string) $this->stringable('Hypervel framework')->lcfirst()); + } + + public function testUcfirst(): void + { + $this->assertSame('Hypervel', (string) $this->stringable('hypervel')->ucfirst()); + $this->assertSame('Hypervel framework', (string) $this->stringable('hypervel framework')->ucfirst()); + } + + public function testConvertCase(): void + { + $this->assertSame('HELLO', (string) $this->stringable('hello')->convertCase(MB_CASE_UPPER)); + $this->assertSame('hello', (string) $this->stringable('HELLO')->convertCase(MB_CASE_LOWER)); + } + + public function testWordWrap(): void + { + $this->assertSame('Hello
World', (string) $this->stringable('Hello World')->wordWrap(3, '
')); + $this->assertSame('Hel
lo
Wor
ld', (string) $this->stringable('Hello World')->wordWrap(3, '
', true)); + } + + public function testPlural(): void + { + $this->assertSame('Laracons', (string) $this->stringable('Laracon')->plural(3)); + $this->assertSame('Laracon', (string) $this->stringable('Laracon')->plural(1)); + } + + public function testSingular(): void + { + $this->assertSame('child', (string) $this->stringable('children')->singular()); + $this->assertSame('mouse', (string) $this->stringable('mice')->singular()); + } + public function testWithoutWordsDoesntProduceError() { $nbsp = chr(0xC2) . chr(0xA0); @@ -1049,6 +1108,20 @@ public function testKebab() $this->assertSame('hypervel-php-framework', (string) $this->stringable('HypervelPhpFramework')->kebab()); } + public function testChopStart(): void + { + $this->assertSame('hypervel.com', (string) $this->stringable('http://hypervel.com')->chopStart('http://')); + $this->assertSame('http://hypervel.com', (string) $this->stringable('http://hypervel.com')->chopStart('https://')); + $this->assertSame('hypervel.com', (string) $this->stringable('http://hypervel.com')->chopStart(['https://', 'http://'])); + } + + public function testChopEnd(): void + { + $this->assertSame('path/to/file', (string) $this->stringable('path/to/file.php')->chopEnd('.php')); + $this->assertSame('path/to/file.php', (string) $this->stringable('path/to/file.php')->chopEnd('.html')); + $this->assertSame('path/to/file', (string) $this->stringable('path/to/file.php')->chopEnd(['.html', '.php'])); + } + public function testLower() { $this->assertSame('foo bar baz', (string) $this->stringable('FOO BAR BAZ')->lower()); @@ -1663,4 +1736,21 @@ public function testEncryptAndDecrypt() $this->assertNotSame('foo', $encrypted->value()); $this->assertSame('foo', $encrypted->decrypt()->value()); } + + public function testDump(): void + { + $log = new Collection; + + $previousHandler = VarDumper::setHandler(function (mixed $value) use ($log): void { + $log->add($value); + }); + + try { + $this->stringable('foo')->dump('one', 'two'); + + $this->assertSame(['foo', 'one', 'two'], $log->all()); + } finally { + VarDumper::setHandler($previousHandler); + } + } } diff --git a/tests/Support/SupportTestingQueueFakeTest.php b/tests/Support/SupportTestingQueueFakeTest.php index 4c51f1e5a..2feb4f866 100644 --- a/tests/Support/SupportTestingQueueFakeTest.php +++ b/tests/Support/SupportTestingQueueFakeTest.php @@ -14,6 +14,7 @@ use Hypervel\Contracts\Queue\Queue; use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Foundation\Application; +use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\CallQueuedClosure; use Hypervel\Queue\Jobs\InspectedJob; use Hypervel\Queue\QueueManager; @@ -235,6 +236,31 @@ public function testAssertPushedUsingBulk(): void $this->fake->assertPushed(JobStub::class, 2); } + public function testBulkRespectsDelayAttribute(): void + { + $this->fake->bulk([ + new JobWithDelayAttributeStub, + new JobStub, + ], ['foo' => 'bar'], 'redis'); + + $this->assertSame(1, $this->fake->delayedSize('redis')); + $this->fake->assertPushedOn('redis', JobWithDelayAttributeStub::class); + $this->fake->assertPushed(JobWithDelayAttributeStub::class, function (JobWithDelayAttributeStub $job, ?string $queue, mixed $data): bool { + return $queue === 'redis' && $data === ['foo' => 'bar']; + }); + $this->fake->assertPushedOn('redis', JobStub::class); + } + + public function testBulkRespectsRuntimeDelay(): void + { + $job = (new JobWithRuntimeDelayStub)->delay(30); + + $this->fake->bulk([$job], '', 'redis'); + + $this->assertSame(1, $this->fake->delayedSize('redis')); + $this->fake->assertPushedOn('redis', JobWithRuntimeDelayStub::class); + } + public function testPushOnAndLaterOnAcceptUnitEnums(): void { $this->fake->pushOn(QueueNameEnumStub::Foo, $this->job); @@ -641,6 +667,23 @@ public function testAllPendingJobs(): void $this->assertTrue($pending->contains(fn ($job) => $job->name === JobToFakeStub::class)); } + public function testTotalSize(): void + { + $this->fake->push($this->job, '', 'foo'); + $this->fake->later(10, new JobToFakeStub, '', 'bar'); + $this->fake->reserve(new JobToFakeStub, 'baz'); + + $this->assertSame(3, $this->fake->totalSize()); + } + + public function testTotalPendingSize(): void + { + $this->fake->push($this->job, '', 'foo'); + $this->fake->push(new JobToFakeStub, '', 'bar'); + + $this->assertSame(2, $this->fake->totalPendingSize()); + } + public function testDelayedJobs(): void { $this->fake->later(10, $this->job, '', 'foo'); @@ -668,6 +711,14 @@ public function testAllDelayedJobs(): void $this->assertTrue($delayed->contains(fn ($job) => $job->name === JobToFakeStub::class)); } + public function testTotalDelayedSize(): void + { + $this->fake->later(10, $this->job, '', 'foo'); + $this->fake->later(10, new JobToFakeStub, '', 'bar'); + + $this->assertSame(2, $this->fake->totalDelayedSize()); + } + public function testDelayedSize(): void { $this->fake->later(10, $this->job, '', 'foo'); @@ -695,6 +746,10 @@ public function testPendingDelayedAndReservedJobsAreDisjoint(): void $this->assertSame(1, $this->fake->delayedSize('foo')); $this->assertSame(1, $this->fake->reservedSize('foo')); $this->assertSame(3, $this->fake->size('foo')); + $this->assertSame(1, $this->fake->totalPendingSize()); + $this->assertSame(1, $this->fake->totalDelayedSize()); + $this->assertSame(1, $this->fake->totalReservedSize()); + $this->assertSame(3, $this->fake->totalSize()); $this->fake->assertCount(2); } @@ -756,6 +811,14 @@ public function testAllReservedJobs(): void $this->assertTrue($reserved->contains(fn ($job) => $job->name === JobToFakeStub::class)); } + public function testTotalReservedSize(): void + { + $this->fake->reserve($this->job, 'foo'); + $this->fake->reserve(new JobToFakeStub, 'bar'); + + $this->assertSame(2, $this->fake->totalReservedSize()); + } + public function testReservedSize(): void { $this->fake->reserve($this->job, 'foo'); @@ -964,6 +1027,31 @@ public function handle(): void } } +#[Delay(15)] +class JobWithDelayAttributeStub +{ + use Queueable; + + /** + * Handle the job. + */ + public function handle(): void + { + } +} + +class JobWithRuntimeDelayStub +{ + use Queueable; + + /** + * Handle the job. + */ + public function handle(): void + { + } +} + class JobWithConnectionStub { use Queueable; diff --git a/tests/Testbench/Foundation/Console/ServeCommandTest.php b/tests/Testbench/Foundation/Console/ServeCommandTest.php index c7ade76e8..980ef773a 100644 --- a/tests/Testbench/Foundation/Console/ServeCommandTest.php +++ b/tests/Testbench/Foundation/Console/ServeCommandTest.php @@ -4,6 +4,8 @@ namespace Hypervel\Tests\Testbench\Foundation\Console; +use Composer\Config as ComposerConfig; +use Composer\Util\ProcessExecutor; use Hypervel\Console\OutputStyle; use Hypervel\Console\View\Components\Factory; use Hypervel\Contracts\Config\Repository; @@ -30,10 +32,16 @@ class ServeCommandTest extends TestCase /** @var array{process: false|string, environment_exists: bool, environment: mixed, server_exists: bool, server: mixed} */ private array $workingPathState; + private int $processTimeout; + + /** + * Capture the working environment and Composer timeout. + */ protected function setUp(): void { parent::setUp(); + $this->processTimeout = ProcessExecutor::getTimeout(); $this->workingPathState = [ 'process' => getenv(self::WORKING_PATH_ENV), 'environment_exists' => array_key_exists(self::WORKING_PATH_ENV, $_ENV), @@ -43,6 +51,9 @@ protected function setUp(): void ]; } + /** + * Restore the working environment and Composer timeout. + */ protected function tearDown(): void { try { @@ -63,6 +74,8 @@ protected function tearDown(): void unset($_SERVER[self::WORKING_PATH_ENV]); } } finally { + ProcessExecutor::setTimeout($this->processTimeout); + parent::tearDown(); } } @@ -100,9 +113,13 @@ public function itStartsTheUnderlyingServerCommandAndDispatchesLifecycleEvents() Application::getInstance()->setRunningInConsole(false); + class_exists(ComposerConfig::class); + ProcessExecutor::setTimeout(300); + $result = $command->run(new ArrayInput([]), new NullOutput); $this->assertSame(0, $result); + $this->assertSame(0, ProcessExecutor::getTimeout()); $this->assertCount(1, $startedEvents); $this->assertCount(1, $endedEvents); $this->assertSame(0, $endedEvents[0]->exitCode); diff --git a/tests/Validation/ValidationCompiledExecutionTest.php b/tests/Validation/ValidationCompiledExecutionTest.php index 7158ca991..7d27fc242 100644 --- a/tests/Validation/ValidationCompiledExecutionTest.php +++ b/tests/Validation/ValidationCompiledExecutionTest.php @@ -16,10 +16,12 @@ use Hypervel\Validation\Rule; use Hypervel\Validation\Validator; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionProperty; -use SplFileInfo; use stdClass; use Stringable; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; class ValidationCompiledExecutionTest extends TestCase { @@ -73,7 +75,7 @@ public function testRuntimeDispatchedSizeRulesMatchCompiledAndDelegatedExecution [['value' => '2.00'], ['value' => 'max:3|decimal:2'], true], [['value' => '2.00'], ['value' => 'string|numeric|max:3'], true], [['value' => [1, 2, 3]], ['value' => 'between:2,3'], true], - [['value' => new SplFileInfo(__FILE__)], ['value' => 'file|min:0|max:1000'], true], + [['value' => new File(__FILE__)], ['value' => 'file|min:0|max:1000'], true], [['value' => 'abc'], ['value' => 'size:3.0000000000000000001'], false], ]; @@ -691,20 +693,28 @@ public function getMultiCount(string $collection, string $column, array $values, $this->assertTrue($v->passes()); } - public function testInvalidUploadedFileProducesUploadedError() + #[TestWith([UploadedFile::class, UPLOAD_ERR_INI_SIZE, ''])] + #[TestWith([SymfonyUploadedFile::class, UPLOAD_ERR_INI_SIZE, ''])] + #[TestWith([UploadedFile::class, UPLOAD_ERR_PARTIAL, __DIR__ . '/Fixtures/image.png'])] + #[TestWith([SymfonyUploadedFile::class, UPLOAD_ERR_PARTIAL, __DIR__ . '/Fixtures/image.png'])] + public function testInvalidUploadedFileProducesUploadedError(string $fileClass, int $error, string $path): void { - $file = new UploadedFile( - path: '', + $file = new $fileClass( + path: $path, originalName: 'test.jpg', mimeType: 'image/jpeg', - error: UPLOAD_ERR_INI_SIZE, + error: $error, test: true, ); - $v = $this->makeValidator(['file' => $file], ['file' => 'required|image']); - $v->passes(); + foreach ([Validator::class, DelegatedValidationValidator::class] as $validatorClass) { + foreach (['required|image', 'max:1000'] as $rules) { + $v = $this->makeValidator(['file' => $file], ['file' => $rules], validatorClass: $validatorClass); - $this->assertTrue($v->errors()->has('file')); + $this->assertFalse($v->passes()); + $this->assertSame(['validation.uploaded'], $v->errors()->get('file')); + } + } } public function testExcludeAttributesResetAcrossValidatorReuse() diff --git a/tests/Validation/ValidationPlanExecutorTest.php b/tests/Validation/ValidationPlanExecutorTest.php index d3a72932d..6db1e6752 100644 --- a/tests/Validation/ValidationPlanExecutorTest.php +++ b/tests/Validation/ValidationPlanExecutorTest.php @@ -12,8 +12,8 @@ use Hypervel\Validation\InlineCheck; use Hypervel\Validation\RulePlan\ExposedExecutorValidator; use PHPUnit\Framework\Attributes\DataProvider; -use SplFileInfo; use Stringable; +use Symfony\Component\HttpFoundation\File\File; class ValidationPlanExecutorTest extends TestCase { @@ -182,9 +182,12 @@ public function __toString(): string return 'value'; } }; - $file = new class(__FILE__) extends SplFileInfo { + $file = new class(__FILE__, false) extends File { public int $reads = 0; + /** + * Get the file size and record the read. + */ public function getSize(): int|false { ++$this->reads; diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index af2ddf6ed..4ca8538a5 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -41,10 +41,13 @@ use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhpExtension; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionProperty; use RuntimeException; use SplFileInfo; use stdClass; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile; use UnitEnum; class ValidationValidatorTest extends TestCase @@ -1104,7 +1107,7 @@ public function testCustomValidationLinesAreRespected() $this->assertSame('really required!', $v->messages()->first('name')); } - public function testCustomValidationLinesForSizeRules() + public function testCustomValidationLinesForSizeRules(): void { $trans = $this->getArrayTranslator(); $trans->getLoader()->addMessages('en', 'validation', [ @@ -1123,10 +1126,15 @@ public function testCustomValidationLinesForSizeRules() $this->assertFalse($v->passes()); $this->assertSame('Custom message for image filenames.', $v->messages()->first('image')); - $file = new UploadedFile(__FILE__, ''); - $v = new Validator($trans, ['image' => $file], ['image' => 'gte:50']); - $this->assertFalse($v->passes()); - $this->assertSame('Custom message for image files.', $v->messages()->first('image')); + foreach ([ + new UploadedFile(__FILE__, '', test: true), + new SymfonyUploadedFile(__FILE__, '', test: true), + new File(__FILE__), + ] as $file) { + $v = new Validator($trans, ['image' => $file], ['image' => 'gte:50']); + $this->assertFalse($v->passes()); + $this->assertSame('Custom message for image files.', $v->messages()->first('image')); + } } public function testCustomValidationLinesAreRespectedWithAsterisks() @@ -1204,7 +1212,7 @@ public function testValidationDotCustomDotAnythingCanBeTranslated() $this->assertSame('should be integer!', $v->messages()->first('validation.custom.1')); } - public function testInlineValidationMessagesAreRespected() + public function testInlineValidationMessagesAreRespected(): void { $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['name' => ''], ['name' => 'Required'], ['name.required' => 'require it please!']); @@ -1223,6 +1231,21 @@ public function testInlineValidationMessagesAreRespected() $this->assertFalse($v->passes()); $v->messages()->setFormat(':message'); $this->assertSame('name should be of length 9', $v->messages()->first('name')); + + foreach ([ + $this->uploadedFile(__FILE__, '', isValid: true, size: 4072), + new SymfonyUploadedFile(__FILE__, '', test: true), + new File(__FILE__), + ] as $file) { + $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:3'], [ + 'max' => [ + 'file' => ':attribute must not exceed :max kilobytes.', + 'string' => ':attribute must not exceed :max characters.', + ], + ]); + $this->assertFalse($v->passes()); + $this->assertSame('photo must not exceed 3 kilobytes.', $v->messages()->first('photo')); + } } #[DataProvider('integerMessageParameterCases')] @@ -1338,25 +1361,25 @@ public function testIfRulesAreSuccessfullyAdded() $this->assertFalse($v->hasRule('bar', 'Required')); } - public function testValidateArray() + public function testValidateArray(): void { $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['foo' => [1, 2, 3]], ['foo' => 'Array']); $this->assertTrue($v->passes()); - $v = new Validator($trans, ['foo' => new SplFileInfo('/tmp/foo')], ['foo' => 'Array']); + $v = new Validator($trans, ['foo' => new File('/tmp/foo', false)], ['foo' => 'Array']); $this->assertFalse($v->passes()); } - public function testValidateList() + public function testValidateList(): void { $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['foo' => [1, 2, 3]], ['foo' => 'list']); $this->assertTrue($v->passes()); - $v = new Validator($trans, ['foo' => new SplFileInfo('/tmp/foo')], ['foo' => 'list']); + $v = new Validator($trans, ['foo' => new File('/tmp/foo', false)], ['foo' => 'list']); $this->assertFalse($v->passes()); $v = new Validator($trans, ['foo' => [1 => 1, 2 => 2]], ['foo' => 'list']); @@ -1675,7 +1698,7 @@ public function testValidatePresentWithAll() $this->assertSame('The foo field must be present when bar / baz are present.', $v->errors()->first('foo')); } - public function testValidateRequired() + public function testValidateRequired(): void { $trans = $this->getArrayTranslator(); $v = new Validator($trans, [], ['name' => 'Required']); @@ -1687,16 +1710,16 @@ public function testValidateRequired() $v = new Validator($trans, ['name' => 'foo'], ['name' => 'Required']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(''); + $file = new File('', false); $v = new Validator($trans, ['name' => $file], ['name' => 'Required']); $this->assertFalse($v->passes()); - $file = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); $v = new Validator($trans, ['name' => $file], ['name' => 'Required']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(__FILE__); - $file2 = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); + $file2 = new File(__FILE__, false); $v = new Validator($trans, ['files' => [$file, $file2]], ['files.0' => 'Required', 'files.1' => 'Required']); $this->assertTrue($v->passes()); @@ -1704,7 +1727,7 @@ public function testValidateRequired() $this->assertTrue($v->passes()); } - public function testValidateRequiredWith() + public function testValidateRequiredWith(): void { $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['first' => 'Taylor'], ['last' => 'required_with:first']); @@ -1722,17 +1745,17 @@ public function testValidateRequiredWith() $v = new Validator($trans, ['first' => 'Taylor', 'last' => 'Otwell'], ['last' => 'required_with:first']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(''); + $file = new File('', false); $v = new Validator($trans, ['file' => $file, 'foo' => ''], ['foo' => 'required_with:file']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(__FILE__); - $foo = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); + $foo = new File(__FILE__, false); $v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_with:file']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(__FILE__); - $foo = new SplFileInfo(''); + $file = new File(__FILE__, false); + $foo = new File('', false); $v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_with:file']); $this->assertFalse($v->passes()); } @@ -1747,7 +1770,7 @@ public function testRequiredWithAll() $this->assertFalse($v->passes()); } - public function testValidateRequiredWithout() + public function testValidateRequiredWithout(): void { $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['first' => 'Taylor'], ['last' => 'required_without:first']); @@ -1768,35 +1791,35 @@ public function testValidateRequiredWithout() $v = new Validator($trans, ['last' => 'Otwell'], ['last' => 'required_without:first']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(''); + $file = new File('', false); $v = new Validator($trans, ['file' => $file], ['foo' => 'required_without:file']); $this->assertFalse($v->passes()); - $foo = new SplFileInfo(''); + $foo = new File('', false); $v = new Validator($trans, ['foo' => $foo], ['foo' => 'required_without:file']); $this->assertFalse($v->passes()); - $foo = new SplFileInfo(__FILE__); + $foo = new File(__FILE__, false); $v = new Validator($trans, ['foo' => $foo], ['foo' => 'required_without:file']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(__FILE__); - $foo = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); + $foo = new File(__FILE__, false); $v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_without:file']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(__FILE__); - $foo = new SplFileInfo(''); + $file = new File(__FILE__, false); + $foo = new File('', false); $v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_without:file']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(''); - $foo = new SplFileInfo(__FILE__); + $file = new File('', false); + $foo = new File(__FILE__, false); $v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_without:file']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(''); - $foo = new SplFileInfo(''); + $file = new File('', false); + $foo = new File('', false); $v = new Validator($trans, ['file' => $file, 'foo' => $foo], ['foo' => 'required_without:file']); $this->assertFalse($v->passes()); } @@ -2072,7 +2095,7 @@ public function testRequiredUnless() $this->assertSame('The last field is required unless first is in taylor, sven.', $v->messages()->first('last')); } - public function testProhibited() + public function testProhibited(): void { $trans = $this->getArrayTranslator(); @@ -2085,16 +2108,16 @@ public function testProhibited() $v = new Validator($trans, ['name' => 'foo'], ['name' => 'prohibited']); $this->assertTrue($v->fails()); - $file = new SplFileInfo(''); + $file = new File('', false); $v = new Validator($trans, ['name' => $file], ['name' => 'prohibited']); $this->assertTrue($v->passes()); - $file = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); $v = new Validator($trans, ['name' => $file], ['name' => 'prohibited']); $this->assertTrue($v->fails()); - $file = new SplFileInfo(__FILE__); - $file2 = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); + $file2 = new File(__FILE__, false); $v = new Validator($trans, ['files' => [$file, $file2]], ['files.0' => 'prohibited', 'files.1' => 'prohibited']); $this->assertTrue($v->fails()); @@ -2368,21 +2391,23 @@ public function count(): int ]; } - public function testFailedFileUploads() + #[TestWith([UploadedFile::class])] + #[TestWith([SymfonyUploadedFile::class])] + public function testFailedFileUploads(string $fileClass): void { $trans = $this->getArrayTranslator(); // If file is not successfully uploaded validation should fail with a // 'uploaded' error message instead of the original rule. - $file = m::mock(UploadedFile::class); - $file->shouldReceive('isValid')->andReturn(false); + $file = m::mock($fileClass); + $file->shouldReceive('isValid')->once()->andReturn(false); $file->shouldNotReceive('getSize'); $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:10']); $this->assertTrue($v->fails()); $this->assertEquals(['validation.uploaded'], $v->errors()->get('photo')); // Even "required" will not run if the file failed to upload. - $file = m::mock(UploadedFile::class); + $file = m::mock($fileClass); $file->shouldReceive('isValid')->once()->andReturn(false); $v = new Validator($trans, ['photo' => $file], ['photo' => 'required']); $this->assertTrue($v->fails()); @@ -2390,20 +2415,33 @@ public function testFailedFileUploads() // It should only fail with that rule if a validation rule implies it's // a file. Otherwise it should fail with the regular rule. - $file = m::mock(UploadedFile::class); - $file->shouldReceive('isValid')->andReturn(false); + $file = m::mock($fileClass); + $file->shouldReceive('isValid')->once()->andReturn(false); $v = new Validator($trans, ['photo' => $file], ['photo' => 'string']); $this->assertTrue($v->fails()); $this->assertEquals(['validation.string'], $v->errors()->get('photo')); // Validation shouldn't continue if a file failed to upload. - $file = m::mock(UploadedFile::class); + $file = m::mock($fileClass); $file->shouldReceive('isValid')->once()->andReturn(false); $v = new Validator($trans, ['photo' => $file], ['photo' => 'file|mimes:pdf|min:10']); $this->assertTrue($v->fails()); $this->assertEquals(['validation.uploaded'], $v->errors()->get('photo')); } + #[TestWith([UploadedFile::class])] + #[TestWith([SymfonyUploadedFile::class])] + public function testDirectFileRulesRejectFailedUploads(string $fileClass): void + { + $file = m::mock($fileClass); + $file->shouldReceive('isValid')->twice()->andReturn(false); + $file->shouldNotReceive('getSize'); + $validator = new Validator($this->getArrayTranslator(), [], []); + + $this->assertFalse($validator->isValidFileInstance($file)); + $this->assertFalse($validator->validateMax('photo', $file, [10])); + } + public function testValidateInArray() { $trans = $this->getArrayTranslator(); @@ -4153,7 +4191,7 @@ public static function multipleOfDataProvider() ]; } - public function testProperMessagesAreReturnedForSizes() + public function testProperMessagesAreReturnedForSizes(): void { $trans = $this->getArrayTranslator(); $trans->addLines(['validation.min.numeric' => 'numeric', 'validation.size.string' => 'string', 'validation.max.file' => 'file'], 'en'); @@ -4167,11 +4205,16 @@ public function testProperMessagesAreReturnedForSizes() $v->messages()->setFormat(':message'); $this->assertSame('string', $v->messages()->first('name')); - $file = $this->uploadedFile(__FILE__, '', isValid: true, size: 4072); - $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:3']); - $this->assertFalse($v->passes()); - $v->messages()->setFormat(':message'); - $this->assertSame('file', $v->messages()->first('photo')); + foreach ([ + $this->uploadedFile(__FILE__, '', isValid: true, size: 4072), + new SymfonyUploadedFile(__FILE__, '', test: true), + new File(__FILE__), + ] as $file) { + $v = new Validator($trans, ['photo' => $file], ['photo' => 'Max:3']); + $this->assertFalse($v->passes()); + $v->messages()->setFormat(':message'); + $this->assertSame('file', $v->messages()->first('photo')); + } } public function testValidateGtPlaceHolderIsReplacedProperly() @@ -5570,20 +5613,21 @@ public function testValidateImageDimensions(): void $v = new Validator($trans, ['x' => $svgXmlUploadedFile], ['x' => 'dimensions:max_width=1,max_height=1']); $this->assertTrue($v->passes()); - $svgXmlFile = new UploadedFile(__DIR__ . '/Fixtures/image.svg', '', 'image/svg+xml', null, true); + $svgXmlFile = new File(__DIR__ . '/Fixtures/image.svg'); $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['x' => $svgXmlFile], ['x' => 'dimensions:max_width=1,max_height=1']); $this->assertTrue($v->passes()); // Ensure svg images always pass as size is irrelevant (image/svg) - $svgUploadedFile = new UploadedFile(__DIR__ . '/Fixtures/image2.svg', '', 'image/svg', null, true); + $svgUploadedFile = $this->uploadedFile(__DIR__ . '/Fixtures/image2.svg', '', mimeType: 'image/svg'); $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['x' => $svgUploadedFile], ['x' => 'dimensions:max_width=1,max_height=1']); $this->assertTrue($v->passes()); - $svgFile = new UploadedFile(__DIR__ . '/Fixtures/image2.svg', '', 'image/svg', null, true); + $svgFile = m::mock(File::class, [__DIR__ . '/Fixtures/image2.svg'])->makePartial(); + $svgFile->shouldReceive('getMimeType')->once()->andReturn('image/svg'); $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['x' => $svgFile], ['x' => 'dimensions:max_width=1,max_height=1']); @@ -5674,7 +5718,7 @@ public function testValidateExtension() $this->assertFalse($v->passes()); } - public function testValidateMimeEnforcesPhpCheck() + public function testValidateMimeEnforcesPhpCheck(): void { $trans = $this->getArrayTranslator(); $file = $this->uploadedFile(__FILE__, '', guessedExtension: 'pdf', clientOriginalExtension: 'php'); @@ -5684,10 +5728,20 @@ public function testValidateMimeEnforcesPhpCheck() $file2 = $this->uploadedFile(__FILE__, '', guessedExtension: 'php', clientOriginalExtension: 'php'); $v = new Validator($trans, ['x' => $file2], ['x' => 'mimes:pdf,php']); $this->assertTrue($v->passes()); + + $file = new SymfonyUploadedFile(__DIR__ . '/Fixtures/image.png', 'image.php', test: true); + $v = new Validator($trans, ['x' => $file], ['x' => 'mimes:png']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['x' => $file], ['x' => 'mimetypes:image/png']); + $this->assertFalse($v->passes()); + + $v = new Validator($trans, ['x' => $file], ['x' => 'mimes:png,php']); + $this->assertTrue($v->passes()); } #[RequiresPhpExtension('fileinfo')] - public function testValidateFile() + public function testValidateFile(): void { $trans = $this->getArrayTranslator(); $file = new UploadedFile(__FILE__, '', null, null, true); @@ -5697,6 +5751,15 @@ public function testValidateFile() $v = new Validator($trans, ['x' => $file], ['x' => 'file']); $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['x' => new SymfonyUploadedFile(__FILE__, '', test: true)], ['x' => 'file']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['x' => new File(__FILE__)], ['x' => 'file']); + $this->assertTrue($v->passes()); + + $v = new Validator($trans, ['x' => new SplFileInfo(__FILE__)], ['x' => 'file']); + $this->assertTrue($v->fails()); } public function testEmptyRulesSkipped() @@ -8685,19 +8748,19 @@ public function testNestedInvalidMethod() ); } - public function testMultipleFileUploads() + public function testMultipleFileUploads(): void { $trans = $this->getArrayTranslator(); - $file = new SplFileInfo(__FILE__); - $file2 = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); + $file2 = new File(__FILE__, false); $v = new Validator($trans, ['file' => [$file, $file2]], ['file.*' => 'Required|mimes:xls']); $this->assertFalse($v->passes()); } - public function testFileUploads() + public function testFileUploads(): void { $trans = $this->getArrayTranslator(); - $file = new SplFileInfo(__FILE__); + $file = new File(__FILE__, false); $v = new Validator($trans, ['file' => $file], ['file' => 'Required|mimes:xls']); $this->assertFalse($v->passes()); } @@ -10504,9 +10567,12 @@ public function testWhenPasses() $this->assertSame('whenNotPasses', $result); } - protected function fileInfoWithSize(int $size): SplFileInfo + /** + * Create a file with the given size. + */ + protected function fileInfoWithSize(int $size): File { - return new SizedSplFileInfo(__FILE__, $size); + return new SizedFile(__FILE__, $size); } protected function uploadedFile( @@ -10594,13 +10660,19 @@ class NonEloquentModel { } -class SizedSplFileInfo extends SplFileInfo +class SizedFile extends File { + /** + * Create a file with the given size. + */ public function __construct(string $filename, private readonly int $size) { parent::__construct($filename); } + /** + * Get the file size. + */ public function getSize(): int { return $this->size;