diff --git a/src/console/src/Commands/ScheduleInterruptCommand.php b/src/console/src/Commands/ScheduleInterruptCommand.php index 1e9fbe815c..bf43927c59 100644 --- a/src/console/src/Commands/ScheduleInterruptCommand.php +++ b/src/console/src/Commands/ScheduleInterruptCommand.php @@ -15,9 +15,7 @@ class ScheduleInterruptCommand extends Command /** * The console signature name. */ - protected ?string $signature = 'schedule:interrupt - {--minutes=1 : TTL in minutes for the interrupt signal (minimum 1)} - '; + protected ?string $signature = 'schedule:interrupt'; /** * The console command description. @@ -38,19 +36,7 @@ public function __construct( */ public function handle(): int { - $minutes = filter_var($this->option('minutes'), FILTER_VALIDATE_INT); - - if ($minutes === false || $minutes < 1) { - $this->components->error('The --minutes option must be an integer of at least 1.'); - - return self::FAILURE; - } - - $this->cache->put( - 'hypervel:schedule:interrupt', - true, - Date::now()->addMinutes($minutes) - ); + $this->cache->forever('hypervel:schedule:interrupt', Date::now()->getTimestampMs()); $this->components->info('Broadcasting schedule interrupt signal.'); diff --git a/src/console/src/Commands/ScheduleRunCommand.php b/src/console/src/Commands/ScheduleRunCommand.php index 2aef616a8d..539f0beb58 100644 --- a/src/console/src/Commands/ScheduleRunCommand.php +++ b/src/console/src/Commands/ScheduleRunCommand.php @@ -90,9 +90,9 @@ class ScheduleRunCommand extends Command protected bool $eventsRan = false; /** - * Check if scheduler should stop. + * Whether this schedule run has been interrupted. */ - protected bool $shouldStop = false; + protected bool $interrupted = false; /** * Whether a termination signal has requested shutdown. @@ -100,7 +100,7 @@ class ScheduleRunCommand extends Command protected bool $shouldQuit = false; /** - * Last time the stopped state was checked. + * Last time the interruption state was checked. */ protected ?CarbonInterface $lastChecked = null; @@ -147,7 +147,7 @@ public function handle( Cache $cache, ExceptionHandler $handler, ): void { - $this->shouldQuit = false; + $this->startedAt = Date::now(); $this->schedule = $schedule; $this->dispatcher = $dispatcher; @@ -168,15 +168,13 @@ public function handle( $this->listenForSignals(); try { - $this->clearShouldStop(); - $noEventsAlerted = false; - while (! $this->shouldQuit && ! $this->shouldStop()) { - $startedAt = Date::now(); + while (! $this->shouldQuit && ! $this->shouldInterrupt()) { + $now = Date::now(); $this->runEvents( - $this->schedule->dueEventsAt($this->hypervel, $startedAt), - $startedAt + $this->schedule->dueEventsAt($this->hypervel, $now), + $now ); if (! $this->eventsRan && ! $noEventsAlerted && ! $this->option('whisper')) { @@ -215,14 +213,8 @@ protected function stop(): void */ protected function runOnce(): void { - $this->startedAt = Date::now(); - $events = $this->schedule->dueEventsAt($this->hypervel, $this->startedAt); - if ($events->contains->isRepeatable()) { - $this->clearShouldStop(); - } - // The finite --once path runs inside Waiter; signal-wait coroutines keep // that scheduler coroutine alive, so mutex signal cleanup is long-run only. (new Waiter(-1))->wait(function () use ($events) { @@ -250,7 +242,7 @@ protected function repeatEvents(Collection $events): void $paused = $this->isPaused(); foreach ($events as $event) { - if ($this->shouldStop()) { + if ($this->shouldInterrupt()) { return; } @@ -586,7 +578,7 @@ protected function isPaused(): bool /** * Determine if the schedule run should be interrupted. */ - protected function shouldStop(): bool + protected function shouldInterrupt(): bool { if (! Schedule::$interruptible) { return false; @@ -596,23 +588,14 @@ protected function shouldStop(): bool $this->lastChecked = Date::now(); } - if ($this->shouldStop || abs($this->lastChecked->diffInSeconds()) < 1) { - return $this->shouldStop; + // The loop and repeating events check more often; avoid a cache round trip on every check. + if ($this->interrupted || abs($this->lastChecked->diffInSeconds()) < 1) { + return $this->interrupted; } $this->lastChecked = Date::now(); - return $this->shouldStop = (bool) $this->cache->get('hypervel:schedule:interrupt', false); - } - - /** - * Clear the interrupt cache. - */ - protected function clearShouldStop(): void - { - $this->cache->forget('hypervel:schedule:interrupt'); - - $this->shouldStop = false; + return $this->interrupted = $this->schedule->hasBeenInterruptedSince($this->startedAt); } /** diff --git a/src/console/src/Scheduling/Schedule.php b/src/console/src/Scheduling/Schedule.php index 3469fb5b44..36b3ce15b1 100644 --- a/src/console/src/Scheduling/Schedule.php +++ b/src/console/src/Scheduling/Schedule.php @@ -483,6 +483,20 @@ public static function withoutInterruptionPolling(): void static::$interruptible = false; } + /** + * Determine if the schedule has been interrupted since the given time. + */ + public function hasBeenInterruptedSince(DateTimeInterface $time): bool + { + if (! static::$interruptible) { + return false; + } + + $interruptedAt = Container::getInstance()->make(Cache::class)->get('hypervel:schedule:interrupt'); + + return is_numeric($interruptedAt) && (int) $interruptedAt >= Date::instance($time)->getTimestampMs(); + } + /** * Flush all static state. */ diff --git a/src/database/README.md b/src/database/README.md index a88ad86afc..f59b05e497 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -28,6 +28,6 @@ Documentation: https://hypervel.org/docs/database - Eloquent expression plucks use `Query\Builder::pluckWithColumn()` to retain the returned field name for casts and accessors. Custom query builders overriding `pluck()` must also override `pluckWithColumn()` to customize this path. String-based Eloquent plucks still call `pluck()`. - Eloquent's protected `refreshSavedAttributes()` method receives the raw attribute values each write stored, so configured attributes are refreshed from the row that write changed. Models that override this method must accept the `array $attributes` argument. - Eloquent `updateFrom()` maintains the model's `updated_at` timestamp, like `update()`. Laravel forwards this method without adding a timestamp. -- `BelongsToMany` records pivot filters in an ordered `$pivotConstraints` list instead of Laravel's `$pivotWheres`, `$pivotWhereIns`, and `$pivotWhereNulls` properties. Relationship extensions should use the `wherePivot*` methods instead of accessing those arrays. +- `BelongsToMany` records pivot filters in an ordered `$pivotConstraints` list instead of Laravel's `$pivotWheres`, `$pivotWhereIns`, `$pivotWhereNulls`, and `$pivotWhereBetweens` properties. Relationship extensions should use the `wherePivot*` methods instead of accessing those arrays. Ported from: https://github.com/laravel/framework diff --git a/src/database/src/Eloquent/Concerns/HasRelationships.php b/src/database/src/Eloquent/Concerns/HasRelationships.php index 1d0d59b45f..cb21554cad 100644 --- a/src/database/src/Eloquent/Concerns/HasRelationships.php +++ b/src/database/src/Eloquent/Concerns/HasRelationships.php @@ -476,18 +476,14 @@ protected function guessBelongsToRelation(): string * Create a pending has-many-through or has-one-through relationship. * * @template TIntermediateModel of Model + * @template TLocalRelationship of HasOneOrMany * - * @param HasMany|HasOne|string $relationship + * @param string|TLocalRelationship $relationship * @return ( * $relationship is string * ? PendingHasThroughRelationship> - * : ( - * $relationship is HasMany - * ? PendingHasThroughRelationship> - * : PendingHasThroughRelationship> - * ) + * : PendingHasThroughRelationship * ) - * @phpstan-ignore conditionalType.alwaysFalse (template covariance limitation with conditional return types) */ public function through(string|HasOneOrMany $relationship): PendingHasThroughRelationship { diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index dbc4984eca..b5ae0d7c90 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -2338,6 +2338,7 @@ public function replicate(?array $except = null): static $this->getCreatedAtColumn(), $this->getUpdatedAtColumn(), ...$this->uniqueIds(), + ...$this->refreshes, 'hypervel_through_key', ])); diff --git a/src/database/src/Eloquent/PendingHasThroughRelationship.php b/src/database/src/Eloquent/PendingHasThroughRelationship.php index 3985ba4eb9..177c9cca07 100644 --- a/src/database/src/Eloquent/PendingHasThroughRelationship.php +++ b/src/database/src/Eloquent/PendingHasThroughRelationship.php @@ -10,6 +10,7 @@ use Hypervel\Database\Eloquent\Relations\HasOne; use Hypervel\Database\Eloquent\Relations\HasOneOrMany; use Hypervel\Database\Eloquent\Relations\HasOneThrough; +use Hypervel\Database\Eloquent\Relations\MorphMany; use Hypervel\Database\Eloquent\Relations\MorphOneOrMany; use Hypervel\Support\Str; use Hypervel\Support\Stringable; @@ -52,22 +53,22 @@ public function __construct(Model $rootModel, HasOneOrMany $localRelationship) * * @template TRelatedModel of Model * - * @param (callable(TIntermediateModel): (HasMany|HasOne|MorphOneOrMany))|string $callback + * @param (callable(TIntermediateModel): (HasMany|HasOne|MorphMany|MorphOneOrMany))|string $callback * @return ( * $callback is string * ? HasManyThrough|HasOneThrough * : ( - * TLocalRelationship is HasMany + * TLocalRelationship is HasMany|MorphMany * ? HasManyThrough * : ( - * $callback is callable(TIntermediateModel): HasMany + * $callback is callable(TIntermediateModel): (HasMany|MorphMany) * ? HasManyThrough * : HasOneThrough * ) * ) * ) */ - public function has(callable|string $callback): mixed + public function has(callable|string $callback): HasManyThrough|HasOneThrough { if (is_string($callback)) { $callback = fn () => $this->localRelationship->getRelated()->{$callback}(); @@ -75,7 +76,8 @@ public function has(callable|string $callback): mixed $distantRelation = $callback($this->localRelationship->getRelated()); - if ($distantRelation instanceof HasMany || $this->localRelationship instanceof HasMany) { + if ($distantRelation instanceof HasMany || $distantRelation instanceof MorphMany + || $this->localRelationship instanceof HasMany || $this->localRelationship instanceof MorphMany) { $returnedRelation = $this->rootModel->hasManyThrough( $distantRelation->getRelatedClass(), $this->localRelationship->getRelatedClass(), @@ -96,7 +98,11 @@ public function has(callable|string $callback): mixed } if ($this->localRelationship instanceof MorphOneOrMany) { - $returnedRelation->where($this->localRelationship->getQualifiedMorphType(), $this->localRelationship->getMorphClass()); + $returnedRelation->whereMorphClass($this->localRelationship->getQualifiedMorphType(), $this->localRelationship->getMorphClass()); + } + + if ($distantRelation instanceof MorphOneOrMany) { + $returnedRelation->whereMorphClass($distantRelation->getQualifiedMorphType(), $distantRelation->getMorphClass()); } return $returnedRelation; diff --git a/src/database/src/Eloquent/Relations/HasManyThrough.php b/src/database/src/Eloquent/Relations/HasManyThrough.php index 43e52a503c..2e481c18ac 100644 --- a/src/database/src/Eloquent/Relations/HasManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasManyThrough.php @@ -27,7 +27,7 @@ class HasManyThrough extends HasOneOrManyThrough */ public function one(): HasOneThrough { - return HasOneThrough::noConstraints(fn () => new HasOneThrough( + $relation = HasOneThrough::noConstraints(fn () => new HasOneThrough( tap($this->getQuery(), fn (Builder $query) => $query->getQuery()->joins = []), $this->farParent, $this->throughParent, @@ -36,6 +36,10 @@ public function one(): HasOneThrough $this->getLocalKeyName(), $this->getSecondLocalKeyName(), )); + + $relation->morphConstraints = $this->morphConstraints; + + return $relation; } public function initRelation(array $models, string $relation): array diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index de72b10122..223f9be7ad 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -70,6 +70,13 @@ abstract class HasOneOrManyThrough extends Relation */ protected string $secondLocalKey; + /** + * Morph identities that also constrain one-of-many subqueries. + * + * @var array + */ + protected array $morphConstraints = []; + /** * Create a new has many through relationship instance. * @@ -105,6 +112,19 @@ public function addConstraints(): void } } + /** + * Constrain the relationship to records of the given morph class. + * + * @return $this + */ + public function whereMorphClass(string $column, string $morphClass): static + { + $this->morphConstraints[$column] = $morphClass; + $this->query->where($column, $morphClass); + + return $this; + } + /** * Set the join clause on the query. * diff --git a/src/database/src/Eloquent/Relations/HasOneThrough.php b/src/database/src/Eloquent/Relations/HasOneThrough.php index d376fc086d..df6a809185 100644 --- a/src/database/src/Eloquent/Relations/HasOneThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneThrough.php @@ -82,6 +82,10 @@ public function addOneOfManySubQueryConstraints(Builder $query, ?string $column { $query->addSelect([$this->getQualifiedFirstKeyName()]); + foreach ($this->morphConstraints as $morphColumn => $morphClass) { + $query->where($morphColumn, $morphClass); + } + // We need to join subqueries that aren't the inner-most subquery which is joined in the CanBeOneOfMany::ofMany method... if ($this->getOneOfManySubQuery() !== null) { // @phpstan-ignore argument.type (Builder param typed without template in inherited interface) diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php index b343a520bf..61a8b32757 100755 --- a/src/database/src/Schema/Builder.php +++ b/src/database/src/Schema/Builder.php @@ -11,6 +11,7 @@ use Hypervel\Database\MultipleColumnsSelectedException; use Hypervel\Database\PostgresConnection; use Hypervel\Database\Schema\Grammars\Grammar; +use Hypervel\Support\Str; use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; use LogicException; @@ -260,8 +261,9 @@ public function getTypes(array|string|null $schema = null): array public function hasColumn(string $table, string $column): bool { return in_array( - strtolower($column), - array_map(strtolower(...), $this->getColumnListing($table)) + Str::lower($column), + array_map(Str::lower(...), $this->getColumnListing($table)), + true ); } @@ -272,11 +274,11 @@ public function hasColumn(string $table, string $column): bool */ public function hasColumns(string $table, array $columns): bool { - $tableColumns = array_map(strtolower(...), $this->getColumnListing($table)); + $tableColumns = array_map(Str::lower(...), $this->getColumnListing($table)); // Keep this loop to avoid an extra callback per item. foreach ($columns as $column) { - if (! in_array(strtolower($column), $tableColumns)) { + if (! in_array(Str::lower($column), $tableColumns, true)) { return false; } } @@ -331,12 +333,10 @@ public function whenTableDoesntHaveIndex(string $table, array|string $index, Clo */ public function getColumnType(string $table, string $column, bool $fullDefinition = false): string { - $columns = $this->getColumns($table); + $value = $this->getColumn($table, $column); - foreach ($columns as $value) { - if (strtolower($value['name']) === strtolower($column)) { - return $fullDefinition ? $value['type'] : $value['type_name']; - } + if ($value !== []) { + return $fullDefinition ? $value['type'] : $value['type_name']; } throw new InvalidArgumentException("There is no column with name '{$column}' on table '{$table}'."); @@ -352,6 +352,30 @@ public function getColumnListing(string $table): array return array_column($this->getColumns($table), 'name'); } + /** + * Get the column for a given table. + * + * @return array{name: string, type: string, type_name: string, collation: null|string, nullable: bool, default: mixed, auto_increment: bool, comment: null|string, generation: null|array{type: null|string, expression: null|string}}|array{} + */ + public function getColumn(string $table, string $column): array + { + $lowercaseColumn = Str::lower($column); + $match = []; + + foreach ($this->getColumns($table) as $value) { + if ($value['name'] === $column) { + return $value; + } + + // Keep the first case-insensitive match unless an exact name appears later. + if ($match === [] && Str::lower($value['name']) === $lowercaseColumn) { + $match = $value; + } + } + + return $match; + } + /** * Get the columns for a given table. * diff --git a/src/docs/database.md b/src/docs/database.md index d32dd3c48c..cda6734a48 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -1071,6 +1071,12 @@ $indexes = Schema::getIndexes('users'); $foreignKeys = Schema::getForeignKeys('users'); ``` +To inspect a single column, use the `getColumn` method. An exact column name takes precedence over a case-insensitive match, and an empty array is returned if the column does not exist: + +```php +$column = Schema::getColumn('users', 'email'); +``` + If you would like to inspect a database connection that is not your application's default connection, you may use the `connection` method: ```php diff --git a/src/docs/events.md b/src/docs/events.md index 5832733035..0f91278a2e 100644 --- a/src/docs/events.md +++ b/src/docs/events.md @@ -1302,6 +1302,14 @@ Event::assertDispatched(function (OrderShipped $event) use ($order) { }); ``` +Alternatively, you may pass an array of expected property values as the second argument to `assertDispatched` or `assertNotDispatched`: + +```php +Event::assertDispatched(OrderShipped::class, ['order' => $order]); +``` + +All specified properties must match. Values are compared using strict equality, while Eloquent models are compared using their `is` method. + If you would simply like to assert that an event listener is listening to a given event, you may use the `assertListening` method: ```php diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md index f680976fc0..8988423a3b 100644 --- a/src/docs/filesystem.md +++ b/src/docs/filesystem.md @@ -111,7 +111,18 @@ The credential variables use the AWS SDK's standard names, while the region foll The optional `AWS_ROOT` value scopes the disk to a key prefix within the bucket. When it is empty, the disk operates from the bucket root. -If you supply a callable `credentials` provider, calls to that provider run one at a time, including when it is shared with SQS or SES. Hypervel does not cache provider results. If your provider fetches the same credentials remotely for every caller, wrap it with the AWS SDK's `CredentialProvider::memoize()` to reuse them until they need refreshing. Do not share a memoized provider between callers that need different credentials, such as different tenants. +To use an IAM role, set the disk's `credentials` option to `ecs` for container credentials or `instance` for EC2 instance credentials. You may also provide an array containing the provider name and its AWS SDK options: + +```php +'credentials' => [ + 'provider' => 'ecs', + 'timeout' => 7, +], +``` + +Named providers take precedence over the disk's `key` and `secret`. Each pooled client fetches and reuses its credentials until they need refreshing. Provider names and options are part of the [client pool identity](#driver-pools), so equivalent configurations share a pool without an explicit fingerprint. + +You may also supply a callable `credentials` provider, which takes precedence over `key` and `secret`. Calls to that provider run one at a time, including when it is shared with SQS or SES. Hypervel does not cache custom provider results. If your provider fetches the same credentials remotely for every caller, wrap it with the AWS SDK's `CredentialProvider::memoize()` to reuse them until they need refreshing. Do not share a memoized provider between callers that need different credentials, such as different tenants. Configuration containing objects or closures requires an explicit `pool.fingerprint`. #### FTP Driver Configuration diff --git a/src/docs/mail.md b/src/docs/mail.md index c89a98b6f4..a53585113b 100644 --- a/src/docs/mail.md +++ b/src/docs/mail.md @@ -215,7 +215,7 @@ To utilize AWS [temporary credentials](https://docs.aws.amazon.com/IAM/latest/Us ], ``` -If you supply a callable `credentials` provider instead of static keys, calls to that provider run one at a time, including when it is shared with SQS or S3. Hypervel does not cache provider results. If your provider fetches the same credentials remotely for every caller, wrap it with the AWS SDK's `CredentialProvider::memoize()` to reuse them until they need refreshing. Do not share a memoized provider between callers that need different credentials, such as different tenants. For pooled mailers, configure `pool.fingerprint` as described under [transport pooling](#transport-pooling). +If you supply a callable `credentials` provider, it takes precedence over `key` and `secret`. Calls to that provider run one at a time, including when it is shared with SQS or S3. Hypervel does not cache provider results. If your provider fetches the same credentials remotely for every caller, wrap it with the AWS SDK's `CredentialProvider::memoize()` to reuse them until they need refreshing. Do not share a memoized provider between callers that need different credentials, such as different tenants. For pooled mailers, configuration containing objects or closures requires `pool.fingerprint`, as described under [transport pooling](#transport-pooling). String callables and static-method arrays can form an automatic pool identity. To interact with SES's [subscription management features](https://docs.aws.amazon.com/ses/latest/dg/sending-email-subscription-management.html), you may return the `X-Ses-List-Management-Options` header in the array returned by the [headers](#headers) method of a mail message: diff --git a/src/docs/notifications.md b/src/docs/notifications.md index b4de840af2..6f35fdd2b2 100644 --- a/src/docs/notifications.md +++ b/src/docs/notifications.md @@ -1854,6 +1854,14 @@ Notification::assertSentTo( ); ``` +Alternatively, you may pass an array of expected property values as the third argument to `assertSentTo` or `assertNotSentTo`: + +```php +Notification::assertSentTo($user, OrderShipped::class, ['order' => $order]); +``` + +All specified properties must match. Values are compared using strict equality, while Eloquent models are compared using their `is` method. You may also pass an array of expected property values as the second argument to `assertSentOnDemand`. + #### On-Demand Notifications diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 00df1e9722..c50b4515a9 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -768,6 +768,8 @@ Custom filesystem contract implementations must also provide `fileExists()` and S3, Google Cloud Storage, FTP, SFTP, and poolable custom disks are pooled, so they do not hand out objects that outlive an operation. Replace `getAdapter()` and `getDriver()` with `withAdapter()` and `withDriver()`, and an S3 or Google Cloud Storage disk's `getClient()` with `withClient()`; each callback receives the borrowed object. Methods a pooled disk does not support throw instead of forwarding to the driver. See [driver pools](/docs/{{version}}/filesystem#driver-pools). +If you override `FilesystemManager::formatS3Config()`, named credential providers remain configuration data there. Customize their resolution through `createS3Client()` instead. + Unlike Laravel, Hypervel honors `read-only` on scoped disk records. Remove that option from any scoped disk that must accept writes. Rename any configured disk called `ondemand`; Hypervel reserves that name for [on-demand disk fakes](/docs/{{version}}/filesystem#on-demand-disks). diff --git a/src/docs/queue-outbox.md b/src/docs/queue-outbox.md index 0ed15f4f1b..0d5ae7421b 100644 --- a/src/docs/queue-outbox.md +++ b/src/docs/queue-outbox.md @@ -253,7 +253,7 @@ The relay supports the following options: | `--sleep` | `1` | Seconds to wait after finding no work or after a delivery failure. Must be positive; fractions are supported. | | `--max-jobs` | `0` | Stop after taking this many messages from the outbox; zero means unlimited. | | `--max-time` | `0` | Stop taking new work after this many seconds; zero means unlimited. | -| `--memory` | `128` | Memory limit in megabytes. | +| `--memory` | `128` | Memory limit in megabytes, or a percentage of PHP's finite `memory_limit` (for example, `60%`). | | `--stop-when-empty` | Off | Stop when no selected database has messages ready for delivery, after finishing work already started. | | `--once` | Off | Process at most one batch from each selected database, without sleeping. | diff --git a/src/docs/queues.md b/src/docs/queues.md index 772b87129c..30844080ae 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -166,7 +166,7 @@ Configure a connection pool inside its queue connection definition: Hypervel uses a complete `key` and `secret` pair when both are configured, including the optional `token` as a temporary AWS session credential. When both static values are null, the AWS SDK's default credential chain is used. Configure both static values together. -The optional `credentials` setting takes precedence and may contain an AWS credential value or a supported `ecs` or `instance` provider. If you supply callable or object credentials, set `pool.fingerprint` because these values cannot form an automatic pool identity. The optional `version` setting defaults to `latest`. Within the optional `http` array, `timeout` and `connect_timeout` each default to 60 seconds, and additional AWS SDK HTTP options are preserved. +The optional `credentials` setting takes precedence and may contain an AWS credential value or a supported `ecs` or `instance` provider. If your configuration contains objects or closures, set `pool.fingerprint` because these values cannot form an automatic pool identity. String callables and static-method arrays can form an automatic identity. The optional `version` setting defaults to `latest`. Within the optional `http` array, `timeout` and `connect_timeout` each default to 60 seconds, and additional AWS SDK HTTP options are preserved. Calls to the same custom credential provider run one at a time, including when it is shared with S3 or SES. Hypervel does not cache provider results. If your provider fetches the same credentials remotely for every caller, wrap it with the AWS SDK's `CredentialProvider::memoize()` to reuse them until they need refreshing. Do not share a memoized provider between callers that need different credentials, such as different tenants. @@ -2942,6 +2942,14 @@ php artisan queue:work --force #### Resource Considerations +The `--memory` option specifies the memory limit in megabytes at which the worker should exit. By default, this limit is 128 megabytes. You may also specify a percentage of PHP's configured `memory_limit`: + +```shell +php artisan queue:work --memory=60% +``` + +The `queue:listen` command also accepts memory limits in megabytes or as a percentage. Percentage-based limits require a finite PHP `memory_limit`. If PHP's memory limit is unlimited (`-1`), use a limit in megabytes instead. A process monitor such as [Supervisor](#supervisor-configuration) can automatically restart the worker after it exits. + Daemon queue workers do not "reboot" the framework before processing each job. Therefore, you should release any heavy resources after each job completes. For example, after [image manipulation](/docs/{{version}}/images), release references to image objects when you no longer need them. Do not retain them in static properties or singleton services between jobs. @@ -3804,6 +3812,16 @@ Queue::assertClosurePushed(function (CallQueuedClosure $job) { }); ``` +Alternatively, you may pass an array of expected property values as the second argument to `assertPushed` or `assertNotPushed`, or as the third argument to `assertPushedOn`: + +```php +Queue::assertPushed(ShipOrder::class, ['order' => $order]); + +Queue::assertPushedOn('shipping', ShipOrder::class, ['order' => $order]); +``` + +All specified properties must match. Values are compared using strict equality, while Eloquent models are compared using their `is` method. The `Bus` facade's `assertDispatched`, `assertNotDispatched`, `assertDispatchedSync`, and `assertDispatchedAfterResponse` methods also accept an array of expected property values as their second argument. + ### Testing Bus Dispatches diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md index 6195364d64..e3b22f8d04 100644 --- a/src/docs/scheduling.md +++ b/src/docs/scheduling.md @@ -576,7 +576,7 @@ Schedule::command('users:delete')->everyTenSeconds()->runInBackground(); When the long-running `schedule:run` process receives `SIGTERM`, `SIGINT`, or `SIGQUIT`, it stops starting new tasks and waits for running tasks to finish. A second termination signal stops the process immediately and releases its owned overlap locks, unless the task disables `releaseOnTerminationSignals`. Configure your process manager's shutdown timeout to allow your longest task to finish. A forced `SIGKILL` cannot release overlap locks; they remain until they expire or you run `schedule:clear-cache`. -When deploying your application, you may also use `schedule:interrupt` to stop the scheduler so that the next invocation picks up your newly deployed code. This command writes a flag to the cache that `schedule:run` polls. After detecting the flag, the scheduler finishes the current set of due events and waits for running background tasks before exiting. +When deploying your application, you may also use `schedule:interrupt` to stop the scheduler so that the next invocation picks up your newly deployed code. This command records an interruption timestamp in the cache. Schedulers started before the interruption finish the current set of due events and wait for running background tasks before exiting. Schedulers started afterward are unaffected. This command should be invoked after your application is finished deploying: @@ -584,13 +584,23 @@ This command should be invoked after your application is finished deploying: php artisan schedule:interrupt ``` -By default, the interrupt flag expires after one minute. You may use the `--minutes` option to extend this window if your deployment process takes longer: +Long-running scheduled commands may check for an interruption between units of work using `Schedule::hasBeenInterruptedSince`. Record the command's start time and pass it to this method: -```shell -php artisan schedule:interrupt --minutes=5 +```php +use Hypervel\Support\Facades\Schedule; + +$startedAt = now(); + +foreach ($items as $item) { + if (Schedule::hasBeenInterruptedSince($startedAt)) { + break; + } + + // Process the item... +} ``` -If you do not want the scheduler to poll for pause or interrupt flags, call `Schedule::withoutInterruptionPolling()` during boot. The `schedule:pause` command will then fail instead of pausing the scheduler. Operating-system termination signals still stop the scheduler. +If you do not want the scheduler to poll for pause or interrupt signals, call `Schedule::withoutInterruptionPolling()` during boot. The `schedule:pause` command will then fail instead of pausing the scheduler, and `Schedule::hasBeenInterruptedSince` will return `false`. Operating-system termination signals still stop the scheduler. ### Running the Scheduler Locally diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 47746c4f1d..a4c868e15b 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -21,6 +21,8 @@ On read-through disks, raw `listContents()` results from pooled, S3 and Google C Filesystem construction differs from Laravel in how it carries logical disk identity. `callCustomCreator()` accepts the logical disk name as an optional second parameter, so existing one-argument calls remain valid while overrides must adopt the parameter. The public `build()` method uses a logical-name-aware construction path rather than the protected `resolve()` method because anonymous builds must pass a null name to creators; `resolve()` remains the configured-disk seam. `createScopedDriver()` resolves its prepared descriptor directly rather than through `build()`, so override `createScopedDriver()` for scoped construction customization. Customize on-demand construction through `Storage::extend()` or the public driver creator methods. Creator callbacks may accept the nullable name as a third argument after the application and configuration, while existing two-argument callbacks remain valid. Hypervel carries the name through scoped reconstruction and whole-driver pool fingerprints. A matching explicit fingerprint declares that every construction detail is equivalent, including serving-route ownership. +`formatS3Config()` leaves named credential providers as configuration data so they can define pool identity. Override `createS3Client()` to customize provider resolution; see the [porting guide](https://hypervel.org/docs/porting-from-laravel#filesystem). + Hypervel registers signed file-serving routes for any configured disk whose `serve` option is exactly `true`, while Laravel limits these routes to local disks and accepts truthy values. Every served disk must use a unique URL or application boot will fail. Custom drivers that opt in must provide the filesystem response methods used by these routes. Hypervel also provides `ScopedFilesystemProxy` and `ScopedCloudFilesystemProxy` for prefixes resolved independently on every operation. The underlying disk may be fixed or resolved once per operation when its configuration varies with the current context. These decorators fail closed on empty prefixes and reject unmapped calls so request- or tenant-scoped boundaries cannot be bypassed. diff --git a/src/filesystem/src/Filesystem.php b/src/filesystem/src/Filesystem.php index 426b098687..80460a422d 100644 --- a/src/filesystem/src/Filesystem.php +++ b/src/filesystem/src/Filesystem.php @@ -19,6 +19,7 @@ use Symfony\Component\Finder\SplFileInfo; use Symfony\Component\Mime\MimeTypes; use Throwable; +use UnexpectedValueException; class Filesystem { @@ -672,7 +673,19 @@ public function deleteDirectory(string $directory, bool $preserve = false): bool return false; } - $items = new FilesystemIterator($directory); + try { + $items = new FilesystemIterator($directory); + } catch (UnexpectedValueException $exception) { + // Another process may remove the directory after the check above. + $this->clearStatCache($directory); + + if (! $this->isDirectory($directory)) { + return false; + } + + throw $exception; + } + $success = true; foreach ($items as $item) { diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php index 93c870591d..1be29b8246 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -4,6 +4,7 @@ namespace Hypervel\Filesystem; +use Aws\Credentials\CredentialProvider; use Aws\Middleware; use Aws\S3\S3Client; use Closure; @@ -561,11 +562,30 @@ protected static function s3ArgumentNames(): array /** * Create an S3 client from normalized client config. + * + * @throws InvalidArgumentException */ protected function createS3Client(array $clientConfig): S3Client { - if (is_callable($clientConfig['credentials'] ?? null)) { - $clientConfig['credentials'] = new SerializedCredentialProvider($clientConfig['credentials']); + $credentials = $clientConfig['credentials'] ?? null; + $provider = $this->s3CredentialProvider($credentials); + + if (is_callable($provider)) { + $provider = new SerializedCredentialProvider($provider); + } elseif (is_string($provider)) { + $options = is_array($credentials) ? Arr::except($credentials, ['provider']) : []; + + $provider = CredentialProvider::memoize(match ($provider) { + 'ecs' => CredentialProvider::ecsCredentials($options), + 'instance' => CredentialProvider::instanceProfile($options), + default => throw new InvalidArgumentException( + "Invalid credential provider [{$provider}]." + ), + }); + } + + if ($provider) { + $clientConfig['credentials'] = $provider; } $client = new S3Client($clientConfig); @@ -612,7 +632,12 @@ protected function formatS3Config(array $config): array 'version' => 'latest', ]; - if (! empty($config['key']) && ! empty($config['secret'])) { + $provider = $this->s3CredentialProvider($config['credentials'] ?? null); + + // Named providers stay as data for pool identity; createS3Client() resolves them per client. + if ($provider && (! is_string($provider) || is_callable($provider))) { + $config['credentials'] = $provider; + } elseif (! $provider && ! empty($config['key']) && ! empty($config['secret'])) { $config['credentials'] = Arr::only($config, ['key', 'secret']); if (! empty($config['token'])) { @@ -623,6 +648,16 @@ protected function formatS3Config(array $config): array return Arr::except($config, ['token']); } + /** + * Get the provider described by an S3 credentials value. + */ + protected function s3CredentialProvider(mixed $credentials): mixed + { + return is_callable($credentials) + ? $credentials + : (is_array($credentials) ? ($credentials['provider'] ?? null) : $credentials); + } + /** * Create an instance of the Google Cloud Storage driver. */ diff --git a/src/mail/src/MailManager.php b/src/mail/src/MailManager.php index 2a4900f466..8ed77ffb69 100644 --- a/src/mail/src/MailManager.php +++ b/src/mail/src/MailManager.php @@ -502,7 +502,7 @@ protected function createSesV2Transport(array $config): SesV2Transport */ protected function addSesCredentials(array $config): array { - if (! empty($config['key']) && ! empty($config['secret'])) { + if (! is_callable($config['credentials'] ?? null) && ! empty($config['key']) && ! empty($config['secret'])) { $config['credentials'] = Arr::only($config, ['key', 'secret']); if (! empty($config['token'])) { diff --git a/src/queue/src/Connectors/SqsConnector.php b/src/queue/src/Connectors/SqsConnector.php index ceb872a6fb..f070bfd088 100644 --- a/src/queue/src/Connectors/SqsConnector.php +++ b/src/queue/src/Connectors/SqsConnector.php @@ -140,11 +140,16 @@ protected function withCredentials(array $config): array protected function resolveCredentialProvider(array $config): mixed { $credentials = $config['credentials']; + $provider = is_callable($credentials) + ? $credentials + : (is_array($credentials) ? ($credentials['provider'] ?? null) : $credentials); - $provider = is_array($credentials) ? ($credentials['provider'] ?? null) : $credentials; + if (is_callable($provider)) { + return new SerializedCredentialProvider($provider); + } if (! is_string($provider)) { - return is_callable($provider) ? new SerializedCredentialProvider($provider) : $provider; + return $provider; } $options = is_array($credentials) ? Arr::except($credentials, ['provider']) : []; diff --git a/src/queue/src/Console/ListenCommand.php b/src/queue/src/Console/ListenCommand.php index 2a0ec74b94..ca295cb2d1 100644 --- a/src/queue/src/Console/ListenCommand.php +++ b/src/queue/src/Console/ListenCommand.php @@ -23,7 +23,7 @@ class ListenCommand extends Command {--delay=0 : The number of seconds to delay failed jobs (Deprecated)} {--backoff=0 : The number of seconds to wait before retrying a job that encountered an uncaught exception} {--force : Force the worker to run even in maintenance mode} - {--memory=128 : The memory limit in megabytes} + {--memory=128 : The memory limit in megabytes, or as a percentage of the PHP memory limit} {--queue= : The queue to listen on} {--sleep=3 : The number of seconds to sleep when no job is available} {--rest=0 : The number of seconds to rest between jobs} @@ -97,7 +97,7 @@ protected function gatherOptions(): ListenerOptions name: $this->option('name'), environment: $this->option('env'), backoff: (int) $backoff, - memory: (int) $this->option('memory'), + memory: (string) $this->option('memory'), timeout: (int) $this->option('timeout'), sleep: (float) $this->option('sleep'), maxTries: (int) $this->option('tries'), diff --git a/src/queue/src/Console/RelayCommand.php b/src/queue/src/Console/RelayCommand.php index 65ee006ed7..79f40bc434 100644 --- a/src/queue/src/Console/RelayCommand.php +++ b/src/queue/src/Console/RelayCommand.php @@ -21,7 +21,7 @@ class RelayCommand extends Command {--concurrency=1 : The maximum number of batches to relay at once} {--max-jobs=0 : The number of messages to claim before stopping} {--max-time=0 : The maximum number of seconds the relay should run} - {--memory=128 : The memory limit in megabytes}'; + {--memory=128 : The memory limit in megabytes, or as a percentage of the PHP memory limit}'; protected string $description = 'Relay committed outbox messages to their queues'; @@ -53,7 +53,7 @@ public function handle(): int concurrency: (int) $this->option('concurrency'), maxJobs: (int) $this->option('max-jobs'), maxTime: (int) $this->option('max-time'), - memory: (float) $this->option('memory'), + memory: (string) $this->option('memory'), stopWhenEmpty: (bool) $this->option('stop-when-empty'), once: (bool) $this->option('once'), )); diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php index 9b2edd6acb..a11b98ef7c 100644 --- a/src/queue/src/Console/WorkCommand.php +++ b/src/queue/src/Console/WorkCommand.php @@ -56,7 +56,7 @@ class WorkCommand extends Command {--max-jobs=0 : The number of jobs to process before stopping} {--max-time=0 : The maximum number of seconds the worker should run} {--force : Force the worker to run even in maintenance mode} - {--memory=128 : The memory limit in megabytes} + {--memory=128 : The memory limit in megabytes, or as a percentage of the PHP memory limit} {--sleep=3 : The number of seconds to sleep when no job is available} {--rest=0 : The number of seconds to rest between jobs} {--timeout=60 : The number of seconds a child process can run} @@ -155,7 +155,7 @@ protected function gatherWorkerOptions(): WorkerOptions return new WorkerOptions( name: (string) $this->option('name'), backoff: (int) max($this->option('backoff'), $this->option('delay')), - memory: (float) $this->option('memory'), + memory: (string) $this->option('memory'), timeout: (int) $this->option('timeout'), sleep: (float) $this->option('sleep'), maxTries: (int) $this->option('tries'), diff --git a/src/queue/src/Listener.php b/src/queue/src/Listener.php index 08c8a91ea6..5483ad5bfd 100644 --- a/src/queue/src/Listener.php +++ b/src/queue/src/Listener.php @@ -134,7 +134,7 @@ protected function createCommand(?string $connection, string $queue, ListenerOpt /** * Run the given process. */ - public function runProcess(Process $process, float $memory): void + public function runProcess(Process $process, float|string $memory): void { $process->run(function ($type, $line) { $this->handleWorkerOutput($type, $line); @@ -161,9 +161,9 @@ protected function handleWorkerOutput(string $type, string $line): void /** * Determine if the memory limit has been exceeded. */ - public function memoryExceeded(float $memoryLimit): bool + public function memoryExceeded(float|string $memoryLimit): bool { - return (memory_get_usage(true) / 1024 / 1024) >= $memoryLimit; + return (memory_get_usage(true) / 1024 / 1024) >= MemoryLimit::toMegabytes($memoryLimit); } /** diff --git a/src/queue/src/ListenerOptions.php b/src/queue/src/ListenerOptions.php index 99527539d8..ee0da3dca4 100644 --- a/src/queue/src/ListenerOptions.php +++ b/src/queue/src/ListenerOptions.php @@ -20,7 +20,7 @@ public function __construct( string $name = 'default', ?string $environment = null, array|int $backoff = 0, - int $memory = 128, + float|string $memory = 128, int $timeout = 60, float $sleep = 3, int $maxTries = 1, diff --git a/src/queue/src/MemoryLimit.php b/src/queue/src/MemoryLimit.php new file mode 100644 index 0000000000..5e8609fe30 --- /dev/null +++ b/src/queue/src/MemoryLimit.php @@ -0,0 +1,18 @@ += $options->memory) { + if (memory_get_usage(true) / 1024 / 1024 >= MemoryLimit::toMegabytes($options->memory)) { $status = Worker::EXIT_MEMORY_LIMIT; break; } diff --git a/src/queue/src/Outbox/OutboxWorkerOptions.php b/src/queue/src/Outbox/OutboxWorkerOptions.php index 45692bbac5..c0d966f673 100644 --- a/src/queue/src/Outbox/OutboxWorkerOptions.php +++ b/src/queue/src/Outbox/OutboxWorkerOptions.php @@ -4,6 +4,7 @@ namespace Hypervel\Queue\Outbox; +use Hypervel\Queue\MemoryLimit; use InvalidArgumentException; readonly class OutboxWorkerOptions @@ -17,7 +18,7 @@ public function __construct( public int $concurrency = 1, public int $maxJobs = 0, public int $maxTime = 0, - public float $memory = 128, + public float|string $memory = 128, public bool $stopWhenEmpty = false, public bool $once = false, ) { @@ -29,7 +30,7 @@ public function __construct( throw new InvalidArgumentException('Outbox sleep and concurrency must be greater than zero.'); } - if ($maxJobs < 0 || $maxTime < 0 || $memory <= 0) { + if ($maxJobs < 0 || $maxTime < 0 || MemoryLimit::toMegabytes($memory) <= 0) { throw new InvalidArgumentException('Outbox job and time limits must be non-negative, and memory must be greater than zero.'); } } diff --git a/src/queue/src/Worker.php b/src/queue/src/Worker.php index 4acbad0fde..456cae9964 100644 --- a/src/queue/src/Worker.php +++ b/src/queue/src/Worker.php @@ -1852,8 +1852,10 @@ protected function supportsAsyncSignals(): bool /** * Determine if the memory limit has been exceeded. */ - public function memoryExceeded(float $memoryLimit): bool + public function memoryExceeded(float|string $memoryLimit): bool { + $memoryLimit = MemoryLimit::toMegabytes($memoryLimit); + return $memoryLimit > 0 && $this->currentMemoryUsage() >= $memoryLimit; } diff --git a/src/queue/src/WorkerOptions.php b/src/queue/src/WorkerOptions.php index bbb0fb7207..3a9d437acd 100644 --- a/src/queue/src/WorkerOptions.php +++ b/src/queue/src/WorkerOptions.php @@ -11,7 +11,7 @@ class WorkerOptions * * @param string $name the name of the worker * @param int|int[] $backoff - * @param float $memory the maximum amount of RAM in megabytes the worker may consume + * @param float|string $memory the maximum RAM in megabytes or a percentage of PHP's memory limit * @param int $timeout the maximum number of seconds a child worker may run * @param float $sleep the number of seconds to wait in between polling the queue * @param int $maxTries the maximum number of times a job may be attempted @@ -28,7 +28,7 @@ class WorkerOptions public function __construct( public string $name = 'default', public array|int $backoff = 0, - public float $memory = 128, + public float|string $memory = 128, public int $timeout = 60, public float $sleep = 3, public int $maxTries = 1, diff --git a/src/redis/src/Limiters/DurationLimiter.php b/src/redis/src/Limiters/DurationLimiter.php index df06f46a59..e1c6f71a3b 100644 --- a/src/redis/src/Limiters/DurationLimiter.php +++ b/src/redis/src/Limiters/DurationLimiter.php @@ -84,7 +84,8 @@ public function acquire(): bool $this->decaysAt = (int) $results[1]; - $this->remaining = max(0, (int) $results[2]); + // Lua numbers lose precision above 2^53, so remaining is calculated here. + $this->remaining = max(0, $this->maxLocks - (int) $results[2]); return (bool) $results[0]; } @@ -103,7 +104,7 @@ public function tooManyAttempts(): bool ); $this->decaysAt = (int) $results[0]; - $this->remaining = max(0, (int) $results[1]); + $this->remaining = max(0, $this->maxLocks - (int) $results[1]); return $this->remaining <= 0; } @@ -134,18 +135,18 @@ protected function luaScript(): string end if redis.call('EXISTS', KEYS[1]) == 0 then - return {reset(), ARGV[2] + ARGV[3], ARGV[4] - 1} + return {reset(), ARGV[2] + ARGV[3], 1} end if ARGV[1] >= redis.call('HGET', KEYS[1], 'start') and ARGV[1] <= redis.call('HGET', KEYS[1], 'end') then return { tonumber(redis.call('HINCRBY', KEYS[1], 'count', 1)) <= tonumber(ARGV[4]), redis.call('HGET', KEYS[1], 'end'), - ARGV[4] - redis.call('HGET', KEYS[1], 'count') + redis.call('HGET', KEYS[1], 'count') } end -return {reset(), ARGV[2] + ARGV[3], ARGV[4] - 1} +return {reset(), ARGV[2] + ARGV[3], 1} LUA; } @@ -163,17 +164,17 @@ protected function tooManyAttemptsLuaScript(): string return <<<'LUA' if redis.call('EXISTS', KEYS[1]) == 0 then - return {ARGV[2] + ARGV[3], ARGV[4]} + return {ARGV[2] + ARGV[3], 0} end if ARGV[1] >= redis.call('HGET', KEYS[1], 'start') and ARGV[1] <= redis.call('HGET', KEYS[1], 'end') then return { redis.call('HGET', KEYS[1], 'end'), - ARGV[4] - redis.call('HGET', KEYS[1], 'count') + redis.call('HGET', KEYS[1], 'count') } end -return {ARGV[2] + ARGV[3], ARGV[4]} +return {ARGV[2] + ARGV[3], 0} LUA; } } diff --git a/src/routing/src/Route.php b/src/routing/src/Route.php index c049d116ba..57510f1ba8 100755 --- a/src/routing/src/Route.php +++ b/src/routing/src/Route.php @@ -1546,7 +1546,11 @@ public function toSymfonyRoute(): SymfonyRoute $this->wheres, ['utf8' => true], $this->getDomain() ?: '', - $this->httpOnly() ? ['http'] : ($this->httpsOnly() ? ['https'] : []), + match (true) { + $this->httpOnly() => ['http'], + $this->secure() => ['https'], + default => [], + }, $this->methods ); } diff --git a/src/support/src/Facades/Bus.php b/src/support/src/Facades/Bus.php index 6ac399f510..a7a0fafed3 100644 --- a/src/support/src/Facades/Bus.php +++ b/src/support/src/Facades/Bus.php @@ -30,27 +30,27 @@ * @method static void assertBatchCount(int $count) * @method static void assertBatched(array|callable $callback) * @method static void assertChained(array $expectedChain) - * @method static void assertDispatched(\Closure|string $command, callable|int|null $callback = null) - * @method static void assertDispatchedAfterResponse(\Closure|string $command, callable|int|null $callback = null) + * @method static void assertDispatched(\Closure|string $command, null|array|callable|int $callback = null) + * @method static void assertDispatchedAfterResponse(\Closure|string $command, null|array|callable|int $callback = null) * @method static void assertDispatchedAfterResponseTimes(\Closure|string $command, int $times = 1) * @method static void assertDispatchedOnce(\Closure|string $command) - * @method static void assertDispatchedSync(\Closure|string $command, callable|int|null $callback = null) + * @method static void assertDispatchedSync(\Closure|string $command, null|array|callable|int $callback = null) * @method static void assertDispatchedSyncTimes(\Closure|string $command, int $times = 1) * @method static void assertDispatchedTimes(\Closure|string $command, int $times = 1) - * @method static void assertDispatchedWithoutChain(\Closure|string $command, callable|null $callback = null) - * @method static void assertNotDispatched(\Closure|string $command, callable|null $callback = null) - * @method static void assertNotDispatchedAfterResponse(\Closure|string $command, callable|null $callback = null) - * @method static void assertNotDispatchedSync(\Closure|string $command, callable|null $callback = null) + * @method static void assertDispatchedWithoutChain(\Closure|string $command, null|array|callable $callback = null) + * @method static void assertNotDispatched(\Closure|string $command, null|array|callable $callback = null) + * @method static void assertNotDispatchedAfterResponse(\Closure|string $command, null|array|callable $callback = null) + * @method static void assertNotDispatchedSync(\Closure|string $command, null|array|callable $callback = null) * @method static void assertNothingBatched() * @method static void assertNothingChained() * @method static void assertNothingDispatched() * @method static void assertNothingPlaced() * @method static \Hypervel\Support\Collection batched(callable $callback) * @method static \Hypervel\Support\Testing\Fakes\ChainedBatchTruthTest chainedBatch(\Closure $callback) - * @method static \Hypervel\Support\Collection dispatched(string $command, callable|null $callback = null) - * @method static \Hypervel\Support\Collection dispatchedAfterResponse(string $command, callable|null $callback = null) + * @method static \Hypervel\Support\Collection dispatched(string $command, null|array|callable $callback = null) + * @method static \Hypervel\Support\Collection dispatchedAfterResponse(string $command, null|array|callable $callback = null) * @method static array dispatchedBatches() - * @method static \Hypervel\Support\Collection dispatchedSync(string $command, callable|null $callback = null) + * @method static \Hypervel\Support\Collection dispatchedSync(string $command, null|array|callable $callback = null) * @method static \Hypervel\Bus\Batch dispatchFakeBatch(string $name = '') * @method static \Hypervel\Support\Testing\Fakes\BusFake except(array|string $jobsToDispatch) * @method static bool hasDispatched(string $command) diff --git a/src/support/src/Facades/Event.php b/src/support/src/Facades/Event.php index b5af9fe17d..f0031f2e13 100644 --- a/src/support/src/Facades/Event.php +++ b/src/support/src/Facades/Event.php @@ -34,13 +34,13 @@ * @method static \Hypervel\Events\Dispatcher setTransactionManagerResolver(callable $resolver) * @method static void subscribe(object|string $subscriber) * @method static mixed until(object|string $event, mixed $payload = []) - * @method static void assertDispatched(\Closure|string $event, callable|int|null $callback = null) + * @method static void assertDispatched(\Closure|string $event, null|array|callable|int $callback = null) * @method static void assertDispatchedOnce(string $event) * @method static void assertDispatchedTimes(string $event, int $times = 1) * @method static void assertListening(string $expectedEvent, array|string $expectedListener) - * @method static void assertNotDispatched(\Closure|string $event, callable|null $callback = null) + * @method static void assertNotDispatched(\Closure|string $event, null|array|callable $callback = null) * @method static void assertNothingDispatched() - * @method static \Hypervel\Support\Collection dispatched(string $event, callable|null $callback = null) + * @method static \Hypervel\Support\Collection dispatched(string $event, null|array|callable $callback = null) * @method static array dispatchedEvents() * @method static \Hypervel\Support\Testing\Fakes\EventFake except(array|string $eventsToDispatch) * @method static bool hasDispatched(string $event) diff --git a/src/support/src/Facades/Notification.php b/src/support/src/Facades/Notification.php index 284ae5b2e4..6b328c7654 100644 --- a/src/support/src/Facades/Notification.php +++ b/src/support/src/Facades/Notification.php @@ -33,16 +33,16 @@ * @method static void assertCount(int $expectedCount) * @method static void assertNothingSent() * @method static void assertNothingSentTo(mixed $notifiable) - * @method static void assertNotSentTo(mixed $notifiable, \Closure|string $notification, callable|null $callback = null) - * @method static void assertSentOnDemand(\Closure|string $notification, callable|null $callback = null) + * @method static void assertNotSentTo(mixed $notifiable, \Closure|string $notification, null|array|callable $callback = null) + * @method static void assertSentOnDemand(\Closure|string $notification, null|array|callable $callback = null) * @method static void assertSentOnDemandOnce(string $notification) * @method static void assertSentOnDemandTimes(string $notification, int $times = 1) * @method static void assertSentTimes(string $notification, int $expectedCount) - * @method static void assertSentTo(mixed $notifiable, \Closure|string $notification, callable|int|null $callback = null) + * @method static void assertSentTo(mixed $notifiable, \Closure|string $notification, null|array|callable|int $callback = null) * @method static void assertSentToOnce(mixed $notifiable, string $notification) * @method static void assertSentToTimes(mixed $notifiable, string $notification, int $times = 1) * @method static bool hasSent(mixed $notifiable, string $notification) - * @method static \Hypervel\Support\Collection sent(mixed $notifiable, string $notification, callable|null $callback = null) + * @method static \Hypervel\Support\Collection sent(mixed $notifiable, string $notification, null|array|callable $callback = null) * @method static array sentNotifications() * @method static \Hypervel\Support\Testing\Fakes\NotificationFake serializeAndRestore(bool $serializeAndRestore = true) * diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index 0396182126..42e1437bb9 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -72,17 +72,17 @@ * @method static \Hypervel\Support\Collection allDelayedJobs() * @method static \Hypervel\Support\Collection allPendingJobs() * @method static \Hypervel\Support\Collection allReservedJobs() - * @method static void assertClosureNotPushed(callable|null $callback = null) - * @method static void assertClosurePushed(callable|int|null $callback = null) + * @method static void assertClosureNotPushed(null|array|callable $callback = null) + * @method static void assertClosurePushed(null|array|callable|int $callback = null) * @method static void assertCount(int $expectedCount) * @method static void assertNothingPushed() - * @method static void assertNotPushed(\Closure|string $job, callable|null $callback = null) - * @method static void assertPushed(\Closure|string $job, callable|int|null $callback = null) - * @method static void assertPushedOn(\UnitEnum|string|null $queue, \Closure|string $job, callable|null $callback = null) + * @method static void assertNotPushed(\Closure|string $job, null|array|callable $callback = null) + * @method static void assertPushed(\Closure|string $job, null|array|callable|int $callback = null) + * @method static void assertPushedOn(\UnitEnum|string|null $queue, \Closure|string $job, null|array|callable $callback = null) * @method static void assertPushedOnce(string $job) * @method static void assertPushedTimes(string $job, int $times = 1) - * @method static void assertPushedWithChain(string $job, array $expectedChain = [], callable|null $callback = null) - * @method static void assertPushedWithoutChain(string $job, callable|null $callback = null) + * @method static void assertPushedWithChain(string $job, array $expectedChain = [], null|array|callable $callback = null) + * @method static void assertPushedWithoutChain(string $job, null|array|callable $callback = null) * @method static \Hypervel\Support\Testing\Fakes\QueueFake beforePushing(callable $callback) * @method static void clearReserved() * @method static \Hypervel\Support\Collection delayedJobs(\UnitEnum|string|null $queue = null) @@ -90,7 +90,7 @@ * @method static bool hasPushed(string $job) * @method static \Hypervel\Support\Collection listenersPushed(string $listenerClass, callable|null $callback = null) * @method static \Hypervel\Support\Collection pendingJobs(\UnitEnum|string|null $queue = null) - * @method static \Hypervel\Support\Collection pushed(string $job, callable|null $callback = null) + * @method static \Hypervel\Support\Collection pushed(string $job, null|array|callable $callback = null) * @method static array pushedJobs() * @method static \Hypervel\Support\Collection pushedRaw(null|\Closure $callback = null) * @method static array rawPushes() diff --git a/src/support/src/Facades/Schedule.php b/src/support/src/Facades/Schedule.php index c89850b2e6..3b9e420c58 100644 --- a/src/support/src/Facades/Schedule.php +++ b/src/support/src/Facades/Schedule.php @@ -18,6 +18,7 @@ * @method static void flushMacros() * @method static void flushState() * @method static void group(\Closure $events) + * @method static bool hasBeenInterruptedSince(\DateTimeInterface $time) * @method static bool hasMacro(string $name) * @method static \Hypervel\Console\Scheduling\CallbackEvent job(object|string $job, \UnitEnum|string|null $queue = null, \UnitEnum|string|null $connection = null) * @method static void macro(string $name, callable|object $macro) diff --git a/src/support/src/Facades/Schema.php b/src/support/src/Facades/Schema.php index 48fa53d97d..14a01ebcab 100644 --- a/src/support/src/Facades/Schema.php +++ b/src/support/src/Facades/Schema.php @@ -29,6 +29,7 @@ * @method static void executeBlueprint(\Hypervel\Database\Schema\Blueprint $blueprint) * @method static void flushMacros() * @method static void flushState() + * @method static array getColumn(string $table, string $column) * @method static array getColumnListing(string $table) * @method static array getColumns(string $table) * @method static string getColumnType(string $table, string $column, bool $fullDefinition = false) diff --git a/src/support/src/Str.php b/src/support/src/Str.php index 4d763d1ddd..860d1188b6 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -1505,7 +1505,10 @@ public static function trim(string $value, ?string $charlist = null): string $trimDefaultCharacters = " \n\r\t\v\0"; - return preg_replace('~^[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']+|[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']+$~u', '', $value) ?? $trimmed; + $whitespace = '[\s' . self::INVISIBLE_CHARACTERS . $trimDefaultCharacters . ']'; + + // The trailing match may only begin at the first character of a whitespace run, keeping this linear... + return preg_replace('~^' . $whitespace . '+|' . $whitespace . '(?|callable|int $callback */ - public function assertDispatched(Closure|string $command, callable|int|null $callback = null): void + public function assertDispatched(Closure|string $command, callable|array|int|null $callback = null): void { if ($command instanceof Closure) { [$command, $callback] = [$this->firstClosureParameterType($command), $command]; @@ -145,8 +148,10 @@ public function assertDispatchedTimes(Closure|string $command, int $times = 1): /** * Determine if a job was dispatched based on a truth-test callback. + * + * @param null|array|callable $callback */ - public function assertNotDispatched(Closure|string $command, ?callable $callback = null): void + public function assertNotDispatched(Closure|string $command, callable|array|null $callback = null): void { if ($command instanceof Closure) { [$command, $callback] = [$this->firstClosureParameterType($command), $command]; @@ -174,8 +179,10 @@ public function assertNothingDispatched(): void /** * Assert if a job was explicitly dispatched synchronously based on a truth-test callback. + * + * @param null|array|callable|int $callback */ - public function assertDispatchedSync(Closure|string $command, callable|int|null $callback = null): void + public function assertDispatchedSync(Closure|string $command, callable|array|int|null $callback = null): void { if ($command instanceof Closure) { [$command, $callback] = [$this->firstClosureParameterType($command), $command]; @@ -218,8 +225,10 @@ public function assertDispatchedSyncTimes(Closure|string $command, int $times = /** * Determine if a job was dispatched based on a truth-test callback. + * + * @param null|array|callable $callback */ - public function assertNotDispatchedSync(Closure|string $command, ?callable $callback = null): void + public function assertNotDispatchedSync(Closure|string $command, callable|array|null $callback = null): void { if ($command instanceof Closure) { [$command, $callback] = [$this->firstClosureParameterType($command), $command]; @@ -234,8 +243,10 @@ public function assertNotDispatchedSync(Closure|string $command, ?callable $call /** * Assert if a job was dispatched after the response was sent based on a truth-test callback. + * + * @param null|array|callable|int $callback */ - public function assertDispatchedAfterResponse(Closure|string $command, callable|int|null $callback = null): void + public function assertDispatchedAfterResponse(Closure|string $command, callable|array|int|null $callback = null): void { if ($command instanceof Closure) { [$command, $callback] = [$this->firstClosureParameterType($command), $command]; @@ -278,8 +289,10 @@ public function assertDispatchedAfterResponseTimes(Closure|string $command, int /** * Determine if a job was dispatched based on a truth-test callback. + * + * @param null|array|callable $callback */ - public function assertNotDispatchedAfterResponse(Closure|string $command, ?callable $callback = null): void + public function assertNotDispatchedAfterResponse(Closure|string $command, callable|array|null $callback = null): void { if ($command instanceof Closure) { [$command, $callback] = [$this->firstClosureParameterType($command), $command]; @@ -352,8 +365,10 @@ protected function resetChainPropertiesToDefaults(mixed $job): mixed /** * Assert if a job was dispatched with an empty chain based on a truth-test callback. + * + * @param null|array|callable $callback */ - public function assertDispatchedWithoutChain(Closure|string $command, ?callable $callback = null): void + public function assertDispatchedWithoutChain(Closure|string $command, callable|array|null $callback = null): void { if ($command instanceof Closure) { [$command, $callback] = [$this->firstClosureParameterType($command), $command]; @@ -370,9 +385,11 @@ public function assertDispatchedWithoutChain(Closure|string $command, ?callable /** * Assert if a job was dispatched with chained jobs based on a truth-test callback. * + * @param null|array|callable $callback + * * @throws RuntimeException */ - protected function assertDispatchedWithChainOfObjects(string $command, array $expectedChain, ?callable $callback): void + protected function assertDispatchedWithChainOfObjects(string $command, array $expectedChain, callable|array|null $callback): void { $chain = $expectedChain; @@ -479,42 +496,48 @@ public function assertNothingPlaced(): void /** * Get all of the jobs matching a truth-test callback. + * + * @param null|array|callable $callback */ - public function dispatched(string $command, ?callable $callback = null): Collection + public function dispatched(string $command, callable|array|null $callback = null): Collection { if (! $this->hasDispatched($command)) { return new Collection; } - $callback = $callback ?: fn () => true; + $callback = $this->resolveTruthTest($callback) ?: fn () => true; return (new Collection($this->commands[$command]))->filter(fn ($command) => $callback($command)); } /** * Get all of the jobs dispatched synchronously matching a truth-test callback. + * + * @param null|array|callable $callback */ - public function dispatchedSync(string $command, ?callable $callback = null): Collection + public function dispatchedSync(string $command, callable|array|null $callback = null): Collection { if (! $this->hasDispatchedSync($command)) { return new Collection; } - $callback = $callback ?: fn () => true; + $callback = $this->resolveTruthTest($callback) ?: fn () => true; return (new Collection($this->commandsSync[$command]))->filter(fn ($command) => $callback($command)); } /** * Get all of the jobs dispatched after the response was sent matching a truth-test callback. + * + * @param null|array|callable $callback */ - public function dispatchedAfterResponse(string $command, ?callable $callback = null): Collection + public function dispatchedAfterResponse(string $command, callable|array|null $callback = null): Collection { if (! $this->hasDispatchedAfterResponse($command)) { return new Collection; } - $callback = $callback ?: fn () => true; + $callback = $this->resolveTruthTest($callback) ?: fn () => true; return (new Collection($this->commandsAfterResponse[$command]))->filter(fn ($command) => $callback($command)); } diff --git a/src/support/src/Testing/Fakes/EventFake.php b/src/support/src/Testing/Fakes/EventFake.php index c4a9a09fdc..6f71457a15 100644 --- a/src/support/src/Testing/Fakes/EventFake.php +++ b/src/support/src/Testing/Fakes/EventFake.php @@ -20,6 +20,7 @@ class EventFake implements Fake, DispatcherContract { use ForwardsCalls; + use MatchesProperties; use ReflectsClosures; /** @@ -113,8 +114,10 @@ public function assertListening(string $expectedEvent, array|string $expectedLis /** * Assert if an event was dispatched based on a truth-test callback. + * + * @param null|array|callable|int $callback */ - public function assertDispatched(Closure|string $event, callable|int|null $callback = null): void + public function assertDispatched(Closure|string $event, callable|array|int|null $callback = null): void { if ($event instanceof Closure) { [$event, $callback] = [$this->firstClosureParameterType($event), $event]; @@ -159,8 +162,10 @@ public function assertDispatchedTimes(string $event, int $times = 1): void /** * Determine if an event was dispatched based on a truth-test callback. + * + * @param null|array|callable $callback */ - public function assertNotDispatched(Closure|string $event, ?callable $callback = null): void + public function assertNotDispatched(Closure|string $event, callable|array|null $callback = null): void { if ($event instanceof Closure) { [$event, $callback] = [$this->firstClosureParameterType($event), $event]; @@ -198,14 +203,16 @@ public function assertNothingDispatched(): void /** * Get all of the events matching a truth-test callback. + * + * @param null|array|callable $callback */ - public function dispatched(string $event, ?callable $callback = null): Collection + public function dispatched(string $event, callable|array|null $callback = null): Collection { if (! $this->hasDispatched($event)) { return new Collection; } - $callback = $callback ?: function () { + $callback = $this->resolveTruthTest($callback) ?: function () { return true; }; diff --git a/src/support/src/Testing/Fakes/MatchesProperties.php b/src/support/src/Testing/Fakes/MatchesProperties.php new file mode 100644 index 0000000000..ba3d1053db --- /dev/null +++ b/src/support/src/Testing/Fakes/MatchesProperties.php @@ -0,0 +1,50 @@ +|callable $callback + */ + protected function resolveTruthTest(callable|array|null $callback): ?callable + { + if (! is_array($callback) || is_callable($callback)) { + return $callback; + } + + return fn (object|string $object): bool => (new Collection($callback))->every(function (mixed $expected, string $property) use ($object): bool { + if (! is_object($object)) { + return false; + } + + if (! isset($object->{$property})) { + if (! property_exists($object, $property)) { + return false; + } + + $reflection = new ReflectionProperty($object, $property); + + if (! $reflection->isPublic() || $reflection->isStatic() + || (! $reflection->hasHook(PropertyHookType::Get) && ! $reflection->isInitialized($object))) { + return false; + } + } + + $actual = $object->{$property}; + + return $expected instanceof Model && $actual instanceof Model + ? $expected->is($actual) + : $actual === $expected; + }); + } +} diff --git a/src/support/src/Testing/Fakes/NotificationFake.php b/src/support/src/Testing/Fakes/NotificationFake.php index 7ffd01cbf4..3c1558500e 100644 --- a/src/support/src/Testing/Fakes/NotificationFake.php +++ b/src/support/src/Testing/Fakes/NotificationFake.php @@ -21,6 +21,7 @@ class NotificationFake implements Fake, NotificationDispatcher, NotificationFactory { use Macroable; + use MatchesProperties; use ReflectsClosures; /** @@ -41,9 +42,11 @@ class NotificationFake implements Fake, NotificationDispatcher, NotificationFact /** * Assert if a notification was sent on-demand based on a truth-test callback. * + * @param null|array|callable $callback + * * @throws Exception */ - public function assertSentOnDemand(Closure|string $notification, ?callable $callback = null): void + public function assertSentOnDemand(Closure|string $notification, callable|array|null $callback = null): void { $this->assertSentTo(new AnonymousNotifiable, $notification, $callback); } @@ -51,9 +54,11 @@ public function assertSentOnDemand(Closure|string $notification, ?callable $call /** * Assert if a notification was sent based on a truth-test callback. * + * @param null|array|callable|int $callback + * * @throws Exception */ - public function assertSentTo(mixed $notifiable, Closure|string $notification, callable|int|null $callback = null): void + public function assertSentTo(mixed $notifiable, Closure|string $notification, callable|array|int|null $callback = null): void { if (is_array($notifiable) || $notifiable instanceof Collection) { if (count($notifiable) === 0) { @@ -123,9 +128,11 @@ public function assertSentToOnce(mixed $notifiable, string $notification): void /** * Determine if a notification was sent based on a truth-test callback. * + * @param null|array|callable $callback + * * @throws Exception */ - public function assertNotSentTo(mixed $notifiable, Closure|string $notification, ?callable $callback = null): void + public function assertNotSentTo(mixed $notifiable, Closure|string $notification, callable|array|null $callback = null): void { if (is_array($notifiable) || $notifiable instanceof Collection) { if (count($notifiable) === 0) { @@ -226,14 +233,16 @@ public function assertCount(int $expectedCount): void /** * Get all of the notifications matching a truth-test callback. + * + * @param null|array|callable $callback */ - public function sent(mixed $notifiable, string $notification, ?callable $callback = null): Collection + public function sent(mixed $notifiable, string $notification, callable|array|null $callback = null): Collection { if (! $this->hasSent($notifiable, $notification)) { return new Collection; } - $callback = $callback ?: fn () => true; + $callback = $this->resolveTruthTest($callback) ?: fn () => true; $notifications = new Collection($this->notificationsFor($notifiable, $notification)); diff --git a/src/support/src/Testing/Fakes/QueueFake.php b/src/support/src/Testing/Fakes/QueueFake.php index 4e20deeddd..00b42b714b 100644 --- a/src/support/src/Testing/Fakes/QueueFake.php +++ b/src/support/src/Testing/Fakes/QueueFake.php @@ -36,6 +36,7 @@ */ class QueueFake extends QueueManager implements Fake, Queue { + use MatchesProperties; use ReadsQueueAttributes; use ReflectsClosures; @@ -119,8 +120,10 @@ public function except(array|string $jobsToBeQueued): static /** * Assert if a job was pushed based on a truth-test callback. + * + * @param null|array|callable|int $callback */ - public function assertPushed(Closure|string $job, callable|int|null $callback = null): void + public function assertPushed(Closure|string $job, callable|array|int|null $callback = null): void { if ($job instanceof Closure) { [$job, $callback] = [$this->firstClosureParameterType($job), $job]; @@ -165,11 +168,13 @@ public function assertPushedOnce(string $job): void /** * Assert if a job was pushed based on a truth-test callback. + * + * @param null|array|callable $callback */ public function assertPushedOn( UnitEnum|string|null $queue, Closure|string $job, - ?callable $callback = null, + callable|array|null $callback = null, ): void { $queue = $this->normalizeQueue($queue); @@ -177,6 +182,8 @@ public function assertPushedOn( [$job, $callback] = [$this->firstClosureParameterType($job), $job]; } + $callback = $this->resolveTruthTest($callback); + $this->assertPushed($job, function ($job, $pushedQueue) use ($callback, $queue) { if ($pushedQueue !== $queue) { return false; @@ -188,8 +195,10 @@ public function assertPushedOn( /** * Assert if a job was pushed with chained jobs based on a truth-test callback. + * + * @param null|array|callable $callback */ - public function assertPushedWithChain(string $job, array $expectedChain = [], ?callable $callback = null): void + public function assertPushedWithChain(string $job, array $expectedChain = [], callable|array|null $callback = null): void { PHPUnit::assertTrue( $this->pushed($job, $callback)->isNotEmpty(), @@ -208,8 +217,10 @@ public function assertPushedWithChain(string $job, array $expectedChain = [], ?c /** * Assert if a job was pushed with an empty chain based on a truth-test callback. + * + * @param null|array|callable $callback */ - public function assertPushedWithoutChain(string $job, ?callable $callback = null): void + public function assertPushedWithoutChain(string $job, callable|array|null $callback = null): void { PHPUnit::assertTrue( $this->pushed($job, $callback)->isNotEmpty(), @@ -221,8 +232,10 @@ public function assertPushedWithoutChain(string $job, ?callable $callback = null /** * Assert if a job was pushed with chained jobs based on a truth-test callback. + * + * @param null|array|callable $callback */ - protected function assertPushedWithChainOfObjects(string $job, array $expectedChain, ?callable $callback): void + protected function assertPushedWithChainOfObjects(string $job, array $expectedChain, callable|array|null $callback): void { $chain = (new Collection($expectedChain))->map(fn ($job) => serialize($job))->all(); @@ -234,8 +247,10 @@ protected function assertPushedWithChainOfObjects(string $job, array $expectedCh /** * Assert if a job was pushed with chained jobs based on a truth-test callback. + * + * @param null|array|callable $callback */ - protected function assertPushedWithChainOfClasses(string $job, array $expectedChain, ?callable $callback): void + protected function assertPushedWithChainOfClasses(string $job, array $expectedChain, callable|array|null $callback): void { $matching = $this->pushed($job, $callback)->contains(function (object $pushedJob) use ($expectedChain): bool { return (new Collection($pushedJob->chained))->map(function (string $job): string { @@ -251,16 +266,20 @@ protected function assertPushedWithChainOfClasses(string $job, array $expectedCh /** * Assert if a closure was pushed based on a truth-test callback. + * + * @param null|array|callable|int $callback */ - public function assertClosurePushed(callable|int|null $callback = null): void + public function assertClosurePushed(callable|array|int|null $callback = null): void { $this->assertPushed(CallQueuedClosure::class, $callback); } /** * Assert that a closure was not pushed based on a truth-test callback. + * + * @param null|array|callable $callback */ - public function assertClosureNotPushed(?callable $callback = null): void + public function assertClosureNotPushed(callable|array|null $callback = null): void { $this->assertNotPushed(CallQueuedClosure::class, $callback); } @@ -275,8 +294,10 @@ protected function isChainOfObjects(array $chain): bool /** * Determine if a job was pushed based on a truth-test callback. + * + * @param null|array|callable $callback */ - public function assertNotPushed(Closure|string $job, ?callable $callback = null): void + public function assertNotPushed(Closure|string $job, callable|array|null $callback = null): void { if ($job instanceof Closure) { [$job, $callback] = [$this->firstClosureParameterType($job), $job]; @@ -315,14 +336,16 @@ public function assertNothingPushed(): void /** * Get all of the jobs matching a truth-test callback. + * + * @param null|array|callable $callback */ - public function pushed(string $job, ?callable $callback = null): Collection + public function pushed(string $job, callable|array|null $callback = null): Collection { if (! $this->hasPushed($job)) { return new Collection; } - $callback = $callback ?: fn () => true; + $callback = $this->resolveTruthTest($callback) ?: fn () => true; return (new Collection($this->jobs[$job]))->filter( fn ($data) => $callback($data['job'], $data['queue'], $data['data']) diff --git a/tests/Console/Fixtures/command_signatures.php b/tests/Console/Fixtures/command_signatures.php index f0ab292614..6eff29c6a3 100644 --- a/tests/Console/Fixtures/command_signatures.php +++ b/tests/Console/Fixtures/command_signatures.php @@ -119,19 +119,6 @@ // REMOVED: schedule:finish; coroutine tasks finish directly through Event::finish(). \Hypervel\Console\Commands\ScheduleInterruptCommand::class => [ 'name' => 'schedule:interrupt', - 'options' => [ - [ - 'name' => 'minutes', - 'shortcut' => null, - 'negatable' => false, - 'valueRequired' => false, - 'valueOptional' => true, - 'isArray' => false, - 'acceptValue' => true, - 'default' => '1', - 'description' => 'TTL in minutes for the interrupt signal (minimum 1)', - ], - ], ], \Hypervel\Console\Commands\ScheduleListCommand::class => [ 'name' => 'schedule:list', @@ -3605,7 +3592,7 @@ 'isArray' => false, 'acceptValue' => true, 'default' => '128', - 'description' => 'The memory limit in megabytes', + 'description' => 'The memory limit in megabytes, or as a percentage of the PHP memory limit', ], [ 'name' => 'queue', @@ -4031,7 +4018,7 @@ 'isArray' => false, 'acceptValue' => true, 'default' => '128', - 'description' => 'The memory limit in megabytes', + 'description' => 'The memory limit in megabytes, or as a percentage of the PHP memory limit', ], [ 'name' => 'sleep', diff --git a/tests/Console/Scheduling/ScheduleInterruptCommandTest.php b/tests/Console/Scheduling/ScheduleInterruptCommandTest.php deleted file mode 100644 index c1ed06a521..0000000000 --- a/tests/Console/Scheduling/ScheduleInterruptCommandTest.php +++ /dev/null @@ -1,63 +0,0 @@ -shouldReceive('put') - ->once() - ->with('hypervel:schedule:interrupt', true, m::type(DateTimeInterface::class)); - - $this->app->instance(Cache::class, $cache); - - $this->artisan('schedule:interrupt') - ->assertSuccessful(); - } - - public function testInterruptCommandRejectsZeroMinutes(): void - { - $this->artisan('schedule:interrupt', ['--minutes' => '0']) - ->assertFailed(); - } - - public function testInterruptCommandRejectsNegativeMinutes(): void - { - $this->artisan('schedule:interrupt', ['--minutes' => '-1']) - ->assertFailed(); - } - - public function testInterruptCommandRejectsNonNumericMinutes(): void - { - $this->artisan('schedule:interrupt', ['--minutes' => 'abc']) - ->assertFailed(); - } - - public function testInterruptCommandRejectsDecimalMinutes(): void - { - $this->artisan('schedule:interrupt', ['--minutes' => '1.5']) - ->assertFailed(); - } - - public function testInterruptCommandAcceptsCustomMinutes(): void - { - $cache = m::mock(Cache::class); - $cache->shouldReceive('put') - ->once() - ->with('hypervel:schedule:interrupt', true, m::type(DateTimeInterface::class)); - - $this->app->instance(Cache::class, $cache); - - $this->artisan('schedule:interrupt', ['--minutes' => '5']) - ->assertSuccessful(); - } -} diff --git a/tests/Console/Scheduling/ScheduleRunCommandTest.php b/tests/Console/Scheduling/ScheduleRunCommandTest.php index 3f168ea8a1..7c83b95ef4 100644 --- a/tests/Console/Scheduling/ScheduleRunCommandTest.php +++ b/tests/Console/Scheduling/ScheduleRunCommandTest.php @@ -497,6 +497,7 @@ public function testRunOncePreservesLogContextAcrossItsOuterCoroutine(): void $command = $this->makeCommand(); (new ReflectionProperty($command, 'schedule'))->setValue($command, $schedule); $command->setInput(new ArrayInput(['--whisper' => true], $command->getDefinition())); + (new ReflectionProperty($command, 'startedAt'))->setValue($command, Date::now()); $this->captureOutput($command); (new ReflectionMethod($command, 'runOnce'))->invoke($command); @@ -984,11 +985,11 @@ public function testPauseAndInterruptChecksDoNotReadTheCacheWhenPollingIsDisable $command = $this->makeCommand($cache); $this->invokeRunEvents($command, [$callbackEvent]); - // A stale last check makes shouldStop() read the interrupt signal unless polling is disabled. + // A stale last check makes shouldInterrupt() read the interrupt signal unless polling is disabled. (new ReflectionProperty($command, 'lastChecked'))->setValue($command, CarbonImmutable::now()->subSeconds(5)); $this->assertSame(1, $runCount); - $this->assertFalse((new ReflectionMethod($command, 'shouldStop'))->invoke($command)); + $this->assertFalse((new ReflectionMethod($command, 'shouldInterrupt'))->invoke($command)); } public function testNonRepeatableEventOnlyRunsOncePerMinute(): void @@ -1063,6 +1064,7 @@ public function testSingleServerEventClaimedElsewhereDoesNotReportThatNoCommands $command = $this->makeCommand(); (new ReflectionProperty($command, 'schedule'))->setValue($command, $schedule); $command->setInput(new ArrayInput([], $command->getDefinition())); + (new ReflectionProperty($command, 'startedAt'))->setValue($command, Date::now()); $output = $this->captureOutput($command); (new ReflectionMethod($command, 'runOnce'))->invoke($command); @@ -1237,7 +1239,6 @@ public function testInFlightExecutionsFinishBeforeTheWorkerQuits(): void $schedule = m::mock(Schedule::class); $schedule->expects('dueEventsAt')->andReturn(new Collection([$event])); $cache = m::mock(Cache::class); - $cache->expects('forget')->with('hypervel:schedule:interrupt'); $cache->shouldNotReceive('get'); $command = $this->makeCommand($cache); $command->setInput(new ArrayInput([], $command->getDefinition())); @@ -1372,30 +1373,6 @@ function () use ($waiting, $release, $signal): void { $this->assertSame($first, $this->dispatched[0]->task); } - public function testRunOnceResetsThePreviousShutdownRequest(): void - { - $signal = null; - $this->captureTerminationSignal($signal); - $command = $this->makeCommand(); - (new ReflectionMethod($command, 'listenForSignals'))->invoke($command); - $signal(SIGTERM); - $ran = false; - $event = new CallbackEvent(m::mock(EventMutex::class), function () use (&$ran): void { - $ran = true; - }); - $schedule = m::mock(Schedule::class); - $schedule->expects('dueEventsAt')->andReturn(new Collection([$event])); - $cache = m::mock(Cache::class); - $cache->shouldReceive('get')->with('hypervel:schedule:paused', false)->andReturnFalse(); - $cache->shouldNotReceive('get')->with('hypervel:schedule:interrupt', false); - $command->setInput(new ArrayInput(['--once' => true], $command->getDefinition())); - $this->captureOutput($command); - - $command->handle($schedule, $this->dispatcher, $cache, $this->handler); - - $this->assertTrue($ran); - } - public function testSecondTerminationSignalReleasesMutexesBeforeTerminating(): void { $process = new Process([PHP_BINARY, '-r', <<<'PHP' diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index c43989673c..bbcafa7749 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -368,6 +368,26 @@ public function testConfiguredAttributesAreRefreshedAfterIncrementAndDecrement() $user->save(); } + public function testConfiguredAttributesAreExcludedWhenReplicating(): void + { + $user = GeneratedUser::create([ + 'first_name' => 'Taylor', + 'last_name' => 'Otwell', + ]); + + $replica = $user->replicate(); + $replica->save(); + + $this->assertSame(2, $replica->id); + $this->assertSame('Taylor Otwell', $replica->name); + + $quietReplica = $user->replicateQuietly(); + $quietReplica->save(); + + $this->assertSame(3, $quietReplica->id); + $this->assertSame('Taylor Otwell', $quietReplica->name); + } + public function testRefreshedAttributesAreNotWrittenBackAfterIncrements(): void { $item = GeneratedTotal::create(['price' => 2, 'quantity' => 3, 'options' => ['color' => 'red']]); diff --git a/tests/Database/DatabaseSchemaBuilderTest.php b/tests/Database/DatabaseSchemaBuilderTest.php index 83c0e5f8f6..688864102e 100644 --- a/tests/Database/DatabaseSchemaBuilderTest.php +++ b/tests/Database/DatabaseSchemaBuilderTest.php @@ -348,6 +348,61 @@ public function testTableHasColumns(): void $this->assertFalse($builder->hasColumns('users', ['id', 'address'])); } + #[DataProvider('columnNames')] + public function testColumnExistenceMatchesUnicodeWithoutCoercingNumericNames(string $column, bool $exists): void + { + $connection = m::mock(Connection::class); + $connection->expects('getSchemaGrammar')->andReturn(m::mock(Grammar::class)); + $builder = m::mock(Builder::class . '[getColumnListing]', [$connection]); + $builder->expects('getColumnListing')->with('users')->twice()->andReturn(['Émail', '01']); + + $this->assertSame($exists, $builder->hasColumn('users', $column)); + $this->assertSame($exists, $builder->hasColumns('users', [$column])); + } + + /** + * Provide column names and their expected existence. + */ + public static function columnNames(): array + { + return [ + ['émail', true], + ['01', true], + ['1', false], + ]; + } + + #[DataProvider('columnLookups')] + public function testColumnLookupPrefersExactNamesBeforeCaseInsensitiveMatches(string $column, int $index): void + { + $columns = [ + ['name' => 'foo', 'type_name' => 'integer', 'type' => 'integer'], + ['name' => 'FOO', 'type_name' => 'varchar', 'type' => 'varchar(255)'], + ['name' => 'Émail', 'type_name' => 'text', 'type' => 'text'], + ]; + $connection = m::mock(Connection::class); + $connection->expects('getSchemaGrammar')->andReturn(m::mock(Grammar::class)); + $builder = m::mock(Builder::class . '[getColumns]', [$connection]); + $builder->expects('getColumns')->with('users')->times(3)->andReturn($columns); + + $this->assertSame($columns[$index], $builder->getColumn('users', $column)); + $this->assertSame($columns[$index]['type_name'], $builder->getColumnType('users', $column)); + $this->assertSame($columns[$index]['type'], $builder->getColumnType('users', $column, true)); + } + + /** + * Provide exact and case-insensitive column lookups. + */ + public static function columnLookups(): array + { + return [ + ['foo', 0], + ['FOO', 1], + ['fOo', 0], + ['émail', 2], + ]; + } + public function testGetColumnTypeAddsPrefix(): void { $connection = m::mock(Connection::class); diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php index 91c6d6fd6a..92ba69b64f 100644 --- a/tests/Filesystem/FilesystemManagerTest.php +++ b/tests/Filesystem/FilesystemManagerTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Filesystem; use Aws\CommandInterface; +use Aws\Credentials\Credentials; +use Aws\Exception\CredentialsException; use Aws\MockHandler; use Aws\Result; use Aws\S3\S3Client; @@ -12,6 +14,9 @@ use DateTimeInterface; use Google\Cloud\Storage\Bucket; use Google\Cloud\Storage\StorageClient as GcsClient; +use GuzzleHttp\Promise\Create; +use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Psr7\Response; use Hypervel\Config\Repository; use Hypervel\Container\Container; use Hypervel\Contracts\Container\Container as ContainerContract; @@ -45,11 +50,14 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use PHPUnit\Framework\Attributes\TestWith; +use Psr\Http\Message\RequestInterface; use ReflectionProperty; use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; +use function Aws\serialize; + enum FilesystemTestStringBackedDisk: string { case Local = 'local'; @@ -100,6 +108,191 @@ protected function tearDown(): void parent::tearDown(); } + public function testEcsS3DiskUsesExplicitEndpointAndIgnoresStaticCredentials(): void + { + $environment = [ + 'AWS_ACCESS_KEY_ID' => 'ambient-r2-key', + 'AWS_SECRET_ACCESS_KEY' => 'ambient-r2-secret', + 'AWS_ENDPOINT_URL' => 'https://r2.example.com', + 'AWS_ENDPOINT_URL_S3' => 'https://r2.example.com', + 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI' => '', + 'AWS_CONTAINER_CREDENTIALS_FULL_URI' => 'http://192.0.2.1/credentials', + ]; + $previous = []; + + foreach ($environment as $key => $value) { + $previous[$key] = getenv($key); + putenv($key . '=' . $value); + } + + try { + $disk = (new FilesystemManager($this->getContainer()))->build([ + 'driver' => 's3', + 'credentials' => 'ecs', + 'region' => 'us-east-2', + 'bucket' => 'arn:aws:s3:us-east-2:123456789012:accesspoint/environment-bucket', + 'key' => 'disk-r2-key', + 'secret' => 'disk-r2-secret', + 'endpoint' => 'https://s3-accesspoint.us-east-2.amazonaws.com', + ]); + + // An invalid container host fails before HTTP, rather than falling back to R2 keys. + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('unsupported host'); + + $disk->withClient(function (S3Client $client): void { + $this->assertSame('us-east-2', $client->getRegion()); + $this->assertSame('https://s3-accesspoint.us-east-2.amazonaws.com', (string) $client->getEndpoint()); + $client->getCredentials()->wait(); + }); + } finally { + foreach ($previous as $key => $value) { + putenv($value === false ? $key : $key . '=' . $value); + } + } + } + + public function testS3DisksRouteAccessPointsAndR2ToTheirOwnEndpoints(): void + { + $previous = getenv('AWS_ENDPOINT_URL'); + putenv('AWS_ENDPOINT_URL=https://ambient.example.com'); + $manager = new FilesystemManager($this->getContainer()); + + try { + $aws = $manager->build([ + 'driver' => 's3', + 'region' => 'us-east-2', + 'endpoint' => 'https://s3-accesspoint.us-east-2.amazonaws.com', + 'bucket' => 'arn:aws:s3:us-east-2:123456789012:accesspoint/environment-bucket', + 'credentials' => false, + ]); + $r2 = $manager->build([ + 'driver' => 's3', + 'region' => 'auto', + 'endpoint' => 'https://account.r2.cloudflarestorage.com', + 'bucket' => 'archive', + 'key' => 'r2-key', + 'secret' => 'r2-secret', + ]); + + // Serialize requests without sending them to either provider. + foreach (['GetObject', 'PutObject', 'DeleteObject'] as $operation) { + $awsUri = $aws->withClient(static fn (S3Client $client): string => (string) serialize($client->getCommand($operation, [ + 'Bucket' => $aws->getConfig()['bucket'], 'Key' => 'hello.txt', + ]))->getUri()); + $r2Uri = $r2->withClient(static fn (S3Client $client): string => (string) serialize($client->getCommand($operation, [ + 'Bucket' => $r2->getConfig()['bucket'], 'Key' => 'hello.txt', + ]))->getUri()); + + $this->assertSame('https://environment-bucket-123456789012.s3-accesspoint.us-east-2.amazonaws.com/hello.txt', $awsUri); + $this->assertSame('https://archive.account.r2.cloudflarestorage.com/hello.txt', $r2Uri); + } + } finally { + putenv($previous === false ? 'AWS_ENDPOINT_URL' : 'AWS_ENDPOINT_URL=' . $previous); + } + } + + #[TestWith([false])] + #[TestWith([true])] + public function testS3CredentialProviderOptionsAndMemoization(bool $clientOverride): void + { + $previous = getenv('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'); + putenv('AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=/credentials'); + $requests = 0; + + try { + $config = [ + ...$this->s3DiskConfig('documents'), + 'credentials' => [ + 'provider' => 'ecs', + 'timeout' => 7, + 'client' => function (RequestInterface $request, array $options) use (&$requests): PromiseInterface { + ++$requests; + $this->assertEquals(7, $options['timeout']); + + return Create::promiseFor(new Response(200, [], json_encode([ + 'AccessKeyId' => 'container-key', + 'SecretAccessKey' => 'container-secret', + 'Token' => 'container-token', + 'Expiration' => gmdate('c', time() + 3600), + ]))); + }, + ], + 'endpoint' => 'https://custom-s3.example.com', + 'pool' => ['fingerprint' => 'ecs-test-client'], + ]; + + if ($clientOverride) { + $config['client']['credentials'] = $config['credentials']; + $config['credentials'] = 'instance'; + } + + $disk = (new FilesystemManager($this->getContainer()))->build($config); + + $disk->withClient(function (S3Client $client) use (&$requests): void { + $this->assertSame('container-key', $client->getCredentials()->wait()->getAccessKeyId()); + $this->assertSame('container-token', $client->getCredentials()->wait()->getSecurityToken()); + $this->assertSame(1, $requests); + $this->assertSame('https://custom-s3.example.com', (string) $client->getEndpoint()); + }); + } finally { + putenv($previous === false ? 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI' : 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=' . $previous); + } + } + + public function testS3InstanceCredentialProvider(): void + { + $previous = getenv('AWS_EC2_METADATA_DISABLED'); + putenv('AWS_EC2_METADATA_DISABLED=true'); + + try { + $disk = (new FilesystemManager($this->getContainer()))->build([ + ...$this->s3DiskConfig('documents'), + 'credentials' => 'instance', + ]); + + $this->expectException(CredentialsException::class); + $this->expectExceptionMessage('EC2 metadata service access disabled'); + + $disk->withClient(static fn (S3Client $client): mixed => $client->getCredentials()->wait()); + } finally { + putenv($previous === false ? 'AWS_EC2_METADATA_DISABLED' : 'AWS_EC2_METADATA_DISABLED=' . $previous); + } + } + + public function testS3RejectsUnknownCredentialProviders(): void + { + $disk = (new FilesystemManager($this->getContainer()))->build([ + ...$this->s3DiskConfig('documents'), + 'credentials' => 'unknown', + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid credential provider [unknown].'); + + $disk->withClient(static fn (S3Client $client): mixed => $client->getCredentials()->wait()); + } + + public function testS3PreservesExistingCredentialConfigurations(): void + { + foreach ([false, ['key' => 'key', 'secret' => 'secret'], fn (): null => null] as $credentials) { + $this->assertSame($credentials, $this->s3Config(['credentials' => $credentials])['credentials']); + } + + $this->assertArrayNotHasKey('credentials', $this->s3Config([])); + $this->assertSame(['key' => 'key', 'secret' => 'secret', 'token' => 'token'], $this->s3Config([ + 'key' => 'key', 'secret' => 'secret', 'token' => 'token', + ])['credentials']); + } + + /** + * Format S3 configuration without constructing a client. + */ + protected function s3Config(array $config): array + { + return (new InspectableFilesystemManager($this->getContainer()))->formatS3ConfigForTest($config); + } + public function testExceptionThrownOnUnsupportedDriver(): void { $this->expectExceptionObject(new InvalidArgumentException('Disk [local] does not have a configured driver.')); @@ -399,7 +592,7 @@ public function testDisksDoNotExposePoolConstructionMetadata(): void 'pool' => ['max_objects' => 2], ]); $clientPooled = $manager->build([ - ...$this->s3Config('pooled-cloud'), + ...$this->s3DiskConfig('pooled-cloud'), 'pool' => ['max_objects' => 2], ]); @@ -1237,8 +1430,8 @@ public function testS3DisksWithTheSameClientConfigShareOneClientPoolAcrossBucket { $container = $this->getContainer([ 'disks' => [ - 'documents' => $this->s3Config('documents'), - 'archives' => $this->s3Config('archives'), + 'documents' => $this->s3DiskConfig('documents'), + 'archives' => $this->s3DiskConfig('archives'), ], ]); Container::setInstance($container); @@ -1257,12 +1450,68 @@ public function testS3DisksWithTheSameClientConfigShareOneClientPoolAcrossBucket $this->assertCount(1, $container->make(PoolFactory::class)->getPools()); } + public function testNamedS3ProvidersKeepStableAndDistinctClientPools(): void + { + $container = $this->getContainer(); + $manager = new FilesystemManager($container); + $poolNames = []; + + foreach (['ecs', 'instance', ['provider' => 'ecs', 'timeout' => 2], ['provider' => 'ecs', 'timeout' => 3]] as $credentials) { + $config = [...$this->s3DiskConfig('documents'), 'credentials' => $credentials]; + $first = $manager->build($config); + $second = $manager->build([...$config, 'bucket' => 'archives']); + + $this->assertSame($first->getPoolName(), $second->getPoolName()); + $this->assertSame( + $first->withClient(static fn (S3Client $client): int => spl_object_id($client)), + $second->withClient(static fn (S3Client $client): int => spl_object_id($client)), + ); + $poolNames[] = $first->getPoolName(); + } + + $this->assertCount(4, array_unique($poolNames)); + $this->assertCount(4, $container->make(PoolFactory::class)->getPools()); + } + + #[TestWith([false, false])] + #[TestWith([true, false])] + #[TestWith([true, true])] + public function testS3ArrayCallableCredentialsTakePrecedenceOverLegacyKeys(bool $nested, bool $clientOverride): void + { + $provider = new class { + /** + * Return credentials from the explicitly configured provider. + */ + public function resolve(): PromiseInterface + { + return Create::promiseFor(new Credentials('provider-key', 'provider-secret')); + } + }; + $credentials = [$provider, 'resolve']; + $config = [ + ...$this->s3DiskConfig('documents', 'legacy-key'), + 'credentials' => $nested ? ['provider' => $credentials] : $credentials, + 'pool' => ['fingerprint' => 'array-provider'], + ]; + + if ($clientOverride) { + $config['client']['credentials'] = $config['credentials']; + unset($config['credentials']); + } + + $disk = (new FilesystemManager($this->getContainer()))->build($config); + + $this->assertSame('provider-key', $disk->withClient( + static fn (S3Client $client): string => $client->getCredentials()->wait()->getAccessKeyId(), + )); + } + public function testS3DisksWithDifferentCredentialsUseDifferentClientPools(): void { $container = $this->getContainer([ 'disks' => [ - 'first' => $this->s3Config('documents', 'first-key'), - 'second' => $this->s3Config('documents', 'second-key'), + 'first' => $this->s3DiskConfig('documents', 'first-key'), + 'second' => $this->s3DiskConfig('documents', 'second-key'), ], ]); Container::setInstance($container); @@ -1283,7 +1532,7 @@ public function testRepeatedOnDemandS3BuildsConvergeWithoutNameCollisions(): voi $container = $this->getContainer(); Container::setInstance($container); $manager = new FilesystemManager($container); - $config = $this->s3Config('documents'); + $config = $this->s3DiskConfig('documents'); $first = $manager->build($config); $second = $manager->build($config); @@ -1298,13 +1547,13 @@ public function testRepeatedOnDemandS3BuildsConvergeWithoutNameCollisions(): voi public function testExplicitPoolNamesAndFingerprintsDeclareConstructionEquivalence(): void { - $namedFirst = $this->s3Config('first-bucket', 'first-key'); + $namedFirst = $this->s3DiskConfig('first-bucket', 'first-key'); $namedFirst['pool'] = ['name' => 'shared-account', 'fingerprint' => 'account-v1']; - $namedSecond = $this->s3Config('second-bucket', 'second-key'); + $namedSecond = $this->s3DiskConfig('second-bucket', 'second-key'); $namedSecond['pool'] = ['name' => 'shared-account', 'fingerprint' => 'account-v1']; - $fingerprintedFirst = $this->s3Config('third-bucket', 'third-key'); + $fingerprintedFirst = $this->s3DiskConfig('third-bucket', 'third-key'); $fingerprintedFirst['pool'] = ['fingerprint' => 'declared-equivalent']; - $fingerprintedSecond = $this->s3Config('fourth-bucket', 'fourth-key'); + $fingerprintedSecond = $this->s3DiskConfig('fourth-bucket', 'fourth-key'); $fingerprintedSecond['pool'] = ['fingerprint' => 'declared-equivalent']; $container = $this->getContainer([ 'disks' => [ @@ -1368,9 +1617,9 @@ public function testObjectClientOptionsRequireAnExplicitFingerprint(): void public function testConvergedDisksRejectConflictingPoolOptions(): void { - $firstConfig = $this->s3Config('documents'); + $firstConfig = $this->s3DiskConfig('documents'); $firstConfig['pool'] = ['max_objects' => 1]; - $secondConfig = $this->s3Config('archives'); + $secondConfig = $this->s3DiskConfig('archives'); $secondConfig['pool'] = ['max_objects' => 2]; $container = $this->getContainer([ 'disks' => [ @@ -1395,7 +1644,7 @@ public function testConvergedDisksRejectConflictingPoolOptions(): void public function testScopedClientPoolOptionsMustMatchTheBaseDisk(): void { - $baseConfig = $this->s3Config('documents'); + $baseConfig = $this->s3DiskConfig('documents'); $baseConfig['pool'] = ['max_objects' => 1]; $container = $this->getContainer([ 'disks' => [ @@ -1424,7 +1673,7 @@ public function testScopedClientPoolOptionsMustMatchTheBaseDisk(): void public function testForgetDiskDropsOnlyTheWrapperAndPreservesTheSharedPool(): void { $container = $this->getContainer([ - 'disks' => ['documents' => $this->s3Config('documents')], + 'disks' => ['documents' => $this->s3DiskConfig('documents')], ]); Container::setInstance($container); $manager = new FilesystemManager($container); @@ -1447,8 +1696,8 @@ public function testPurgeClosesCachedAndNeverCachedClientPools(): void { $container = $this->getContainer([ 'disks' => [ - 'documents' => $this->s3Config('documents'), - 'archives' => $this->s3Config('archives'), + 'documents' => $this->s3DiskConfig('documents'), + 'archives' => $this->s3DiskConfig('archives'), ], ]); Container::setInstance($container); @@ -1461,7 +1710,7 @@ public function testPurgeClosesCachedAndNeverCachedClientPools(): void $this->assertFalse($container->make(PoolFactory::class)->has($identity)); - $manager->build($this->s3Config('archives')) + $manager->build($this->s3DiskConfig('archives')) ->withClient(static fn (object $client): object => $client); $this->assertTrue($container->make(PoolFactory::class)->has($identity)); @@ -1489,7 +1738,7 @@ public function testNeverCachedScopedPurgeClosesAClientPoolCreatedThroughEveryEq foreach (['parent disk', 'another scoped disk', 'on-demand build'] as $source) { $container = $this->getContainer([ 'disks' => [ - 'cloud' => $this->s3Config('documents'), + 'cloud' => $this->s3DiskConfig('documents'), 'target' => [ 'driver' => 'scoped', 'disk' => 'cloud', @@ -1507,7 +1756,7 @@ public function testNeverCachedScopedPurgeClosesAClientPoolCreatedThroughEveryEq $disk = match ($source) { 'parent disk' => $manager->disk('cloud'), 'another scoped disk' => $manager->disk('other'), - 'on-demand build' => $manager->build($this->s3Config('archives')), + 'on-demand build' => $manager->build($this->s3DiskConfig('archives')), }; $disk->withClient(static fn (object $client): object => $client); $identity = $disk->getPoolName(); @@ -1594,7 +1843,7 @@ public function testNestedScopedDisksComposePrefixesAndPurgeTheSameClientPool(): ]; $container = $this->getContainer([ 'disks' => [ - 'cloud' => $this->s3Config('documents'), + 'cloud' => $this->s3DiskConfig('documents'), 'inner' => [ 'driver' => 'scoped', 'disk' => 'cloud', @@ -1621,7 +1870,7 @@ public function testCachedScopedPurgeClosesItsClientPool(): void { $container = $this->getContainer([ 'disks' => [ - 'cloud' => $this->s3Config('documents'), + 'cloud' => $this->s3DiskConfig('documents'), 'scoped' => [ 'driver' => 'scoped', 'disk' => 'cloud', @@ -2021,7 +2270,7 @@ public function testExplicitFingerprintOverridesWholeDriverConstructionIdentity( public function testScopedS3BuildsShareTheParentClientPoolWithoutCollisions(): void { $container = $this->getContainer([ - 'disks' => ['cloud' => $this->s3Config('documents')], + 'disks' => ['cloud' => $this->s3DiskConfig('documents')], ]); Container::setInstance($container); $manager = new FilesystemManager($container); @@ -2340,7 +2589,10 @@ public function testDriveAcceptsStringBackedEnum(): void $this->assertInstanceOf(Filesystem::class, $disk); } - private function s3Config(string $bucket, string $key = 'test-key'): array + /** + * Create an S3 disk configuration with static credentials. + */ + private function s3DiskConfig(string $bucket, string $key = 'test-key'): array { return [ 'driver' => 's3', @@ -2412,6 +2664,14 @@ public function __invoke(): Filesystem class InspectableFilesystemManager extends FilesystemManager { + /** + * Expose S3 configuration formatting for credential assertions. + */ + public function formatS3ConfigForTest(array $config): array + { + return $this->formatS3Config($config); + } + public function callCustomCreatorForTest(array $config): Filesystem { return $this->callCustomCreator($config); diff --git a/tests/Filesystem/FilesystemTest.php b/tests/Filesystem/FilesystemTest.php index c2b532557e..9a20bc5025 100755 --- a/tests/Filesystem/FilesystemTest.php +++ b/tests/Filesystem/FilesystemTest.php @@ -366,6 +366,38 @@ public function testDeleteDirectoryReturnFalseWhenNotADirectory(): void $this->assertFalse($files->deleteDirectory($this->tempDir . '/bar/file.txt')); } + public function testDeleteDirectoryReturnsFalseWhenAnotherProcessRemovesItAfterTheCheck(): void + { + $directory = $this->tempDir . '/removed-by-another-process'; + mkdir($directory); + $files = new class extends Filesystem { + private bool $checked = false; + + /** + * Remove the directory after reading its initial metadata. + */ + public function isDirectory(string $directory): bool + { + $exists = parent::isDirectory($directory); + + if (! $this->checked) { + $this->checked = true; + // In-process removal clears PHP's stat cache and would hide the race. + exec('rmdir ' . escapeshellarg($directory), $output, $status); + + if ($status !== 0) { + throw new RuntimeException('The external directory removal failed.'); + } + } + + return $exists; + } + }; + + $this->assertFalse($files->deleteDirectory($directory)); + $this->assertDirectoryDoesNotExist($directory); + } + public function testDeleteDirectoryReportsFailureAfterAttemptingEveryEntry(): void { $directory = $this->tempDir . '/exhaustive-delete'; diff --git a/tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php b/tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php new file mode 100644 index 0000000000..cac00c30db --- /dev/null +++ b/tests/Integration/Console/Scheduling/ScheduleInterruptCommandTest.php @@ -0,0 +1,67 @@ +app->make(Schedule::class); + $startedAt = CarbonImmutable::now(); + + CarbonImmutable::setTestNow($startedAt->addSecond()); + + $this->artisan('schedule:interrupt')->assertSuccessful(); + + $this->assertTrue($schedule->hasBeenInterruptedSince($startedAt)); + $this->assertFalse($schedule->hasBeenInterruptedSince(CarbonImmutable::now()->addSecond())); + } + + public function testReadsTheTimestampAsStoresSuchAsRedisReturnIt(): void + { + Cache::forever('hypervel:schedule:interrupt', (string) CarbonImmutable::now()->getTimestampMs()); + + $this->assertTrue($this->app->make(Schedule::class)->hasBeenInterruptedSince(CarbonImmutable::now()->subSecond())); + } + + public function testDoesNotInterruptWhenInterruptingIsDisabled(): void + { + $this->artisan('schedule:interrupt'); + + Schedule::$interruptible = false; + + $this->assertFalse($this->app->make(Schedule::class)->hasBeenInterruptedSince(CarbonImmutable::now()->subMinute())); + } + + public function testLaterScheduleRunsPreserveEarlierInterruptionsAfterMoreThanAMinute(): void + { + $schedule = $this->app->make(Schedule::class); + $earlierStart = CarbonImmutable::now()->startOfMinute(); + CarbonImmutable::setTestNow($earlierStart->addSecond()); + + $this->artisan('schedule:interrupt')->assertSuccessful(); + + $laterStart = $earlierStart->addMinutes(2)->addSeconds(57); + CarbonImmutable::setTestNow($laterStart); + $runs = 0; + $schedule->call(function () use (&$runs): void { + ++$runs; + })->everySecond(); + Sleep::fake(syncWithCarbon: true); + + $this->artisan('schedule:run', ['--once' => true])->assertSuccessful(); + + $this->assertSame(3, $runs); + $this->assertTrue($schedule->hasBeenInterruptedSince($earlierStart)); + } +} diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php index 73e4d71365..5014ccd577 100644 --- a/tests/Integration/Database/EloquentBelongsToManyTest.php +++ b/tests/Integration/Database/EloquentBelongsToManyTest.php @@ -1222,6 +1222,39 @@ public function testWherePivotNotBetween(): void $this->assertEquals($relationTag->getAttributes(), $tag->getAttributes()); } + public function testWherePivotBetweenIsAppliedToPivotTableOperations(): void + { + $post = Post::create(['title' => Str::random()]); + $tag1 = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + $tag3 = Tag::create(['name' => Str::random()]); + $tag4 = Tag::create(['name' => Str::random()]); + + DB::table('posts_tags')->insert([ + ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => '2024'], + ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => '2025'], + ['post_id' => $post->id, 'tag_id' => $tag3->id, 'flag' => '2026'], + ]); + + $post->tagsWithExtraPivot()->wherePivotBetween('flag', ['2026', '2026'])->sync([$tag4->id => ['flag' => '2026']]); + + $this->assertEquals( + [$tag1->id, $tag2->id, $tag4->id], + $post->tagsWithExtraPivot()->orderBy('tags.id')->pluck('tags.id')->all() + ); + + $post->tagsWithExtraPivot()->wherePivotNotBetween('flag', ['2024', '2025'])->detach(); + + $this->assertEquals( + [$tag1->id, $tag2->id], + $post->tagsWithExtraPivot()->orderBy('tags.id')->pluck('tags.id')->all() + ); + + $post->tagsWithExtraPivot()->wherePivotBetween('flag', ['2025', '2025'])->updateExistingPivot($tag1->id, ['flag' => 'updated']); + + $this->assertSame('2024', $post->tagsWithExtraPivot()->find($tag1->id)->pivot->flag); + } + public function testWherePivotInMethod(): void { $tag = Tag::create(['name' => Str::random()])->fresh(); diff --git a/tests/Integration/Database/EloquentThroughTest.php b/tests/Integration/Database/EloquentThroughTest.php index 0b6cab4f41..4ab6a04812 100644 --- a/tests/Integration/Database/EloquentThroughTest.php +++ b/tests/Integration/Database/EloquentThroughTest.php @@ -8,7 +8,10 @@ use Hypervel\Database\Eloquent\Relations\BelongsTo; use Hypervel\Database\Eloquent\Relations\HasMany; use Hypervel\Database\Eloquent\Relations\HasManyThrough; +use Hypervel\Database\Eloquent\Relations\HasOne; +use Hypervel\Database\Eloquent\Relations\HasOneThrough; use Hypervel\Database\Eloquent\Relations\MorphMany; +use Hypervel\Database\Eloquent\Relations\MorphOne; use Hypervel\Database\Eloquent\Relations\MorphTo; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; @@ -38,6 +41,22 @@ protected function afterRefreshingDatabase(): void $table->unsignedInteger('comment_id'); }); + Schema::create('profiles', function (Blueprint $table) { + $table->increments('id'); + $table->unsignedInteger('comment_id'); + $table->integer('rank')->default(0); + }); + + Schema::create('texts', function (Blueprint $table) { + $table->id(); + $table->foreignId('post_id'); + }); + + Schema::create('images', function (Blueprint $table) { + $table->id(); + $table->morphs('imageable'); + }); + $post = tap(new Post(['public' => true]))->save(); $comment = tap((new Comment)->commentable()->associate($post))->save(); (new Like)->comment()->associate($comment)->save(); @@ -54,6 +73,54 @@ public function test(): void $post = Post::first(); $this->assertEquals(2, $post->commentLikes()->count()); } + + public function testMorphManyLocalRelationshipReturnsHasManyThrough(): void + { + /** @var Post $post */ + $post = Post::first(); + tap((new Comment)->commentable()->associate($post))->save(); + $post->comments->each(fn ($comment) => $comment->profile()->create()); + + $this->assertInstanceOf(HasManyThrough::class, $post->commentProfiles()); + $this->assertCount(2, $post->commentProfiles); + } + + public function testDistantMorphManyExcludesOtherMorphTypes(): void + { + $post = Post::firstOrFail(); + $text = $post->texts()->create(); + $images = $text->images()->createMany([[], []]); + Image::create(['imageable_id' => $text->getKey(), 'imageable_type' => Post::class]); + + $relation = $post->through($post->texts()->one())->has('images'); + + $this->assertInstanceOf(HasManyThrough::class, $relation); + $this->assertEqualsCanonicalizing($images->modelKeys(), $relation->get()->modelKeys()); + } + + public function testDistantMorphOneExcludesOtherMorphTypes(): void + { + $post = Post::firstOrFail(); + $text = $post->texts()->create(); + Image::create(['imageable_id' => $text->getKey(), 'imageable_type' => Post::class]); + $image = $text->image()->create(); + + $relation = $post->through($post->texts()->one())->has('image'); + + $this->assertInstanceOf(HasOneThrough::class, $relation); + $this->assertTrue($image->is($relation->getResults())); + } + + public function testOneOfManyKeepsMorphConstraintsInEveryAggregate(): void + { + $post = Post::firstOrFail(); + $profile = $post->comments()->firstOrFail()->profile()->create(['rank' => 10]); + OtherCommentable::firstOrFail()->comments()->firstOrFail()->profile()->create(['rank' => 10]); + + $relation = $post->commentProfiles()->one()->ofMany(['rank' => 'MAX', 'id' => 'MAX']); + + $this->assertTrue($profile->is($relation->getResults())); + } } class Comment extends Model @@ -69,6 +136,14 @@ public function likes(): HasMany { return $this->hasMany(Like::class); } + + /** + * Get the comment's profile. + */ + public function profile(): HasOne + { + return $this->hasOne(Profile::class); + } } class Post extends Model @@ -89,6 +164,14 @@ public function commentLikes(): HasManyThrough return $this->through($this->comments())->has('likes'); } + /** + * Get profiles through the post's comments. + */ + public function commentProfiles(): HasManyThrough + { + return $this->through($this->comments())->has('profile'); + } + public function texts(): HasMany { return $this->hasMany(Text::class); @@ -115,6 +198,22 @@ public function post(): BelongsTo { return $this->belongsTo(Post::class); } + + /** + * Get the text's images. + */ + public function images(): MorphMany + { + return $this->morphMany(Image::class, 'imageable'); + } + + /** + * Get the text's image. + */ + public function image(): MorphOne + { + return $this->images()->one(); + } } class Like extends Model @@ -126,3 +225,17 @@ public function comment(): BelongsTo return $this->belongsTo(Comment::class); } } + +class Profile extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; +} + +class Image extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; +} diff --git a/tests/Integration/Database/SchemaBuilderSchemaNameTest.php b/tests/Integration/Database/SchemaBuilderSchemaNameTest.php index a987d75d9f..f7eff24ab8 100644 --- a/tests/Integration/Database/SchemaBuilderSchemaNameTest.php +++ b/tests/Integration/Database/SchemaBuilderSchemaNameTest.php @@ -220,9 +220,13 @@ public function testAddColumns($connection): void $this->assertEquals(['id', 'title', 'name', 'count'], $schema->getColumnListing('my_schema.table')); $this->assertEquals(['id', 'name', 'count', 'title'], $schema->getColumnListing('my_table')); $this->assertStringContainsString('default schema name', collect($schema->getColumns('my_schema.table'))->firstWhere('name', 'name')['default']); + $this->assertStringContainsString('default schema name', $schema->getColumn('my_schema.table', 'name')['default']); $this->assertStringContainsString('default schema title', collect($schema->getColumns('my_schema.table'))->firstWhere('name', 'title')['default']); + $this->assertStringContainsString('default schema title', $schema->getColumn('my_schema.table', 'title')['default']); $this->assertStringContainsString('default name', collect($schema->getColumns('my_table'))->firstWhere('name', 'name')['default']); + $this->assertStringContainsString('default name', $schema->getColumn('my_table', 'name')['default']); $this->assertStringContainsString('default title', collect($schema->getColumns('my_table'))->firstWhere('name', 'title')['default']); + $this->assertStringContainsString('default title', $schema->getColumn('my_table', 'title')['default']); } #[DataProvider('connectionProvider')] diff --git a/tests/Integration/Database/SchemaBuilderTest.php b/tests/Integration/Database/SchemaBuilderTest.php index b38b1ca342..13052d6bdd 100644 --- a/tests/Integration/Database/SchemaBuilderTest.php +++ b/tests/Integration/Database/SchemaBuilderTest.php @@ -423,6 +423,57 @@ public function testGetColumns(): void )); } + public function testGetColumnsWithUnknownTable(): void + { + Schema::create('foo', function (Blueprint $table) { + $table->id(); + $table->string('bar')->nullable(); + }); + + $columns = Schema::getColumns('baz'); + + $this->assertIsArray($columns); + $this->assertEmpty($columns); + } + + public function testGetColumn(): void + { + Schema::create('foo', function (Blueprint $table) { + $table->id(); + $table->string('bar')->nullable(); + $table->string('baz')->default('test'); + }); + + $column = Schema::getColumn('foo', 'bar'); + + $this->assertSame('bar', $column['name']); + $this->assertTrue($column['nullable']); + } + + public function testGetColumnIsCaseInsensitive(): void + { + Schema::create('foo', function (Blueprint $table) { + $table->string('bar'); + $table->string('Émail'); + }); + + $this->assertSame('bar', Schema::getColumn('foo', 'BAR')['name']); + $this->assertSame('Émail', Schema::getColumn('foo', 'émail')['name']); + } + + public function testGetColumnWithUnknownColumn(): void + { + Schema::create('foo', function (Blueprint $table) { + $table->id(); + $table->string('bar')->nullable(); + }); + + $column = Schema::getColumn('foo', 'baz'); + + $this->assertIsArray($column); + $this->assertEmpty($column); + } + public function testGetColumnsOnView(): void { DB::statement('create view foo (bar) as select 1'); @@ -433,6 +484,25 @@ public function testGetColumnsOnView(): void $this->assertSame('bar', $columns[0]['name']); } + public function testGetColumnOnView(): void + { + DB::statement('create view foo (bar) as select 1'); + + $column = Schema::getColumn('foo', 'bar'); + + $this->assertSame('bar', $column['name']); + } + + public function testGetColumnOnViewWithUnknownColumn(): void + { + DB::statement('create view foo (bar) as select 1'); + + $column = Schema::getColumn('foo', 'baz'); + + $this->assertIsArray($column); + $this->assertEmpty($column); + } + public function testGetIndexes(): void { Schema::create('foo', function (Blueprint $table) { diff --git a/tests/Integration/Http/Redis/ThrottleRequestsRedisStoreTest.php b/tests/Integration/Http/Redis/ThrottleRequestsRedisStoreTest.php index 48f542fe97..b6131e8156 100644 --- a/tests/Integration/Http/Redis/ThrottleRequestsRedisStoreTest.php +++ b/tests/Integration/Http/Redis/ThrottleRequestsRedisStoreTest.php @@ -60,4 +60,18 @@ public function testResponseBasedLimitUsesRedisInspectionAndConditionalConsumpti $this->get('/')->assertTooManyRequests(); $this->get('/?missing=yes')->assertTooManyRequests(); } + + public function testItReturnsRemainingAttemptsHeaderWhenResponseIsNotCounted(): void + { + $manager = $this->app->make(RateLimiter::class); + $manager->for('throttle-not-found', fn () => Limit::perMinute(5) + ->after(fn (Response $response): bool => $response->getStatusCode() === 404), store: 'redis'); + + Route::get('/', fn (): string => 'ok')->middleware(ThrottleRequests::using('throttle-not-found')); + + $this->get('/') + ->assertOk() + ->assertHeader('X-RateLimit-Limit', 5) + ->assertHeader('X-RateLimit-Remaining', 5); + } } diff --git a/tests/Integration/Redis/DurationLimiterIntegrationTest.php b/tests/Integration/Redis/DurationLimiterIntegrationTest.php index 1d9fba94b8..edcfa63e75 100644 --- a/tests/Integration/Redis/DurationLimiterIntegrationTest.php +++ b/tests/Integration/Redis/DurationLimiterIntegrationTest.php @@ -108,31 +108,49 @@ public function testAcquireSetsDecaysAtAndRemaining(): void $this->assertSame(0, $limiter->remaining); } - public function testTooManyAttemptsReportsFreshWindowMetadataWithoutCreatingAKey(): void + public function testTooManyAttemptsReportsCorrectly(): void { $redis = $this->redis(); - $limiter = new DurationLimiter($redis, 'fresh-key', 2, 60); + $limiter = new DurationLimiter($redis, 'too-many-key', 2, 60); $before = time(); + // Initially, should not have too many attempts $this->assertFalse($limiter->tooManyAttempts()); $this->assertGreaterThanOrEqual($before + 60, $limiter->decaysAt); $this->assertLessThanOrEqual(time() + 60, $limiter->decaysAt); $this->assertSame(2, $limiter->remaining); - $this->assertSame(0, $redis->exists('fresh-key')); - } - - public function testTooManyAttemptsReportsOccupiedWindowMetadata(): void - { - $redis = $this->redis(); - $limiter = new DurationLimiter($redis, 'occupied-key', 2, 60); + $this->assertSame(0, $redis->exists('too-many-key')); + // Use up the available slots $this->assertTrue($limiter->acquire()); $this->assertTrue($limiter->acquire()); + // Now, too many attempts within the same window $this->assertTrue($limiter->tooManyAttempts()); $this->assertGreaterThan(time(), $limiter->decaysAt); $this->assertSame(0, $limiter->remaining); - $this->assertSame('2', $redis->hget('occupied-key', 'count')); + $this->assertSame('2', $redis->hget('too-many-key', 'count')); + + // After decay window, attempts should be allowed again + $redis->hset('too-many-key', 'end', time() - 1); + $this->assertFalse($limiter->tooManyAttempts()); + } + + public function testRemainingAttemptsStayExactWithLargeLimits(): void + { + $limiter = new DurationLimiter($this->redis(), 'large-limit-key', PHP_INT_MAX, 60); + + $this->assertFalse($limiter->tooManyAttempts()); + $this->assertSame(PHP_INT_MAX, $limiter->remaining); + + $this->assertTrue($limiter->acquire()); + $this->assertSame(PHP_INT_MAX - 1, $limiter->remaining); + + $this->assertFalse($limiter->tooManyAttempts()); + $this->assertSame(PHP_INT_MAX - 1, $limiter->remaining); + + $this->assertTrue($limiter->acquire()); + $this->assertSame(PHP_INT_MAX - 2, $limiter->remaining); } public function testTooManyAttemptsClampsOverLimitRemainingCount(): void diff --git a/tests/Integration/Routing/CompiledRouteCollectionTest.php b/tests/Integration/Routing/CompiledRouteCollectionTest.php index 874d79aeec..da0e59e61e 100644 --- a/tests/Integration/Routing/CompiledRouteCollectionTest.php +++ b/tests/Integration/Routing/CompiledRouteCollectionTest.php @@ -308,6 +308,34 @@ public function testMatchingThrowsNotFoundExceptionWhenRouteIsNotFound(): void $this->collection()->match(Request::create('/foo')); } + #[TestWith(['account', 'account'])] + #[TestWith(['secure/{resource}', 'secure/report'])] + public function testMatchingRespectsHttpsOnlyRoutes(string $uri, string $path): void + { + $this->routeCollection->add($this->newRoute('GET', $uri, ['https', 'uses' => 'FooController@index', 'as' => 'account'])); + + $routes = $this->collection(); + + $this->assertSame('account', $routes->match(Request::create('https://localhost/' . $path))->getName()); + + $this->expectException(NotFoundHttpException::class); + + $routes->match(Request::create('http://localhost/' . $path)); + } + + public function testMatchingRespectsHttpOnlyRoutes(): void + { + $this->routeCollection->add($this->newRoute('GET', 'account', ['http', 'uses' => 'FooController@index', 'as' => 'account'])); + + $routes = $this->collection(); + + $this->assertSame('account', $routes->match(Request::create('http://localhost/account'))->getName()); + + $this->expectException(NotFoundHttpException::class); + + $routes->match(Request::create('https://localhost/account')); + } + public function testMatchingThrowsMethodNotAllowedHttpExceptionWhenMethodIsNotAllowed(): void { $this->routeCollection->add($this->newRoute('GET', '/foo', ['uses' => 'FooController@index'])); @@ -540,46 +568,6 @@ public function testCompiledMatchBindsDomainAndPathParameters(): void $this->assertSame(['tenant' => 'hypervel', 'user' => '42'], $route->originalParameters()); } - public function testCompiledMatchHonorsHttpsOnlyRoute(): void - { - $this->routeCollection->add( - $this->newRoute('GET', '/secure/{resource}', [ - 'uses' => 'FooController@index', - 'as' => 'secure', - 'https', - ]) - ); - - $this->assertSame( - 'secure', - $this->collection()->match(Request::create('https://example.com/secure/report'))->getName() - ); - - $this->expectException(NotFoundHttpException::class); - - $this->collection()->match(Request::create('http://example.com/secure/report')); - } - - public function testCompiledMatchHonorsHttpOnlyRoute(): void - { - $this->routeCollection->add( - $this->newRoute('GET', '/insecure', [ - 'uses' => 'FooController@index', - 'as' => 'insecure', - 'http', - ]) - ); - - $this->assertSame( - 'insecure', - $this->collection()->match(Request::create('http://example.com/insecure'))->getName() - ); - - $this->expectException(NotFoundHttpException::class); - - $this->collection()->match(Request::create('https://example.com/insecure')); - } - public function testCompiledConditionReceivesCompleteRequestContext(): void { $this->routeCollection->add( diff --git a/tests/Mail/MailSesV2TransportTest.php b/tests/Mail/MailSesV2TransportTest.php index 873454d81a..f6b7d27986 100644 --- a/tests/Mail/MailSesV2TransportTest.php +++ b/tests/Mail/MailSesV2TransportTest.php @@ -5,9 +5,12 @@ namespace Hypervel\Tests\Mail; use Aws\Command; +use Aws\Credentials\Credentials; use Aws\Exception\AwsException; use Aws\Result; use Aws\SesV2\SesV2Client; +use GuzzleHttp\Promise\Create; +use GuzzleHttp\Promise\PromiseInterface; use Hypervel\Contracts\View\Factory as ViewFactory; use Hypervel\Mail\Mailer; use Hypervel\Mail\MailManager; @@ -55,6 +58,22 @@ public function testGetTransport(): void $this->assertSame('ses-v2', (string) $transport); } + public function testCallableCredentialsTakePrecedenceOverServiceKeys(): void + { + config(['services.ses' => [ + 'key' => 'base-key', + 'secret' => 'base-secret', + 'region' => 'us-east-1', + ]]); + + $transport = (new MailManager($this->app))->createSymfonyTransport([ + 'transport' => 'ses-v2', + 'credentials' => static fn (): PromiseInterface => Create::promiseFor(new Credentials('role-key', 'role-secret')), + ]); + + $this->assertSame('role-key', $transport->ses()->getCredentials()->wait()->getAccessKeyId()); + } + public function testSend(): void { $message = new Email; diff --git a/tests/Queue/ListenCommandTest.php b/tests/Queue/ListenCommandTest.php index add6333a59..c231272745 100644 --- a/tests/Queue/ListenCommandTest.php +++ b/tests/Queue/ListenCommandTest.php @@ -8,9 +8,22 @@ use Hypervel\Queue\ListenerOptions; use Hypervel\Testbench\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; class ListenCommandTest extends TestCase { + #[TestWith(['60%'])] + #[TestWith(['128.5'])] + public function testMemoryLimitIsPassedToTheListenerUnchanged(string $memory): void + { + $listener = m::mock(Listener::class); + $listener->allows('setOutputHandler'); + $listener->expects('listen')->withArgs(static fn (?string $connection, string $queue, ListenerOptions $options): bool => $options->memory === $memory); + $this->app->instance(Listener::class, $listener); + + $this->artisan('queue:listen', ['--memory' => $memory])->assertSuccessful(); + } + public function testFractionalSleepAndRestSecondsAreKept(): void { $listener = m::mock(Listener::class); diff --git a/tests/Queue/Outbox/OutboxWorkerTest.php b/tests/Queue/Outbox/OutboxWorkerTest.php index 5e25ca9c6e..1f968f790b 100644 --- a/tests/Queue/Outbox/OutboxWorkerTest.php +++ b/tests/Queue/Outbox/OutboxWorkerTest.php @@ -255,6 +255,22 @@ public function testMemoryLimitStopsBeforeClaiming(): void $this->assertSame(Worker::EXIT_MEMORY_LIMIT, $this->worker()->run(null, new OutboxWorkerOptions(memory: 1))); } + public function testPercentageMemoryLimitControlsAdmission(): void + { + $originalLimit = ini_get('memory_limit'); + ini_set('memory_limit', (string) (memory_get_usage(true) * 4)); + + try { + $worker = $this->worker(); + $this->relay->expects('claim')->with('primary', 100)->andReturn([]); + + $this->assertSame(Worker::EXIT_SUCCESS, $worker->run(null, new OutboxWorkerOptions(memory: '50%', once: true))); + $this->assertSame(Worker::EXIT_MEMORY_LIMIT, $this->worker()->run(null, new OutboxWorkerOptions(memory: '1%'))); + } finally { + ini_set('memory_limit', $originalLimit); + } + } + /** * Construct an isolated worker with observable dependencies and signal ownership. */ diff --git a/tests/Queue/QueueListenerTest.php b/tests/Queue/QueueListenerTest.php index f87f955626..ef5374f480 100644 --- a/tests/Queue/QueueListenerTest.php +++ b/tests/Queue/QueueListenerTest.php @@ -9,6 +9,7 @@ use Hypervel\Support\Sleep; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Symfony\Component\Process\Process; @@ -61,12 +62,29 @@ public function testListenRestsForFractionalSecondsBetweenWorkers(): void Sleep::assertSequence([Sleep::usleep(250_000)]); } - public function testMakeProcessCorrectlyFormatsCommandLine(): void + public function testListenerChecksPercentageMemoryLimits(): void + { + $listener = new Listener(__DIR__); + $originalLimit = ini_get('memory_limit'); + ini_set('memory_limit', (string) (memory_get_usage(true) * 4)); + + try { + $this->assertFalse($listener->memoryExceeded('50%')); + $this->assertTrue($listener->memoryExceeded('1%')); + } finally { + ini_set('memory_limit', $originalLimit); + } + } + + #[TestWith([2])] + #[TestWith(['60%'])] + #[TestWith([128.5])] + public function testMakeProcessCorrectlyFormatsCommandLine(float|string $memory): void { $listener = new Listener(__DIR__); $options = new ListenerOptions; $options->backoff = 1; - $options->memory = 2; + $options->memory = $memory; $options->timeout = 3; $process = $listener->makeProcess('connection', 'queue', $options); $escape = '\\' === DIRECTORY_SEPARATOR ? '' : '\''; @@ -75,7 +93,7 @@ public function testMakeProcessCorrectlyFormatsCommandLine(): void $this->assertInstanceOf(Process::class, $process); $this->assertSame(__DIR__, $process->getWorkingDirectory()); $this->assertSame(3.0, $process->getTimeout()); - $this->assertSame($escape . php_binary() . $escape . " {$escape}{$artisanBinary}{$escape} {$escape}queue:work{$escape} {$escape}connection{$escape} {$escape}--once{$escape} {$escape}--name=default{$escape} {$escape}--queue=queue{$escape} {$escape}--backoff=1{$escape} {$escape}--memory=2{$escape} {$escape}--sleep=3{$escape} {$escape}--tries=1{$escape}", $process->getCommandLine()); + $this->assertSame($escape . php_binary() . $escape . " {$escape}{$artisanBinary}{$escape} {$escape}queue:work{$escape} {$escape}connection{$escape} {$escape}--once{$escape} {$escape}--name=default{$escape} {$escape}--queue=queue{$escape} {$escape}--backoff=1{$escape} {$escape}--memory={$memory}{$escape} {$escape}--sleep=3{$escape} {$escape}--tries=1{$escape}", $process->getCommandLine()); } public function testMakeProcessCorrectlyFormatsCommandLineWithAnEnvironmentSpecified(): void diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index 042f6d5ad5..81a6984d48 100644 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -1585,6 +1585,34 @@ public function testWorkerMemoryExceededWhenMemoryIsNegative(): void $this->assertFalse($worker->memoryExceeded(-1)); } + public function testWorkerMemoryExceededWhenMemoryIsAPercentage(): void + { + $worker = new class(...$this->workerDependencies()) extends Worker { + /** + * Get the current memory usage in megabytes. + */ + protected function currentMemoryUsage(): float + { + return 512; + } + }; + + $original = ini_set('memory_limit', '1G'); + + try { + $this->assertTrue($worker->memoryExceeded('50%')); + $this->assertFalse($worker->memoryExceeded('51%')); + $this->assertFalse($worker->memoryExceeded('512.5')); + + ini_set('memory_limit', '-1'); + + $this->assertFalse($worker->memoryExceeded('50%')); + $this->assertTrue($worker->memoryExceeded(512)); + } finally { + ini_set('memory_limit', $original); + } + } + public function testDaemonShouldRunSkipsLoopingEventWhenNoListenersAreRegistered(): void { $events = m::mock(EventDispatcher::class); @@ -3501,7 +3529,7 @@ public function daemonShouldRun(WorkerOptions $options, string $connectionName, return ! ($this->isDownForMaintenance)(); } - public function memoryExceeded(float $memoryLimit): bool + public function memoryExceeded(float|string $memoryLimit): bool { return $this->stopOnMemoryExceeded; } diff --git a/tests/Queue/RelayCommandTest.php b/tests/Queue/RelayCommandTest.php index bebad1f015..3664663f5e 100644 --- a/tests/Queue/RelayCommandTest.php +++ b/tests/Queue/RelayCommandTest.php @@ -55,6 +55,21 @@ public function testCommandDefaultsToAllConfiguredSources(): void $this->assertSame(0, Artisan::call('queue:relay')); } + public function testPercentageMemoryLimitIsPassedToTheWorkerUnchanged(): void + { + $originalLimit = ini_get('memory_limit'); + ini_set('memory_limit', (string) (memory_get_usage(true) * 4)); + $worker = m::mock(OutboxWorker::class); + $worker->expects('run')->with(null, m::on(fn (OutboxWorkerOptions $options): bool => $options->memory === '60%'))->andReturn(0); + $this->app->instance(OutboxWorker::class, $worker); + + try { + $this->assertSame(0, Artisan::call('queue:relay', ['--memory' => '60%'])); + } finally { + ini_set('memory_limit', $originalLimit); + } + } + #[DataProvider('invalidOptions')] public function testInvalidOptionsFailBeforeTheWorkerStarts(array $options): void { @@ -75,6 +90,8 @@ public static function invalidOptions(): array 'hot-loop sleep' => [['--sleep' => 0]], 'no capacity' => [['--concurrency' => 0]], 'negative maximum' => [['--max-jobs' => -1]], + 'zero memory' => [['--memory' => 0]], + 'zero percentage' => [['--memory' => '0%']], ]; } } diff --git a/tests/Queue/WorkCommandTest.php b/tests/Queue/WorkCommandTest.php index cab06ea27b..6db08cce9a 100644 --- a/tests/Queue/WorkCommandTest.php +++ b/tests/Queue/WorkCommandTest.php @@ -101,6 +101,19 @@ public function testFractionalSleepAndRestSecondsAreKept(): void $this->assertSame(0.25, $options->rest); } + public function testPercentageMemoryLimitIsKept(): void + { + $command = new WorkCommandOutputStub( + $this->app, + $this->app->make('config'), + m::mock(Worker::class), + $this->app->make('cache'), + ); + $command->setInput(new ArrayInput(['--memory' => '50%'], $command->getDefinition())); + + $this->assertSame('50%', $command->workerOptions()->memory); + } + #[DataProvider('queueStatusOutputProvider')] public function testQueueStatusOutputUsesTheCurrentCommand(bool $json, bool $decorated, string $queue): void { diff --git a/tests/Redis/DurationLimiterBuilderTest.php b/tests/Redis/DurationLimiterBuilderTest.php index 98ce49a05b..7687ffb109 100644 --- a/tests/Redis/DurationLimiterBuilderTest.php +++ b/tests/Redis/DurationLimiterBuilderTest.php @@ -77,7 +77,7 @@ public function testThenExecutesCallbackWhenLockAcquired(): void { $redis = $this->mockRedis(); // DurationLimiter::acquire() Lua script returns success - $this->expectEvaluation($redis, [1, time() + 60, 4]); + $this->expectEvaluation($redis, [1, time() + 60, 1]); $builder = new DurationLimiterBuilder($redis, 'test-key'); $builder->allow(5)->every(60)->block(0); @@ -93,7 +93,7 @@ public function testThenCallsFailureCallbackOnTimeout(): void { $redis = $this->mockRedis(); // DurationLimiter::acquire() always fails - $this->expectEvaluation($redis, [0, time() + 60, 0]); + $this->expectEvaluation($redis, [0, time() + 60, '6']); $builder = new DurationLimiterBuilder($redis, 'test-key'); $builder->allow(5)->every(60)->block(0)->sleep(1); @@ -117,7 +117,7 @@ public function testThenThrowsExceptionWithoutFailureCallback(): void { $redis = $this->mockRedis(); // DurationLimiter::acquire() always fails - $this->expectEvaluation($redis, [0, time() + 60, 0]); + $this->expectEvaluation($redis, [0, time() + 60, '6']); $builder = new DurationLimiterBuilder($redis, 'test-key'); $builder->allow(5)->every(60)->block(0)->sleep(1); diff --git a/tests/Redis/DurationLimiterTest.php b/tests/Redis/DurationLimiterTest.php index c23b7c0755..377ed79ffe 100644 --- a/tests/Redis/DurationLimiterTest.php +++ b/tests/Redis/DurationLimiterTest.php @@ -21,8 +21,8 @@ class DurationLimiterTest extends TestCase public function testAcquireSucceedsWhenBelowLimit(): void { $redis = $this->mockRedis(); - // Lua script returns: [acquired (1=success), decaysAt, remaining] - $this->expectEvaluation($redis, [1, time() + 60, 4]); + // Lua script returns: [acquired (1=success), decaysAt, count] + $this->expectEvaluation($redis, [1, time() + 60, 1]); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); @@ -37,7 +37,7 @@ public function testAcquireUsesShaCachedEvalSignature(): void $redis = $this->mockRedis(); $this->expectEvaluation( $redis, - [1, time() + 60, 4], + [1, time() + 60, 1], function (string $script, array $keys, array $arguments): bool { $this->assertNotSame('', $script); $this->assertSame(['test-key'], $keys); @@ -58,8 +58,8 @@ function (string $script, array $keys, array $arguments): bool { public function testAcquireFailsWhenAtLimit(): void { $redis = $this->mockRedis(); - // Lua script returns: [acquired (0=failed), decaysAt, remaining] - $this->expectEvaluation($redis, [0, time() + 30, 0]); + // Lua script returns: [acquired (0=failed), decaysAt, count] + $this->expectEvaluation($redis, [0, time() + 30, '6']); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); @@ -69,23 +69,10 @@ public function testAcquireFailsWhenAtLimit(): void $this->assertSame(0, $limiter->remaining); } - public function testRemainingIsNeverNegative(): void - { - $redis = $this->mockRedis(); - // Even if script returns negative, remaining should be 0 - $this->expectEvaluation($redis, [0, time() + 60, -2]); - - $limiter = new DurationLimiter($redis, 'test-key', 5, 60); - - $limiter->acquire(); - - $this->assertSame(0, $limiter->remaining); - } - public function testTooManyAttemptsReturnsTrueWhenNoRemaining(): void { $redis = $this->mockRedis(); - $this->expectEvaluation($redis, [time() + 60, 0]); + $this->expectEvaluation($redis, [time() + 60, '5']); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); @@ -98,7 +85,7 @@ public function testTooManyAttemptsReturnsTrueWhenNoRemaining(): void public function testTooManyAttemptsReturnsFalseWhenHasRemaining(): void { $redis = $this->mockRedis(); - $this->expectEvaluation($redis, [time() + 60, 3]); + $this->expectEvaluation($redis, [time() + 60, '2']); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); @@ -113,7 +100,7 @@ public function testTooManyAttemptsUsesShaCachedEvalSignature(): void $redis = $this->mockRedis(); $this->expectEvaluation( $redis, - [time() + 60, 2], + [time() + 60, '3'], function (string $script, array $keys, array $arguments): bool { $this->assertNotSame('', $script); $this->assertSame(['test-key'], $keys); @@ -150,7 +137,7 @@ public function testClearDeletesKey(): void public function testBlockExecutesCallbackOnSuccess(): void { $redis = $this->mockRedis(); - $this->expectEvaluation($redis, [1, time() + 60, 4]); + $this->expectEvaluation($redis, [1, time() + 60, 1]); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); @@ -174,7 +161,7 @@ public function testBlockThrowsExceptionAfterTimeout(): void // Always fail to acquire $connection->shouldReceive('evalWithShaCache') - ->andReturn([0, time() + 60, 0]); + ->andReturn([0, time() + 60, '6']); $limiter = new DurationLimiter($redis, 'test-key', 5, 60); diff --git a/tests/Redis/LimiterConnectionReleaseTest.php b/tests/Redis/LimiterConnectionReleaseTest.php index cc7d8a57e6..1c614be2c1 100644 --- a/tests/Redis/LimiterConnectionReleaseTest.php +++ b/tests/Redis/LimiterConnectionReleaseTest.php @@ -41,7 +41,7 @@ public function testWaitingReturnsIdleSessionsButPreservesTransactionsAndPins(bo $redis->shouldReceive('withConnection')->twice()->andReturn(false, 'waiting0'); $limiter = new ConcurrencyLimiter($redis, 'waiting', 1, 10); } else { - $redis->shouldReceive('withConnection')->twice()->andReturn([0, time() + 10, 0], [1, time() + 10, 0]); + $redis->shouldReceive('withConnection')->twice()->andReturn([0, time() + 10, '2'], [1, time() + 10, 1]); $limiter = new DurationLimiter($redis, 'waiting', 1, 10); } diff --git a/tests/Support/AwsCredentialConsumerTest.php b/tests/Support/AwsCredentialConsumerTest.php index 1a845dcb2b..537b1e0610 100644 --- a/tests/Support/AwsCredentialConsumerTest.php +++ b/tests/Support/AwsCredentialConsumerTest.php @@ -6,6 +6,7 @@ use Aws\Credentials\CredentialProvider; use Aws\Credentials\Credentials; +use Closure; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\Promise; use GuzzleHttp\Promise\PromiseInterface; @@ -24,9 +25,18 @@ class AwsCredentialConsumerTest extends TestCase { - #[DataProvider('consumers')] - public function testPooledConsumersSerializeSharedProvidersWithoutSharingCallerCredentials(string $consumer): void + protected function tearDown(): void { + AwsCredentialConsumerTestProvider::$callback = null; + + parent::tearDown(); + } + + #[DataProvider('consumers')] + public function testPooledConsumersSerializeSharedProvidersWithoutSharingCallerCredentials( + string $consumer, + string $providerForm, + ): void { $active = 0; $maximumActive = 0; $authorization = []; @@ -41,6 +51,13 @@ public function testPooledConsumersSerializeSharedProvidersWithoutSharingCallerC return $promise; }; + AwsCredentialConsumerTestProvider::$callback = $provider; + $provider = match ($providerForm) { + 'string' => AwsCredentialConsumerTestProvider::class . '::resolve', + 'array' => [AwsCredentialConsumerTestProvider::class, 'resolve'], + 'nested string' => ['provider' => AwsCredentialConsumerTestProvider::class . '::resolve'], + default => $provider, + }; $configuration = [ 'region' => 'us-east-1', 'version' => 'latest', @@ -125,7 +142,17 @@ static function () use ($second, $operation): mixed { */ public static function consumers(): array { - return ['SQS' => ['sqs'], 'S3' => ['s3'], 'SES v2' => ['ses']]; + $cases = []; + + foreach (['sqs', 's3', 'ses'] as $consumer) { + foreach (['closure', 'string', 'array'] as $providerForm) { + $cases["{$consumer} {$providerForm}"] = [$consumer, $providerForm]; + } + } + + $cases['sqs nested string'] = ['sqs', 'nested string']; + + return $cases; } #[DataProvider('credentialExpirations')] @@ -180,3 +207,16 @@ public static function credentialExpirations(): array return ['cached' => [3600], 'refreshing' => [30]]; } } + +class AwsCredentialConsumerTestProvider +{ + public static ?Closure $callback = null; + + /** + * Resolve credentials through the current test's shared provider. + */ + public static function resolve(): PromiseInterface + { + return (self::$callback)(); + } +} diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 0c631f59cf..2aa6502840 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -1457,6 +1457,18 @@ public function testRtrim(): void } } + public function testTrimAndRtrimHandleLongInteriorWhitespaceRuns(): void + { + $run = str_repeat(" \u{200B}\t", 20000); + + $this->assertSame("[{$run}x", Str::trim("{$run}[{$run}x{$run}")); + $this->assertSame("{$run}[{$run}x", Str::rtrim("{$run}[{$run}x{$run}")); + $this->assertSame('', Str::trim($run)); + $this->assertSame('', Str::rtrim($run)); + $this->assertSame('a b c', Str::trim(" a b c\u{00A0}\u{FEFF}\n")); + $this->assertSame(' a b', Str::rtrim(" a b \u{3000}\r\n")); + } + public function testSquish(): void { $this->assertSame('hypervel php framework', Str::squish(' hypervel php framework ')); diff --git a/tests/Support/SupportTestingBusFakeTest.php b/tests/Support/SupportTestingBusFakeTest.php index 43d7176075..1b08415166 100644 --- a/tests/Support/SupportTestingBusFakeTest.php +++ b/tests/Support/SupportTestingBusFakeTest.php @@ -19,6 +19,7 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\ExpectationFailedException; use RuntimeException; @@ -423,6 +424,17 @@ public function testAssertDispatchedSyncTimesWithCallbackFunction(): void }, 2); } + #[TestWith(['dispatch', 'assertDispatched', 'assertNotDispatched'])] + #[TestWith(['dispatchSync', 'assertDispatchedSync', 'assertNotDispatchedSync'])] + #[TestWith(['dispatchAfterResponse', 'assertDispatchedAfterResponse', 'assertNotDispatchedAfterResponse'])] + public function testAssertDispatchedWithArrayOfProperties(string $dispatchMethod, string $assertMethod, string $assertNotMethod): void + { + $this->fake->{$dispatchMethod}(new BusJobWithPropertiesStub('pending')); + + $this->fake->{$assertMethod}(BusJobWithPropertiesStub::class, ['status' => 'pending']); + $this->fake->{$assertNotMethod}(BusJobWithPropertiesStub::class, ['status' => 'complete']); + } + #[DataProvider('countAssertionMethods')] public function testCountAssertionsPluralizeFailureMessages( string $dispatchMethod, @@ -1160,6 +1172,16 @@ class BusJobStub { } +class BusJobWithPropertiesStub +{ + /** + * Create a job with the given status. + */ + public function __construct(public string $status) + { + } +} + class ChainedJobStub { use Queueable; diff --git a/tests/Support/SupportTestingEventFakeTest.php b/tests/Support/SupportTestingEventFakeTest.php index 038b41170b..b599610cc5 100644 --- a/tests/Support/SupportTestingEventFakeTest.php +++ b/tests/Support/SupportTestingEventFakeTest.php @@ -100,6 +100,21 @@ public function testAssertDispatchedTimes(): void $this->fake->assertDispatchedTimes(EventStub::class, 2); } + public function testAssertDispatchedWithArrayOfProperties(): void + { + $this->fake->dispatch(new EventWithPropertiesStub('pending')); + + $this->fake->assertDispatched(EventWithPropertiesStub::class, ['status' => 'pending']); + $this->fake->assertNotDispatched(EventWithPropertiesStub::class, ['status' => 'complete']); + } + + public function testPropertyMapsDoNotMatchStringEvents(): void + { + $this->fake->dispatch(EventWithPropertiesStub::class); + + $this->fake->assertNotDispatched(EventWithPropertiesStub::class, ['status' => null]); + } + public function testAssertNotDispatched(): void { $this->fake->assertNotDispatched(EventStub::class); @@ -176,3 +191,13 @@ class EventStub class ListenerStub { } + +class EventWithPropertiesStub +{ + /** + * Create an event with the given status. + */ + public function __construct(public string $status) + { + } +} diff --git a/tests/Support/SupportTestingNotificationFakeTest.php b/tests/Support/SupportTestingNotificationFakeTest.php index c9d697099c..d6b296d9db 100644 --- a/tests/Support/SupportTestingNotificationFakeTest.php +++ b/tests/Support/SupportTestingNotificationFakeTest.php @@ -76,6 +76,21 @@ public function testAssertSentOnDemandClosure(): void }); } + public function testAssertSentToWithArrayOfProperties(): void + { + $this->fake->send($this->user, new NotificationWithPropertiesStub('pending')); + + $this->fake->assertSentTo($this->user, NotificationWithPropertiesStub::class, ['status' => 'pending']); + $this->fake->assertNotSentTo($this->user, NotificationWithPropertiesStub::class, ['status' => 'complete']); + } + + public function testAssertSentOnDemandWithArrayOfProperties(): void + { + $this->fake->send(new AnonymousNotifiable, new NotificationWithPropertiesStub('pending')); + + $this->fake->assertSentOnDemand(NotificationWithPropertiesStub::class, ['status' => 'pending']); + } + public function testAssertNotSentTo(): void { $this->fake->assertNotSentTo($this->user, NotificationStub::class); @@ -283,6 +298,16 @@ public function shouldSend(mixed $notifiable, string $channel): bool } } +class NotificationWithPropertiesStub extends NotificationStub +{ + /** + * Create a notification with the given status. + */ + public function __construct(public string $status) + { + } +} + class UserStub extends User { } diff --git a/tests/Support/SupportTestingQueueFakeTest.php b/tests/Support/SupportTestingQueueFakeTest.php index cfcb755ecc..7cd3ea2f19 100644 --- a/tests/Support/SupportTestingQueueFakeTest.php +++ b/tests/Support/SupportTestingQueueFakeTest.php @@ -13,6 +13,7 @@ use Hypervel\Contracts\Cache\Repository as CacheContract; use Hypervel\Contracts\Queue\Queue; use Hypervel\Contracts\Queue\ShouldBeUnique; +use Hypervel\Database\Eloquent\Model; use Hypervel\Foundation\Application; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\CallQueuedClosure; @@ -24,6 +25,7 @@ use Mockery as m; use PHPUnit\Framework\ExpectationFailedException; use RuntimeException; +use stdClass; class SupportTestingQueueFakeTest extends TestCase { @@ -95,6 +97,72 @@ public function testAssertPushedWithClosure(): void }); } + public function testAssertPushedWithArrayOfProperties(): void + { + $this->fake->pushOn('high', new JobWithPropertiesStub('pending', new ModelForQueueFakeStub(['id' => 1]))); + + $this->fake->assertPushed(JobWithPropertiesStub::class, ['status' => 'pending', 'model' => new ModelForQueueFakeStub(['id' => 1])]); + $this->fake->assertPushedOn('high', JobWithPropertiesStub::class, ['status' => 'pending']); + $this->fake->assertNotPushed(JobWithPropertiesStub::class, ['status' => 'complete']); + $this->fake->assertNotPushed(JobWithPropertiesStub::class, ['model' => new ModelForQueueFakeStub(['id' => 2])]); + } + + public function testAssertPushedWithArrayOfPropertiesDoesNotMatchMissingOrMismatchedProperties(): void + { + $this->fake->push(new JobWithPropertiesStub('pending')); + + $this->fake->assertNotPushed(JobWithPropertiesStub::class, ['status' => new ModelForQueueFakeStub(['id' => 1])]); + $this->fake->assertNotPushed(JobWithPropertiesStub::class, ['model' => new ModelForQueueFakeStub(['id' => 1])]); + $this->fake->assertNotPushed(JobWithPropertiesStub::class, ['missing' => null]); + $this->fake->assertPushed(JobWithPropertiesStub::class, ['model' => null]); + } + + public function testPropertyMapsDoNotMatchUnreadablePropertiesAsNull(): void + { + $job = new class { + public static ?string $shared = null; + + public string $uninitialized; + + protected string $hidden = 'pending'; + + private string $secret = 'pending'; + }; + + $this->fake->push($job); + + foreach (['shared', 'uninitialized', 'hidden', 'secret'] as $property) { + $this->fake->assertNotPushed($job::class, [$property => null]); + } + } + + public function testPropertyMapsMatchNullDynamicPropertiesAndPropertyHooks(): void + { + $job = new class extends stdClass { + public ?string $status { + get => null; + } + + public ?string $backedStatus { + get => $this->backedStatus ?? null; + } + }; + $job->dynamic = null; + + $this->fake->push($job); + + $this->fake->assertPushed($job::class, ['dynamic' => null, 'status' => null, 'backedStatus' => null]); + } + + public function testAssertPushedPreservesArrayCallbacks(): void + { + $predicate = fn (JobWithPropertiesStub $job): bool => $job->status === 'pending'; + $this->fake->pushOn('high', new JobWithPropertiesStub('pending')); + + $this->fake->assertPushed(JobWithPropertiesStub::class, [$predicate, '__invoke']); + $this->fake->assertPushedOn('high', JobWithPropertiesStub::class, [$predicate, '__invoke']); + } + public function testQueueSize(): void { $this->assertEquals(0, $this->fake->size()); @@ -1055,6 +1123,21 @@ public function handle(): void } } +class JobWithPropertiesStub +{ + /** + * Create a job with the given properties. + */ + public function __construct(public string $status, public ?ModelForQueueFakeStub $model = null) + { + } +} + +class ModelForQueueFakeStub extends Model +{ + protected array $guarded = []; +} + #[Delay(15)] class JobWithDelayAttributeStub { diff --git a/tests/Testing/Concerns/TestViewsTest.php b/tests/Testing/Concerns/TestViewsTest.php index 7ee71316d2..fcf79b4e09 100644 --- a/tests/Testing/Concerns/TestViewsTest.php +++ b/tests/Testing/Concerns/TestViewsTest.php @@ -4,10 +4,12 @@ namespace Hypervel\Tests\Testing\Concerns; +use ErrorException; use Hypervel\Config\Repository as Config; use Hypervel\Container\Container; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Facades\Facade; +use Hypervel\Support\Facades\File; use Hypervel\Testing\Concerns\TestViews; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; @@ -61,8 +63,6 @@ protected function tearDown(): void { $this->filesystem->deleteDirectory($this->tempDir); - Facade::setFacadeApplication(null); - if ($this->originalParallelTesting === null) { unset($_SERVER['HYPERVEL_PARALLEL_TESTING']); } else { @@ -176,6 +176,106 @@ public function testTearDownProcessDeletesCompiledViewDirectory(): void $this->assertFileExists($this->tempDir . '/shared.php'); } + public function testSetUpProcessToleratesDirectoryCreatedByConcurrentRun(): void + { + $path = $this->tempDir . '/test_9'; + + // Another parallel run created the directory after the first metadata check. + mkdir($path, 0755, true); + + $this->bootTestViewsUsingFilesystem($this->tempDir, '9', new class extends Filesystem { + private bool $checked = false; + + /** + * Return the stale metadata once, then observe the concurrent creation. + */ + public function isDirectory(string $directory): bool + { + if (! $this->checked) { + $this->checked = true; + + return false; + } + + return parent::isDirectory($directory); + } + }); + + $this->withWarningsAsExceptions( + fn () => Container::getInstance()->make(ParallelTesting::class)->callSetUpProcessCallbacks() + ); + + $this->assertDirectoryExists($path); + } + + public function testTearDownProcessToleratesDirectoryRemovedByConcurrentRun(): void + { + $path = $this->tempDir . '/test_9'; + + // Another parallel run removed the directory after the first metadata check. + $this->bootTestViewsUsingFilesystem($this->tempDir, '9', new class extends Filesystem { + private bool $checked = false; + + /** + * Return the stale metadata once, then observe the concurrent removal. + */ + public function isDirectory(string $directory): bool + { + if (! $this->checked) { + $this->checked = true; + + return true; + } + + return parent::isDirectory($directory); + } + }); + + $this->withWarningsAsExceptions( + fn () => Container::getInstance()->make(ParallelTesting::class)->callTearDownProcessCallbacks() + ); + + $this->assertDirectoryDoesNotExist($path); + } + + /** + * Boot the view callbacks with the given filesystem and worker token. + */ + protected function bootTestViewsUsingFilesystem(string $compiled, string $token, Filesystem $files): void + { + $container = Container::getInstance(); + $container->make('config')->set('view.compiled', $compiled); + $container->make(ParallelTesting::class)->resolveTokenUsing(fn () => $token); + $container->instance('files', $files); + File::clearResolvedInstance(); + $instance = $this->makeTestViewsInstance(); + + (new ReflectionMethod($instance, 'bootTestViews'))->invoke($instance); + } + + /** + * Run the callback with unsuppressed warnings converted to exceptions. + */ + protected function withWarningsAsExceptions(callable $callback): void + { + $reporting = error_reporting(); + + set_error_handler(function (int $severity, string $message, string $file, int $line) use ($reporting): bool { + // A changed level means the warning was silenced with the @ operator... + if (error_reporting() !== $reporting) { + return false; + } + + throw new ErrorException($message, 0, $severity, $file, $line); + }); + + try { + $callback(); + } finally { + restore_error_handler(); + } + } + /** * Get the compiled view path for the current process. */ diff --git a/types/Database/Eloquent/Relations.php b/types/Database/Eloquent/Relations.php index e9015268f3..0ce3e917ed 100644 --- a/types/Database/Eloquent/Relations.php +++ b/types/Database/Eloquent/Relations.php @@ -300,6 +300,10 @@ public function parts(): HasManyThrough 'Hypervel\Database\Eloquent\Relations\HasManyThrough', $this->through($this->mechanic())->has(fn ($mechanic) => $mechanic->parts()), ); + assertType( + 'Hypervel\Database\Eloquent\Relations\HasManyThrough', + $this->through($this->mechanic())->has(fn ($mechanic) => $mechanic->morphMany(Part::class, 'partable')), + ); return $hasManyThrough; } @@ -340,6 +344,11 @@ public function comments(): MorphMany $morphMany = $this->morphMany(Comment::class, 'commentable'); assertType('Hypervel\Database\Eloquent\Relations\MorphMany', $morphMany); + assertType( + 'Hypervel\Database\Eloquent\Relations\HasManyThrough', + $this->through($morphMany)->has(fn ($comment) => $comment->hasOne(Image::class)), + ); + return $morphMany; } @@ -349,6 +358,11 @@ public function latestComment(): MorphOne $comment = $this->comments()->one(); assertType('Hypervel\Database\Eloquent\Relations\MorphOne', $comment); + assertType( + 'Hypervel\Database\Eloquent\Relations\HasOneThrough', + $this->through($comment)->has(fn ($comment) => $comment->hasOne(Image::class)), + ); + return $comment; }