diff --git a/src/bus/src/Batch.php b/src/bus/src/Batch.php index 643ed9ee6..e12a4f620 100644 --- a/src/bus/src/Batch.php +++ b/src/bus/src/Batch.php @@ -90,8 +90,8 @@ public function add(array|object $jobs): ?Batch $this->queue->connection($this->options['connection'] ?? null)->bulk( $jobs->all(), - $data = '', - $this->options['queue'] ?? null + data: '', + queue: $this->options['queue'] ?? null ); }); diff --git a/src/cache/src/AnyModeTaggedCache.php b/src/cache/src/AnyModeTaggedCache.php index 2c435c03f..16570e146 100644 --- a/src/cache/src/AnyModeTaggedCache.php +++ b/src/cache/src/AnyModeTaggedCache.php @@ -126,7 +126,7 @@ public function pull(UnitEnum|string $key, mixed $default = null): mixed * * @throws BadMethodCallException always - tags are for writing and flushing only */ - public function forget(UnitEnum|string $key): bool + public function forget(array|UnitEnum|string $key): bool { throw new BadMethodCallException( 'Cannot forget items via tags in any mode. Tags are for writing and flushing only. ' diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index 8b99d4893..bc94150f8 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -105,6 +105,16 @@ public function __construct(Store $store, array $config = []) */ public function has(array|UnitEnum|string $key): bool { + if (is_array($key)) { + $keys = []; + + foreach ($key as $name => $value) { + $keys[] = is_string($name) ? $name : $value; + } + + return ! in_array(null, $this->many($keys), true); + } + return ! is_null($this->get($key)); } @@ -807,8 +817,12 @@ public function funnel(UnitEnum|string $name): ConcurrencyLimiterBuilder /** * Remove an item from the cache. */ - public function forget(UnitEnum|string $key): bool + public function forget(array|UnitEnum|string $key): bool { + if (is_array($key)) { + return $this->deleteMultiple($key); + } + $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key; if ($this->events?->hasListeners(ForgettingKey::class)) { diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php index 3cf879fdb..42df2837f 100644 --- a/src/collections/src/Collection.php +++ b/src/collections/src/Collection.php @@ -226,7 +226,7 @@ public function containsStrict(mixed $key, mixed $value = null): bool } if ($this->useAsCallable($key)) { - return ! is_null($this->first($key)); + return array_any($this->items, $key); } return in_array($key, $this->items, true); diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php index 16e56150d..b8243ea8e 100644 --- a/src/collections/src/LazyCollection.php +++ b/src/collections/src/LazyCollection.php @@ -318,7 +318,9 @@ public function containsStrict(mixed $key, mixed $value = null): bool } if ($this->useAsCallable($key)) { - return ! is_null($this->first($key)); + $placeholder = new stdClass; + + return $this->first($key, $placeholder) !== $placeholder; } foreach ($this as $item) { diff --git a/src/collections/src/Traits/EnumeratesValues.php b/src/collections/src/Traits/EnumeratesValues.php index 2e7031f56..413bda172 100644 --- a/src/collections/src/Traits/EnumeratesValues.php +++ b/src/collections/src/Traits/EnumeratesValues.php @@ -50,6 +50,7 @@ * @property-read HigherOrderCollectionProxy<'reject', TValue, static> $reject * @property-read HigherOrderCollectionProxy<'skipUntil', TValue, static> $skipUntil * @property-read HigherOrderCollectionProxy<'skipWhile', TValue, static> $skipWhile + * @property-read HigherOrderCollectionProxy<'sole', TValue, static> $sole * @property-read HigherOrderCollectionProxy<'some', TValue, static> $some * @property-read HigherOrderCollectionProxy<'sortBy', TValue, static> $sortBy * @property-read HigherOrderCollectionProxy<'sortByDesc', TValue, static> $sortByDesc @@ -96,6 +97,7 @@ trait EnumeratesValues 'reject', 'skipUntil', 'skipWhile', + 'sole', 'some', 'sortBy', 'sortByDesc', diff --git a/src/console/src/Concerns/InteractsWithIO.php b/src/console/src/Concerns/InteractsWithIO.php index 3a57f3a29..55fc3ce60 100644 --- a/src/console/src/Concerns/InteractsWithIO.php +++ b/src/console/src/Concerns/InteractsWithIO.php @@ -146,7 +146,7 @@ public function ask(string $question, ?string $default = null): mixed /** * Prompt the user for input with auto completion. * - * @param (callable(string): string[])|iterable $choices + * @param (callable(string): list)|iterable $choices */ public function anticipate(string $question, iterable|callable $choices, ?string $default = null): mixed { @@ -156,7 +156,7 @@ public function anticipate(string $question, iterable|callable $choices, ?string /** * Prompt the user for input with auto completion. * - * @param (callable(string): string[])|iterable $choices + * @param (callable(string): list)|iterable $choices */ public function askWithCompletion(string $question, iterable|callable $choices, ?string $default = null): mixed { diff --git a/src/contracts/src/Cache/Repository.php b/src/contracts/src/Cache/Repository.php index 3999bd348..bd46871b8 100644 --- a/src/contracts/src/Cache/Repository.php +++ b/src/contracts/src/Cache/Repository.php @@ -180,7 +180,7 @@ public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int $ /** * Remove an item from the cache. */ - public function forget(UnitEnum|string $key): bool; + public function forget(array|UnitEnum|string $key): bool; /** * Get the cache store implementation. diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index c2548a003..969c14c93 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -496,6 +496,7 @@ public function withoutPretending(Closure $callback): mixed /** * Execute the given callback in "dry run" mode. * + * @param (Closure(): (array{query: string, bindings: array, time: null|float}[])) $callback * @return array{query: string, bindings: array, time: null|float}[] */ protected function withFreshQueryLog(Closure $callback): array diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 7ed161b53..2b3ae8297 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -2015,6 +2015,16 @@ public function qualifyColumn(Expression|string $column): string { $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; + if (! is_null($alias = $this->getTableAlias())) { + if (! str_contains($column, '.')) { + return $alias . '.' . $column; + } + + if (str_starts_with($column, $table = $this->model->getTable() . '.')) { + return $alias . '.' . substr($column, strlen($table)); + } + } + return $this->model->qualifyColumn($column); } @@ -2023,7 +2033,21 @@ public function qualifyColumn(Expression|string $column): string */ public function qualifyColumns(array $columns): array { - return $this->model->qualifyColumns($columns); + $qualified = []; + + foreach ($columns as $key => $column) { + $qualified[$key] = $this->qualifyColumn($column); + } + + return $qualified; + } + + /** + * Get the alias or table name that qualifies the query's source columns. + */ + protected function getTableAlias(): ?string + { + return $this->query->getFromAlias(); } /** diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index 1dbd95e0b..098118b66 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -522,11 +522,13 @@ protected function throwMissingAttributeExceptionIfApplicable(string $key): mixe && ! $this->wasRecentlyCreated && static::preventsAccessingMissingAttributes() && ! CoroutineContext::get(self::MISSING_ATTRIBUTE_ACCESS_SUPPRESSED_CONTEXT_KEY, false)) { + $exception = new MissingAttributeException($this, $key); + if (isset(static::$missingAttributeViolationCallback)) { - return call_user_func(static::$missingAttributeViolationCallback, $this, $key); + return call_user_func(static::$missingAttributeViolationCallback, $this, $key, $exception); } - throw new MissingAttributeException($this, $key); + throw $exception; } return null; @@ -600,15 +602,22 @@ public function isRelation(string $key): bool */ protected function handleLazyLoadingViolation(string $key): mixed { + $exception = new LazyLoadingViolationException($this, $key); + if (isset(static::$lazyLoadingViolationCallback)) { - return call_user_func(static::$lazyLoadingViolationCallback, $this, $key); + return call_user_func( + static::$lazyLoadingViolationCallback, + $this, + $key, + $exception + ); } if (! $this->exists || $this->wasRecentlyCreated) { return null; } - throw new LazyLoadingViolationException($this, $key); + throw $exception; } /** @@ -1981,7 +1990,7 @@ public function getOriginal(?string $key = null, mixed $default = null): mixed { return (new static)->setRawAttributes( $this->original, - $sync = true + sync: true )->getOriginalWithoutRewindingModel($key, $default); } diff --git a/src/database/src/Eloquent/Factories/Factory.php b/src/database/src/Eloquent/Factories/Factory.php index f6dbe2fee..1e069364a 100644 --- a/src/database/src/Eloquent/Factories/Factory.php +++ b/src/database/src/Eloquent/Factories/Factory.php @@ -209,6 +209,10 @@ public function raw(callable|array $attributes = [], ?Model $parent = null): arr return $this->state($attributes)->getExpandedAttributes($parent); } + if ($this->count < 1) { + return []; + } + return array_map(function () use ($attributes, $parent) { return $this->state($attributes)->getExpandedAttributes($parent); }, range(1, $this->count)); diff --git a/src/database/src/Eloquent/MassPrunable.php b/src/database/src/Eloquent/MassPrunable.php index 54de35753..8b08b3cab 100644 --- a/src/database/src/Eloquent/MassPrunable.php +++ b/src/database/src/Eloquent/MassPrunable.php @@ -15,17 +15,16 @@ trait MassPrunable */ public function pruneAll(int $chunkSize = 1000): int { - $query = tap($this->prunable(), function ($query) use ($chunkSize) { - $query->when(! $query->getQuery()->limit, function ($query) use ($chunkSize) { - $query->limit($chunkSize); - }); + $softDeletable = static::isSoftDeletable(); + + $query = tap($this->prunable(), function (Builder $query) use ($chunkSize, $softDeletable): void { + $query->when($softDeletable, fn (Builder $query): Builder => $query->withTrashed()) + ->when(! $query->getQuery()->limit, fn (Builder $query): Builder => $query->limit($chunkSize)); }); $total = 0; $events = null; - $softDeletable = static::isSoftDeletable(); - do { $total += $count = $softDeletable ? $query->forceDelete() diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 0b22ce421..69291bde2 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -36,6 +36,7 @@ use Hypervel\Database\Eloquent\Relations\HasManyThrough; use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Database\Eloquent\Relations\Relation; +use Hypervel\Database\LazyLoadingViolationException; use Hypervel\Database\Query\Builder as QueryBuilder; use Hypervel\Engine\Coroutine; use Hypervel\Support\Arr; @@ -228,9 +229,9 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt /** * The callback that is responsible for handling lazy loading violations. * - * @var null|(callable(self, string): mixed) + * @var null|(callable(self, string, LazyLoadingViolationException): mixed) */ - protected static $lazyLoadingViolationCallback; + protected static mixed $lazyLoadingViolationCallback = null; /** * Indicates if an exception should be thrown instead of silently discarding non-fillable attributes. @@ -240,9 +241,9 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt /** * The callback that is responsible for handling discarded attribute violations. * - * @var null|(callable(self, array): mixed) + * @var null|(callable(self, array, MassAssignmentException): mixed) */ - protected static $discardedAttributeViolationCallback; + protected static mixed $discardedAttributeViolationCallback = null; /** * Indicates if an exception should be thrown when trying to access a missing attribute on a retrieved model. @@ -252,9 +253,9 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt /** * The callback that is responsible for handling missing attribute violations. * - * @var null|(callable(self, string): mixed) + * @var null|(callable(self, string, MissingAttributeException): mixed) */ - protected static $missingAttributeViolationCallback; + protected static mixed $missingAttributeViolationCallback = null; /** * Indicates if invalid value exceptions during implicit route model binding should be reported. @@ -679,7 +680,7 @@ public static function automaticallyEagerLoadRelationships(bool $value = true): * Boot-only. The callback persists in a static property for the worker * lifetime and runs on every lazy-loading violation across all coroutines. * - * @param null|(callable(self, string): mixed) $callback + * @param null|(callable(self, string, LazyLoadingViolationException): mixed) $callback */ public static function handleLazyLoadingViolationUsing(?callable $callback): void { @@ -704,7 +705,7 @@ public static function preventSilentlyDiscardingAttributes(bool $value = true): * lifetime and runs on every discarded-attribute violation across all * coroutines. * - * @param null|(callable(self, array): mixed) $callback + * @param null|(callable(self, array, MassAssignmentException): mixed) $callback */ public static function handleDiscardedAttributeViolationUsing(?callable $callback): void { @@ -729,7 +730,7 @@ public static function preventAccessingMissingAttributes(bool $value = true): vo * lifetime and runs on every missing-attribute violation across all * coroutines. * - * @param null|(callable(self, string): mixed) $callback + * @param null|(callable(self, string, MissingAttributeException): mixed) $callback */ public static function handleMissingAttributeViolationUsing(?callable $callback): void { @@ -796,14 +797,16 @@ public function fill(array $attributes): static if ($this->isFillable($key)) { $this->setAttribute($key, $value); } elseif ($totallyGuarded || static::preventsSilentlyDiscardingAttributes()) { + $exception = new MassAssignmentException(sprintf( + 'Add [%s] to fillable property to allow mass assignment on [%s].', + $key, + get_class($this) + )); + if (isset(static::$discardedAttributeViolationCallback)) { - call_user_func(static::$discardedAttributeViolationCallback, $this, [$key]); + call_user_func(static::$discardedAttributeViolationCallback, $this, [$key], $exception); } else { - throw new MassAssignmentException(sprintf( - 'Add [%s] to fillable property to allow mass assignment on [%s].', - $key, - get_class($this) - )); + throw $exception; } } } @@ -812,14 +815,16 @@ public function fill(array $attributes): static && static::preventsSilentlyDiscardingAttributes()) { $keys = array_diff(array_keys($attributes), array_keys($fillable)); + $exception = new MassAssignmentException(sprintf( + 'Add fillable property [%s] to allow mass assignment on [%s].', + implode(', ', $keys), + get_class($this) + )); + if (isset(static::$discardedAttributeViolationCallback)) { - call_user_func(static::$discardedAttributeViolationCallback, $this, $keys); + call_user_func(static::$discardedAttributeViolationCallback, $this, $keys, $exception); } else { - throw new MassAssignmentException(sprintf( - 'Add fillable property [%s] to allow mass assignment on [%s].', - implode(', ', $keys), - get_class($this) - )); + throw $exception; } } @@ -2016,12 +2021,7 @@ public function newEloquentBuilder(QueryBuilder $query): Builder */ protected function resolveCustomBuilderClass(): string|false { - $attributes = (new ReflectionClass($this)) - ->getAttributes(UseEloquentBuilder::class); - - return ! empty($attributes) - ? $attributes[0]->newInstance()->builderClass - : false; + return static::resolveClassAttribute(UseEloquentBuilder::class, 'builderClass') ?? false; } /** diff --git a/src/database/src/Eloquent/SoftDeletingScope.php b/src/database/src/Eloquent/SoftDeletingScope.php index 454179ee9..998947c71 100644 --- a/src/database/src/Eloquent/SoftDeletingScope.php +++ b/src/database/src/Eloquent/SoftDeletingScope.php @@ -26,7 +26,7 @@ class SoftDeletingScope implements Scope */ public function apply(Builder $builder, Model $model): void { - $builder->whereNull($model->getQualifiedDeletedAtColumn()); + $builder->whereNull($builder->qualifyColumn($model->getQualifiedDeletedAtColumn())); } /** @@ -57,7 +57,7 @@ public function extend(Builder $builder): void protected function getDeletedAtColumn(Builder $builder): string { if ((array) $builder->getQuery()->joins !== []) { - return $builder->getModel()->getQualifiedDeletedAtColumn(); + return $builder->qualifyColumn($builder->getModel()->getQualifiedDeletedAtColumn()); } return $builder->getModel()->getDeletedAtColumn(); @@ -138,7 +138,7 @@ protected function addWithoutTrashed(Builder $builder): void // @phpstan-ignore argument.type (local macros retain their lexical $this at runtime) $builder->withoutGlobalScope($this)->whereNull( - $model->getQualifiedDeletedAtColumn() + $builder->qualifyColumn($model->getQualifiedDeletedAtColumn()) ); return $builder; @@ -157,7 +157,7 @@ protected function addOnlyTrashed(Builder $builder): void // @phpstan-ignore argument.type (local macros retain their lexical $this at runtime) $builder->withoutGlobalScope($this)->whereNotNull( - $model->getQualifiedDeletedAtColumn() + $builder->qualifyColumn($model->getQualifiedDeletedAtColumn()) ); return $builder; diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php index 4974638f4..853ca35da 100755 --- a/src/database/src/Schema/Builder.php +++ b/src/database/src/Schema/Builder.php @@ -420,6 +420,8 @@ public function hasIndex(string $table, array|string $index, ?string $type = nul { $type = is_null($type) ? $type : strtolower($type); + $index = is_string($index) ? strtolower($index) : $index; + foreach ($this->getIndexes($table) as $value) { $typeMatches = is_null($type) || ($type === 'primary' && $value['primary']) diff --git a/src/docs/cache.md b/src/docs/cache.md index f88fd3410..5371f81c9 100644 --- a/src/docs/cache.md +++ b/src/docs/cache.md @@ -401,6 +401,12 @@ if (Cache::has('key')) { } ``` +Pass an array of keys to check that every item exists and has a non-null value: + +```php +Cache::has(['first', 'second']); +``` + #### Incrementing / Decrementing Values @@ -569,6 +575,12 @@ You may remove items from the cache using the `forget` method: Cache::forget('key'); ``` +You may pass an array of keys to remove multiple items. The method returns `true` only if every removal succeeds: + +```php +Cache::forget(['first', 'second']); +``` + You may also remove items by providing a zero or negative number of expiration seconds: ```php diff --git a/src/docs/collections.md b/src/docs/collections.md index 3c10d81d2..ed089b26e 100644 --- a/src/docs/collections.md +++ b/src/docs/collections.md @@ -4305,7 +4305,7 @@ $zipped->all(); ## Higher Order Messages -Collections also provide support for "higher order messages", which are short-cuts for performing common actions on collections. The collection methods that provide higher order messages are: [average](#method-average), [avg](#method-avg), [contains](#method-contains), [each](#method-each), [every](#method-every), [filter](#method-filter), [first](#method-first), [flatMap](#method-flatmap), [groupBy](#method-groupby), [keyBy](#method-keyby), [map](#method-map), [max](#method-max), [min](#method-min), [partition](#method-partition), [reject](#method-reject), [skipUntil](#method-skipuntil), [skipWhile](#method-skipwhile), [some](#method-some), [sortBy](#method-sortby), [sortByDesc](#method-sortbydesc), [sum](#method-sum), [takeUntil](#method-takeuntil), [takeWhile](#method-takewhile), and [unique](#method-unique). +Collections also provide support for "higher order messages", which are short-cuts for performing common actions on collections. The collection methods that provide higher order messages are: [average](#method-average), [avg](#method-avg), [contains](#method-contains), [each](#method-each), [every](#method-every), [filter](#method-filter), [first](#method-first), [flatMap](#method-flatmap), [groupBy](#method-groupby), [keyBy](#method-keyby), [map](#method-map), [max](#method-max), [min](#method-min), [partition](#method-partition), [reject](#method-reject), [skipUntil](#method-skipuntil), [skipWhile](#method-skipwhile), [sole](#method-sole), [some](#method-some), [sortBy](#method-sortby), [sortByDesc](#method-sortbydesc), [sum](#method-sum), [takeUntil](#method-takeuntil), [takeWhile](#method-takewhile), and [unique](#method-unique). Each higher order message can be accessed as a dynamic property on a collection instance. For instance, let's use the `each` higher order message to call a method on each object within a collection: diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index 122b09deb..dbd317850 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -2427,13 +2427,17 @@ After preventing lazy loading, Eloquent will throw a `Hypervel\Database\LazyLoad You may customize the behavior of lazy loading violations using the `handleLazyLoadingViolationUsing` method. For example, using this method, you may instruct lazy loading violations to only be logged instead of interrupting the application's execution with exceptions: ```php -Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) { +use Hypervel\Database\LazyLoadingViolationException; + +Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation, LazyLoadingViolationException $exception) { $class = $model::class; - info("Attempted to lazy load [{$relation}] on model [{$class}]."); + info("Attempted to lazy load [{$relation}] on model [{$class}].", ['exception' => $exception]); }); ``` +The callback receives the violation exception as its third argument. + ## Inserting and Updating Related Models diff --git a/src/docs/eloquent.md b/src/docs/eloquent.md index 204a45220..2330909d9 100644 --- a/src/docs/eloquent.md +++ b/src/docs/eloquent.md @@ -467,7 +467,7 @@ If you would like to enable all three strictness behaviors, you may invoke the ` Model::shouldBeStrict(! $this->app->isProduction()); ``` -Hypervel also allows you to customize how lazy loading violations, discarded attributes, and missing attributes are handled using the `handleLazyLoadingViolationUsing`, `handleDiscardedAttributeViolationUsing`, and `handleMissingAttributeViolationUsing` methods. +Hypervel also allows you to customize how lazy loading violations, discarded attributes, and missing attributes are handled using the `handleLazyLoadingViolationUsing`, `handleDiscardedAttributeViolationUsing`, and `handleMissingAttributeViolationUsing` methods. Each callback receives the corresponding violation exception as its third argument. > [!WARNING] > Eloquent strictness methods configure worker-wide static state in Hypervel. These methods should be called while your application is booting, such as from a service provider, and not from request-specific code. @@ -1856,6 +1856,8 @@ class User extends Model } ``` +Child models inherit the attribute unless they declare their own `UseEloquentBuilder` attribute. + Your custom builder should extend Hypervel's base Eloquent builder: ```php diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md index 1ea6e9d73..6f67b06c9 100644 --- a/src/docs/filesystem.md +++ b/src/docs/filesystem.md @@ -373,7 +373,7 @@ New files and directory listings use the primary disk. URLs, file existence chec To scope a read-through disk per request or tenant, wrap it in `ScopedCloudFilesystemProxy`. Dynamic scoped proxies cannot be used as its primary or fallback disk. -Fallback reads promote files by default. Set `copy` to `false` to read fallback files without copying them. With promotion enabled, fallback stream reads finish copying the file before returning the stream. +Fallback reads promote files by default. Set `copy` to `false` to read fallback files without copying them. With promotion enabled, fallback stream reads finish copying the file before returning the stream. Stream promotion and fallback copy/move operations buffer up to 2 MB in memory, then use PHP's system temporary directory; allow enough temporary disk space for large files and concurrent operations. Promoted files use the primary disk's default visibility rather than inheriting the fallback file's visibility. Fallback copy and move operations use the same default. Configure a private primary disk when migrating private files. @@ -766,6 +766,18 @@ Storage::copy('old/file.jpg', 'new/file.jpg'); Storage::move('old/file.jpg', 'new/file.jpg'); ``` +You may use the `copyToDisk` and `moveToDisk` methods to copy or move a file to another disk. The source file's path will be used on the destination disk unless you provide a third argument: + +```php +Storage::disk('local')->copyToDisk('s3', 'reports/report.csv'); + +Storage::disk('local')->moveToDisk( + 's3', 'reports/report.csv', 'archive/report.csv' +); +``` + +Transfers from pooled disks, including S3 and Google Cloud Storage, buffer the source before writing to the destination so the source's pool slot is available during the write. Buffering keeps up to 2 MB in memory per transfer, then uses PHP's system temporary directory; allow enough temporary disk space for large files and concurrent transfers. Local sources stream directly. + ### Automatic Streaming diff --git a/src/docs/http-tests.md b/src/docs/http-tests.md index b684f7187..afe65b2dd 100644 --- a/src/docs/http-tests.md +++ b/src/docs/http-tests.md @@ -1637,6 +1637,12 @@ You may assert that it does not contain the `email` property of the `user` objec $response->assertJsonMissingPath('user.email'); ``` +You may use `*` to assert that a path is missing from every item in an array: + +```php +$response->assertJsonMissingPath('users.*.password'); +``` + #### assertJsonMissingPaths diff --git a/src/docs/urls.md b/src/docs/urls.md index 56a73333f..a39a45944 100644 --- a/src/docs/urls.md +++ b/src/docs/urls.md @@ -209,6 +209,14 @@ echo route('post.show', ['post' => 1, 'search' => 'rocket']); You may also pass enum cases as route or query parameters, including values in nested query arrays. Backed enums use their backing value, while unit enums use their case name. +Route parameters should normally contain unencoded values. If a value is already URL-encoded, wrap it in an `EncodedParameter` to avoid encoding it again: + +```php +use Hypervel\Routing\EncodedParameter; + +echo route('post.show', ['post' => new EncodedParameter('hello%20world')]); +``` + #### Eloquent Models diff --git a/src/docs/vite.md b/src/docs/vite.md index d2a52e232..ed1627653 100644 --- a/src/docs/vite.md +++ b/src/docs/vite.md @@ -218,6 +218,14 @@ npm run dev npm run build ``` +You may retrieve the development server's URL using `Vite::devServerUrl()`. The method respects custom hot file paths and returns `null` when the development server is not running: + +```php +use Hypervel\Support\Facades\Vite; + +$url = Vite::devServerUrl(); +``` + ## Working With JavaScript diff --git a/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php b/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php index 2d9803120..6c77c8067 100644 --- a/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php +++ b/src/filesystem/src/Concerns/InteractsWithPooledFilesystem.php @@ -28,6 +28,7 @@ trait InteractsWithPooledFilesystem { use Conditionable; + use TransfersFiles; protected ?Closure $serveCallback = null; diff --git a/src/filesystem/src/Concerns/TransfersFiles.php b/src/filesystem/src/Concerns/TransfersFiles.php new file mode 100644 index 000000000..7fbd2e487 --- /dev/null +++ b/src/filesystem/src/Concerns/TransfersFiles.php @@ -0,0 +1,82 @@ +make(FilesystemFactory::class)->disk($disk); + + return $this->transferFile($this, $from, $destination, $to ?? $from); + } + + /** + * Move a file to another disk. + */ + public function moveToDisk(FilesystemContract|string|UnitEnum $disk, string $from, ?string $to = null): bool + { + return $this->copyToDisk($disk, $from, $to) && $this->delete($from); + } + + /** + * Transfer a file without holding a source lease during the destination write. + */ + protected function transferFile(FilesystemContract $source, string $from, FilesystemContract $destination, string $to): bool + { + if ($source === $destination) { + $normalizer = new WhitespacePathNormalizer; + + if ($normalizer->normalizePath($from) === $normalizer->normalizePath($to)) { + throw new InvalidArgumentException('Cannot copy a file to the same disk and path.'); + } + } + + $stream = $source->readStream($from); + + if (! is_resource($stream)) { + return false; + } + + $temporary = null; + + try { + if ((stream_get_meta_data($stream)['wrapper_data'] ?? null) instanceof LeasedStream) { + // The destination may borrow from the same bounded pool as the source. + $temporary = fopen('php://temp', 'w+b'); + + if ($temporary === false || stream_copy_to_stream($stream, $temporary) === false || ! rewind($temporary)) { + throw UnableToReadFile::fromLocation($from, 'Unable to copy the stream to temporary storage.'); + } + + fclose($stream); + } + + return $destination->writeStream($to, $temporary ?? $stream); + } finally { + if (is_resource($stream)) { + fclose($stream); + } + + if (is_resource($temporary)) { + fclose($temporary); + } + } + } +} diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php index b86abc813..3351b0372 100644 --- a/src/filesystem/src/FilesystemAdapter.php +++ b/src/filesystem/src/FilesystemAdapter.php @@ -14,6 +14,7 @@ use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Filesystem\Cloud as CloudFilesystemContract; use Hypervel\Contracts\Filesystem\Filesystem as FilesystemContract; +use Hypervel\Filesystem\Concerns\TransfersFiles; use Hypervel\Http\File; use Hypervel\Http\Request; use Hypervel\Http\UploadedFile; @@ -61,6 +62,7 @@ class FilesystemAdapter implements CloudFilesystemContract { use Conditionable; + use TransfersFiles; use Macroable { __call as macroCall; } diff --git a/src/filesystem/src/ScopedFilesystemProxy.php b/src/filesystem/src/ScopedFilesystemProxy.php index f808d983b..6d45d675f 100644 --- a/src/filesystem/src/ScopedFilesystemProxy.php +++ b/src/filesystem/src/ScopedFilesystemProxy.php @@ -7,7 +7,10 @@ use BadMethodCallException; use Closure; use DateTimeInterface; +use Hypervel\Container\Container; +use Hypervel\Contracts\Filesystem\Factory as FilesystemFactory; use Hypervel\Contracts\Filesystem\Filesystem; +use Hypervel\Filesystem\Concerns\TransfersFiles; use Hypervel\Http\File; use Hypervel\Http\Request; use Hypervel\Http\UploadedFile; @@ -19,6 +22,7 @@ use RuntimeException; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\StreamedResponse; +use UnitEnum; /** * Isolate every filesystem path behind a dynamically resolved prefix on a @@ -33,6 +37,7 @@ class ScopedFilesystemProxy implements Filesystem { use Conditionable; + use TransfersFiles; protected PathNormalizer $normalizer; @@ -466,6 +471,45 @@ public function move(string $from, string $to): bool ]); } + /** + * Copy a scoped file to another disk. + */ + public function copyToDisk(Filesystem|string|UnitEnum $disk, string $from, ?string $to = null): bool + { + return $this->transferToDisk($disk, $from, $to, false); + } + + /** + * Move a scoped file to another disk. + */ + public function moveToDisk(Filesystem|string|UnitEnum $disk, string $from, ?string $to = null): bool + { + return $this->transferToDisk($disk, $from, $to, true); + } + + /** + * Transfer a file using one resolved source disk and prefix. + */ + protected function transferToDisk(Filesystem|string|UnitEnum $disk, string $from, ?string $to, bool $move): bool + { + $destination = $disk instanceof Filesystem + ? $disk + : Container::getInstance()->make(FilesystemFactory::class)->disk($disk); + $to ??= $from; + + $prefix = $this->prefix(); + $source = $this->resolveDisk(); + $from = $this->applyPrefix($prefix, $from); + + if ($destination === $this) { + $destination = $source; + $to = $this->applyPrefix($prefix, $to); + } + + return $this->transferFile($source, $from, $destination, $to) + && (! $move || $source->delete($from)); + } + /** * Get the size of a scoped file. */ diff --git a/src/foundation/src/Vite.php b/src/foundation/src/Vite.php index 7d26a3b63..f3edf171d 100644 --- a/src/foundation/src/Vite.php +++ b/src/foundation/src/Vite.php @@ -766,6 +766,21 @@ public function reactRefresh(): ?HtmlString */ protected function hotAsset(string $asset): string { + $url = $this->devServerUrl() + ?? throw new ViteException("Unable to read the Vite hot file at [{$this->hotFile()}]."); + + return $url . '/' . $asset; + } + + /** + * Get the URL of the running Vite development server. + */ + public function devServerUrl(): ?string + { + if (! $this->isRunningHot()) { + return null; + } + $path = $this->hotFile(); $contents = @file_get_contents($path); @@ -773,7 +788,7 @@ protected function hotAsset(string $asset): string throw new ViteException("Unable to read the Vite hot file at [{$path}]."); } - return rtrim($contents) . '/' . $asset; + return rtrim($contents); } /** diff --git a/src/mail/src/Transport/ResendTransport.php b/src/mail/src/Transport/ResendTransport.php index d3a20d5fc..1d58dd171 100644 --- a/src/mail/src/Transport/ResendTransport.php +++ b/src/mail/src/Transport/ResendTransport.php @@ -132,7 +132,9 @@ protected function doSend(SentMessage $message): void $messageId = $result->id; - $email->getHeaders()->addHeader('X-Resend-Email-ID', $messageId); + $message->setMessageId($messageId); + + $originalMessage->getHeaders()->addHeader('X-Resend-Email-ID', $messageId); } /** diff --git a/src/redis/src/PhpRedisClusterConnection.php b/src/redis/src/PhpRedisClusterConnection.php index 6d234d7b8..e5f3c6a40 100644 --- a/src/redis/src/PhpRedisClusterConnection.php +++ b/src/redis/src/PhpRedisClusterConnection.php @@ -93,17 +93,10 @@ public function masters(): array /** * Scan all keys based on the given options. * - * @param mixed $cursor - * @param mixed ...$arguments - * * @throws InvalidArgumentException */ - public function scan(&$cursor, ...$arguments): mixed + protected function callScan(mixed &$cursor, mixed ...$arguments): mixed { - if (! $this->shouldTransform) { - return $this->__call('scan', array_merge([&$cursor], $arguments)); - } - $options = $this->getScanOptions($arguments); if (isset($options['node'])) { diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index a38963d43..2fd161456 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Closure; +use ErrorException; use Generator; use Hypervel\ConnectionPool\Connection as BaseConnection; use Hypervel\ConnectionPool\Exceptions\ConnectionException; @@ -393,7 +394,7 @@ public function __call(string $name, array $arguments): mixed $name = strtolower($name); $result = $this->executeCommand($name, $arguments); - } catch (RedisException|RedisClusterException $exception) { + } catch (RedisException|RedisClusterException|ErrorException $exception) { // REMOVED: Laravel's command retry loop can replay writes Redis already committed. if ($this->shouldInvalidateAfter($exception)) { $this->markInvalid(); @@ -983,8 +984,17 @@ public function isLifetimeExpired(?float $now = null): bool /** * Determine whether a failed command left the connection unsafe to reuse. */ - protected function shouldInvalidateAfter(RedisException|RedisClusterException $exception): bool + protected function shouldInvalidateAfter(RedisException|RedisClusterException|ErrorException $exception): bool { + if ($exception instanceof ErrorException) { + // Stream transport failures may surface as warnings before phpredis throws. + $message = $exception->getMessage(); + + return str_contains($message, ' bytes failed with errno=') + || str_contains($message, 'SSL: ') + || str_contains($message, 'SSL operation failed with code '); + } + if ($this->connection->getLastError() !== $exception->getMessage()) { return true; } @@ -1646,17 +1656,18 @@ protected function getScanOptions(array $arguments): array } /** - * Scans all keys based on options. - * - * @param array $arguments - * @param mixed $cursor + * Scan all keys based on options. */ - public function scan(&$cursor, ...$arguments): mixed + public function scan(mixed &$cursor, mixed ...$arguments): mixed { - if (! $this->shouldTransform) { - return $this->__call('scan', array_merge([&$cursor], $arguments)); - } + return $this->__call('scan', array_merge([&$cursor], $arguments)); + } + /** + * Execute the transformed SCAN command. + */ + protected function callScan(mixed &$cursor, mixed ...$arguments): mixed + { $options = $this->getScanOptions($arguments); $result = $this->connection->scan( @@ -1673,18 +1684,20 @@ public function scan(&$cursor, ...$arguments): mixed } /** - * Scans the given set for all values based on options. + * Scan the given set for all values based on options. * * @param string $key - * @param array $arguments - * @param mixed $cursor */ - public function zscan($key, &$cursor, ...$arguments): mixed + public function zscan(mixed $key, mixed &$cursor, mixed ...$arguments): mixed { - if (! $this->shouldTransform) { - return $this->__call('zScan', array_merge([$key, &$cursor], $arguments)); - } + return $this->__call('zScan', array_merge([$key, &$cursor], $arguments)); + } + /** + * Execute the transformed ZSCAN command. + */ + protected function callZscan(mixed $key, mixed &$cursor, mixed ...$arguments): mixed + { $options = $this->getScanOptions($arguments); $result = $this->connection->zscan( @@ -1702,18 +1715,20 @@ public function zscan($key, &$cursor, ...$arguments): mixed } /** - * Scans the given hash for all values based on options. + * Scan the given hash for all values based on options. * * @param string $key - * @param array $arguments - * @param mixed $cursor */ - public function hscan($key, &$cursor, ...$arguments): mixed + public function hscan(mixed $key, mixed &$cursor, mixed ...$arguments): mixed { - if (! $this->shouldTransform) { - return $this->__call('hScan', array_merge([$key, &$cursor], $arguments)); - } + return $this->__call('hScan', array_merge([$key, &$cursor], $arguments)); + } + /** + * Execute the transformed HSCAN command. + */ + protected function callHscan(mixed $key, mixed &$cursor, mixed ...$arguments): mixed + { $options = $this->getScanOptions($arguments); $result = $this->connection->hscan( @@ -1731,18 +1746,20 @@ public function hscan($key, &$cursor, ...$arguments): mixed } /** - * Scans the given set for all values based on options. + * Scan the given set for all values based on options. * * @param string $key - * @param array $arguments - * @param mixed $cursor */ - public function sscan($key, &$cursor, ...$arguments): mixed + public function sscan(mixed $key, mixed &$cursor, mixed ...$arguments): mixed { - if (! $this->shouldTransform) { - return $this->__call('sScan', array_merge([$key, &$cursor], $arguments)); - } + return $this->__call('sScan', array_merge([$key, &$cursor], $arguments)); + } + /** + * Execute the transformed SSCAN command. + */ + protected function callSscan(mixed $key, mixed &$cursor, mixed ...$arguments): mixed + { $options = $this->getScanOptions($arguments); $result = $this->connection->sscan( @@ -2023,36 +2040,44 @@ public function evalWithShaCache(string $script, array $keys = [], array $args = // combined_args = keys first, then other args $combinedArgs = [...$keys, ...$args]; - // Clear any stale error from previous commands to ensure getLastError() - // reflects this call, not a previous one - $this->connection->clearLastError(); - - // Try evalSha first - uses cached compiled script - $result = $this->connection->evalSha($sha, $combinedArgs, $numKeys); - - if ($result === false) { - $error = $this->connection->getLastError(); - - // NOSCRIPT means script not cached yet - fall back to eval - if ($error !== null && str_contains($error, 'NOSCRIPT')) { - $this->connection->clearLastError(); - $result = $this->connection->eval($script, $combinedArgs, $numKeys); - - if ($result === false) { - $evalError = $this->connection->getLastError(); - if ($evalError !== null) { - throw $this->scriptException($evalError); + try { + // Clear any stale error from previous commands to ensure getLastError() + // reflects this call, not a previous one + $this->connection->clearLastError(); + + // Try evalSha first - uses cached compiled script + $result = $this->connection->evalSha($sha, $combinedArgs, $numKeys); + + if ($result === false) { + $error = $this->connection->getLastError(); + + // NOSCRIPT means script not cached yet - fall back to eval + if ($error !== null && str_contains($error, 'NOSCRIPT')) { + $this->connection->clearLastError(); + $result = $this->connection->eval($script, $combinedArgs, $numKeys); + + if ($result === false) { + $evalError = $this->connection->getLastError(); + if ($evalError !== null) { + throw $this->scriptException($evalError); + } + // If no error, script legitimately returned nil (which becomes false) } - // If no error, script legitimately returned nil (which becomes false) + } elseif ($error !== null) { + // Some other error (syntax, OOM, WRONGTYPE, etc.) + throw $this->scriptException($error); } - } elseif ($error !== null) { - // Some other error (syntax, OOM, WRONGTYPE, etc.) - throw $this->scriptException($error); + // If $error is null and $result is false, the script legitimately returned false + } + + return $this->normalizeNullReplies($result); + } catch (RedisException|RedisClusterException|ErrorException $exception) { + if ($this->shouldInvalidateAfter($exception)) { + $this->markInvalid(); } - // If $error is null and $result is false, the script legitimately returned false - } - return $this->normalizeNullReplies($result); + throw $exception; + } } /** diff --git a/src/routing/src/EncodedParameter.php b/src/routing/src/EncodedParameter.php new file mode 100644 index 000000000..058cf66d1 --- /dev/null +++ b/src/routing/src/EncodedParameter.php @@ -0,0 +1,33 @@ +value; + } + + /** + * Get the encoded parameter value. + */ + public function __toString(): string + { + return $this->value; + } +} diff --git a/src/routing/src/RouteUrlGenerator.php b/src/routing/src/RouteUrlGenerator.php index 90c38d8ee..e5c72d988 100644 --- a/src/routing/src/RouteUrlGenerator.php +++ b/src/routing/src/RouteUrlGenerator.php @@ -8,6 +8,7 @@ use Hypervel\Routing\Exceptions\UrlGenerationException; use Hypervel\Support\Arr; use Hypervel\Support\Collection; +use Stringable; use function Hypervel\Support\enum_value; @@ -42,7 +43,7 @@ class RouteUrlGenerator public array $defaultParameters = []; /** - * Characters that should not be URL encoded. + * Characters that should not be URL-encoded. */ public array $dontEncode = [ '%2F' => '/', @@ -372,7 +373,7 @@ protected function replaceRouteParameters( return (! isset($parameters[0]) && ! str_ends_with($match[0], '?}')) ? $match[0] - : $this->escapeParameterValue(Arr::pull($parameters, 0)); + : $this->encodeParameter(Arr::pull($parameters, 0)); }, $path); return trim(preg_replace('/\{.*?\?\}/', '', $path), '/'); @@ -388,12 +389,12 @@ protected function replaceNamedParameters( ): string { return preg_replace_callback('/\{(.*?)(\?)?\}/', function ($m) use (&$parameters, $rootDefaultParameters) { if (isset($parameters[$m[1]]) && $parameters[$m[1]] !== '') { - return $this->escapeParameterValue(Arr::pull($parameters, $m[1])); + return $this->encodeParameter(Arr::pull($parameters, $m[1])); } if (isset($rootDefaultParameters[$m[1]])) { Arr::pull($parameters, $m[1]); - return $this->escapeParameterValue($rootDefaultParameters[$m[1]]); + return $this->encodeParameter($rootDefaultParameters[$m[1]]); } if (isset($parameters[$m[1]])) { Arr::pull($parameters, $m[1]); @@ -404,11 +405,19 @@ protected function replaceNamedParameters( } /** - * Escape delimiters while the value is still known to be parameter data. + * Encode a parameter value that is being substituted into a route URI. + * + * Values wrapped in an EncodedParameter are already URL-encoded and are used as-is. */ - protected function escapeParameterValue(mixed $value): string + protected function encodeParameter(mixed $value): mixed { - return strtr((string) $value, self::PARAMETER_ESCAPES); + if ($value instanceof EncodedParameter) { + return $value->value(); + } + + return is_string($value) || $value instanceof Stringable + ? strtr((string) $value, self::PARAMETER_ESCAPES) + : $value; } /** diff --git a/src/sentry/src/Features/Storage/FilesystemDecorator.php b/src/sentry/src/Features/Storage/FilesystemDecorator.php index 7b1937f0e..34ae43657 100644 --- a/src/sentry/src/Features/Storage/FilesystemDecorator.php +++ b/src/sentry/src/Features/Storage/FilesystemDecorator.php @@ -4,6 +4,8 @@ namespace Hypervel\Sentry\Features\Storage; +use Hypervel\Container\Container; +use Hypervel\Contracts\Filesystem\Factory as FilesystemFactory; use Hypervel\Contracts\Filesystem\Filesystem; use Hypervel\Contracts\ObjectPool\InvalidatesPool; use Hypervel\Http\File; @@ -13,7 +15,9 @@ use Psr\Http\Message\StreamInterface; use Sentry\Breadcrumb; use Sentry\Tracing\SpanContext; +use UnitEnum; +use function Hypervel\Support\enum_value; use function Sentry\trace; /** @@ -225,6 +229,44 @@ public function move(string $from, string $to): bool return $this->withSentry(__FUNCTION__, func_get_args(), sprintf('from "%s" to "%s"', $from, $to), compact('from', 'to')); } + /** + * Copy a file to another disk. + */ + public function copyToDisk(Filesystem|string|UnitEnum $disk, string $from, ?string $to = null): bool + { + return $this->transferToDisk(__FUNCTION__, $disk, $from, $to); + } + + /** + * Move a file to another disk. + */ + public function moveToDisk(Filesystem|string|UnitEnum $disk, string $from, ?string $to = null): bool + { + return $this->transferToDisk(__FUNCTION__, $disk, $from, $to); + } + + /** + * Trace a transfer while preserving the wrapped disk's identity check. + */ + protected function transferToDisk(string $method, Filesystem|string|UnitEnum $disk, string $from, ?string $to): bool + { + $destination = $disk instanceof Filesystem + ? $disk + : Container::getInstance()->make(FilesystemFactory::class)->disk($disk); + $data = ['from' => $from, 'to' => $to ?? $from]; + + if (! $disk instanceof Filesystem) { + $data['destination_disk'] = enum_value($disk); + } + + return $this->withSentry( + $method, + [$destination === $this ? $this->filesystem : $destination, $from, $to], + sprintf('from "%s" to "%s"', $from, $to ?? $from), + $data, + ); + } + /** * Get the file size of a given file. */ diff --git a/src/session/src/ArraySessionHandler.php b/src/session/src/ArraySessionHandler.php index 21530ae39..812069e20 100644 --- a/src/session/src/ArraySessionHandler.php +++ b/src/session/src/ArraySessionHandler.php @@ -5,6 +5,7 @@ namespace Hypervel\Session; use Hypervel\Support\InteractsWithTime; +use RuntimeException; use SessionHandlerInterface; class ArraySessionHandler implements SessionHandlerInterface @@ -36,6 +37,22 @@ public function close(): bool return true; } + /** + * Create a new session ID. + */ + public function create_sid(): string + { + return session_create_id() ?: throw new RuntimeException('Unable to create a session ID.'); + } + + /** + * Determine if the session ID exists. + */ + public function validateId(string $id): bool + { + return isset($this->storage[$id]); + } + public function read(string $sessionId): false|string { if (! isset($this->storage[$sessionId])) { diff --git a/src/session/src/CookieSessionHandler.php b/src/session/src/CookieSessionHandler.php index 848d5b1ad..29c73ec73 100644 --- a/src/session/src/CookieSessionHandler.php +++ b/src/session/src/CookieSessionHandler.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Cookie\QueueingFactory as CookieJar; use Hypervel\Http\Request; use Hypervel\Support\InteractsWithTime; +use RuntimeException; use SessionHandlerInterface; class CookieSessionHandler implements SessionHandlerInterface @@ -43,6 +44,22 @@ public function close(): bool return true; } + /** + * Create a new session ID. + */ + public function create_sid(): string + { + return session_create_id() ?: throw new RuntimeException('Unable to create a session ID.'); + } + + /** + * Determine if the session ID exists. + */ + public function validateId(string $id): bool + { + return $this->getRequest()->cookies->has($id); + } + public function read(string $sessionId): false|string { $value = $this->getRequest()->cookies->get($sessionId) ?: ''; diff --git a/src/session/src/DatabaseSessionHandler.php b/src/session/src/DatabaseSessionHandler.php index 20d43edb4..61da5f9fb 100644 --- a/src/session/src/DatabaseSessionHandler.php +++ b/src/session/src/DatabaseSessionHandler.php @@ -16,6 +16,7 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; use Hypervel\Support\InteractsWithTime; +use RuntimeException; use SessionHandlerInterface; class DatabaseSessionHandler implements CanManageUserSessions, ExistenceAwareInterface, SessionHandlerInterface @@ -67,6 +68,22 @@ public function close(): bool return true; } + /** + * Create a new session ID. + */ + public function create_sid(): string + { + return session_create_id() ?: throw new RuntimeException('Unable to create a session ID.'); + } + + /** + * Determine if the session ID exists. + */ + public function validateId(string $id): bool + { + return ! is_null($this->getQuery()->find($id)); + } + public function read(string $sessionId): false|string { $session = (object) $this->getQuery()->find($sessionId); diff --git a/src/session/src/FileSessionHandler.php b/src/session/src/FileSessionHandler.php index 5076efffc..7e18b005a 100644 --- a/src/session/src/FileSessionHandler.php +++ b/src/session/src/FileSessionHandler.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\CarbonImmutable; +use RuntimeException; use SessionHandlerInterface; use Symfony\Component\Finder\Finder; @@ -36,6 +37,22 @@ public function close(): bool return true; } + /** + * Create a new session ID. + */ + public function create_sid(): string + { + return session_create_id() ?: throw new RuntimeException('Unable to create a session ID.'); + } + + /** + * Determine if the session ID exists. + */ + public function validateId(string $id): bool + { + return $this->files->isFile($this->path . '/' . $id); + } + public function read(string $sessionId): false|string { if ($this->files->isFile($path = $this->path . '/' . $sessionId) diff --git a/src/session/src/NullSessionHandler.php b/src/session/src/NullSessionHandler.php index 730aeb2b3..23e6beb46 100644 --- a/src/session/src/NullSessionHandler.php +++ b/src/session/src/NullSessionHandler.php @@ -4,6 +4,7 @@ namespace Hypervel\Session; +use RuntimeException; use SessionHandlerInterface; class NullSessionHandler implements SessionHandlerInterface @@ -18,6 +19,22 @@ public function close(): bool return true; } + /** + * Create a new session ID. + */ + public function create_sid(): string + { + return session_create_id() ?: throw new RuntimeException('Unable to create a session ID.'); + } + + /** + * Determine if the session ID exists. + */ + public function validateId(string $id): bool + { + return true; + } + public function read(string $sessionId): string { return ''; diff --git a/src/session/src/RedisSessionHandler.php b/src/session/src/RedisSessionHandler.php index 21c4766c9..da19f97bf 100644 --- a/src/session/src/RedisSessionHandler.php +++ b/src/session/src/RedisSessionHandler.php @@ -309,6 +309,24 @@ public function close(): bool return true; } + /** + * Create a new session ID. + */ + public function create_sid(): string + { + return session_create_id() ?: throw new RuntimeException('Unable to create a session ID.'); + } + + /** + * Determine if the session ID exists. + */ + public function validateId(string $id): bool + { + return $this->withConnection( + fn (RedisConnection $connection): bool => $connection->exists($this->payloadKey($id)) > 0, + ); + } + /** * Read the session data. */ diff --git a/src/support/src/Facades/Cache.php b/src/support/src/Facades/Cache.php index 6256caa6f..881217123 100644 --- a/src/support/src/Facades/Cache.php +++ b/src/support/src/Facades/Cache.php @@ -37,7 +37,7 @@ * @method static void flushMacros() * @method static void flushState() * @method static bool forever(\UnitEnum|string $key, mixed $value) - * @method static bool forget(\UnitEnum|string $key) + * @method static bool forget(\UnitEnum|array|string $key) * @method static \Hypervel\Cache\Limiters\ConcurrencyLimiterBuilder funnel(\UnitEnum|string $name) * @method static mixed get(\UnitEnum|array|string $key, mixed $default = null) * @method static int|null getDefaultCacheTime() diff --git a/src/support/src/Facades/Storage.php b/src/support/src/Facades/Storage.php index 5d40705f8..39f9371ca 100644 --- a/src/support/src/Facades/Storage.php +++ b/src/support/src/Facades/Storage.php @@ -69,6 +69,7 @@ * @method static void buildTemporaryUploadUrlsUsing(\Closure|null $callback) * @method static void buildTemporaryUrlsUsing(\Closure|null $callback) * @method static string|false checksum(string $path, array $options = []) + * @method static bool copyToDisk(\Hypervel\Contracts\Filesystem\Filesystem|UnitEnum|string $disk, string $from, string|null $to = null) * @method static bool directoryExists(string $path) * @method static bool directoryMissing(string $path) * @method static \Symfony\Component\HttpFoundation\StreamedResponse download(string $path, string|null $name = null, array $headers = []) @@ -79,6 +80,7 @@ * @method static array|string|int|float|bool|null json(string $path, int $flags = 0) * @method static string|false mimeType(string $path) * @method static bool missing(string $path) + * @method static bool moveToDisk(\Hypervel\Contracts\Filesystem\Filesystem|UnitEnum|string $disk, string $from, string|null $to = null) * @method static bool providesTemporaryUploadUrls() * @method static bool providesTemporaryUrls() * @method static \Symfony\Component\HttpFoundation\StreamedResponse response(string $path, string|null $name = null, array $headers = [], string $disposition = 'inline') diff --git a/src/support/src/Facades/Vite.php b/src/support/src/Facades/Vite.php index 4338e0ffe..dc59d1a49 100644 --- a/src/support/src/Facades/Vite.php +++ b/src/support/src/Facades/Vite.php @@ -11,6 +11,7 @@ * @method static string content(string $asset, string|null $buildDirectory = null) * @method static \Hypervel\Foundation\Vite createAssetPathsUsing(callable|null $resolver) * @method static string|null cspNonce() + * @method static string|null devServerUrl() * @method static void flush() * @method static void flushMacros() * @method static \Hypervel\Support\HtmlString fonts(null|array|string $aliases = null) diff --git a/src/testing/src/AssertableJsonString.php b/src/testing/src/AssertableJsonString.php index 4d6bf59c9..770a3476f 100644 --- a/src/testing/src/AssertableJsonString.php +++ b/src/testing/src/AssertableJsonString.php @@ -202,11 +202,50 @@ public function assertMissingExact(array $data): static */ public function assertMissingPath(string $path): static { - PHPUnit::assertFalse(Arr::has($this->json(), $path)); + if (! str_contains($path, '*')) { + PHPUnit::assertFalse(Arr::has($this->json(), $path)); + + return $this; + } + + PHPUnit::assertFalse( + $this->hasJsonPath($this->json(), explode('.', $path)), + "Found unexpected path [{$path}] within the response JSON." + ); return $this; } + /** + * Determine whether a JSON path exists, where each wildcard matches any child at its level. + * + * @param list $segments + */ + protected function hasJsonPath(mixed $data, array $segments): bool + { + if ($segments === []) { + return true; + } + + if (! is_array($data)) { + return false; + } + + $segment = array_shift($segments); + + if ($segment === '*') { + foreach ($data as $value) { + if ($this->hasJsonPath($value, $segments)) { + return true; + } + } + + return false; + } + + return array_key_exists($segment, $data) && $this->hasJsonPath($data[$segment], $segments); + } + /** * Assert that the expected value and type exists at the given path in the response. */ diff --git a/src/validation/src/Rule.php b/src/validation/src/Rule.php index 3439e77ce..047ad9c7c 100644 --- a/src/validation/src/Rule.php +++ b/src/validation/src/Rule.php @@ -125,11 +125,7 @@ public static function exists(string $table, string $column = 'NULL'): Exists */ public static function in(array|Arrayable|UnitEnum|string $values): In { - if ($values instanceof Arrayable) { - $values = $values->toArray(); - } - - return new In(is_array($values) ? $values : func_get_args()); + return new In(...func_get_args()); } /** @@ -137,11 +133,7 @@ public static function in(array|Arrayable|UnitEnum|string $values): In */ public static function notIn(array|Arrayable|UnitEnum|string $values): NotIn { - if ($values instanceof Arrayable) { - $values = $values->toArray(); - } - - return new NotIn(is_array($values) ? $values : func_get_args()); + return new NotIn(...func_get_args()); } /** @@ -149,11 +141,7 @@ public static function notIn(array|Arrayable|UnitEnum|string $values): NotIn */ public static function contains(array|Arrayable|UnitEnum|string $values): Contains { - if ($values instanceof Arrayable) { - $values = $values->toArray(); - } - - return new Contains(is_array($values) ? $values : func_get_args()); + return new Contains(...func_get_args()); } /** @@ -161,11 +149,7 @@ public static function contains(array|Arrayable|UnitEnum|string $values): Contai */ public static function doesntContain(array|Arrayable|UnitEnum|string $values): DoesntContain { - if ($values instanceof Arrayable) { - $values = $values->toArray(); - } - - return new DoesntContain(is_array($values) ? $values : func_get_args()); + return new DoesntContain(...func_get_args()); } /** diff --git a/src/view/src/Compilers/ComponentTagCompiler.php b/src/view/src/Compilers/ComponentTagCompiler.php index 67d33e87e..d34d643d0 100644 --- a/src/view/src/Compilers/ComponentTagCompiler.php +++ b/src/view/src/Compilers/ComponentTagCompiler.php @@ -244,7 +244,7 @@ protected function componentString(string $component, array $attributes): string $parameters = [ 'view' => $view, - 'data' => '[' . $this->attributesToString($data->all(), $escapeBound = false) . ']', + 'data' => '[' . $this->attributesToString($data->all(), escapeBound: false) . ']', ]; $class = AnonymousComponent::class; @@ -252,7 +252,7 @@ protected function componentString(string $component, array $attributes): string $parameters = $data->all(); } - return "##BEGIN-COMPONENT-CLASS##@component('{$class}', '{$component}', [" . $this->attributesToString($parameters, $escapeBound = false) . ']) + return "##BEGIN-COMPONENT-CLASS##@component('{$class}', '{$component}', [" . $this->attributesToString($parameters, escapeBound: false) . ']) except(\\' . $class . '::ignoredParameterNames()); ?> diff --git a/src/view/src/ComponentAttributeBag.php b/src/view/src/ComponentAttributeBag.php index 9ca55fd52..da846473c 100644 --- a/src/view/src/ComponentAttributeBag.php +++ b/src/view/src/ComponentAttributeBag.php @@ -305,7 +305,7 @@ public function setAttributes(array $attributes): void unset($attributes['attributes']); - $attributes = $parentBag->merge($attributes, $escape = false)->getAttributes(); + $attributes = $parentBag->merge($attributes, escape: false)->getAttributes(); } $this->attributes = $attributes; diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php index 66b800f7e..e336e26b4 100644 --- a/tests/Cache/CacheRepositoryTest.php +++ b/tests/Cache/CacheRepositoryTest.php @@ -41,6 +41,7 @@ use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; @@ -185,6 +186,20 @@ public function testHasMethod() $this->assertTrue($repo->has('baz')); } + #[TestWith([['foo', TestCacheKey::Bar], ['foo' => 'foo', 'bar' => 'bar'], true])] + #[TestWith([['foo', 'baz'], ['foo' => 'foo', 'baz' => null], false])] + #[TestWith([['foo' => false, 5 => TestCacheKey::Bar], ['foo' => false, 'bar' => 'bar'], true])] + #[TestWith([[5 => 'foo', 9 => TestCacheKey::Bar], ['foo' => 'foo', 'bar' => 'bar'], true])] + #[TestWith([['foo' => false], ['foo' => null], false])] + #[TestWith([['foo' => false], ['foo' => NullSentinel::VALUE], false])] + public function testHasMethodWithArray(array $keys, array $values, bool $expected): void + { + $repo = $this->getRepository(); + $repo->getStore()->expects('many')->with(array_keys($values))->andReturn($values); + + $this->assertSame($expected, $repo->has($keys)); + } + public function testMissingMethod() { $repo = $this->getRepository(); @@ -961,6 +976,15 @@ public function testForgettingCacheKey(): void $repo->forget('a-key'); } + public function testForgettingWithArray(): void + { + $repo = $this->getRepository(); + $repo->getStore()->expects('forget')->with('foo')->andReturn(true); + $repo->getStore()->expects('forget')->with('bar')->andReturn(false); + + $this->assertFalse($repo->forget(['foo', TestCacheKey::Bar])); + } + public function testRemovingCacheKey(): void { // Alias of Forget @@ -1865,4 +1889,5 @@ protected static function getTestDate(): string enum TestCacheKey: string { case Foo = 'foo'; + case Bar = 'bar'; } diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index 6810e4766..98734e2fc 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -366,6 +366,7 @@ public function testQualifyColumn(): void { $builder = new Builder(m::mock(BaseBuilder::class)); $builder->expects('from')->with('foo_table'); + $builder->getQuery()->allows('getFromAlias')->andReturnNull(); $builder->setModel(new StubStringPrimaryKey); @@ -376,12 +377,39 @@ public function testQualifyColumns(): void { $builder = new Builder(m::mock(BaseBuilder::class)); $builder->expects('from')->with('foo_table'); + $builder->getQuery()->allows('getFromAlias')->andReturnNull(); $builder->setModel(new StubStringPrimaryKey); $this->assertEquals(['foo_table.column', 'foo_table.name'], $builder->qualifyColumns(['column', 'name'])); } + public function testQualifyColumnWithTableAlias(): void + { + $connection = m::mock(Connection::class, ['getTablePrefix' => '']); + $query = new BaseBuilder($connection, new Grammar($connection), new Processor); + $builder = new Builder($query); + $builder->setModel(new StubStringPrimaryKey); + $builder->from('foo_table as alias'); + + $this->assertSame('alias.column', $builder->qualifyColumn('column')); + $this->assertSame('alias.column', $builder->qualifyColumn('foo_table.column')); + $this->assertSame('other_table.column', $builder->qualifyColumn('other_table.column')); + $this->assertEquals(['alias.column', 'alias.name'], $builder->qualifyColumns(['column', 'name'])); + + $builder->fromSub('select * from foo_table', 'sub'); + $this->assertSame('sub.column', $builder->qualifyColumn('foo_table.column')); + + $builder->from(new Expression('foo_table'), 'expression_alias'); + $this->assertSame('expression_alias.column', $builder->qualifyColumn('column')); + + $builder->from('foo_archive'); + $this->assertSame('foo_archive.column', $builder->qualifyColumn('foo_table.column')); + + $builder->fromRaw('foo_table as opaque'); + $this->assertSame('foo_table.column', $builder->qualifyColumn('column')); + } + public function testGetMethodLoadsModelsAndHydratesEagerRelations(): void { $builder = m::mock(Builder::class . '[getModels,eagerLoadRelations]', [$this->getMockQueryBuilder()]); diff --git a/tests/Database/DatabaseEloquentFactoryTest.php b/tests/Database/DatabaseEloquentFactoryTest.php index ad15ea580..4af34f012 100644 --- a/tests/Database/DatabaseEloquentFactoryTest.php +++ b/tests/Database/DatabaseEloquentFactoryTest.php @@ -289,6 +289,13 @@ public function testMultipleModelAttributesCanBeCreated() $this->assertCount(10, $posts); } + public function testModelAttributesCanBeCreatedWithACountOfZero(): void + { + $posts = PostFactory::new()->count(0)->raw(); + + $this->assertSame([], $posts); + } + public function testAfterCreatingAndMakingCallbacksAreCalled() { $user = UserFactory::new() diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index ba4dbf593..5d37c0f0c 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -25,6 +25,7 @@ use Hypervel\Database\Eloquent\Attributes\ObservedBy; use Hypervel\Database\Eloquent\Attributes\RouteKey; use Hypervel\Database\Eloquent\Attributes\Table; +use Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder; use Hypervel\Database\Eloquent\Attributes\UseFactory; use Hypervel\Database\Eloquent\Attributes\WithoutTimestamps; use Hypervel\Database\Eloquent\Builder; @@ -2083,9 +2084,11 @@ public function testUsesOverriddenHandlerWhenDiscardingAttributes(): void $callbackModel = null; $callbackKeys = null; - Model::handleDiscardedAttributeViolationUsing(function (Model $model, array $keys) use (&$callbackModel, &$callbackKeys): void { + $callbackException = null; + Model::handleDiscardedAttributeViolationUsing(function (Model $model, array $keys, MassAssignmentException $exception) use (&$callbackModel, &$callbackKeys, &$callbackException): void { $callbackModel = $model; $callbackKeys = $keys; + $callbackException = $exception; }); $model = new ModelStub; @@ -2094,6 +2097,8 @@ public function testUsesOverriddenHandlerWhenDiscardingAttributes(): void $this->assertInstanceOf(ModelStub::class, $callbackModel); $this->assertEquals(['Foo'], $callbackKeys); + $this->assertInstanceOf(MassAssignmentException::class, $callbackException); + $this->assertSame('Add [Foo] to fillable property to allow mass assignment on [' . ModelStub::class . '].', $callbackException->getMessage()); Model::preventSilentlyDiscardingAttributes(false); Model::handleDiscardedAttributeViolationUsing(null); @@ -4221,9 +4226,12 @@ public function testUsesOverriddenHandlerWhenAccessingMissingAttributes(): void $callbackModel = null; $callbackKey = null; - Model::handleMissingAttributeViolationUsing(function (Model $model, string $key) use (&$callbackModel, &$callbackKey): void { + $callbackException = null; + + Model::handleMissingAttributeViolationUsing(function (Model $model, string $key, MissingAttributeException $exception) use (&$callbackModel, &$callbackKey, &$callbackException): void { $callbackModel = $model; $callbackKey = $key; + $callbackException = $exception; }); $model = new ModelStub(['id' => 1]); @@ -4235,6 +4243,7 @@ public function testUsesOverriddenHandlerWhenAccessingMissingAttributes(): void $this->assertInstanceOf(ModelStub::class, $callbackModel); $this->assertSame('this_attribute_does_not_exist', $callbackKey); + $this->assertInstanceOf(MissingAttributeException::class, $callbackException); Model::preventAccessingMissingAttributes($originalMode); Model::handleMissingAttributeViolationUsing(null); @@ -4845,7 +4854,7 @@ protected static function boot(): void }; } - public function testUseCustomBuilderWithUseEloquentBuilderAttribute() + public function testUseCustomBuilderWithUseEloquentBuilderAttribute(): void { $model = new ModelWithUseEloquentBuilderAttributeStub; @@ -4855,7 +4864,25 @@ public function testUseCustomBuilderWithUseEloquentBuilderAttribute() $this->assertInstanceOf(CustomBuilder::class, $eloquentBuilder); } - public function testDefaultBuilderIsUsedWhenUseEloquentBuilderAttributeIsNotPresent() + public function testUseEloquentBuilderAttributeIsInherited(): void + { + $model = new EloquentModelInheritingBuilderStub; + + $query = $this->createStub(BaseBuilder::class); + + $this->assertInstanceOf(CustomBuilder::class, $model->newEloquentBuilder($query)); + } + + public function testUseEloquentBuilderAttributeOnChildClassOverridesParentAttribute(): void + { + $model = new EloquentModelOverridingBuilderStub; + + $query = $this->createStub(BaseBuilder::class); + + $this->assertInstanceOf(ChildCustomBuilder::class, $model->newEloquentBuilder($query)); + } + + public function testDefaultBuilderIsUsedWhenUseEloquentBuilderAttributeIsNotPresent(): void { $model = new ModelWithoutUseEloquentBuilderAttributeStub; @@ -4884,11 +4911,24 @@ class CustomBuilder extends Builder { } -#[\Hypervel\Database\Eloquent\Attributes\UseEloquentBuilder(CustomBuilder::class)] +class ChildCustomBuilder extends Builder +{ +} + +#[UseEloquentBuilder(CustomBuilder::class)] class ModelWithUseEloquentBuilderAttributeStub extends Model { } +class EloquentModelInheritingBuilderStub extends ModelWithUseEloquentBuilderAttributeStub +{ +} + +#[UseEloquentBuilder(ChildCustomBuilder::class)] +class EloquentModelOverridingBuilderStub extends ModelWithUseEloquentBuilderAttributeStub +{ +} + class ModelWithoutUseEloquentBuilderAttributeStub extends Model { } diff --git a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php index af7f5b356..b963def1b 100644 --- a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php +++ b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php @@ -115,6 +115,19 @@ public function testSoftDeletesAreNotRetrieved() $this->assertNull(User::find(1)); } + public function testSoftDeletesAreNotRetrievedWhenTableIsAliased(): void + { + $this->createUsers(); + + $users = User::from('users as u')->get(); + + $this->assertCount(1, $users); + $this->assertEquals(2, $users->first()->id); + $this->assertCount(2, User::from('users as u')->withTrashed()->get()); + $this->assertCount(1, User::from('users as u')->withTrashed()->withoutTrashed()->get()); + $this->assertEquals(1, User::from('users as u')->onlyTrashed()->first()->id); + } + public function testSoftDeletesAreNotRetrievedFromBaseQuery() { $this->createUsers(); diff --git a/tests/Database/DatabaseSoftDeletingScopeTest.php b/tests/Database/DatabaseSoftDeletingScopeTest.php index 6d118b495..59017cdc3 100644 --- a/tests/Database/DatabaseSoftDeletingScopeTest.php +++ b/tests/Database/DatabaseSoftDeletingScopeTest.php @@ -22,6 +22,7 @@ public function testApplyingScopeToABuilder(): void $builder = m::mock(EloquentBuilder::class); $model = m::mock(Model::class); $model->expects('getQualifiedDeletedAtColumn')->andReturn('table.deleted_at'); + $builder->expects('qualifyColumn')->with('table.deleted_at')->andReturn('table.deleted_at'); $builder->expects('whereNull')->with('table.deleted_at'); $scope->apply($builder, $model); @@ -125,6 +126,7 @@ public function testOnlyTrashedExtension(): void $givenBuilder->expects('getModel')->andReturn($model); $givenBuilder->expects('withoutGlobalScope')->with($scope)->andReturn($givenBuilder); $model->expects('getQualifiedDeletedAtColumn')->andReturn('table.deleted_at'); + $givenBuilder->expects('qualifyColumn')->with('table.deleted_at')->andReturn('table.deleted_at'); $givenBuilder->expects('whereNotNull')->with('table.deleted_at'); $result = $callback($givenBuilder); @@ -146,6 +148,7 @@ public function testWithoutTrashedExtension(): void $givenBuilder->expects('getModel')->andReturn($model); $givenBuilder->expects('withoutGlobalScope')->with($scope)->andReturn($givenBuilder); $model->expects('getQualifiedDeletedAtColumn')->andReturn('table.deleted_at'); + $givenBuilder->expects('qualifyColumn')->with('table.deleted_at')->andReturn('table.deleted_at'); $givenBuilder->expects('whereNull')->with('table.deleted_at'); $result = $callback($givenBuilder); diff --git a/tests/Database/Eloquent/UseEloquentBuilderTest.php b/tests/Database/Eloquent/UseEloquentBuilderTest.php index 72f238e65..030753051 100644 --- a/tests/Database/Eloquent/UseEloquentBuilderTest.php +++ b/tests/Database/Eloquent/UseEloquentBuilderTest.php @@ -12,17 +12,6 @@ class UseEloquentBuilderTest extends TestCase { - protected function tearDown(): void - { - // Clear the static cache between tests - UseEloquentBuilderTestModel::clearResolvedBuilderClasses(); - UseEloquentBuilderTestModelWithAttribute::clearResolvedBuilderClasses(); - UseEloquentBuilderTestChildModel::clearResolvedBuilderClasses(); - UseEloquentBuilderTestChildModelWithOwnAttribute::clearResolvedBuilderClasses(); - - parent::tearDown(); - } - public function testNewModelBuilderReturnsDefaultBuilderWhenNoAttribute(): void { $model = new UseEloquentBuilderTestModel; @@ -92,28 +81,6 @@ public function testDifferentModelsUseDifferentCaches(): void $this->assertNotInstanceOf(CustomTestBuilder::class, $builder1); $this->assertInstanceOf(CustomTestBuilder::class, $builder2); } - - public function testChildModelWithoutAttributeUsesDefaultBuilder(): void - { - $model = new UseEloquentBuilderTestChildModel; - $query = m::mock(\Hypervel\Database\Query\Builder::class); - - $builder = $model->newEloquentBuilder($query); - - // PHP attributes are not inherited - child needs its own attribute - $this->assertInstanceOf(Builder::class, $builder); - $this->assertNotInstanceOf(CustomTestBuilder::class, $builder); - } - - public function testChildModelWithOwnAttributeUsesOwnBuilder(): void - { - $model = new UseEloquentBuilderTestChildModelWithOwnAttribute; - $query = m::mock(\Hypervel\Database\Query\Builder::class); - - $builder = $model->newEloquentBuilder($query); - - $this->assertInstanceOf(AnotherCustomTestBuilder::class, $builder); - } } // Test fixtures @@ -129,14 +96,6 @@ public function testResolveCustomBuilderClass(): string|false { return $this->resolveCustomBuilderClass(); } - - /** - * Clear the static cache for testing. - */ - public static function clearResolvedBuilderClasses(): void - { - static::$resolvedBuilderClasses = []; - } } #[UseEloquentBuilder(CustomTestBuilder::class)] @@ -151,37 +110,6 @@ public function testResolveCustomBuilderClass(): string|false { return $this->resolveCustomBuilderClass(); } - - /** - * Clear the static cache for testing. - */ - public static function clearResolvedBuilderClasses(): void - { - static::$resolvedBuilderClasses = []; - } -} - -class UseEloquentBuilderTestChildModel extends UseEloquentBuilderTestModelWithAttribute -{ - /** - * Clear the static cache for testing. - */ - public static function clearResolvedBuilderClasses(): void - { - static::$resolvedBuilderClasses = []; - } -} - -#[UseEloquentBuilder(AnotherCustomTestBuilder::class)] -class UseEloquentBuilderTestChildModelWithOwnAttribute extends UseEloquentBuilderTestModelWithAttribute -{ - /** - * Clear the static cache for testing. - */ - public static function clearResolvedBuilderClasses(): void - { - static::$resolvedBuilderClasses = []; - } } /** @@ -191,11 +119,3 @@ public static function clearResolvedBuilderClasses(): void class CustomTestBuilder extends Builder { } - -/** - * @template TModel of Model - * @extends Builder - */ -class AnotherCustomTestBuilder extends Builder -{ -} diff --git a/tests/Filesystem/ClientPooledFilesystemTest.php b/tests/Filesystem/ClientPooledFilesystemTest.php index d78ae8f2f..6b17cd6b6 100644 --- a/tests/Filesystem/ClientPooledFilesystemTest.php +++ b/tests/Filesystem/ClientPooledFilesystemTest.php @@ -10,6 +10,7 @@ use Hypervel\Container\Container; use Hypervel\Context\RequestContext; use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Contracts\Filesystem\Filesystem as FilesystemContract; use Hypervel\Contracts\ObjectPool\Factory; use Hypervel\Contracts\ObjectPool\InvalidatesPool; use Hypervel\Contracts\ObjectPool\ObjectPool as ObjectPoolContract; @@ -70,6 +71,50 @@ protected function tearDown(): void parent::tearDown(); } + public function testCrossDiskTransfersReleaseSharedClientBeforeWriting(): void + { + $definition = new PoolDefinition('filesystem:transfer', 'local-client', 'shared', PoolOptions::fromArray([ + 'max_objects' => 1, + 'wait_timeout' => 0.02, + ])); + $makeDisk = fn (string $root): ClientPooledFilesystem => new ClientPooledFilesystem( + $definition, + static fn (): object => new stdClass, + static function (object $client) use ($root): FilesystemAdapter { + $adapter = new LocalFilesystemAdapter($root); + + return new FilesystemAdapter(new Filesystem($adapter), $adapter, ['root' => $root]); + }, + $this->pools, + ['root' => $root], + ); + $source = $makeDisk($this->tempDir . '/source'); + $destination = $makeDisk($this->tempDir . '/destination'); + $source->put('file.txt', 'contents'); + + $this->assertTrue($source->copyToDisk($destination, 'file.txt')); + $this->assertSame('contents', $destination->get('file.txt')); + $this->assertTrue($source->moveToDisk($destination, 'file.txt', 'moved.txt')); + $this->assertSame('contents', $destination->get('moved.txt')); + $this->assertFalse($source->exists('file.txt')); + + $source->put('file.txt', 'retained'); + $failedDestination = m::mock(FilesystemContract::class); + $stream = null; + $failedDestination->shouldReceive('writeStream')->once()->with('file.txt', m::on(function (mixed $resource) use (&$stream): bool { + $stream = $resource; + $this->assertSame(0, $this->pools->get('filesystem:transfer')->getBorrowedCount()); + $this->assertSame('retained', stream_get_contents($resource)); + + return true; + }))->andReturnFalse(); + + $this->assertFalse($source->moveToDisk($failedDestination, 'file.txt')); + $this->assertSame('retained', $source->get('file.txt')); + $this->assertFalse(is_resource($stream)); + $this->assertSame(0, $this->pools->get('filesystem:transfer')->getBorrowedCount()); + } + public function testReadThroughDisksReleaseSharedClientBorrowsBeforeCopyingAndProcessingListings(): void { $definition = new PoolDefinition('filesystem:shared-read-through', 'local-client', 'shared', PoolOptions::fromArray([ diff --git a/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php index 9b7fa6bfe..098f96c96 100644 --- a/tests/Filesystem/FilesystemAdapterTest.php +++ b/tests/Filesystem/FilesystemAdapterTest.php @@ -9,6 +9,7 @@ use Hypervel\Container\Container; use Hypervel\Context\RequestContext; use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Contracts\Filesystem\Factory as FilesystemFactory; use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Coroutine as EngineCoroutine; use Hypervel\Filesystem\FilesystemAdapter; @@ -522,6 +523,100 @@ public function testMove() $this->assertEquals($data, file_get_contents($this->tempDir . '/foo/foo2.txt')); } + public function testCopyToDisk(): void + { + $this->filesystem->write('file.txt', 'Hello World'); + + $backupFilesystem = new Filesystem($backupAdapter = new LocalFilesystemAdapter($this->tempDir . '/backup')); + + Container::getInstance()->instance(FilesystemFactory::class, m::mock(FilesystemFactory::class, [ + 'disk' => new FilesystemAdapter($backupFilesystem, $backupAdapter), + ])); + + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + $filesystemAdapter->copyToDisk('backup', 'file.txt'); + + $this->assertFileExists($this->tempDir . '/file.txt'); + $this->assertFileExists($this->tempDir . '/backup/file.txt'); + } + + public function testMoveToDisk(): void + { + $this->filesystem->write('file.txt', 'Hello World'); + + $backupFilesystem = new Filesystem($backupAdapter = new LocalFilesystemAdapter($this->tempDir . '/backup')); + + Container::getInstance()->instance(FilesystemFactory::class, m::mock(FilesystemFactory::class, [ + 'disk' => new FilesystemAdapter($backupFilesystem, $backupAdapter), + ])); + + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + $filesystemAdapter->moveToDisk('backup', 'file.txt', 'copy.txt'); + + $this->assertFileDoesNotExist($this->tempDir . '/file.txt'); + $this->assertFileExists($this->tempDir . '/backup/copy.txt'); + } + + public function testCopyToDiskRejectsSameDiskAndPath(): void + { + $this->filesystem->write('file.txt', 'Hello World'); + + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + + Container::getInstance()->instance(FilesystemFactory::class, m::mock(FilesystemFactory::class, [ + 'disk' => $filesystemAdapter, + ])); + + $this->expectException(InvalidArgumentException::class); + + $filesystemAdapter->copyToDisk('local', 'file.txt'); + } + + public function testCopyToDiskWithFilesystem(): void + { + $this->filesystem->write('file.txt', 'Hello World'); + + $backupFilesystem = new Filesystem($backupAdapter = new LocalFilesystemAdapter($this->tempDir . '/backup')); + + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + $filesystemAdapter->copyToDisk(new FilesystemAdapter($backupFilesystem, $backupAdapter), 'file.txt'); + + $this->assertFileExists($this->tempDir . '/file.txt'); + $this->assertFileExists($this->tempDir . '/backup/file.txt'); + } + + public function testMoveToDiskWithFilesystem(): void + { + $this->filesystem->write('file.txt', 'Hello World'); + + $backupFilesystem = new Filesystem($backupAdapter = new LocalFilesystemAdapter($this->tempDir . '/backup')); + + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + $filesystemAdapter->moveToDisk(new FilesystemAdapter($backupFilesystem, $backupAdapter), 'file.txt', 'copy.txt'); + + $this->assertFileDoesNotExist($this->tempDir . '/file.txt'); + $this->assertFileExists($this->tempDir . '/backup/copy.txt'); + } + + #[TestWith(['copyToDisk', null])] + #[TestWith(['copyToDisk', './file.txt'])] + #[TestWith(['moveToDisk', 'dir/../file.txt'])] + public function testCopyToDiskWithFilesystemRejectsSameDiskAndPath(string $method, ?string $to): void + { + $this->filesystem->write('file.txt', 'Hello World'); + + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + + try { + $filesystemAdapter->{$method}($filesystemAdapter, 'file.txt', $to); + $this->fail('Expected a same-path transfer to be rejected.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame('Cannot copy a file to the same disk and path.', $exception->getMessage()); + } + + $this->assertSame('Hello World', $filesystemAdapter->get('file.txt')); + } + public function testStream() { $this->filesystem->write('file.txt', $original_content = 'Hello World'); diff --git a/tests/Filesystem/FilesystemPoolProxyTest.php b/tests/Filesystem/FilesystemPoolProxyTest.php index 303605ef5..144823184 100644 --- a/tests/Filesystem/FilesystemPoolProxyTest.php +++ b/tests/Filesystem/FilesystemPoolProxyTest.php @@ -196,6 +196,27 @@ public function testEveryCallbackSlotIsWrittenOnEveryBorrowAcrossSharedProxies() $this->assertFalse($second->providesTemporaryUploadUrls()); } + public function testCrossDiskTransfersReleaseTheOnlyDriverBeforeWriting(): void + { + $proxy = new FilesystemPoolProxy( + new PoolDefinition('filesystem:transfer', 'custom', 'transfer', PoolOptions::fromArray([ + 'max_objects' => 1, + 'wait_timeout' => 0.02, + ])), + fn (): FilesystemAdapter => $this->filesystem(), + $this->pools, + ['driver' => 'custom'], + ); + $this->driver->write('file.txt', 'contents'); + + $this->assertTrue($proxy->copyToDisk($proxy, 'file.txt', 'copy.txt')); + $this->assertSame('contents', $this->driver->read('copy.txt')); + $this->assertTrue($proxy->moveToDisk($proxy, 'file.txt', 'moved.txt')); + $this->assertSame('contents', $this->driver->read('moved.txt')); + $this->assertFalse($this->driver->fileExists('file.txt')); + $this->assertSame(0, $this->pools->get('filesystem:transfer')->getBorrowedCount()); + } + public function testReadStreamKeepsTheWholeDriverBorrowedUntilClose(): void { $this->driver->write('file.txt', 'streamed'); diff --git a/tests/Filesystem/ScopedFilesystemProxyTest.php b/tests/Filesystem/ScopedFilesystemProxyTest.php index c15b9f282..cd63cd53a 100644 --- a/tests/Filesystem/ScopedFilesystemProxyTest.php +++ b/tests/Filesystem/ScopedFilesystemProxyTest.php @@ -18,12 +18,14 @@ use Hypervel\Image\ImageException; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; +use InvalidArgumentException; use League\Flysystem\CorruptedPathDetected; use League\Flysystem\Filesystem; use League\Flysystem\Local\LocalFilesystemAdapter; use League\Flysystem\PathTraversalDetected; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Symfony\Component\HttpFoundation\StreamedResponse; use TypeError; @@ -55,6 +57,67 @@ protected function tearDown(): void parent::tearDown(); } + #[TestWith(['copyToDisk', false, null])] + #[TestWith(['copyToDisk', false, 'copy.txt'])] + #[TestWith(['copyToDisk', true, 'copy.txt'])] + #[TestWith(['moveToDisk', false, null])] + #[TestWith(['moveToDisk', false, 'copy.txt'])] + #[TestWith(['moveToDisk', true, 'copy.txt'])] + public function testTransfersUseContractMethodsAndResolveSourceOnce(string $method, bool $sameDisk, ?string $to): void + { + $stream = fopen('php://temp', 'w+b'); + fwrite($stream, 'contents'); + rewind($stream); + $source = m::mock(FilesystemContract::class); + $source->shouldReceive('readStream')->once()->with('tenant/file.txt')->andReturn($stream); + $destination = $sameDisk ? $source : m::mock(FilesystemContract::class); + $destination->shouldReceive('writeStream')->once() + ->with(($sameDisk ? 'tenant/' : '') . ($to ?? 'file.txt'), $stream)->andReturnTrue(); + + if ($method === 'moveToDisk') { + $source->shouldReceive('delete')->once()->with('tenant/file.txt')->andReturnTrue(); + } + + $diskCalls = $prefixCalls = 0; + $proxy = new ScopedFilesystemProxy( + function () use ($source, &$diskCalls): FilesystemContract { + ++$diskCalls; + + return $source; + }, + function () use (&$prefixCalls): string { + ++$prefixCalls; + + return 'tenant'; + }, + ); + + $this->assertTrue($proxy->{$method}($sameDisk ? $proxy : $destination, 'file.txt', $to)); + $this->assertSame(1, $diskCalls); + $this->assertSame(1, $prefixCalls); + $this->assertFalse(is_resource($stream)); + } + + #[TestWith(['copyToDisk', null, false])] + #[TestWith(['moveToDisk', null, false])] + #[TestWith(['copyToDisk', './file.txt', false])] + #[TestWith(['moveToDisk', 'dir/../file.txt', false])] + #[TestWith(['moveToDisk', 'tenant/file.txt', true])] + public function testTransfersRejectSameScopedDiskAndPath(string $method, ?string $to, bool $innerDestination): void + { + $proxy = new ScopedFilesystemProxy($this->disk, static fn (): string => 'tenant'); + $proxy->put('file.txt', 'contents'); + + try { + $proxy->{$method}($innerDestination ? $this->disk : $proxy, 'file.txt', $to); + $this->fail('Expected a same-path transfer to be rejected.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame('Cannot copy a file to the same disk and path.', $exception->getMessage()); + } + + $this->assertSame('contents', $proxy->get('file.txt')); + } + #[DataProvider('mappedMethodProvider')] public function testEveryMappedMethodPrefixesExactlyOnce( string $method, diff --git a/tests/Foundation/FoundationViteTest.php b/tests/Foundation/FoundationViteTest.php index 6254f02ca..02deff254 100644 --- a/tests/Foundation/FoundationViteTest.php +++ b/tests/Foundation/FoundationViteTest.php @@ -13,6 +13,8 @@ use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; use JsonException; +use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; class FoundationViteTest extends TestCase { @@ -126,6 +128,29 @@ public function testViteWithNestedCssImport(): void ); } + public function testItCanRetrieveTheDevServerUrl(): void + { + $this->makeViteHotFile(); + + $this->assertSame('http://localhost:3000', app(Vite::class)->devServerUrl()); + } + + public function testTheDevServerUrlIsNullWhenNotRunningHot(): void + { + $this->assertNull(app(Vite::class)->devServerUrl()); + } + + public function testTheDevServerUrlRespectsACustomHotFile(): void + { + $path = $this->tempDir . '/custom-hot'; + $this->makeViteHotFile($path); + + $this->assertSame( + 'http://localhost:3000', + app(Vite::class)->useHotFile($path)->devServerUrl() + ); + } + public function testViteWithCyclicNestedImports(): void { $buildDir = Str::random(); @@ -648,10 +673,14 @@ public function testItCanGenerateIndividualAssetUrlInHotMode(): void $this->assertSame('http://localhost:3000/resources/js/app.js', $url); } - public function testItThrowsWhenHotFileDisappearsBeforeItCanBeRead(): void + #[TestWith([true])] + #[TestWith([false])] + public function testItThrowsWhenHotFileDisappearsBeforeItCanBeRead(bool $existsBeforeRead): void { - $this->withUnreadableViteStream(function (string $path): void { - $vite = (new Vite)->useHotFile($path . '/hot'); + $this->withUnreadableViteStream(function (string $path) use ($existsBeforeRead): void { + $vite = m::mock(Vite::class . '[isRunningHot]'); + $vite->useHotFile($path . '/hot'); + $vite->expects('isRunningHot')->twice()->andReturn(true, $existsBeforeRead); $this->expectException(ViteException::class); $this->expectExceptionMessageIsOrContains('Unable to read the Vite hot file'); diff --git a/tests/Integration/Database/EloquentMassPrunableTest.php b/tests/Integration/Database/EloquentMassPrunableTest.php index fdb5dec2c..776590610 100644 --- a/tests/Integration/Database/EloquentMassPrunableTest.php +++ b/tests/Integration/Database/EloquentMassPrunableTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Integration\Database; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\MassPrunable; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\SoftDeletes; @@ -16,6 +17,9 @@ class EloquentMassPrunableTest extends DatabaseTestCase { + /** + * Create the pruning tables. + */ protected function afterRefreshingDatabase(): void { collect([ @@ -40,7 +44,7 @@ public function testPrunableMethodMustBeImplemented(): void MassPrunableTestModelMissingPrunableMethod::create()->pruneAll(); } - public function testPrunesRecords() + public function testPrunesRecords(): void { Event::fake(); @@ -75,7 +79,7 @@ static function (ModelsPruned $event) use (&$observedEvents): void { $this->assertSame([], $observedEvents); } - public function testPrunesSoftDeletedRecords() + public function testPrunesSoftDeletedRecords(): void { Event::fake(); @@ -93,13 +97,33 @@ public function testPrunesSoftDeletedRecords() Event::assertDispatched(ModelsPruned::class, 3); } + + public function testPrunesActiveAndSoftDeletedRecords(): void + { + Event::fake(); + + MassPrunableSoftDeleteTestModel::insert([ + ['deleted_at' => null], + ['deleted_at' => now()], + ]); + + $count = (new MassPrunableSoftDeleteTestModel)->pruneAll(); + + $this->assertEquals(2, $count); + $this->assertEquals(0, MassPrunableSoftDeleteTestModel::withTrashed()->count()); + + Event::assertDispatched(ModelsPruned::class, 1); + } } class MassPrunableTestModel extends Model { use MassPrunable; - public function prunable() + /** + * Get the prunable model query. + */ + public function prunable(): Builder { return $this->where('id', '<=', 1500); } @@ -110,7 +134,10 @@ class MassPrunableSoftDeleteTestModel extends Model use MassPrunable; use SoftDeletes; - public function prunable() + /** + * Get the prunable model query. + */ + public function prunable(): Builder { return $this->where('id', '<=', 3000); } diff --git a/tests/Integration/Database/EloquentStrictLoadingTest.php b/tests/Integration/Database/EloquentStrictLoadingTest.php index 73cd4aeff..af330b6eb 100644 --- a/tests/Integration/Database/EloquentStrictLoadingTest.php +++ b/tests/Integration/Database/EloquentStrictLoadingTest.php @@ -116,11 +116,15 @@ public function testStrictModeThrowsAnExceptionOnLazyLoadingInRelations() $models[0]->modelTwos[0]->modelThrees; } - public function testStrictModeWithCustomCallbackOnLazyLoading() + public function testStrictModeWithCustomCallbackOnLazyLoading(): void { Event::fake(); - Model::handleLazyLoadingViolationUsing(function ($model, $key) { + $callbackException = null; + + Model::handleLazyLoadingViolationUsing(function (Model $model, string $key, LazyLoadingViolationException $exception) use (&$callbackException): void { + $callbackException = $exception; + event(new ViolatedLazyLoadingEvent($model, $key)); }); @@ -132,6 +136,8 @@ public function testStrictModeWithCustomCallbackOnLazyLoading() $models[0]->modelTwos; Event::assertDispatched(ViolatedLazyLoadingEvent::class); + $this->assertInstanceOf(LazyLoadingViolationException::class, $callbackException); + $this->assertSame(EloquentStrictLoadingTestModel1::class, $callbackException->model); } public function testStrictModeWithOverriddenHandlerOnLazyLoading(): void diff --git a/tests/Integration/Database/SchemaBuilderTest.php b/tests/Integration/Database/SchemaBuilderTest.php index b2a2571d6..180a13213 100644 --- a/tests/Integration/Database/SchemaBuilderTest.php +++ b/tests/Integration/Database/SchemaBuilderTest.php @@ -467,6 +467,17 @@ public function testGetPartialIndexMetadata(): void ], collect($indexes)->firstWhere('name', 'partial_index_records_email_unique')); } + public function testHasIndexNameIsCaseInsensitive(): void + { + Schema::create('foo', function (Blueprint $table): void { + $table->string('bar')->index('IDX_MyIndex'); + }); + + $this->assertTrue(Schema::hasIndex('foo', 'IDX_MyIndex')); + $this->assertTrue(Schema::hasIndex('foo', 'idx_myindex')); + $this->assertSame('idx_myindex', Schema::getIndexes('foo')[0]['name']); + } + public function testGetUniqueIndexes() { Schema::create('foo', function (Blueprint $table) { diff --git a/tests/Integration/Routing/UrlSigningTest.php b/tests/Integration/Routing/UrlSigningTest.php index 8963548b6..eb6e4ed16 100644 --- a/tests/Integration/Routing/UrlSigningTest.php +++ b/tests/Integration/Routing/UrlSigningTest.php @@ -13,6 +13,7 @@ use Hypervel\Support\Facades\Route; use Hypervel\Support\Facades\URL; use Hypervel\Support\Uri; +use Hypervel\Testing\TestResponse; use Hypervel\Tests\Integration\Routing\RoutingTestCase; use InvalidArgumentException; use Symfony\Component\HttpFoundation\Response; @@ -78,6 +79,31 @@ public function testSigningUrlWithCustomRouteSlug() }); } + public function testSigningUrlWithPercentSignInRouteSlug(): void + { + Route::get('/foo/{post:slug}', function (Request $request, string $slug): array { + return ['slug' => $slug, 'valid' => $request->hasValidSignature() ? 'valid' : 'invalid']; + })->name('foo'); + + $model = new RoutableInterfaceStub; + $model->slug = '%66oo'; + + // The percent sign has to be escaped in the generated URL. Otherwise the router + // decodes "%66" back into an "f" when matching the URL and binds a different + // model than the one the URL was generated for... + $this->assertSame( + '/foo/%2566oo', + parse_url($url = URL::signedRoute('foo', ['post' => $model]), PHP_URL_PATH) + ); + + tap($this->get($url), function (TestResponse $response): void { + $this->assertSame('valid', $response->original['valid']); + $this->assertSame('%66oo', $response->original['slug']); + + $this->assertSame('%66oo', $response->baseRequest->route('post')); + }); + } + public function testTemporarySignedUrls(): void { Route::get('/foo/{id}', function (Request $request, $id) { diff --git a/tests/Mail/MailResendTransportTest.php b/tests/Mail/MailResendTransportTest.php index d1e123f17..62e8c6970 100644 --- a/tests/Mail/MailResendTransportTest.php +++ b/tests/Mail/MailResendTransportTest.php @@ -67,7 +67,13 @@ public function testSend(): void $client = m::mock(Client::class); $client->emails = $emails; - (new ResendTransport($client))->send($message); + $sentMessage = (new ResendTransport($client))->send($message); + + $this->assertSame('resend-message-id', $sentMessage->getMessageId()); + $this->assertSame( + 'resend-message-id', + $sentMessage->getOriginalMessage()->getHeaders()->get('X-Resend-Email-ID')?->getBodyAsString() + ); } public function testSendError(): void diff --git a/tests/Redis/PhpRedisClusterConnectionTest.php b/tests/Redis/PhpRedisClusterConnectionTest.php index d9fd2d002..f2cedb667 100644 --- a/tests/Redis/PhpRedisClusterConnectionTest.php +++ b/tests/Redis/PhpRedisClusterConnectionTest.php @@ -257,6 +257,7 @@ public function testItScansUsingOptionNode(): void public function testItThrowsExceptionWithoutNodes(): void { $client = m::mock(RedisCluster::class); + $client->allows('getMode')->andReturn(Redis::ATOMIC); $client->expects('_masters')->andReturn([]); $client->shouldNotReceive('scan'); $connection = new PhpRedisClusterConnectionStub; @@ -272,6 +273,7 @@ public function testItReturnsFalseWhenCursorIsZeroAndResultIsEmpty(): void { $master = ['127.0.0.1', 6379]; $client = m::mock(RedisCluster::class); + $client->allows('getMode')->andReturn(Redis::ATOMIC); $client->expects('_masters')->andReturn([$master]); $client->expects('scan')->with(0, $master, '*', 10)->andReturnFalse(); $connection = new PhpRedisClusterConnectionStub; @@ -287,6 +289,7 @@ public function testItScansEveryMasterInTurn(): void $masters = [['127.0.0.1', 6379], ['127.0.0.2', 6379], ['127.0.0.3', 6379]]; $keys = ['a', 'b', 'c']; $client = m::mock(RedisCluster::class); + $client->allows('getMode')->andReturn(Redis::ATOMIC); $client->allows('_masters')->andReturn($masters); foreach ($masters as $index => $master) { @@ -321,6 +324,7 @@ public function testItResumesAMasterFromTheEncodedCursor(array $firstPage): void { $master = ['127.0.0.1', 6379]; $client = m::mock(RedisCluster::class); + $client->allows('getMode')->andReturn(Redis::ATOMIC); $client->allows('_masters')->andReturn([$master]); $client->expects('scan')->with(null, $master, '*', 10) ->andReturnUsing(function (&$cursor) use ($firstPage): array { @@ -363,6 +367,7 @@ public function testItKeepsScanningWhenAMasterReturnsNoKeys(): void { $masters = [['127.0.0.1', 6379], ['127.0.0.2', 6379]]; $client = m::mock(RedisCluster::class); + $client->allows('getMode')->andReturn(Redis::ATOMIC); $client->allows('_masters')->andReturn($masters); $client->expects('scan')->with(null, $masters[0], '*', 10) ->andReturnUsing(function (&$cursor): array { @@ -388,6 +393,7 @@ public function testItPreservesLargeStringCursors(): void $master = ['127.0.0.1', 6379]; $largeCursor = '18446744073709551615'; $client = m::mock(RedisCluster::class); + $client->allows('getMode')->andReturn(Redis::ATOMIC); $client->allows('_masters')->andReturn([$master]); $client->expects('scan')->with(null, $master, '*', 10) ->andReturnUsing(function (&$cursor) use ($largeCursor): array { @@ -413,6 +419,7 @@ public function testItKeepsNodeAffinityWhenMastersAreReordered(): void { $masters = [['127.0.0.1', 6379], ['127.0.0.2', 6379]]; $client = m::mock(RedisCluster::class); + $client->allows('getMode')->andReturn(Redis::ATOMIC); $client->expects('_masters')->twice()->andReturn($masters, array_reverse($masters)); foreach ($masters as $index => $master) { @@ -436,6 +443,7 @@ public function testItContinuesWithAnotherMasterWhenTheCurrentMasterDisappears() { $masters = [['127.0.0.1', 6379], ['127.0.0.2', 6379]]; $client = m::mock(RedisCluster::class); + $client->allows('getMode')->andReturn(Redis::ATOMIC); $client->expects('_masters')->twice()->andReturn($masters, [$masters[1]]); $client->expects('scan')->with(null, $masters[0], '*', 10) ->andReturnUsing(function (&$cursor): array { diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index 6cdc5b965..baa7ab32e 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Closure; +use ErrorException; use Hypervel\ConnectionPool\Events\ConnectionReleasing; use Hypervel\ConnectionPool\Exceptions\ConnectionException; use Hypervel\ConnectionPool\PoolOptions; @@ -1579,10 +1580,10 @@ public function testClusterTransportFailureInvalidatesWithoutReplayingCommand(): $this->assertTrue($connection->isInvalidForTest()); } - // REMOVED: Automatic read/write retries and configured command retries can replay committed commands. + // REMOVED: Automatic command retries and reopening pipelines/transactions can replay committed commands. #[DataProvider('connectionFailureProvider')] public function testConnectionRebuildsItsClientOnNextAcquisitionWithoutReplayingCommand( - RedisException|RedisClusterException $exception, + RedisException|RedisClusterException|ErrorException $exception, string $command, array $arguments, bool $synchronized, @@ -1592,7 +1593,9 @@ public function testConnectionRebuildsItsClientOnNextAcquisitionWithoutReplaying $this->expectDefaultConnectionOptions($failedClient); $this->expectDefaultConnectionOptions($healthyClient); $failedClient->expects($command)->once()->with(...$arguments)->andThrow($exception); - $failedClient->expects('getLastError')->andReturn($synchronized ? $exception->getMessage() : null); + if (! $exception instanceof ErrorException) { + $failedClient->expects('getLastError')->andReturn($synchronized ? $exception->getMessage() : null); + } $failedClient->shouldNotReceive('isConnected'); $healthyClient->expects('get')->once()->with('foo')->andReturn('bar'); @@ -1623,7 +1626,7 @@ protected function createRedis(array $config): Redis try { $connection->__call($command, $arguments); $this->fail('Expected the command failure to propagate.'); - } catch (RedisException|RedisClusterException $throwable) { + } catch (RedisException|RedisClusterException|ErrorException $throwable) { $this->assertSame($exception, $throwable); } @@ -1644,12 +1647,36 @@ public static function connectionFailureProvider(): array 'read-only replica' => [new RedisException('READONLY replica is read-only'), 'set', ['foo', 'bar'], true], 'disconnected replica' => [new RedisException('MASTERDOWN link is down'), 'get', ['foo'], true], 'read failure' => [new RedisException('Connection lost'), 'get', ['foo'], false], + 'warning-shaped reset' => [new ErrorException('Redis::get(): SSL: Connection reset by peer'), 'get', ['foo'], false], + 'stream write failure' => [new ErrorException('Redis::get(): Send of 30 bytes failed with errno=32 Broken pipe'), 'get', ['foo'], false], + 'TLS protocol failure' => [new ErrorException('Redis::get(): SSL operation failed with code 1. OpenSSL Error messages: bad length'), 'get', ['foo'], false], + 'transaction opening' => [new RedisException('Connection lost'), 'multi', [], false], 'non-idempotent write' => [new RedisException('Connection lost'), 'incr', ['foo'], false], 'write with options' => [new RedisException('Connection lost'), 'set', ['foo', 'bar', ['ex' => 60]], false], 'cluster response error' => [new RedisClusterException('Error processing response from Redis node!'), 'get', ['foo'], false], ]; } + public function testConnectionDoesNotRetryUnrelatedWarnings(): void + { + $exception = new ErrorException('Redis::get(): Undefined variable'); + $redis = m::mock(Redis::class); + $redis->expects('get')->once()->with('foo')->andThrow($exception); + $redis->shouldNotReceive('getLastError'); + $connection = new PhpRedisConnectionStub($this->getContainer(), $this->getMockedPool()); + $connection->setActiveConnection($redis); + + try { + $connection->__call('get', ['foo']); + $this->fail('Expected the unrelated warning to propagate.'); + } catch (ErrorException $throwable) { + $this->assertSame($exception, $throwable); + } + + $this->assertFalse($connection->isInvalidForTest()); + $this->assertSame($redis, $connection->client()); + } + #[DataProvider('synchronizedServerErrorDispositionProvider')] public function testSynchronizedServerErrorDispositionDoesNotReplayCommand( string $message, @@ -2080,6 +2107,28 @@ public static function scanOptions(): array ]; } + public function testTransformedScanInvalidatesOnTransportFailure(): void + { + $exception = new RedisException('Connection lost'); + $redis = m::mock(Redis::class); + $redis->expects('getMode')->andReturn(Redis::ATOMIC); + $redis->expects('scan')->once()->with(null, '*', 10)->andThrow($exception); + $redis->expects('getLastError')->andReturnNull(); + $connection = new PhpRedisConnectionStub($this->getContainer(), $this->getMockedPool()); + $connection->setActiveConnection($redis); + $connection->shouldTransform(true); + $cursor = null; + + try { + $connection->scan($cursor); + $this->fail('Expected the scan failure to propagate.'); + } catch (RedisException $throwable) { + $this->assertSame($exception, $throwable); + } + + $this->assertTrue($connection->isInvalidForTest()); + } + public function testScanWithOptions(): void { $connection = $this->mockRedisConnection(transform: true); @@ -2952,6 +3001,27 @@ public function testEvalWithShaCacheThrowsOnNonNoscriptError(): void $connection->evalWithShaCache($script, ['mykey']); } + public function testEvalWithShaCacheInvalidatesAReadOnlyReplica(): void + { + $exception = new RedisException('READONLY replica is read-only'); + $redis = m::mock(Redis::class); + $redis->expects('clearLastError')->once(); + $redis->expects('evalSha')->once()->with(sha1('return 1'), [], 0)->andThrow($exception); + $redis->expects('getLastError')->andReturn($exception->getMessage()); + $redis->shouldNotReceive('eval'); + $connection = new PhpRedisConnectionStub($this->getContainer(), $this->getMockedPool()); + $connection->setActiveConnection($redis); + + try { + $connection->evalWithShaCache('return 1'); + $this->fail('Expected the replica error to propagate.'); + } catch (RedisException $throwable) { + $this->assertSame($exception, $throwable); + } + + $this->assertTrue($connection->isInvalidForTest()); + } + public function testEvalWithShaCacheReturnsLegitimatelyFalseResult(): void { $connection = $this->mockRedisConnection(); diff --git a/tests/Routing/RoutingUrlGeneratorTest.php b/tests/Routing/RoutingUrlGeneratorTest.php index 918b2d384..43f2a9582 100755 --- a/tests/Routing/RoutingUrlGeneratorTest.php +++ b/tests/Routing/RoutingUrlGeneratorTest.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Routing\UrlRoutable; use Hypervel\Database\Eloquent\Model; use Hypervel\Http\Request; +use Hypervel\Routing\EncodedParameter; use Hypervel\Routing\Exceptions\UrlGenerationException; use Hypervel\Routing\Route; use Hypervel\Routing\RouteCollection; @@ -1048,6 +1049,47 @@ public function testRouteNotDefinedException(): void $url->route('not_exists_route'); } + public function testRouteParametersContainingPercentSignsAreEncoded(): void + { + $url = new UrlGenerator( + $routes = new RouteCollection, + $request = Request::create('http://www.foo.com/') + ); + + $routes->add(new Route(['GET'], 'foo/{bar}', ['as' => 'foo', function (): void { + }])); + + // A raw percent sign would be decoded again when the router matches the URL, + // so the parameter has to survive a round trip through rawurldecode()... + $this->assertSame('http://www.foo.com/foo/%2566oo', $url->route('foo', ['bar' => '%66oo'])); + $this->assertSame('http://www.foo.com/foo/100%25', $url->route('foo', ['bar' => '100%'])); + + $this->assertSame('%66oo', rawurldecode('%2566oo')); + $this->assertSame('100%', rawurldecode('100%25')); + + // Values without a percent sign are unaffected... + $this->assertSame('http://www.foo.com/foo/bar', $url->route('foo', ['bar' => 'bar'])); + $this->assertSame('http://www.foo.com/foo/1', $url->route('foo', ['bar' => 1])); + } + + public function testEncodedRouteParametersAreNotEncodedAgain(): void + { + $url = new UrlGenerator( + $routes = new RouteCollection, + $request = Request::create('http://www.foo.com/') + ); + + $routes->add(new Route(['GET'], 'foo/{bar}', ['as' => 'foo', function (): void { + }])); + + // Values that are already URL encoded may be marked as such so they are used as-is... + $this->assertSame('http://www.foo.com/foo/foo%20bar', $url->route('foo', ['bar' => new EncodedParameter('foo%20bar')])); + $this->assertSame('http://www.foo.com/foo/%66oo', $url->route('foo', ['bar' => new EncodedParameter('%66oo')])); + + // While plain strings continue to be encoded... + $this->assertSame('http://www.foo.com/foo/foo%2520bar', $url->route('foo', ['bar' => 'foo%20bar'])); + } + public function testSignedUrl() { $url = new UrlGenerator( diff --git a/tests/Sentry/Features/StorageIntegrationTest.php b/tests/Sentry/Features/StorageIntegrationTest.php index e875da368..113b15f77 100644 --- a/tests/Sentry/Features/StorageIntegrationTest.php +++ b/tests/Sentry/Features/StorageIntegrationTest.php @@ -8,19 +8,25 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\ObjectPool\Factory as PoolFactory; use Hypervel\Filesystem\AwsS3V3Adapter; +use Hypervel\Filesystem\Filesystem; use Hypervel\Filesystem\FilesystemAdapter; use Hypervel\Filesystem\FilesystemManager; use Hypervel\Filesystem\FilesystemPoolProxy; +use Hypervel\Filesystem\ScopedFilesystemProxy; use Hypervel\Sentry\Features\Storage\DecoratedFilesystem; use Hypervel\Sentry\Features\Storage\Integration; +use Hypervel\Sentry\Features\Storage\SentryFilesystem; use Hypervel\Sentry\Features\Storage\SentryFilesystemAdapter; use Hypervel\Sentry\Features\Storage\SentryS3V3Adapter; use Hypervel\Support\Facades\Route; use Hypervel\Support\Facades\Storage; +use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\Sentry\SentryTestCase; +use InvalidArgumentException; use League\Flysystem\Filesystem as Flysystem; use League\Flysystem\Local\LocalFilesystemAdapter; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; use ReflectionMethod; @@ -92,6 +98,69 @@ public function testCreatesSpansFor(): void $this->assertSame(['paths' => ['foo', 'bar'], 'disk' => 'local', 'driver' => 'local'], $span->getData()); } + #[TestWith([false])] + #[TestWith([true])] + public function testCrossDiskTransfersPreserveIdentityAndTracing(bool $scoped): void + { + $root = ParallelTesting::tempDir('SentryCrossDiskTransfers'); + $files = new Filesystem; + $files->deleteDirectory($root); + $files->ensureDirectoryExists($root); + + try { + $this->resetApplicationWithConfig([ + 'filesystems.disks' => Integration::configureDisks([ + 'source' => ['driver' => $scoped ? 'dynamic-scoped' : 'local', 'root' => $root . '/source'], + ]), + ]); + $manager = $this->app->make(FilesystemManager::class); + $manager->extend('dynamic-scoped', static function (Container $app, array $config): ScopedFilesystemProxy { + $adapter = new LocalFilesystemAdapter($config['root']); + + return new ScopedFilesystemProxy( + new FilesystemAdapter(new Flysystem($adapter), $adapter), + static fn (): string => 'tenant', + ); + }); + $source = Storage::disk('source'); + $this->assertInstanceOf($scoped ? SentryFilesystem::class : SentryFilesystemAdapter::class, $source); + $source->put('file.txt', 'contents'); + + try { + $source->moveToDisk('source', 'file.txt'); + $this->fail('Expected a same-path move to be rejected.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame('Cannot copy a file to the same disk and path.', $exception->getMessage()); + } + + $this->assertSame('contents', $source->get('file.txt')); + $adapter = new LocalFilesystemAdapter($root . '/destination'); + $destination = new FilesystemAdapter(new Flysystem($adapter), $adapter); + $transaction = $this->startTransaction(); + + $this->assertTrue($source->copyToDisk($destination, 'file.txt')); + $this->assertTrue($source->moveToDisk('source', 'file.txt', 'moved.txt')); + $spans = $transaction->getSpanRecorder()->getSpans(); + + $this->assertCount(3, $spans); + $this->assertSame('file.copyToDisk', $spans[1]->getOp()); + $this->assertSame('file.moveToDisk', $spans[2]->getOp()); + $this->assertSame([ + 'from' => 'file.txt', 'to' => 'file.txt', + 'disk' => 'source', 'driver' => $scoped ? 'dynamic-scoped' : 'local', + ], $spans[1]->getData()); + $this->assertSame([ + 'from' => 'file.txt', 'to' => 'moved.txt', 'destination_disk' => 'source', + 'disk' => 'source', 'driver' => $scoped ? 'dynamic-scoped' : 'local', + ], $spans[2]->getData()); + $this->assertSame('contents', $destination->get('file.txt')); + $this->assertSame('contents', $source->get('moved.txt')); + $this->assertFalse($source->exists('file.txt')); + } finally { + $files->deleteDirectory($root); + } + } + public function testDoesntCreateSpansWhenDisabled(): void { $this->resetApplicationWithConfig([ diff --git a/tests/Session/ArraySessionHandlerTest.php b/tests/Session/ArraySessionHandlerTest.php index a6a1f1b38..399238734 100644 --- a/tests/Session/ArraySessionHandlerTest.php +++ b/tests/Session/ArraySessionHandlerTest.php @@ -20,6 +20,14 @@ public function testIsSessionHandlerInterface(): void ); } + public function testItCreatesSessionIds(): void + { + $sessionId = (new ArraySessionHandler(10))->create_sid(); + + $this->assertIsString($sessionId); + $this->assertNotEmpty($sessionId); + } + public function testInitializeSession(): void { $handler = new ArraySessionHandler(10); @@ -27,6 +35,17 @@ public function testInitializeSession(): void $this->assertTrue($handler->open('', '')); } + public function testItValidatesSessionIds(): void + { + $handler = new ArraySessionHandler(10); + + $this->assertFalse($handler->validateId('foo')); + + $handler->write('foo', 'bar'); + + $this->assertTrue($handler->validateId('foo')); + } + public function testCloseSession(): void { $handler = new ArraySessionHandler(10); diff --git a/tests/Session/FileSessionHandlerTest.php b/tests/Session/FileSessionHandlerTest.php index 733b34f54..aa07018b5 100644 --- a/tests/Session/FileSessionHandlerTest.php +++ b/tests/Session/FileSessionHandlerTest.php @@ -40,6 +40,24 @@ public function testClose(): void $this->assertTrue($this->sessionHandler->close()); } + public function testItCreatesSessionIds(): void + { + $sessionId = $this->sessionHandler->create_sid(); + + $this->assertIsString($sessionId); + $this->assertNotEmpty($sessionId); + } + + public function testValidateIdChecksFileExists(): void + { + $sessionId = 'session_id'; + $path = '/path/to/sessions/' . $sessionId; + + $this->files->expects('isFile')->with($path)->andReturn(true); + + $this->assertTrue($this->sessionHandler->validateId($sessionId)); + } + public function testReadReturnsDataWhenFileExistsAndIsValid(): void { $sessionId = 'session_id'; diff --git a/tests/Session/RedisSessionHandlerTest.php b/tests/Session/RedisSessionHandlerTest.php index 1008f5978..73be0f5e4 100644 --- a/tests/Session/RedisSessionHandlerTest.php +++ b/tests/Session/RedisSessionHandlerTest.php @@ -54,6 +54,31 @@ public function testLifecycleAndCapabilityMethodsDoNotUseRedis(): void $this->assertTrue($tracked->supportsUserSessionManagement()); } + public function testItCreatesSessionIds(): void + { + $sessionId = $this->handler()->create_sid(); + + $this->assertIsString($sessionId); + $this->assertNotEmpty($sessionId); + } + + #[DataProvider('sessionExistenceProvider')] + public function testValidateIdChecksRedis(int $exists, bool $expected): void + { + $connection = $this->expectConnection(); + $connection->expects('exists')->with('sessions:' . self::SESSION_ID)->andReturn($exists); + + $this->assertSame($expected, $this->handler()->validateId(self::SESSION_ID)); + } + + /** + * Provide Redis session existence results. + */ + public static function sessionExistenceProvider(): array + { + return [[0, false], [1, true]]; + } + #[DataProvider('storedPayloadProvider')] public function testReadUnderstandsRawAndVersionedPayloads(string $stored, string $expected): void { diff --git a/tests/Support/SupportBinaryCodecTest.php b/tests/Support/SupportBinaryCodecTest.php index 0f2aa172f..f36d4c066 100644 --- a/tests/Support/SupportBinaryCodecTest.php +++ b/tests/Support/SupportBinaryCodecTest.php @@ -244,6 +244,7 @@ public function testIsBinary(): void // Invalid UTF-8 sequences $this->assertTrue(BinaryCodec::isBinary("\xFF\xFE")); + $this->assertTrue(BinaryCodec::isBinary("\xE3\x81")); // Binary identifier bytes can still look like text $this->assertFalse(BinaryCodec::isBinary(Uuid::fromString(self::UTF8_SAFE_BINARY_UUID)->toBinary())); diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php index 921445907..3cd6be9c8 100644 --- a/tests/Support/SupportCollectionTest.php +++ b/tests/Support/SupportCollectionTest.php @@ -1097,6 +1097,17 @@ public function active() $this->assertCount(1, $c->filter->active()); } + #[DataProvider('collectionClassProvider')] + public function testHigherOrderSole(string $collection): void + { + $c = new $collection([ + new TestSupportCollectionHigherOrderItem('Adam'), + new TestSupportCollectionHigherOrderItem('Taylor'), + ]); + + $this->assertSame('Taylor', $c->sole->is('Taylor')->name); + } + #[DataProvider('collectionClassProvider')] public function testWhere(string $collection): void { @@ -4329,8 +4340,12 @@ public function testSome($collection): void } #[DataProvider('collectionClassProvider')] - public function testContainsStrict($collection): void + public function testContainsStrict(string $collection): void { + $c = new $collection([1, null, 2]); + $this->assertTrue($c->containsStrict(fn (mixed $value): bool => is_null($value))); + $this->assertFalse($c->containsStrict(fn (mixed $value): bool => $value === 0)); + $c = new $collection([1, 3, 5, '02']); $this->assertTrue($c->containsStrict(1)); diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php index 61fbba872..603e43e3e 100644 --- a/tests/Testing/TestResponseTest.php +++ b/tests/Testing/TestResponseTest.php @@ -1906,6 +1906,62 @@ public function testAssertJsonMissingPathCanFail3(): void $response->assertJsonMissingPath('numeric_keys.3'); } + public function testAssertJsonMissingPathWithWildcard(): void + { + $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub)); + + $response->assertJsonMissingPath('bars.*.missing'); + $response->assertJsonMissingPath('missing.*.bar'); + + // A wildcard matches a single segment, so this must not match "barfoo.*.bar.foo"... + $response->assertJsonMissingPath('barfoo.*.foo'); + + $literalKeys = TestResponse::fromBaseResponse(new JsonResponse(['users' => [['password.hash' => 'value']]])); + $literalKeys->assertJsonMissingPath('users.*.password'); + $literalKeys->assertJsonMissingPath('users.*.password.hash'); + $literalKeys->assertJsonMissingPath('users.0.password.hash'); + + TestResponse::fromBaseResponse(new JsonResponse(42))->assertJsonMissingPath('*'); + TestResponse::fromBaseResponse(new JsonResponse([]))->assertJsonMissingPath('*'); + } + + public function testAssertJsonMissingPathWithTrailingWildcardCanFail(): void + { + $this->expectException(AssertionFailedError::class); + + $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub)); + + $response->assertJsonMissingPath('bars.*'); + } + + public function testAssertJsonMissingPathWithWildcardCanFail(): void + { + $this->expectException(AssertionFailedError::class); + + $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub)); + + $response->assertJsonMissingPath('bars.*.bar'); + } + + public function testAssertJsonMissingPathWithWildcardCanFailWhenPresentOnSomeItems(): void + { + $this->expectException(AssertionFailedError::class); + + $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub)); + + $response->assertJsonMissingPath('barfoo.*.bar.foo'); + } + + #[TestWith([['present'], '*'])] + #[TestWith([['' => ['value' => null]], '*.value'])] + #[TestWith([['child' => []], '*'])] + public function testAssertJsonMissingPathWithWildcardCanFailForMatchingNodes(array $data, string $path): void + { + $this->expectException(AssertionFailedError::class); + + TestResponse::fromBaseResponse(new JsonResponse($data))->assertJsonMissingPath($path); + } + public function testAssertJsonMissingPaths(): void { $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub));