diff --git a/src/console/src/Commands/ScheduleListCommand.php b/src/console/src/Commands/ScheduleListCommand.php index 2f3760bca2..7e48234acc 100644 --- a/src/console/src/Commands/ScheduleListCommand.php +++ b/src/console/src/Commands/ScheduleListCommand.php @@ -15,6 +15,7 @@ use Hypervel\Support\Arr; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; +use Hypervel\Support\Str; use ReflectionClass; use ReflectionFunction; use Symfony\Component\Console\Attribute\AsCommand; @@ -333,7 +334,7 @@ private function getClosureLocation(CallbackEvent $event): string return sprintf( '%s:%s', - str_replace($this->hypervel->basePath() . DIRECTORY_SEPARATOR, '', $function->getFileName() ?: ''), + Str::replaceStart($this->hypervel->basePath() . DIRECTORY_SEPARATOR, '', $function->getFileName() ?: ''), $function->getStartLine() ); } diff --git a/src/console/src/View/Components/Mutators/EnsureRelativePaths.php b/src/console/src/View/Components/Mutators/EnsureRelativePaths.php index 20cc55f627..17e595504d 100644 --- a/src/console/src/View/Components/Mutators/EnsureRelativePaths.php +++ b/src/console/src/View/Components/Mutators/EnsureRelativePaths.php @@ -12,7 +12,8 @@ class EnsureRelativePaths public function __invoke(string $string): string { if (function_exists('app') && app()->has('path.base')) { - $string = str_replace(base_path() . '/', '', $string); + // Shorten standalone paths without removing part of another path or URL. + $string = (string) preg_replace('#(?\'"])' . preg_quote(base_path() . '/', '#') . '#', '', $string); } return $string; diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index fccb71ecea..4d1e98b96d 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -8,12 +8,14 @@ use Closure; use Exception; use Hypervel\Contracts\Database\Eloquent\Builder as BuilderContract; +use Hypervel\Contracts\Database\Eloquent\SupportsPartialRelations; use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Database\BinaryParameter; use Hypervel\Database\Concerns\BuildsQueries; use Hypervel\Database\Eloquent\Concerns\QueriesRelationships; use Hypervel\Database\Eloquent\Relations\BelongsToMany; +use Hypervel\Database\Eloquent\Relations\HasOneOrManyThrough; use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\MultipleRecordsFoundException; use Hypervel\Database\Query\Builder as QueryBuilder; @@ -755,7 +757,7 @@ public function updateOrCreate(array $attributes, Closure|array $values = []): M */ public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model { - return tap($this->firstOrCreate($attributes, [$column => $default]), function ($instance) use ($column, $step, $extra) { + return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function ($instance) use ($column, $step, $extra) { if (! $instance->wasRecentlyCreated) { $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call) } @@ -1840,6 +1842,8 @@ protected function createSelectWithConstraint(string $name): array return [explode(':', $name)[0], static function ($query) use ($name) { $query->select(array_map(static function ($column) use ($query) { return $query instanceof BelongsToMany + || $query instanceof HasOneOrManyThrough + || ($query instanceof SupportsPartialRelations && $query->isOneOfMany()) ? $query->getRelated()->qualifyColumn($column) : $column; }, explode(',', explode(':', $name)[1]))); diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index c8c164a8e3..4144b7266d 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -341,7 +341,11 @@ protected function loadMissingRelation(self $models, array $path): void $models = $models->collapse(); } - $this->loadMissingRelation(new static($models), $path); + $models->groupBy(fn (Model $model): string => $model::class)->each( + function (BaseCollection $models) use ($path): void { + $this->loadMissingRelation(new static($models), $path); + } + ); } /** diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index ca7ab0a1e3..dd7426e492 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -299,6 +299,8 @@ protected function getBelongsToRelation(MorphTo $relation, string $type): Belong ); }); + $relation->replayMacros($belongsTo->getQuery()); + $belongsTo->getQuery()->mergeConstraintsFrom($relation->getQuery()); // @phpstan-ignore return.type (TModel IS TDeclaringModel in this context) diff --git a/src/database/src/Eloquent/Factories/BelongsToManyRelationship.php b/src/database/src/Eloquent/Factories/BelongsToManyRelationship.php index 67696ed1ac..0aa4131b14 100644 --- a/src/database/src/Eloquent/Factories/BelongsToManyRelationship.php +++ b/src/database/src/Eloquent/Factories/BelongsToManyRelationship.php @@ -57,15 +57,15 @@ public function createFor(Model $model): void /** * Specify the model instances to always use when creating relationships. - * - * @return $this */ public function recycle(Collection $recycle): static { + $relationship = clone $this; + if ($this->factory instanceof Factory) { - $this->factory = $this->factory->recycle($recycle); + $relationship->factory = $this->factory->recycle($recycle); } - return $this; + return $relationship; } } diff --git a/src/database/src/Eloquent/Factories/BelongsToRelationship.php b/src/database/src/Eloquent/Factories/BelongsToRelationship.php index b6cde04da7..7592384152 100644 --- a/src/database/src/Eloquent/Factories/BelongsToRelationship.php +++ b/src/database/src/Eloquent/Factories/BelongsToRelationship.php @@ -70,15 +70,16 @@ protected function resolver(?string $key): Closure /** * Specify the model instances to always use when creating relationships. - * - * @return $this */ public function recycle(Collection $recycle): static { + $relationship = clone $this; + if ($this->factory instanceof Factory) { - $this->factory = $this->factory->recycle($recycle); + $relationship->factory = $this->factory->recycle($recycle); + $relationship->resolved = null; } - return $this; + return $relationship; } } diff --git a/src/database/src/Eloquent/Factories/Factory.php b/src/database/src/Eloquent/Factories/Factory.php index 1e069364a0..3874b019fd 100644 --- a/src/database/src/Eloquent/Factories/Factory.php +++ b/src/database/src/Eloquent/Factories/Factory.php @@ -359,7 +359,7 @@ protected function createChildren(Model $model): void { Model::unguarded(function () use ($model) { $this->has->each(function ($has) use ($model) { - $has->recycle($this->recycle)->createFor($model); + $has->createFor($model); }); }); } @@ -536,7 +536,7 @@ protected function getRawAttributes(?Model $parent): array protected function parentResolvers(): array { return $this->for - ->map(fn (BelongsToRelationship $for) => $for->recycle($this->recycle)->attributesFor($this->newModel())) + ->map(fn (BelongsToRelationship $for) => $for->attributesFor($this->newModel())) ->collapse() ->all(); } @@ -598,7 +598,7 @@ public function state(callable|array $state): static public function prependState(callable|array $state): static { return $this->newInstance([ - 'states' => $this->states->prepend( + 'states' => (new Collection($this->states))->prepend( is_callable($state) ? $state : fn () => $state, ), ]); @@ -641,11 +641,17 @@ public function crossJoinSequence(array ...$sequence): static */ public function has(self $factory, ?string $relationship = null): static { + $definition = new Relationship( + $factory, + $relationship ?? $this->guessRelationship($factory->modelName()) + ); + + if ($this->recycle->isNotEmpty()) { + $definition = $definition->recycle($this->recycle); + } + return $this->newInstance([ - 'has' => $this->has->concat([new Relationship( - $factory, - $relationship ?? $this->guessRelationship($factory->modelName()) - )]), + 'has' => $this->has->concat([$definition]), ]); } @@ -674,17 +680,21 @@ public function hasAttached(self|Collection|Model|array $factory, callable|array $pivot = new Sequence(...$pivot); } - return $this->newInstance([ - 'has' => $this->has->concat([new BelongsToManyRelationship( - $factory, - $pivot, - $relationship ?? StrCache::camel(StrCache::plural(class_basename( - $factory instanceof Factory - ? $factory->modelName() - : Collection::wrap($factory)->first() - ))) - )]), - ]); + $definition = new BelongsToManyRelationship( + $factory, + $pivot, + $relationship ?? StrCache::camel(StrCache::plural(class_basename( + $factory instanceof Factory + ? $factory->modelName() + : Collection::wrap($factory)->first() + ))) + ); + + if ($this->recycle->isNotEmpty()) { + $definition = $definition->recycle($this->recycle); + } + + return $this->newInstance(['has' => $this->has->concat([$definition])]); } /** @@ -692,12 +702,18 @@ public function hasAttached(self|Collection|Model|array $factory, callable|array */ public function for(self|Model $factory, ?string $relationship = null): static { - return $this->newInstance(['for' => $this->for->concat([new BelongsToRelationship( + $definition = new BelongsToRelationship( $factory, $relationship ?? StrCache::camel(class_basename( $factory instanceof Factory ? $factory->modelName() : $factory )) - )])]); + ); + + if ($this->recycle->isNotEmpty()) { + $definition = $definition->recycle($this->recycle); + } + + return $this->newInstance(['for' => $this->for->concat([$definition])]); } /** @@ -705,14 +721,21 @@ public function for(self|Model $factory, ?string $relationship = null): static */ public function recycle(Model|Collection|array $model): static { + $models = Collection::wrap($model instanceof Model ? func_get_args() : $model)->flatten(); + + if ($models->isEmpty()) { + return $this->newInstance(); + } + // Group provided models by the type and merge them into existing recycle collection + $recycle = $this->recycle->flatten()->merge($models)->groupBy(fn ($model) => get_class($model)); + + // Recycle relationship copies when configured, not per model: counted groups + // share one parent without leaking it or repeatedly merging the same candidates. return $this->newInstance([ - 'recycle' => $this->recycle - ->flatten() - ->merge( - Collection::wrap($model instanceof Model ? func_get_args() : $model) - ->flatten() - )->groupBy(fn ($model) => get_class($model)), + 'recycle' => $recycle, + 'for' => $this->for->map(fn (BelongsToRelationship $relationship): BelongsToRelationship => $relationship->recycle($models)), + 'has' => $this->has->map(fn (BelongsToManyRelationship|Relationship $relationship): BelongsToManyRelationship|Relationship => $relationship->recycle($models)), ]); } diff --git a/src/database/src/Eloquent/Factories/Relationship.php b/src/database/src/Eloquent/Factories/Relationship.php index c336db2cd1..54eb125e81 100644 --- a/src/database/src/Eloquent/Factories/Relationship.php +++ b/src/database/src/Eloquent/Factories/Relationship.php @@ -56,13 +56,13 @@ public function createFor(Model $parent): void /** * Specify the model instances to always use when creating relationships. - * - * @return $this */ public function recycle(Collection $recycle): static { - $this->factory = $this->factory->recycle($recycle); + $relationship = clone $this; - return $this; + $relationship->factory = $this->factory->recycle($recycle); + + return $relationship; } } diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index 5bc99afb19..b3438e19fc 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -733,6 +733,20 @@ public function updateOrCreate(array $attributes, Closure|array $values = [], ar }); } + /** + * Create a related record matching the attributes, or increment the existing record. + * + * @return TRelatedModel + */ + public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model + { + return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function (Model $instance) use ($column, $step, $extra): void { + if (! $instance->wasRecentlyCreated) { + $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call) + } + }); + } + /** * Find a related model by its primary key. * @@ -1146,6 +1160,27 @@ public function each(callable $callback, int $count = 1000): bool }); } + /** + * Run a map over each item while chunking. + * + * @template TReturn + * + * @param callable(object{pivot: TPivotModel}&TRelatedModel): TReturn $callback + * @return BaseCollection + */ + public function chunkMap(callable $callback, int $count = 1000): BaseCollection + { + $collection = new BaseCollection; + + $this->chunk($count, function (BaseCollection $items) use ($collection, $callback): void { + $items->each(function (mixed $item) use ($collection, $callback): void { + $collection->push($callback($item)); + }); + }); + + return $collection; + } + /** * Query lazily, by chunks of the given size. * diff --git a/src/database/src/Eloquent/Relations/HasOneOrMany.php b/src/database/src/Eloquent/Relations/HasOneOrMany.php index 9da47cdef1..e8b1de81e9 100755 --- a/src/database/src/Eloquent/Relations/HasOneOrMany.php +++ b/src/database/src/Eloquent/Relations/HasOneOrMany.php @@ -286,6 +286,20 @@ public function updateOrCreate(array $attributes, Closure|array $values = []): M }); } + /** + * Create a related record matching the attributes, or increment the existing record. + * + * @return TRelatedModel + */ + public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model + { + return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function (Model $instance) use ($column, $step, $extra): void { + if (! $instance->wasRecentlyCreated) { + $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call) + } + }); + } + /** * Insert new records or update the existing ones. */ diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index 223f9be7ad..0f51aca971 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -211,6 +211,20 @@ protected function buildDictionary(EloquentCollection $results): array return $dictionary; } + /** + * Find a related model by its primary key or return a new instance of the related model. + * + * @return ($id is Model ? TRelatedModel : ($id is (array|Arrayable>) ? EloquentCollection : TRelatedModel)) + */ + public function findOrNew(mixed $id, array $columns = ['*']): EloquentCollection|Model + { + if (is_null($instance = $this->find($id, $columns))) { + $instance = $this->related->newInstance(); + } + + return $instance; + } + /** * Get the first related model record matching the attributes or instantiate it. * @@ -219,7 +233,7 @@ protected function buildDictionary(EloquentCollection $results): array */ public function firstOrNew(array $attributes = [], Closure|array $values = []): Model { - if (! is_null($instance = $this->where($attributes)->first())) { + if (! is_null($instance = $this->where($this->qualifyRelatedAttributes($attributes))->first())) { return $instance; } @@ -236,7 +250,7 @@ public function firstOrCreate(array $attributes = [], Closure|array $values = [] { $this->getQuery()->ensureCanCreateOrFirst(); - if (! is_null($instance = (clone $this)->where($attributes)->first())) { + if (! is_null($instance = (clone $this)->where($this->qualifyRelatedAttributes($attributes))->first())) { return $instance; } @@ -258,7 +272,7 @@ public function createOrFirst(array $attributes = [], Closure|array $values = [] try { return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values)))); } catch (UniqueConstraintViolationException $exception) { - return $this->useWritePdo()->where($attributes)->first() ?? throw $exception; + return $this->useWritePdo()->where($this->qualifyRelatedAttributes($attributes))->first() ?? throw $exception; } } @@ -277,6 +291,30 @@ public function updateOrCreate(array $attributes, Closure|array $values = []): M }); } + /** + * Qualify the given attribute keys with the related model's table. + */ + protected function qualifyRelatedAttributes(array $attributes): array + { + return (new BaseCollection($attributes)) + ->mapWithKeys(fn (mixed $value, int|string $key): array => [$this->related->qualifyColumn((string) $key) => $value]) + ->all(); + } + + /** + * Create a related record matching the attributes, or increment the existing record. + * + * @return TRelatedModel + */ + public function incrementOrCreate(array $attributes, string $column = 'count', float|int $default = 1, float|int $step = 1, array $extra = []): Model + { + return tap($this->firstOrCreate($attributes, array_merge($extra, [$column => $default])), function (Model $instance) use ($column, $step, $extra): void { + if (! $instance->wasRecentlyCreated) { + $instance->increment($column, $step, $extra); // @phpstan-ignore method.protected (handled by __call) + } + }); + } + /** * Add a basic where clause to the query, and return the first result. * @@ -611,6 +649,27 @@ public function each(callable $callback, int $count = 1000): bool }); } + /** + * Run a map over each item while chunking. + * + * @template TReturn + * + * @param callable(TRelatedModel): TReturn $callback + * @return BaseCollection + */ + public function chunkMap(callable $callback, int $count = 1000): BaseCollection + { + $collection = new BaseCollection; + + $this->chunk($count, function (BaseCollection $items) use ($collection, $callback): void { + $items->each(function (mixed $item) use ($collection, $callback): void { + $collection->push($callback($item)); + }); + }); + + return $collection; + } + /** * Query lazily, by chunks of the given size. * diff --git a/src/database/src/Eloquent/Relations/MorphTo.php b/src/database/src/Eloquent/Relations/MorphTo.php index 8b5d48c98a..bbcd48827f 100644 --- a/src/database/src/Eloquent/Relations/MorphTo.php +++ b/src/database/src/Eloquent/Relations/MorphTo.php @@ -428,7 +428,7 @@ public function onlyTrashed(): static * @param Builder $query * @return Builder */ - protected function replayMacros(Builder $query): Builder + public function replayMacros(Builder $query): Builder { foreach ($this->macroBuffer as $macro) { $query->{$macro['method']}(...$macro['parameters']); diff --git a/src/database/src/Query/Grammars/MySqlGrammar.php b/src/database/src/Query/Grammars/MySqlGrammar.php index 5a7ac94112..b67c44ade6 100755 --- a/src/database/src/Query/Grammars/MySqlGrammar.php +++ b/src/database/src/Query/Grammars/MySqlGrammar.php @@ -265,7 +265,11 @@ protected function compileJsonOverlaps(string $column, string $value): string { [$field, $path] = $this->wrapJsonFieldAndPath($column); - return 'json_overlaps(' . $field . ', ' . $value . $path . ')'; + if ($path !== '') { + $field = 'json_extract(' . $field . $path . ')'; + } + + return 'json_overlaps(' . $field . ', ' . $value . ')'; } /** diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index a2f9d5b8a8..c567c17bee 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -2543,6 +2543,18 @@ $user->posts()->createManyQuietly([ You may also use the `findOrNew`, `firstOrNew`, `firstOrCreate`, and `updateOrCreate` methods to [create and update models on relationships](/docs/{{version}}/eloquent#upserts). +The `incrementOrCreate` method creates a related record with the given default value, or increments the column if a matching record already exists. Any extra attributes are applied when creating or incrementing the record: + +```php +$post->views()->incrementOrCreate( + ['date' => today()->toDateString()], + column: 'count', + default: 1, + step: 1, + extra: ['last_viewed_at' => now()], +); +``` + > [!NOTE] > Before using the `create` method, be sure to review the [mass assignment](/docs/{{version}}/eloquent#mass-assignment) documentation. diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md index f4bf2e193e..66ab9f626a 100644 --- a/src/docs/filesystem.md +++ b/src/docs/filesystem.md @@ -451,6 +451,14 @@ If your application interacts with multiple disks, you may use the `disk` method Storage::disk('s3')->put('avatars/1', $content); ``` +You may also use enums for disk names. Backed enums use their value, while unit enums use their case name: + +```php +Storage::disk(Disk::S3)->put('avatars/1', $content); +``` + +The `set`, `forgetDisk`, and `purge` methods also accept enums. The `forgetDisk` method accepts an array of disk names or enums as well. + ### On-Demand Disks diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php index ee245b0dbb..4cf2d2374a 100644 --- a/src/filesystem/src/FilesystemManager.php +++ b/src/filesystem/src/FilesystemManager.php @@ -971,9 +971,9 @@ protected function createFlysystem(FlysystemAdapter $adapter, array $config): Fi * resolution will return the replacement. Any shared pool remains * available until its idle TTL expires or purge() invalidates it. */ - public function set(string $name, mixed $disk): static + public function set(UnitEnum|string $name, mixed $disk): static { - $this->disks[$name] = $disk; + $this->disks[enum_value($name)] = $disk; return $this; } @@ -1015,11 +1015,13 @@ public function getDefaultDriver(): string * coroutines may already hold a reference to the disk and next resolution * will rebuild a wrapper. Shared pools remain available until their idle * TTL expires or purge() deliberately invalidates them. + * + * @param array|string|UnitEnum $disk */ - public function forgetDisk(array|string $disk): static + public function forgetDisk(UnitEnum|array|string $disk): static { - foreach ((array) $disk as $diskName) { - unset($this->disks[$diskName]); + foreach (Arr::wrap($disk) as $diskName) { + unset($this->disks[enum_value($diskName)]); } return $this; @@ -1032,9 +1034,9 @@ public function forgetDisk(array|string $disk): static * Closing deliberately invalidates a shared pool; other converged disks * acquire a fresh pool on their next operation. */ - public function purge(?string $name = null): void + public function purge(UnitEnum|string|null $name = null): void { - $name ??= $this->getDefaultDriver(); + $name = (string) (enum_value($name) ?? $this->getDefaultDriver()); $disk = $this->disks[$name] ?? null; unset($this->disks[$name]); diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index e577397b0e..a7e317258a 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -17,6 +17,7 @@ use Hypervel\Contracts\Foundation\MaintenanceMode as MaintenanceModeContract; use Hypervel\Contracts\Http\Kernel as HttpKernelContract; use Hypervel\Contracts\Translation\Translator as TranslatorContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Events\EventServiceProvider; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Configuration\ApplicationBuilder; @@ -33,7 +34,6 @@ use JsonException; use ReflectionClass; use RuntimeException; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\ConsoleOutput; @@ -1234,8 +1234,7 @@ public function terminate(): void try { $this->call($callback); } catch (Throwable $throwable) { - // Cancellation is injected asynchronously, so a dedicated catch analyzes as unreachable here. - if ($throwable instanceof CanceledException) { + if (Coroutine::causedByCancellation($throwable)) { throw $throwable; } diff --git a/src/foundation/src/Bootstrap/HandleExceptions.php b/src/foundation/src/Bootstrap/HandleExceptions.php index 221515c544..6000490cbb 100644 --- a/src/foundation/src/Bootstrap/HandleExceptions.php +++ b/src/foundation/src/Bootstrap/HandleExceptions.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Config\Repository as ConfigRepository; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Coroutine\Coroutine; use Hypervel\Foundation\Configuration\ConfigMutationTracker; use Hypervel\Log\LogManager; use Hypervel\Support\Env; @@ -16,7 +17,6 @@ use PHPUnit\Framework\TestCase; use PHPUnit\Runner\ErrorHandler; use Psr\Log\LoggerInterface; -use Swoole\Coroutine\CanceledException; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\ErrorHandler\Error\FatalError; use Throwable; @@ -135,8 +135,7 @@ public function handleDeprecationError(string $message, string $file, int $line, try { $logger = static::$app->make(LogManager::class); } catch (Throwable $exception) { - // Cancellation is injected asynchronously, so a dedicated catch analyzes as unreachable here. - if ($exception instanceof CanceledException) { + if (Coroutine::causedByCancellation($exception)) { throw $exception; } @@ -165,9 +164,11 @@ public function handleDeprecationError(string $message, string $file, int $line, )); } }); - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return; } } diff --git a/src/foundation/src/Concerns/ResolvesDumpSource.php b/src/foundation/src/Concerns/ResolvesDumpSource.php index c181dfacd3..4338ad17d1 100644 --- a/src/foundation/src/Concerns/ResolvesDumpSource.php +++ b/src/foundation/src/Concerns/ResolvesDumpSource.php @@ -78,14 +78,15 @@ public function resolveDumpSource(): ?array return null; } - $relativeFile = $file; - if ($this->isCompiledViewFile($file)) { $file = $this->getOriginalFileForCompiledView($file); $line = null; } - if (str_starts_with($file, $this->basePath)) { + // External views keep their original path rather than the compiled filename. + $relativeFile = $file; + + if (str_starts_with($file, $this->basePath . DIRECTORY_SEPARATOR)) { $relativeFile = substr($file, strlen($this->basePath) + 1); } @@ -101,7 +102,11 @@ protected function isCompiledViewFile(string $file): bool return false; } - return str_starts_with($file, $this->compiledViewPath) && str_ends_with($file, '.php'); + // The compiler can use an extension other than .php; the footer identifies the source. + // Frames contain resolved paths, and the compiler may create a symlinked directory after boot. + $directory = realpath($this->compiledViewPath) ?: $this->compiledViewPath; + + return str_starts_with($file, rtrim($directory, '/\\') . DIRECTORY_SEPARATOR); } /** @@ -115,7 +120,8 @@ protected function getOriginalFileForCompiledView(string $file): string return $file; } - preg_match('/\/\*\*PATH\s(.*)\sENDPATH/', $contents, $matches); + // The compiler writes the footer last; a template can contain the same text. + preg_match('/.*\/\*\*PATH\s(.*)\sENDPATH/s', $contents, $matches); if (isset($matches[1])) { $file = $matches[1]; diff --git a/src/foundation/src/Concerns/ResolvesSourceHref.php b/src/foundation/src/Concerns/ResolvesSourceHref.php index 9a5549f20a..fa79715526 100644 --- a/src/foundation/src/Concerns/ResolvesSourceHref.php +++ b/src/foundation/src/Concerns/ResolvesSourceHref.php @@ -68,7 +68,12 @@ protected function resolveSourceHref(string $file, ?int $line): ?string $basePath = $editor['base_path'] ?? false; if ($basePath !== false) { - $file = Str::replaceStart($this->basePath, $basePath, $file); + // Remap application files without rewriting neighboring directories that share its name. + $file = Str::replaceStart( + $this->basePath . DIRECTORY_SEPARATOR, + rtrim($basePath, '/\\') . DIRECTORY_SEPARATOR, + $file, + ); } return str_replace( diff --git a/src/foundation/src/Console/EventListCommand.php b/src/foundation/src/Console/EventListCommand.php index b082c68024..1070cb43a3 100644 --- a/src/foundation/src/Console/EventListCommand.php +++ b/src/foundation/src/Console/EventListCommand.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Events\Dispatcher; use Hypervel\Support\Collection; +use Hypervel\Support\Str; use ReflectionFunction; use Symfony\Component\Console\Attribute\AsCommand; @@ -175,7 +176,12 @@ protected function stringifyClosure(Closure $rawListener): string { $reflection = new ReflectionFunction($rawListener); - $path = str_replace([base_path(), DIRECTORY_SEPARATOR], ['', '/'], $reflection->getFileName() ?: ''); + // Relative labels must not look absolute when console components normalize their output. + $path = str_replace( + DIRECTORY_SEPARATOR, + '/', + Str::replaceStart(base_path() . DIRECTORY_SEPARATOR, '', $reflection->getFileName() ?: '') + ); return 'Closure at: ' . $path . ':' . $reflection->getStartLine(); } diff --git a/src/foundation/src/Console/Kernel.php b/src/foundation/src/Console/Kernel.php index 3256541f87..54c7271700 100644 --- a/src/foundation/src/Console/Kernel.php +++ b/src/foundation/src/Console/Kernel.php @@ -17,6 +17,7 @@ use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Foundation\Application as ContainerContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Foundation\Bootstrap\BootProviders; use Hypervel\Foundation\Bus\PendingDispatch; use Hypervel\Foundation\Events\Terminating; @@ -207,31 +208,38 @@ public function terminate(InputInterface $input, int $status): void if ($this->events->hasListeners(Terminating::class)) { $this->events->dispatch(new Terminating); } - } catch (CanceledException $throwable) { - $cancellation = $throwable; } catch (Throwable $throwable) { - $exception = $throwable; + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + } else { + $exception = $throwable; + } } if ($cancellation === null) { try { $this->app->terminate(); - } catch (CanceledException $throwable) { - $cancellation = $throwable; } catch (Throwable $throwable) { - $exception ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + } else { + $exception ??= $throwable; + } } } if ($cancellation === null && $this->commandStartedAt !== null) { try { + // Resolving config runs application callbacks, whose cleanup can wrap cancellation. $this->commandStartedAt = $this->commandStartedAt->setTimezone( $this->app->make('config')->string('app.timezone') ); - } catch (CanceledException $throwable) { - $cancellation = $throwable; } catch (Throwable $throwable) { - $exception ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + } else { + $exception ??= $throwable; + } } if ($cancellation === null) { @@ -242,11 +250,13 @@ public function terminate(InputInterface $input, int $status): void if ($this->commandStartedAt->diffInMilliseconds($end) > $threshold) { $handler($this->commandStartedAt, $input, $status); } - } catch (CanceledException $throwable) { - $cancellation = $throwable; - - break; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + + break; + } + $exception ??= $throwable; } } diff --git a/src/foundation/src/Console/RouteListCommand.php b/src/foundation/src/Console/RouteListCommand.php index 5ca1991267..9cec3d0168 100644 --- a/src/foundation/src/Console/RouteListCommand.php +++ b/src/foundation/src/Console/RouteListCommand.php @@ -242,7 +242,7 @@ protected function getClosurePath(Route $route): ?string return str_replace( '\\', '/', - ltrim(Str::after($path, base_path()), DIRECTORY_SEPARATOR) + Str::replaceStart(base_path() . DIRECTORY_SEPARATOR, '', $path) ) . ':' . $reflection->getStartLine(); } @@ -268,7 +268,7 @@ protected function isVendorRoute(Route $route): bool return false; } - return is_string($path) && str_starts_with($path, base_path('vendor')); + return is_string($path) && str_starts_with($path, base_path('vendor') . DIRECTORY_SEPARATOR); } /** @@ -457,7 +457,7 @@ protected function formatActionForCli(array $route): ?string ? (new ReflectionClass($actionClass))->getFilename() : false; - if (is_string($path) && str_starts_with($path, base_path('vendor'))) { + if (is_string($path) && str_starts_with($path, base_path('vendor') . DIRECTORY_SEPARATOR)) { $actionCollection = (new Stringable($action))->explode('\\'); return $name . $actionCollection->take(2)->implode('\\') . ' ' . $actionCollection->last(); diff --git a/src/foundation/src/Console/VendorPublishCommand.php b/src/foundation/src/Console/VendorPublishCommand.php index 7ff84feedd..81ee3228a4 100644 --- a/src/foundation/src/Console/VendorPublishCommand.php +++ b/src/foundation/src/Console/VendorPublishCommand.php @@ -254,14 +254,14 @@ protected function publishFile(string $from, string $to): void if ($this->option('existing')) { $this->components->twoColumnDetail(sprintf( 'File [%s] does not exist', - str_replace(base_path() . '/', '', $to), + Str::replaceStart(base_path() . '/', '', $to), ), 'SKIPPED'); } else { $resolvedTo = realpath($to); $this->components->twoColumnDetail(sprintf( 'File [%s] already exists', - str_replace(base_path() . '/', '', is_string($resolvedTo) ? $resolvedTo : $to), + Str::replaceStart(base_path() . '/', '', is_string($resolvedTo) ? $resolvedTo : $to), ), 'SKIPPED'); } } @@ -444,8 +444,8 @@ protected function status(string $from, string $to, string $type): void $resolvedFrom = realpath($from); $resolvedTo = realpath($to); - $from = str_replace(base_path() . '/', '', is_string($resolvedFrom) ? $resolvedFrom : $from); - $to = str_replace(base_path() . '/', '', is_string($resolvedTo) ? $resolvedTo : $to); + $from = Str::replaceStart(base_path() . '/', '', is_string($resolvedFrom) ? $resolvedFrom : $from); + $to = Str::replaceStart(base_path() . '/', '', is_string($resolvedTo) ? $resolvedTo : $to); $this->components->task(sprintf( 'Copying %s [%s] to [%s]', diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index e127ab7fff..1289039c02 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -477,12 +477,31 @@ public function report(Throwable $e, array $context = [], ?string $level = null) return; } + // Cancellation unwinds through multiple reporting boundaries. Suppress duplicates + // using the original exception because mappers may create new wrappers each time. + $original = $e; + $canceled = Coroutine::causedByCancellation($e); + + if (($this->withoutDuplicates || $canceled) && $this->reportedExceptionMap()->offsetExists($original)) { + return; + } + $e = $this->mapException($e); if ($this->shouldntReport($e)) { + // Rejected cleanup failures must not redraw sampling or consume limits at each boundary. + if ($canceled) { + $this->reportedExceptionMap()[$original] = false; + } + return; } + // reportThrowable() records the mapped exception; retain a distinct original for later boundaries. + if ($e !== $original) { + $this->reportedException($original); + } + $this->reportThrowable($e, $context, $level); } @@ -496,9 +515,7 @@ public function report(Throwable $e, array $context = [], ?string $level = null) */ protected function reportThrowable(Throwable $e, array $context = [], ?string $level = null): void { - if ($this->withoutDuplicates) { - $this->reportedException($e); - } + $this->reportedException($e); if (Reflector::isCallable($reportCallable = [$e, 'report']) && $this->container->call($reportCallable) !== false @@ -518,9 +535,11 @@ protected function reportThrowable(Throwable $e, array $context = [], ?string $l try { $logger = $this->newLogger(); - } catch (CanceledException $exception) { - throw $exception; - } catch (Exception) { + } catch (Exception $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + throw $e; } @@ -585,11 +604,15 @@ protected function reportedException(Throwable $e): void */ protected function hasReportedException(Throwable $e): bool { - return $this->reportedExceptionMap()->offsetExists($e); + return ($this->reportedExceptionMap()[$e] ?? false) === true; } /** - * Get the reported exception map for the current request. + * Get the reporting decisions for the current request. + * + * True records an accepted exception; false records a rejected cancellation chain. + * + * @return WeakMap */ protected function reportedExceptionMap(): WeakMap { @@ -608,7 +631,8 @@ protected function reportedExceptionMap(): WeakMap */ public function shouldReport(Throwable $e): bool { - return ! $this->shouldntReport($e); + // Later checks must not sample or consume a rate limit again after a reporting decision. + return $this->reportedExceptionMap()[$e] ?? ! $this->shouldntReport($e); } /** @@ -780,9 +804,11 @@ protected function context(): array return array_filter([ 'userId' => Auth::id(), ]); - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable) { + } catch (Throwable $exception) { + if (Coroutine::causedByCancellation($exception)) { + throw $exception; + } + return []; } } @@ -809,7 +835,7 @@ public function buildContextUsing(Closure $contextCallback): static */ public function render(Request $request, Throwable $e): SymfonyResponse { - if ($e instanceof CanceledException) { + if (Coroutine::causedByCancellation($e)) { throw $e; } @@ -1115,9 +1141,11 @@ protected function renderExceptionContent(Throwable $e): string } return $this->renderExceptionWithSymfony($e, config()->boolean('app.debug')); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + return $this->renderExceptionWithSymfony($e, config()->boolean('app.debug')); } } @@ -1160,9 +1188,11 @@ protected function renderHttpException(HttpExceptionInterface $e): SymfonyRespon $e->getStatusCode(), $e->getHeaders() ); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $t) { + if (Coroutine::causedByCancellation($t)) { + throw $t; + } + config()->boolean('app.debug') && throw $t; $this->report($t); @@ -1256,7 +1286,7 @@ protected function convertExceptionToArray(Throwable $e): array */ public function renderForConsole(OutputInterface $output, Throwable $e): void { - if ($e instanceof CanceledException) { + if (Coroutine::causedByCancellation($e)) { throw $e; } diff --git a/src/foundation/src/Exceptions/Renderer/Frame.php b/src/foundation/src/Exceptions/Renderer/Frame.php index 378cf4e6dc..51257286c7 100644 --- a/src/foundation/src/Exceptions/Renderer/Frame.php +++ b/src/foundation/src/Exceptions/Renderer/Frame.php @@ -6,6 +6,7 @@ use Hypervel\Di\Bootstrap\GenerateProxies; use Hypervel\Foundation\Concerns\ResolvesSourceHref; +use Hypervel\Support\Str; use Symfony\Component\ErrorHandler\Exception\FlattenException; use function Hypervel\Filesystem\join_paths; @@ -76,7 +77,7 @@ public function file(): string return match (true) { ! isset($this->frame['file']) => '[internal function]', ! is_string($this->frame['file']) => '[unknown file]', - default => str_replace($this->basePath . DIRECTORY_SEPARATOR, '', $this->frame['file']), + default => Str::replaceStart($this->basePath . DIRECTORY_SEPARATOR, '', $this->frame['file']), }; } @@ -160,8 +161,9 @@ public function isFromVendor(): bool // A generated AOP proxy runs its source file's code, so the source decides where the frame comes from. $file = GenerateProxies::proxySources()[$this->frame['file']] ?? $this->frame['file']; - return ! str_starts_with($file, $this->basePath) - || str_starts_with($file, join_paths($this->basePath, 'vendor')); + // Match complete directories so sibling names do not change a frame's classification. + return ! str_starts_with($file, $this->basePath . DIRECTORY_SEPARATOR) + || str_starts_with($file, join_paths($this->basePath, 'vendor') . DIRECTORY_SEPARATOR); } /** diff --git a/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php b/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php index b76ac90e6e..17cdd0134b 100644 --- a/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php +++ b/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php @@ -4,14 +4,12 @@ namespace Hypervel\Foundation\Exceptions\Renderer\Mappers; -use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Coroutine\Coroutine; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\View\Compilers\BladeCompiler; -use Hypervel\View\Engines\CompilerEngine; use Hypervel\View\ViewException; -use Swoole\Coroutine\CanceledException; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Throwable; @@ -86,33 +84,24 @@ public function map(FlattenException $exception): FlattenException */ public function findCompiledView(string $compiledPath): ?string { - return once(fn () => $this->getKnownPaths())[$compiledPath] ?? null; - } + // Frames contain resolved paths, even when the configured cache directory is a symlink. + $directory = config()->string('view.compiled'); + $directory = realpath($directory) ?: $directory; - /** - * Get the list of known paths from the compiler engine. - * - * In Hypervel, compiled paths are stored in coroutine Context (not an instance - * property) because the CompilerEngine is a process-global singleton in Swoole. - * - * @return array - */ - protected function getKnownPaths(): array - { - $lastCompiled = CoroutineContext::get(CompilerEngine::COMPILED_PATH_CONTEXT_KEY, []); + if (! str_starts_with($compiledPath, rtrim($directory, '/\\') . DIRECTORY_SEPARATOR)) { + return null; + } - $knownPaths = []; - foreach ($lastCompiled as $lastCompiledPath) { - $compiledPath = $this->bladeCompiler->getCompiledPath($lastCompiledPath); - $compiledPath = realpath($compiledPath); - $originalPath = realpath($lastCompiledPath); + // Rendering has unwound by now and the active echo format may differ, + // so read the source path recorded in the compiled file's footer. + $contents = @file_get_contents($compiledPath); - if (is_string($compiledPath) && is_string($originalPath)) { - $knownPaths[$compiledPath] = $originalPath; - } + // The compiler writes the footer last; a template can contain the same text. + if ($contents === false || ! preg_match('/.*\/\*\*PATH\s(.*)\sENDPATH/s', $contents, $matches)) { + return null; } - return $knownPaths; + return is_file($matches[1]) ? $matches[1] : null; } /** @@ -161,9 +150,11 @@ protected function compileSourcemap(string $value): string $value = $this->bladeCompiler->compileString($value); return $this->trimEmptyLines($value); - } catch (CanceledException $exception) { - throw $exception; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + report($e); return $value; diff --git a/src/foundation/src/Http/FormRequest.php b/src/foundation/src/Http/FormRequest.php index a471440cba..5fdec02d3e 100644 --- a/src/foundation/src/Http/FormRequest.php +++ b/src/foundation/src/Http/FormRequest.php @@ -19,6 +19,7 @@ use Hypervel\Foundation\Http\Concerns\HasCasts; use Hypervel\Http\Request; use Hypervel\Routing\Redirector; +use Hypervel\Support\Arr; use Hypervel\Support\ValidatedInput; use Hypervel\Validation\UnknownFields; use Hypervel\Validation\ValidatesWhenResolvedTrait; @@ -267,6 +268,8 @@ protected function validateNoUnknownFields(Validator $validator): void { $input = $this->isJson() ? $this->json()->all() : $this->request->all(); + $input = Arr::except($input, ['_token', '_method']); + UnknownFields::validate($validator, $input, $this->unfilteredValidationRules); } diff --git a/src/foundation/src/Http/Kernel.php b/src/foundation/src/Http/Kernel.php index 47bacaa473..99c6c22991 100644 --- a/src/foundation/src/Http/Kernel.php +++ b/src/foundation/src/Http/Kernel.php @@ -12,6 +12,7 @@ use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Foundation\Application; use Hypervel\Contracts\Http\Kernel as KernelContract; +use Hypervel\Coroutine\Coroutine; use Hypervel\Foundation\Events\Terminating; use Hypervel\Foundation\Http\Events\RequestHandled; use Hypervel\Http\Request; @@ -260,29 +261,35 @@ public function terminate(Request $request, Response $response): void if ($events->hasListeners(Terminating::class)) { $events->dispatch(new Terminating); } - } catch (CanceledException $throwable) { - $cancellation = $throwable; } catch (Throwable $throwable) { - $exception = $throwable; + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + } else { + $exception = $throwable; + } } if ($cancellation === null) { try { $this->terminateMiddleware($request, $response); - } catch (CanceledException $throwable) { - $cancellation = $throwable; } catch (Throwable $throwable) { - $exception ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + } else { + $exception ??= $throwable; + } } } if ($cancellation === null) { try { $this->app->terminate(); - } catch (CanceledException $throwable) { - $cancellation = $throwable; } catch (Throwable $throwable) { - $exception ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + } else { + $exception ??= $throwable; + } } } @@ -304,27 +311,25 @@ public function terminate(Request $request, Response $response): void if ($requestStartedAt->diffInMilliseconds($end) > $threshold) { $handler($requestStartedAt, $request, $response); } - } catch (CanceledException $throwable) { - throw $throwable; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } } } - } catch (CanceledException $throwable) { - $cancellation = $throwable; } catch (Throwable $throwable) { - $exception ??= $throwable; + if (Coroutine::causedByCancellation($throwable)) { + $cancellation = $throwable; + } else { + $exception ??= $throwable; + } } } - try { - CoroutineContext::forget(self::REQUEST_STARTED_AT_CONTEXT_KEY); - } catch (CanceledException $throwable) { - $cancellation ??= $throwable; - } catch (Throwable $throwable) { - $exception ??= $throwable; - } + CoroutineContext::forget(self::REQUEST_STARTED_AT_CONTEXT_KEY); if ($cancellation !== null) { throw $cancellation; @@ -378,9 +383,11 @@ protected function terminateMiddleware(Request $request, Response $response): vo if ($terminable) { $instance->terminate($request, $response); } - } catch (CanceledException $throwable) { - throw $throwable; } catch (Throwable $throwable) { + if (Coroutine::causedByCancellation($throwable)) { + throw $throwable; + } + $exception ??= $throwable; } } diff --git a/src/log/composer.json b/src/log/composer.json index 61f616ec47..f35a19e8e4 100644 --- a/src/log/composer.json +++ b/src/log/composer.json @@ -31,6 +31,7 @@ "hypervel/container": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", + "hypervel/coroutine": "^0.4", "hypervel/macroable": "^0.4", "hypervel/queue": "^0.4", "hypervel/support": "^0.4", diff --git a/src/log/src/LogManager.php b/src/log/src/LogManager.php index 9e1f430958..ce50c702d0 100644 --- a/src/log/src/LogManager.php +++ b/src/log/src/LogManager.php @@ -10,6 +10,7 @@ use Hypervel\Contracts\Log\ContextLogProcessor; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Jsonable; +use Hypervel\Coroutine\Coroutine; use Hypervel\Log\Context\ResolvedContextLogProcessor; use Hypervel\Log\Handlers\FingersCrossedHandler; use Hypervel\Log\Handlers\RotatingFileHandler; @@ -37,7 +38,6 @@ use ReflectionException; use RuntimeException; use Stringable; -use Swoole\Coroutine\CanceledException; use Throwable; use UnitEnum; @@ -169,9 +169,11 @@ protected function createLogger(?string $name, ?array $config = null, bool $cach } return $logger; - } catch (CanceledException $e) { - throw $e; } catch (Throwable $e) { + if (Coroutine::causedByCancellation($e)) { + throw $e; + } + return tap($this->createEmergencyLogger(), function ($logger) use ($e) { $logger->emergency('Unable to create configured logger. Using emergency logger.', [ 'exception' => $e, diff --git a/src/sentry/src/Tracing/BacktraceHelper.php b/src/sentry/src/Tracing/BacktraceHelper.php index 98d1fa2be0..5ab81d817a 100644 --- a/src/sentry/src/Tracing/BacktraceHelper.php +++ b/src/sentry/src/Tracing/BacktraceHelper.php @@ -71,14 +71,17 @@ public function findFirstInAppFrameForBacktrace(array $backtrace): ?Frame */ public function getOriginalViewPathForFrameOfCompiledViewPath(Frame $frame): ?string { - // Check if we are dealing with a frame for a cached view path - if (! Str::startsWith($frame->getFile(), '/storage/framework/views/')) { + $absoluteFilePath = $frame->getAbsoluteFilePath(); + + if ($absoluteFilePath === null) { return null; } - $absoluteFilePath = $frame->getAbsoluteFilePath(); + // Frames contain resolved paths, even when the configured cache directory is a symlink. + $compiledDirectory = config()->string('view.compiled'); + $compiledDirectory = realpath($compiledDirectory) ?: $compiledDirectory; - if ($absoluteFilePath === null) { + if (! str_starts_with($absoluteFilePath, rtrim($compiledDirectory, '/\\') . DIRECTORY_SEPARATOR)) { return null; } @@ -89,7 +92,8 @@ public function getOriginalViewPathForFrameOfCompiledViewPath(Frame $frame): ?st return null; } - preg_match('/PATH (?.*?) ENDPATH/', $viewFileContents, $matches); + // The compiler writes the footer last; a template can contain the same text. + preg_match('/.*\/\*\*PATH (?.*) ENDPATH/s', $viewFileContents, $matches); // No path comment found in the file if (empty($matches['originalPath'])) { diff --git a/src/support/src/Facades/Storage.php b/src/support/src/Facades/Storage.php index 67a919916e..35aea74253 100644 --- a/src/support/src/Facades/Storage.php +++ b/src/support/src/Facades/Storage.php @@ -25,13 +25,13 @@ * @method static \Hypervel\Contracts\Filesystem\Filesystem drive(UnitEnum|string|null $name = null) * @method static \Hypervel\Filesystem\FilesystemManager extend(string $driver, \Closure $callback, bool $poolable = false) * @method static void flushState() - * @method static \Hypervel\Filesystem\FilesystemManager forgetDisk(array|string $disk) + * @method static \Hypervel\Filesystem\FilesystemManager forgetDisk(array|string|UnitEnum $disk) * @method static string getDefaultDriver() * @method static array getPoolableDrivers() * @method static \Closure|null getReleaseCallback(string $driver) - * @method static void purge(string|null $name = null) + * @method static void purge(UnitEnum|string|null $name = null) * @method static \Hypervel\Filesystem\FilesystemManager removePoolableDriver(string $driver) - * @method static \Hypervel\Filesystem\FilesystemManager set(string $name, mixed $disk) + * @method static \Hypervel\Filesystem\FilesystemManager set(UnitEnum|string $name, mixed $disk) * @method static \Hypervel\Filesystem\FilesystemManager setApplication(\Hypervel\Contracts\Container\Container $app) * @method static \Hypervel\Filesystem\FilesystemManager setPoolableDrivers(array $poolableDrivers) * @method static \Hypervel\Filesystem\FilesystemManager setReleaseCallback(string $driver, \Closure $callback) diff --git a/src/telescope/src/Watchers/ViewWatcher.php b/src/telescope/src/Watchers/ViewWatcher.php index 37e886fec0..e83f028173 100644 --- a/src/telescope/src/Watchers/ViewWatcher.php +++ b/src/telescope/src/Watchers/ViewWatcher.php @@ -60,7 +60,7 @@ protected function extractPath(ViewContract $view): string { $path = $view->getPath(); - if (Str::startsWith($path, base_path())) { + if (Str::startsWith($path, base_path() . DIRECTORY_SEPARATOR)) { $path = substr($path, strlen(base_path())); } diff --git a/src/view/src/Compilers/BladeCompiler.php b/src/view/src/Compilers/BladeCompiler.php index 9f8282fc74..dbcaa74394 100644 --- a/src/view/src/Compilers/BladeCompiler.php +++ b/src/view/src/Compilers/BladeCompiler.php @@ -45,6 +45,8 @@ class BladeCompiler extends Compiler implements CompilerInterface use Concerns\CompilesUseStatements; use ReflectsClosures; + public const string DEFAULT_ECHO_FORMAT = 'e(%s)'; + /** * Temporarily store the raw blocks found in the template. */ @@ -122,7 +124,7 @@ class BladeCompiler extends Compiler implements CompilerInterface /** * The "regular" / legacy echo string format. */ - protected string $echoFormat = 'e(%s)'; + protected string $echoFormat = self::DEFAULT_ECHO_FORMAT; /** * The array of anonymous component paths to search for components in. @@ -1044,6 +1046,22 @@ public function precompiler(callable $precompiler): void $this->precompilers[] = $precompiler; } + /** + * Get the path to the compiled version of a view. + */ + public function getCompiledPath(string $path): string + { + $compiled = parent::getCompiledPath($path); + $echoFormat = $this->getEchoFormat(); + + // Non-default echo formats need their own compiled file so cached views are not reused... + if ($echoFormat === self::DEFAULT_ECHO_FORMAT) { + return $compiled; + } + + return Str::beforeLast($compiled, '.') . '_' . hash('xxh128', $echoFormat) . '.' . $this->compiledExtension; + } + /** * Execute the given callback using a custom echo format. */ diff --git a/src/view/src/Compilers/Compiler.php b/src/view/src/Compilers/Compiler.php index 3a8e596e76..f56590a02b 100755 --- a/src/view/src/Compilers/Compiler.php +++ b/src/view/src/Compilers/Compiler.php @@ -31,7 +31,13 @@ public function __construct( */ public function getCompiledPath(string $path): string { - return $this->cachePath . '/' . hash('xxh128', 'v3' . Str::after($path, $this->basePath)) . '.' . $this->compiledExtension; + // A null byte cannot occur in a real path, so relative in-app identities + // cannot collide with external views, including relative file paths. + $identity = $this->basePath === '' + ? $path + : Str::replaceStart(rtrim($this->basePath, '/\\') . DIRECTORY_SEPARATOR, "\0", $path); + + return $this->cachePath . '/' . hash('xxh128', 'v3' . $identity) . '.' . $this->compiledExtension; } /** diff --git a/src/view/src/Engines/CompilerEngine.php b/src/view/src/Engines/CompilerEngine.php index d1a8e2a055..311f415d8a 100755 --- a/src/view/src/Engines/CompilerEngine.php +++ b/src/view/src/Engines/CompilerEngine.php @@ -24,7 +24,7 @@ class CompilerEngine extends PhpEngine public const string COMPILED_PATH_CONTEXT_KEY = '__view.compiled_path'; /** - * The view paths that were compiled or are not expired, keyed by the path. + * The compiled view paths verified as not expired. * * @var array */ @@ -50,14 +50,17 @@ public function get(string $path, array $data = []): string $this->pushCompiledPath($path); try { + $compiled = $this->compiler->getCompiledPath($path); + // If this given view has expired, which means it has simply been edited since // it was last compiled, we will re-compile the views so we can evaluate a // fresh copy of the view. We'll pass the compiler the path of the view. - if (! isset(static::$compiledOrNotExpired[$path])) { + // One source can have separate compiled files for different echo formats. + if (! isset(static::$compiledOrNotExpired[$compiled])) { if ($this->compiler->isExpired($path)) { $this->compiler->compile($path); } else { - static::$compiledOrNotExpired[$path] = true; + static::$compiledOrNotExpired[$compiled] = true; } } @@ -65,19 +68,19 @@ public function get(string $path, array $data = []): string // typical PHP just like any other templates. We also keep a stack of views // which have been rendered for right exception messages to be generated. try { - return $this->evaluatePath($this->compiler->getCompiledPath($path), $data); + return $this->evaluatePath($compiled, $data); } catch (ViewException $e) { if ((new Stringable($e->getMessage()))->doesntContain(['No such file or directory', 'File does not exist at path'])) { throw $e; } - if (! isset(static::$compiledOrNotExpired[$path])) { + if (! isset(static::$compiledOrNotExpired[$compiled])) { throw $e; } $this->compiler->compile($path); - return $this->evaluatePath($this->compiler->getCompiledPath($path), $data); + return $this->evaluatePath($compiled, $data); } } finally { $this->popCompiledPath(); @@ -144,7 +147,7 @@ public function getCompiler(): CompilerInterface } /** - * Clear the cache of views that were compiled or not expired. + * Clear the cache of compiled view paths verified as not expired. * * Boot or tests only when called directly. Clears the worker-wide compile-check * cache shared by every coroutine; concurrent compilations may re-check disk diff --git a/tests/Console/View/ComponentsTest.php b/tests/Console/View/ComponentsTest.php index 5f1facffad..1e97e45cee 100644 --- a/tests/Console/View/ComponentsTest.php +++ b/tests/Console/View/ComponentsTest.php @@ -13,14 +13,17 @@ use Hypervel\Console\View\Components\Confirm; use Hypervel\Console\View\Components\Error; use Hypervel\Console\View\Components\Info; +use Hypervel\Console\View\Components\Mutators\EnsureRelativePaths; use Hypervel\Console\View\Components\Success; use Hypervel\Console\View\Components\Task; use Hypervel\Console\View\Components\TwoColumnDetail; use Hypervel\Console\View\Components\Warn; use Hypervel\Console\View\TaskResult; +use Hypervel\Foundation\Application; use Hypervel\Tests\Console\Concerns\CreatesAnsweredOutputStyles; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Question\Question; @@ -82,6 +85,33 @@ public function testInfo(): void $this->assertStringContainsString('INFO The application is in the [production] environment.', $output->fetch()); } + #[DataProvider('relativePathMessages')] + public function testRelativePathsPreserveOtherPathsAndUrls(string $message, string $expected): void + { + new Application('/app'); + + $this->assertSame($expected, (new EnsureRelativePaths)($message)); + } + + /** + * Return messages and their expected relative paths. + */ + public static function relativePathMessages(): array + { + return [ + 'bracketed absolute path' => ['File [/app/config/app.php]', 'File [config/app.php]'], + 'caller-written markup' => ['/app/x.php', 'x.php'], + 'nested app directory' => ['/app/app/Models/User.php', 'app/Models/User.php'], + 'already relative path' => ['modules/app/config.php', 'modules/app/config.php'], + 'Unicode parent directory' => ['/café/app/config.php', '/café/app/config.php'], + 'service URL' => ['http://app/login', 'http://app/login'], + 'URL path' => ['https://example.com/app/dashboard', 'https://example.com/app/dashboard'], + 'editor URL query' => ['phpstorm://open?file=/app/Models/User.php&line=1', 'phpstorm://open?file=/app/Models/User.php&line=1'], + 'file URI' => ['file:/app/x.php', 'file:/app/x.php'], + 'no application path' => ['Finished successfully', 'Finished successfully'], + ]; + } + public function testConfirm(): void { $result = (new Confirm($this->outputStyleWithAnswer('')))->render('Question?'); diff --git a/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php b/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php index 62c28d25a6..5f96d4a8f4 100755 --- a/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php @@ -415,6 +415,35 @@ public function testIncrementOrCreateMethodIncrementParametersArePassed(): void ], $result->toArray()); } + public function testIncrementOrCreateMethodExtraParametersArePassedWhenCreating(): void + { + $model = new TestModel; + $this->mockConnectionForModel($model, 'SQLite', [123]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getWritableName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true, []) + ->andReturn([]); + + $model->getConnection()->expects('insert')->with( + 'insert into "table" ("attr", "val", "count", "updated_at", "created_at") values (?, ?, ?, ?, ?)', + ['foo', 'baz', '1', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->newQuery()->incrementOrCreate(['attr' => 'foo'], step: 2, extra: ['val' => 'baz']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'baz', + 'count' => 1, + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + public function testIncrementOrCreateMethodRetrievesRecordCreatedJustNow(): void { $model = new TestModel; diff --git a/tests/Database/DatabaseEloquentFactoryTest.php b/tests/Database/DatabaseEloquentFactoryTest.php index 22c1107928..740dd7d200 100644 --- a/tests/Database/DatabaseEloquentFactoryTest.php +++ b/tests/Database/DatabaseEloquentFactoryTest.php @@ -23,6 +23,7 @@ use Hypervel\Database\Eloquent\Factories\HasFactory; use Hypervel\Database\Eloquent\Factories\Sequence; use Hypervel\Database\Eloquent\Model as Eloquent; +use Hypervel\Database\Eloquent\Relations\BelongsTo; use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Schema\Blueprint; @@ -32,6 +33,7 @@ use Hypervel\Tests\Database\Fixtures\Models\Money\Price; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; class DatabaseEloquentFactoryTest extends TestCase @@ -361,6 +363,19 @@ public function testWithoutAfterCreatingRemovesConfigureCallbacks(): void unset($_SERVER['__test.user.making']); } + public function testPrependingStatePreservesExplicitStateAndTheOriginalFactory(): void + { + $factory = UserFactory::new()->state(['name' => 'explicit']); + + $derived = $factory->prependState(['name' => 'prepended', 'options' => 'derived'])->make(); + $original = $factory->make(); + + $this->assertSame('explicit', $derived->name); + $this->assertSame('derived', $derived->options); + $this->assertSame('explicit', $original->name); + $this->assertNull($original->options); + } + public function testHasManyRelationship(): void { $users = UserFactory::times(10) @@ -998,6 +1013,70 @@ public function testRecycledModelsCanBeCombinedWithMultipleCalls(): void $this->assertSame(3, Post::count()); } + public function testRecycledModelsDoNotLeakIntoLaterCallsOnTheSameFactory(): void + { + Factory::guessFactoryNamesUsing(function (string $model): string { + return $model . 'Factory'; + }); + + $user = UserFactory::new()->create(); + + $factory = PostFactory::new() + ->has(CommentFactory::new()->count(2), 'comments'); + + $factory->recycle($user)->create(); + $post = $factory->create(); + + $this->assertCount(2, $post->comments); + $this->assertNotContains($user->id, $post->comments->pluck('user_id')); + } + + public function testRecyclingKeepsParentAndChildDefinitionsOnTheDerivedFactory(): void + { + $first = UserFactory::new()->create(); + $second = UserFactory::new()->create(); + $factory = PostFactory::new() + ->for(UserFactory::new(), 'user') + ->has(CommentFactory::new()->count(2), 'comments') + ->count(2); + + foreach ([$first, $second] as $user) { + $posts = $factory->recycle($user)->create(); + + $this->assertSame([$user->id, $user->id], $posts->pluck('user_id')->all()); + foreach ($posts as $post) { + $this->assertSame([$user->id, $user->id], $post->comments->pluck('user_id')->all()); + } + } + + $posts = $factory->create(); + $userIds = $posts->pluck('user_id') + ->merge($posts->flatMap(fn (Post $post): Collection => $post->comments)->pluck('user_id')); + + $this->assertNotContains($first->id, $userIds); + $this->assertNotContains($second->id, $userIds); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testNestedFactoriesShareTheirExplicitParentAcrossTheGroup(bool $recycle): void + { + $factory = PostFactory::new()->count(3)->has( + CommentFactory::new()->count(2)->for(UserFactory::new(), 'user'), + 'comments' + ); + + if ($recycle) { + $factory = $factory->recycle(RoleFactory::new()->create()); + } + + $posts = $factory->create(); + $comments = $posts->flatMap(fn (Post $post): Collection => $post->comments); + + $this->assertCount(6, $comments); + $this->assertCount(1, $comments->pluck('user_id')->unique()); + } + public function testNoModelsCanBeProvidedToRecycle(): void { Factory::guessFactoryNamesUsing(function ($model) { @@ -1540,6 +1619,16 @@ public function commentable() { return $this->morphTo(); } + + /** + * Get the comment's author. + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } } class RoleFactory extends Factory diff --git a/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php b/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php index 5aa2980ddd..976f223c5c 100644 --- a/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php @@ -70,7 +70,7 @@ public function testCreateOrFirstMethodRetrievesExistingRecord(): void $parent->getConnection() ->expects('select') ->with( - 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1', [123, 'foo'], false, [], @@ -110,7 +110,7 @@ public function testFirstOrCreateMethodCreatesNewRecord(): void $parent->getConnection() ->expects('select') ->with( - 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1', [123, 'foo'], true, [], @@ -145,7 +145,7 @@ public function testFirstOrCreateMethodRetrievesExistingRecord(): void $parent->getConnection() ->expects('select') ->with( - 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1', [123, 'foo'], true, [], @@ -186,7 +186,7 @@ public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(Closure|arr $parent->getConnection() ->expects('select') ->with( - 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1', [123, 'foo'], true, [], @@ -204,7 +204,7 @@ public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(Closure|arr $parent->getConnection() ->expects('select') ->with( - 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1', [123, 'foo'], false, [], @@ -256,7 +256,7 @@ public function testUpdateOrCreateMethodCreatesNewRecord(Closure|array $values): $parent->getConnection() ->expects('select') ->with( - 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1', [123, 'foo'], true, [], @@ -295,7 +295,7 @@ public function testUpdateOrCreateMethodUpdatesExistingRecord(Closure|array $val $parent->getConnection() ->expects('select') ->with( - 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1', [123, 'foo'], true, [], @@ -344,7 +344,7 @@ public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(Closure|arra $parent->getConnection() ->expects('select') ->with( - 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1', [123, 'foo'], true, [], @@ -362,7 +362,7 @@ public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(Closure|arra $parent->getConnection() ->expects('select') ->with( - 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("child"."attr" = ?) limit 1', [123, 'foo'], false, [], diff --git a/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php b/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php index 00d41fc266..fb5115a27b 100644 --- a/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php +++ b/tests/Database/DatabaseEloquentHasManyThroughIntegrationTest.php @@ -203,6 +203,24 @@ public function testFindOrFailThrowsAnException(bool $model): void Country::first()->posts()->findOrFail($model ? (new Post)->forceFill(['id' => 1]) : 1); } + public function testFindOrNewReturnsTheRelatedModel(): void + { + $this->seedData(); + $this->seedDataExtended(); + $country = Country::find(2); + + $post = $country->posts()->findOrNew(5); + + $this->assertTrue($post->exists); + $this->assertSame(5, $post->id); + $this->assertSame('Example2 title1', $post->title); + + $post = $country->posts()->findOrNew(1); + + $this->assertFalse($post->exists); + $this->assertInstanceOf(Post::class, $post); + } + public function testFindOrFailWithManyThrowsAnException(): void { $this->expectExceptionObject(new ModelNotFoundException('No query results for model [Hypervel\Tests\Database\DatabaseEloquentHasManyThroughIntegrationTest\Post] 1, 2')); @@ -393,6 +411,24 @@ public function testChunkReturnsCorrectModels(): void }); } + public function testChunkMapReturnsCorrectModels(): void + { + $this->seedData(); + $this->seedDataExtended(); + $country = Country::find(2); + + $posts = $country->posts()->chunkMap(fn (Post $post): array => [$post->id, $post->title], 2); + + $this->assertEquals([ + [3, 'Example1 title1'], + [4, 'Example1 title2'], + [5, 'Example2 title1'], + [6, 'Example2 title2'], + [7, 'Example3 title1'], + [8, 'Example3 title2'], + ], $posts->all()); + } + public function testChunkById(): void { $this->seedData(); diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index bbcafa7749..f42e851e41 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -1965,6 +1965,19 @@ public function testBelongsToManyRelationshipModelsAreProperlyHydratedOverEachRe }); } + public function testBelongsToManyRelationshipModelsAreProperlyHydratedOverChunkMapRequest(): void + { + $user = User::create(['email' => 'taylorotwell@gmail.com']); + $friend = $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + + $results = User::first()->friends()->chunkMap(fn (User $result): User => $result, 2); + + $this->assertCount(1, $results); + $this->assertSame('abigailotwell@gmail.com', $results->first()->email); + $this->assertEquals($user->id, $results->first()->pivot->user_id); + $this->assertEquals($friend->id, $results->first()->pivot->friend_id); + } + public function testBelongsToManyRelationshipModelsAreProperlyHydratedOverCursorRequest(): void { $user = User::create(['email' => 'taylorotwell@gmail.com']); diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 34be2523c8..7968983056 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -8397,12 +8397,12 @@ public function testWhereJsonOverlapsMySql(): void $builder = $this->getMySqlBuilder(); $builder->select('*')->from('users')->whereJsonOverlaps('users.options->languages', ['en', 'fr']); - $this->assertSame('select * from `users` where json_overlaps(`users`.`options`, ?, \'$."languages"\')', $builder->toSql()); + $this->assertSame('select * from `users` where json_overlaps(json_extract(`users`.`options`, \'$."languages"\'), ?)', $builder->toSql()); $this->assertEquals(['["en","fr"]'], $builder->getBindings()); $builder = $this->getMySqlBuilder(); $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonOverlaps('options->languages', new Raw("'[\"en\", \"fr\"]'")); - $this->assertSame('select * from `users` where `id` = ? or json_overlaps(`options`, \'["en", "fr"]\', \'$."languages"\')', $builder->toSql()); + $this->assertSame('select * from `users` where `id` = ? or json_overlaps(json_extract(`options`, \'$."languages"\'), \'["en", "fr"]\')', $builder->toSql()); $this->assertEquals([1], $builder->getBindings()); } @@ -8462,12 +8462,12 @@ public function testWhereJsonDoesntOverlapMySql(): void { $builder = $this->getMySqlBuilder(); $builder->select('*')->from('users')->whereJsonDoesntOverlap('options->languages', ['en', 'fr']); - $this->assertSame('select * from `users` where not json_overlaps(`options`, ?, \'$."languages"\')', $builder->toSql()); + $this->assertSame('select * from `users` where not json_overlaps(json_extract(`options`, \'$."languages"\'), ?)', $builder->toSql()); $this->assertEquals(['["en","fr"]'], $builder->getBindings()); $builder = $this->getMySqlBuilder(); $builder->select('*')->from('users')->where('id', '=', 1)->orWhereJsonDoesntOverlap('options->languages', new Raw("'[\"en\", \"fr\"]'")); - $this->assertSame('select * from `users` where `id` = ? or not json_overlaps(`options`, \'["en", "fr"]\', \'$."languages"\')', $builder->toSql()); + $this->assertSame('select * from `users` where `id` = ? or not json_overlaps(json_extract(`options`, \'$."languages"\'), \'["en", "fr"]\')', $builder->toSql()); $this->assertEquals([1], $builder->getBindings()); } diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php index 3191023a61..181398b211 100644 --- a/tests/Filesystem/FilesystemManagerTest.php +++ b/tests/Filesystem/FilesystemManagerTest.php @@ -55,6 +55,7 @@ use RuntimeException; use stdClass; use Swoole\Coroutine\CanceledException; +use UnitEnum; use function Aws\serialize; @@ -66,6 +67,7 @@ enum FilesystemTestStringBackedDisk: string enum FilesystemTestIntBackedDisk: int { case Local = 1; + case Zero = 0; } enum FilesystemTestUnitDisk @@ -321,6 +323,48 @@ public function testCanBuildOnDemandDisk(): void ])); } + public function testCanSetDiskUsingEnum(): void + { + $filesystem = new FilesystemManager($this->getContainer()); + $filesystem->set(FilesystemManagerTestDisk::Local, $disk = m::mock(Filesystem::class)); + + $this->assertSame($disk, $filesystem->disk('local')); + } + + public function testCanForgetDiskUsingEnum(): void + { + $filesystem = $this->filesystemManagerWithLocalDisks(); + $filesystem->set('local', $local = m::mock(Filesystem::class)); + $filesystem->set('backup', $backup = m::mock(Filesystem::class)); + + $filesystem->forgetDisk(FilesystemManagerTestDisk::Local); + + $this->assertNotSame($local, $filesystem->disk('local')); + $this->assertSame($backup, $filesystem->disk('backup')); + + $filesystem->forgetDisk([FilesystemManagerTestDisk::Backup]); + + $this->assertNotSame($backup, $filesystem->disk('backup')); + } + + #[TestWith([FilesystemManagerTestDisk::Local, 'local'])] + #[TestWith([FilesystemTestUnitDisk::local, 'local'])] + #[TestWith([FilesystemTestIntBackedDisk::Local, '1'])] + #[TestWith([FilesystemTestIntBackedDisk::Zero, '0'])] + public function testCanPurgeDiskUsingEnum(UnitEnum $name, string $key): void + { + $filesystem = $this->filesystemManagerWithLocalDisks(); + $filesystem->set($key, $local = m::mock(Filesystem::class)); + + $filesystem->purge($name); + + $this->assertNotSame($local, $filesystem->disk($key)); + + // An uncached integer-backed name must also reach the string-only config lookup. + $filesystem->forgetDisk($key); + $filesystem->purge($name); + } + public function testCanBuildLocalDisksThatLazilyCreateTheirRootDirectory(): void { $filesystem = new FilesystemManager($this->getContainer()); @@ -2662,6 +2706,21 @@ protected function readThroughFilesystemManager( ])); } + /** + * Create a filesystem manager with isolated local disks. + */ + protected function filesystemManagerWithLocalDisks(): FilesystemManager + { + return new FilesystemManager($this->getContainer([ + 'disks' => [ + 'local' => ['driver' => 'local', 'root' => $this->tempDir . '/local'], + 'backup' => ['driver' => 'local', 'root' => $this->tempDir . '/backup'], + '1' => ['driver' => 'local', 'root' => $this->tempDir . '/integer'], + '0' => ['driver' => 'local', 'root' => $this->tempDir . '/zero'], + ], + ])); + } + protected function getContainer(array $config = []): Container { $config = new Repository(['filesystems' => $config]); @@ -2734,3 +2793,9 @@ public function s3ArgumentNamesForTest(): array return static::s3ArgumentNames(); } } + +enum FilesystemManagerTestDisk: string +{ + case Local = 'local'; + case Backup = 'backup'; +} diff --git a/tests/Foundation/Bootstrap/HandleExceptionsTest.php b/tests/Foundation/Bootstrap/HandleExceptionsTest.php index 2770f5a9df..04f7f6f68c 100644 --- a/tests/Foundation/Bootstrap/HandleExceptionsTest.php +++ b/tests/Foundation/Bootstrap/HandleExceptionsTest.php @@ -113,9 +113,12 @@ public function testPhpDeprecations(): void ))); } - public function testDeprecationLoggingPreservesCancellationDuringLoggerResolution(): void + #[TestWith([false])] + #[TestWith([true])] + public function testDeprecationLoggingPreservesCancellationDuringLoggerResolution(bool $throwingCleanup): void { $cancellation = new CanceledException('canceled'); + $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation; $this->app->bind(LogManager::class, fn () => throw $cancellation); try { @@ -126,7 +129,7 @@ public function testDeprecationLoggingPreservesCancellationDuringLoggerResolutio ); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } @@ -554,9 +557,12 @@ public function testIgnoreDeprecationIfLoggingFails(): void ); } - public function testDeprecationLoggingPreservesCancellationDuringChannelCreation(): void + #[TestWith([false])] + #[TestWith([true])] + public function testDeprecationLoggingPreservesCancellationDuringChannelCreation(bool $throwingCleanup): void { $cancellation = new CanceledException('canceled'); + $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation; $logger = new LogManager($this->app); $logger->extend('canceling', static fn (): never => throw $cancellation); $this->config->set('logging.channels.deprecations', ['driver' => 'canceling']); @@ -566,7 +572,7 @@ public function testDeprecationLoggingPreservesCancellationDuringChannelCreation $this->handleExceptions()->handleDeprecationError('Deprecated behavior', __FILE__, __LINE__); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } diff --git a/tests/Foundation/Console/CliDumperTest.php b/tests/Foundation/Console/CliDumperTest.php index 912056a020..14784545a5 100644 --- a/tests/Foundation/Console/CliDumperTest.php +++ b/tests/Foundation/Console/CliDumperTest.php @@ -9,6 +9,7 @@ use Hypervel\Foundation\Application; use Hypervel\Foundation\Console\CliDumper; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; use RuntimeException; use stdClass; @@ -141,10 +142,10 @@ public function testNull(): void $this->assertSame($expected, $output); } - public function testWhenIsFileViewIsNotViewCompiled(): void + #[TestWith(['/my-work-directory/routes/console.php'])] + #[TestWith(['/my-work-directory/storage/framework/views-other/example.php'])] + public function testWhenIsFileViewIsNotViewCompiled(string $file): void { - $file = '/my-work-directory/routes/console.php'; - $output = new BufferedOutput; $dumper = new CliDumper( $output, @@ -159,9 +160,11 @@ public function testWhenIsFileViewIsNotViewCompiled(): void $this->assertFalse($isCompiledViewFile); } - public function testWhenIsFileViewIsViewCompiled(): void + #[TestWith(['php'])] + #[TestWith(['cache'])] + public function testWhenIsFileViewIsViewCompiled(string $extension): void { - $file = '/my-work-directory/storage/framework/views/6687c33c38b71a8560.php'; + $file = '/my-work-directory/storage/framework/views/6687c33c38b71a8560.' . $extension; $output = new BufferedOutput; $dumper = new CliDumper( diff --git a/tests/Foundation/Console/KernelTerminateTest.php b/tests/Foundation/Console/KernelTerminateTest.php index ece0a3e9f4..77c4cdefd2 100644 --- a/tests/Foundation/Console/KernelTerminateTest.php +++ b/tests/Foundation/Console/KernelTerminateTest.php @@ -12,10 +12,12 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Testbench\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\ConsoleOutput; +use Throwable; class KernelTerminateTest extends TestCase { @@ -354,10 +356,13 @@ public function testEventFailureDoesNotSkipApplicationOrDurationHandlers(): void $this->assertNull($kernel->commandStartedAt()); } - public function testEventCancellationStopsLaterTerminationStagesAndClearsTheCommandStartTime(): void + #[TestWith([false])] + #[TestWith([true])] + public function testEventCancellationStopsLaterTerminationStagesAndClearsTheCommandStartTime(bool $throwingCleanup): void { $calls = []; $cancellation = new CanceledException('canceled'); + $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation; $kernel = $this->app->make(KernelContract::class); $kernel->command('foo', fn () => null); $this->app->make(Dispatcher::class)->listen(Terminating::class, function () use (&$calls, $cancellation): void { @@ -380,7 +385,7 @@ public function testEventCancellationStopsLaterTerminationStagesAndClearsTheComm try { $kernel->terminate($input, 0); $this->fail('Expected console termination to preserve cancellation.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -388,11 +393,14 @@ public function testEventCancellationStopsLaterTerminationStagesAndClearsTheComm $this->assertNull($kernel->commandStartedAt()); } - public function testDurationCancellationSupersedesAnEarlierOrdinaryTerminationFailure(): void + #[TestWith([false])] + #[TestWith([true])] + public function testDurationCancellationSupersedesAnEarlierOrdinaryTerminationFailure(bool $throwingCleanup): void { $calls = []; $eventFailure = new RuntimeException('event failed'); $cancellation = new CanceledException('canceled'); + $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation; $kernel = $this->app->make(KernelContract::class); $kernel->command('foo', fn () => null); $this->app->make(Dispatcher::class)->listen(Terminating::class, function () use (&$calls, $eventFailure): void { @@ -417,7 +425,7 @@ public function testDurationCancellationSupersedesAnEarlierOrdinaryTerminationFa try { $kernel->terminate($input, 0); $this->fail('Expected duration cancellation to supersede the earlier failure.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -425,6 +433,48 @@ public function testDurationCancellationSupersedesAnEarlierOrdinaryTerminationFa $this->assertNull($kernel->commandStartedAt()); } + public function testTimezoneResolutionCancellationSupersedesAnEarlierTerminationFailure(): void + { + $cancellation = new RuntimeException('cleanup failed', 0, new CanceledException('canceled')); + $kernel = $this->app->make(KernelContract::class); + $kernel->command('foo', fn () => null); + $this->app->make(Dispatcher::class)->listen(Terminating::class, function (): void { + throw new RuntimeException('event failed'); + }); + $called = false; + $kernel->whenCommandLifecycleIsLongerThan(0, function () use (&$called): void { + $called = true; + }); + + CarbonImmutable::setTestNow(CarbonImmutable::now()); + $input = new StringInput('foo'); + $kernel->handle($input, new ConsoleOutput); + CarbonImmutable::setTestNow(CarbonImmutable::now()->addSecond()); + + // Earlier termination stages also resolve config; target the timezone lookup. + $armed = false; + $this->app->terminating(function () use (&$armed): void { + $armed = true; + }); + $this->app->beforeResolving('config', function () use (&$armed, $cancellation): void { + if ($armed) { + $armed = false; + + throw $cancellation; + } + }); + + try { + $kernel->terminate($input, 0); + $this->fail('Expected timezone resolution cancellation to supersede the earlier failure.'); + } catch (Throwable $exception) { + $this->assertSame($cancellation, $exception); + } + + $this->assertFalse($called); + $this->assertNull($kernel->commandStartedAt()); + } + public function testApplicationFailurePrecedesDurationHandlerFailure(): void { $calls = []; diff --git a/tests/Foundation/Console/RouteListCommandTest.php b/tests/Foundation/Console/RouteListCommandTest.php index c64133c5dd..8dcbd05e0f 100644 --- a/tests/Foundation/Console/RouteListCommandTest.php +++ b/tests/Foundation/Console/RouteListCommandTest.php @@ -6,10 +6,12 @@ use Hypervel\Console\Application; use Hypervel\Events\Dispatcher; +use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Application as FoundationApplication; use Hypervel\Foundation\Console\RouteListCommand; use Hypervel\Foundation\Http\Kernel; use Hypervel\Routing\Router; +use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\Console\Output\BufferedOutput; @@ -307,6 +309,35 @@ public function testClosureRouteShowsPathInCli(): void RouteListCommand::resolveTerminalWidthUsing(null); } + public function testRoutePathsRespectApplicationAndVendorDirectoryBoundaries(): void + { + $root = ParallelTesting::tempDir('RouteListPathBoundaries'); + $files = new Filesystem; + $files->deleteDirectory($root); + $app = FoundationApplication::getInstance(); + $originalBasePath = $app->basePath(); + + try { + $app->setBasePath($root . '/app'); + $files->ensureDirectoryExists($root . '/app/vendor-tools'); + $files->ensureDirectoryExists($root . '/app-other'); + $files->put($root . '/app/vendor-tools/routes.php', ' "ok";'); + $files->put($root . '/app-other/routes.php', ' "ok";'); + $this->router->get('/vendor-prefix', require $root . '/app/vendor-tools/routes.php'); + $this->router->get('/external', require $root . '/app-other/routes.php'); + + $this->consoleApp->call('route:list', ['--json' => true, '--except-vendor' => true]); + $routes = array_column(json_decode($this->consoleApp->output(), true, flags: JSON_THROW_ON_ERROR), null, 'uri'); + + $this->assertArrayHasKey('vendor-prefix', $routes); + $this->assertSame('vendor-tools/routes.php:1', $routes['vendor-prefix']['path']); + $this->assertSame($root . '/app-other/routes.php:1', $routes['external']['path']); + } finally { + $app->setBasePath($originalBasePath); + $files->deleteDirectory($root); + } + } + public function testControllerRoutePathIsNull(): void { $hypervel = new FoundationApplication(__DIR__); diff --git a/tests/Foundation/Console/VendorPublishCommandTest.php b/tests/Foundation/Console/VendorPublishCommandTest.php index 745c2de389..fbcddf4536 100644 --- a/tests/Foundation/Console/VendorPublishCommandTest.php +++ b/tests/Foundation/Console/VendorPublishCommandTest.php @@ -64,6 +64,36 @@ public function testPublishesFile(): void $this->assertFileEquals($source, $dest); } + public function testPublishedAndSkippedPathsRemoveOnlyTheLeadingApplicationDirectory(): void + { + $source = $this->sourceDir . '/nested' . $this->sourceDir . '/config.php'; + $destination = $this->destDir . '/nested' . $this->sourceDir . '/config.php'; + $this->filesystem->ensureDirectoryExists(dirname($source)); + $this->filesystem->put($source, ' $destination]; + $originalBasePath = $this->app->basePath(); + + try { + $this->app->setBasePath($this->sourceDir); + $tester = $this->commandTester($this->filesystem); + + $this->assertSame(0, $tester->execute(['--provider' => TestPublishProvider::class])); + $this->assertStringContainsString( + 'Copying file [nested' . $this->sourceDir . '/config.php] to [' . $destination . ']', + $tester->getDisplay(), + ); + + $this->assertSame(0, $tester->execute(['--provider' => TestPublishProvider::class])); + $this->assertStringContainsString('File [' . $destination . '] already exists', $tester->getDisplay()); + + $this->filesystem->delete($destination); + $this->assertSame(0, $tester->execute(['--provider' => TestPublishProvider::class, '--existing' => true])); + $this->assertStringContainsString('File [' . $destination . '] does not exist', $tester->getDisplay()); + } finally { + $this->app->setBasePath($originalBasePath); + } + } + public function testPublishesDirectory(): void { $sourceSubDir = $this->sourceDir . '/views'; diff --git a/tests/Foundation/Exceptions/Renderer/FrameTest.php b/tests/Foundation/Exceptions/Renderer/FrameTest.php index 5ed0e4bbb5..8ad33849a9 100644 --- a/tests/Foundation/Exceptions/Renderer/FrameTest.php +++ b/tests/Foundation/Exceptions/Renderer/FrameTest.php @@ -74,6 +74,11 @@ public static function unixFileDataProvider(): iterable '/path/to/your-app', '/other/path/app/Http/Controllers/UserController.php', ]; + yield 'base directory repeated inside the path' => [ + ['file' => '/app/modules/app/File.php', 'line' => 10], + '/app', + 'modules/app/File.php', + ]; } // REMOVED: test_it_normalizes_file_path_on_windows - Swoole doesn't run on Windows @@ -106,6 +111,16 @@ public static function unixIsFromVendorDataProvider(): iterable '/path/to/your-app', true, ]; + yield 'outside base path with matching prefix' => [ + ['file' => '/path/to/your-app-other/file.php', 'line' => 10], + '/path/to/your-app', + true, + ]; + yield 'application directory with vendor prefix' => [ + ['file' => '/path/to/your-app/vendor-tools/file.php', 'line' => 10], + '/path/to/your-app', + false, + ]; yield 'vendor in filename' => [ ['file' => '/path/to/your-app/app/vendorfile.php', 'line' => 10], '/path/to/your-app', diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index d055df195c..626bc699cc 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -26,11 +26,13 @@ use Hypervel\Tests\TestCase; use JsonException; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use Psr\Log\LoggerInterface; use RuntimeException; use Swoole\Coroutine\CanceledException; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Throwable; class FoundationApplicationTest extends TestCase { @@ -427,11 +429,14 @@ public function testTerminationCallbacksAreExhaustiveAndPreserveTheFirstFailure( $this->assertSame(['first', 'second'], $called); } - public function testTerminationCancellationSupersedesAnEarlierFailureAndStopsLaterCallbacks(): void + #[TestWith([false])] + #[TestWith([true])] + public function testTerminationCancellationSupersedesAnEarlierFailureAndStopsLaterCallbacks(bool $throwingCleanup): void { $app = new Application; $called = []; $cancellation = new CanceledException('canceled'); + $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation; $app->terminating(function () use (&$called): void { $called[] = 'first'; @@ -450,7 +455,7 @@ public function testTerminationCancellationSupersedesAnEarlierFailureAndStopsLat try { $app->terminate(); $this->fail('Expected application termination to preserve cancellation.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php index e4d893aea8..ac3968980b 100644 --- a/tests/Foundation/FoundationExceptionsHandlerTest.php +++ b/tests/Foundation/FoundationExceptionsHandlerTest.php @@ -50,6 +50,8 @@ use Mockery as m; use OutOfRangeException; use PHPUnit\Framework\AssertionFailedError; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use Psr\Log\LoggerInterface; use Psr\Log\LoggerTrait; use Psr\Log\LogLevel; @@ -113,11 +115,13 @@ protected function getRealLogger(): LoggerSpy return $logger; } - public function testHandlerReportsExceptionAsContext(): void + #[TestWith([false])] + #[TestWith([true])] + public function testHandlerReportsExceptionAsContext(bool $throwingCleanup): void { $logger = $this->getRealLogger(); - $this->handler->report(new RuntimeException('Exception message')); + $this->handler->report(new RuntimeException('Exception message', 0, $throwingCleanup ? new CanceledException : null)); $this->assertCount(1, $logger->logs); $this->assertSame('error', $logger->logs[0]['level']); @@ -136,24 +140,24 @@ public function testHandlerNeverReportsCancellation(): void $this->handler->report($cancellation); } - public function testHandlerPreservesCancellationFromLoggerResolution(): void + #[DataProvider('cancellations')] + public function testHandlerPreservesCancellationFromLoggerResolution(Throwable $cancellation): void { $failure = new RuntimeException('failed'); - $cancellation = new CanceledException('canceled'); $this->container->bind(LoggerInterface::class, fn () => throw $cancellation); try { $this->handler->report($failure); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } - public function testHandlerPreservesCancellationFromDefaultContext(): void + #[DataProvider('cancellations')] + public function testHandlerPreservesCancellationFromDefaultContext(Throwable $cancellation): void { - $cancellation = new CanceledException('canceled'); $auth = m::mock(); $auth->expects('id')->andThrow($cancellation); $this->container->instance('auth', $auth); @@ -171,7 +175,7 @@ public function defaultContext(): array $handler->defaultContext(); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } finally { Auth::clearResolvedInstance(); @@ -179,23 +183,22 @@ public function defaultContext(): array } } - public function testHandlerPreservesCancellationDuringRendering(): void + #[DataProvider('cancellations')] + public function testHandlerPreservesCancellationDuringRendering(Throwable $cancellation): void { - $cancellation = new CanceledException('canceled'); - try { $this->handler->render(Request::create('/'), $cancellation); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } - public function testHandlerPreservesCancellationFromCustomRenderer(): void + #[DataProvider('cancellations')] + public function testHandlerPreservesCancellationFromCustomRenderer(Throwable $cancellation): void { $failure = new RuntimeException('failed'); - $cancellation = new CanceledException('canceled'); $renderer = m::mock(ExceptionRenderer::class); $renderer->expects('render')->with($failure)->andThrow($cancellation); $this->container->instance(ExceptionRenderer::class, $renderer); @@ -205,20 +208,19 @@ public function testHandlerPreservesCancellationFromCustomRenderer(): void $this->handler->render(Request::create('/'), $failure); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } - public function testHandlerPreservesCancellationDuringConsoleRendering(): void + #[DataProvider('cancellations')] + public function testHandlerPreservesCancellationDuringConsoleRendering(Throwable $cancellation): void { - $cancellation = new CanceledException('canceled'); - try { $this->handler->renderForConsole(new BufferedOutput, $cancellation); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } @@ -1014,9 +1016,9 @@ public function testItDoesNotCrashIfErrorViewThrowsWhileRenderingAndDebugTrue(): $this->executeScenarioWhereErrorViewThrowsWhileRenderingAndDebugIs(true); } - public function testErrorViewCancellationEscapesWithoutReportingOrFallback(): void + #[DataProvider('cancellations')] + public function testErrorViewCancellationEscapesWithoutReportingOrFallback(Throwable $cancellation): void { - $cancellation = new CanceledException('canceled'); $this->viewFactory->expects('exists')->with('errors::404')->andReturnTrue(); $this->viewFactory->expects('make')->andThrow($cancellation); $handler = new class($this->container) extends Handler { @@ -1041,13 +1043,24 @@ protected function registerErrorViewPaths(): void $handler->renderHttpExceptionForTest(new HttpException(404)); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } $this->assertFalse($handler->reported); } + /** + * Provide direct cancellation and cancellation carried by throwing cleanup. + */ + public static function cancellations(): array + { + return [ + 'direct' => [new CanceledException('canceled')], + 'cleanup' => [new RuntimeException('cleanup failed', 0, new CanceledException('canceled'))], + ]; + } + public function testAssertExceptionIsThrown(): void { $this->assertThrows(function () { @@ -1178,10 +1191,15 @@ public function testItReportsDuplicateExceptions(): void $this->assertSame($reported, [$one, $one, $two]); } - public function testItCanDedupeExceptions(): void + #[TestWith([false])] + #[TestWith([true])] + public function testItCanDedupeExceptions(bool $mapped): void { $reported = []; - $e = new RuntimeException('foo'); + if ($mapped) { + $this->handler->map(RuntimeException::class, static fn (RuntimeException $exception): Exception => new Exception('mapped', 0, $exception)); + } + $this->handler->reportable(function (Throwable $e) use (&$reported) { $reported[] = $e; @@ -1193,7 +1211,28 @@ public function testItCanDedupeExceptions(): void $this->handler->report($one); $this->handler->report($two = new RuntimeException('foo')); - $this->assertSame($reported, [$one, $two]); + $this->assertSame([$one, $two], $mapped + ? array_map(static fn (Throwable $exception): Throwable => $exception->getPrevious(), $reported) + : $reported); + $this->assertTrue($this->handler->shouldReport($one)); + } + + public function testItDedupesExceptionsMappedToTheSameCause(): void + { + $reported = []; + $this->handler->map(RuntimeException::class, static fn (RuntimeException $exception): Throwable => $exception->getPrevious()); + $this->handler->reportable(function (Throwable $exception) use (&$reported): false { + $reported[] = $exception; + + return false; + }); + $this->handler->dontReportDuplicates(); + $cause = new Exception('shared cause'); + + $this->handler->report(new RuntimeException('first wrapper', 0, $cause)); + $this->handler->report(new RuntimeException('second wrapper', 0, $cause)); + + $this->assertSame([$cause], $reported); } public function testItDedupesByObjectIdentityNotEquality(): void diff --git a/tests/Foundation/FoundationFormRequestTest.php b/tests/Foundation/FoundationFormRequestTest.php index da2a857230..78008b3b69 100644 --- a/tests/Foundation/FoundationFormRequestTest.php +++ b/tests/Foundation/FoundationFormRequestTest.php @@ -660,6 +660,38 @@ public function testFailOnUnknownFieldsRejectsExtraJsonInput(): void $this->assertTrue($exception->validator->errors()->has('unexpected')); } + public function testFailOnUnknownFieldsIgnoresCsrfTokenAndMethodFields(): void + { + FormRequest::failOnUnknownFields(); + + $request = $this->createRequest( + ['_token' => 'token', '_method' => 'PUT', 'name' => 'Taylor'], + FoundationTestFormRequestStub::class, + 'POST' + ); + + $request->validateResolved(); + + $this->assertEquals(['name' => 'Taylor'], $request->validated()); + } + + public function testFailOnUnknownFieldsStillRejectsUnknownFieldsAlongsideCsrfTokenAndMethodFields(): void + { + FormRequest::failOnUnknownFields(); + + $request = $this->createRequest( + ['_token' => 'token', '_method' => 'PUT', 'name' => 'Taylor', 'unexpected' => 'value'], + FoundationTestFormRequestStub::class, + 'POST' + ); + + $exception = $this->catchException(ValidationException::class, function () use ($request): void { + $request->validateResolved(); + }); + + $this->assertSame(['unexpected'], $exception->validator->errors()->keys()); + } + public function testFailOnUnknownFieldsAllowsConfirmationFieldsWhenBaseFieldIsConfirmed(): void { FormRequest::failOnUnknownFields(); diff --git a/tests/Foundation/Http/HtmlDumperTest.php b/tests/Foundation/Http/HtmlDumperTest.php index 9d35406002..6031136a63 100644 --- a/tests/Foundation/Http/HtmlDumperTest.php +++ b/tests/Foundation/Http/HtmlDumperTest.php @@ -253,6 +253,17 @@ public function testHref(): void $resolveSourceHref() ); + $this->assertSame( + 'phpstorm://open?file=/my-work-directory-shared/example.blade.php&line=10', + (fn () => $this->resolveSourceHref('/my-work-directory-shared/example.blade.php', 10))->call($dumper), + ); + + $this->config->set('app.editor', ['name' => 'phpstorm', 'base_path' => '/my-docker-work-directory/']); + $this->assertSame( + 'phpstorm://open?file=/my-docker-work-directory/app/my-file&line=10', + $resolveSourceHref() + ); + // A literal false disables base-path replacement. $this->config->set('app.editor', ['name' => 'phpstorm', 'base_path' => false]); $this->assertSame( diff --git a/tests/Foundation/Http/KernelTest.php b/tests/Foundation/Http/KernelTest.php index 6725d6ef9d..8dc37d1a63 100644 --- a/tests/Foundation/Http/KernelTest.php +++ b/tests/Foundation/Http/KernelTest.php @@ -19,8 +19,10 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Throwable; use function Hypervel\Coroutine\parallel; @@ -584,7 +586,9 @@ public function terminate(Request $request, Response $response): void $this->assertNull($kernel->requestStartedAt()); } - public function testTerminationCancellationStopsLaterStagesAndClearsTheRequestStartTime(): void + #[TestWith([false])] + #[TestWith([true])] + public function testTerminationCancellationStopsLaterStagesAndClearsTheRequestStartTime(bool $throwingCleanup): void { $called = []; $app = new Application; @@ -598,6 +602,7 @@ public function testTerminationCancellationStopsLaterStagesAndClearsTheRequestSt $request = Request::create('/'); $response = $kernel->handle($request); $cancellation = new CanceledException('canceled'); + $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation; $events->listen(function (Terminating $event) use (&$called, $cancellation): void { $called[] = 'event'; @@ -625,7 +630,7 @@ public function terminate(Request $request, Response $response): void try { $kernel->terminate($request, $response); $this->fail('Expected request termination to preserve cancellation.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -633,7 +638,9 @@ public function terminate(Request $request, Response $response): void $this->assertNull($kernel->requestStartedAt()); } - public function testMiddlewareCancellationSupersedesAnEarlierOrdinaryTerminationFailure(): void + #[TestWith([false])] + #[TestWith([true])] + public function testMiddlewareCancellationSupersedesAnEarlierOrdinaryTerminationFailure(bool $throwingCleanup): void { $called = []; $app = new Application; @@ -647,6 +654,7 @@ public function testMiddlewareCancellationSupersedesAnEarlierOrdinaryTermination $request = Request::create('/'); $response = $kernel->handle($request); $cancellation = new CanceledException('canceled'); + $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation; $events->listen(function (Terminating $event) use (&$called): void { $called[] = 'event'; @@ -654,7 +662,7 @@ public function testMiddlewareCancellationSupersedesAnEarlierOrdinaryTermination throw new RuntimeException('event failed'); }); $app->instance('cancelling-middleware', new class($called, $cancellation) { - public function __construct(private array &$called, private CanceledException $cancellation) + public function __construct(private array &$called, private Throwable $cancellation) { } @@ -683,7 +691,7 @@ public function terminate(Request $request, Response $response): void try { $kernel->terminate($request, $response); $this->fail('Expected middleware cancellation to supersede the earlier failure.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } @@ -691,7 +699,9 @@ public function terminate(Request $request, Response $response): void $this->assertNull($kernel->requestStartedAt()); } - public function testDurationHandlerCancellationSupersedesAnEarlierOrdinaryTerminationFailure(): void + #[TestWith([false])] + #[TestWith([true])] + public function testDurationHandlerCancellationSupersedesAnEarlierOrdinaryTerminationFailure(bool $throwingCleanup): void { $called = []; $app = new Application; @@ -705,6 +715,7 @@ public function testDurationHandlerCancellationSupersedesAnEarlierOrdinaryTermin $request = Request::create('/'); $response = $kernel->handle($request); $cancellation = new CanceledException('canceled'); + $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation; $events->listen(function (Terminating $event) use (&$called): void { $called[] = 'event'; @@ -723,7 +734,7 @@ public function testDurationHandlerCancellationSupersedesAnEarlierOrdinaryTermin try { $kernel->terminate($request, $response); $this->fail('Expected duration handler cancellation to supersede the earlier failure.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } diff --git a/tests/Foundation/Providers/FoundationServiceProviderTest.php b/tests/Foundation/Providers/FoundationServiceProviderTest.php index 7bbe34ef1a..2e621d2289 100644 --- a/tests/Foundation/Providers/FoundationServiceProviderTest.php +++ b/tests/Foundation/Providers/FoundationServiceProviderTest.php @@ -7,6 +7,7 @@ use Hypervel\Console\Scheduling\Schedule; use Hypervel\Contracts\Console\Kernel; use Hypervel\Contracts\Foundation\MaintenanceMode as MaintenanceModeContract; +use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\ArrayMaintenanceMode; use Hypervel\Foundation\Console\CliDumper; use Hypervel\Foundation\DevCommands; @@ -19,7 +20,9 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Date; use Hypervel\Testbench\TestCase; +use Hypervel\Testing\ParallelTesting; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use Psr\Clock\ClockInterface; use ReflectionClass; use ReflectionFunction; @@ -198,4 +201,52 @@ public static function dumperFormatValues(): array 'unknown' => ['unknown', CliDumper::class], ]; } + + #[TestWith([false], 'view inside the application')] + #[TestWith([true], 'view outside the application with a matching path prefix')] + public function testDumperResolvesBladeSourceFromSymlinkedCompiledDirectory(bool $outsideApplication): void + { + $filesystem = new Filesystem; + $root = ParallelTesting::tempDir('FoundationDumperSource'); + $originalBasePath = $this->app->basePath(); + $directory = $outsideApplication + ? $root . '/app-dump-sources' + : $root . '/app/storage/framework/dump-sources'; + $filesystem->deleteDirectory($root); + $originalFormatExists = array_key_exists('VAR_DUMPER_FORMAT', $_SERVER); + $originalFormat = $_SERVER['VAR_DUMPER_FORMAT'] ?? null; + unset($_SERVER['VAR_DUMPER_FORMAT']); + $originalHandler = VarDumper::setHandler(null); + + try { + $this->app->setBasePath($root . '/app'); + $filesystem->ensureDirectoryExists($directory); + symlink($directory, $directory . '/linked'); + config(['view.compiled' => $directory . '/linked/compiled']); + $this->app->forgetInstance('blade.compiler'); + $this->app->make('view.engine.resolver')->forget('blade'); + + $_SERVER['VAR_DUMPER_FORMAT'] = 'html'; + (new FoundationServiceProvider($this->app))->registerDumper(); + $path = $directory . '/example.blade.php'; + $filesystem->put($path, "/**PATH /another/view.blade.php ENDPATH**/ @dump('example value')"); + + $output = $this->app->make('view')->file($path)->render(); + + $this->assertStringContainsString( + $outsideApplication ? $path : 'storage/framework/dump-sources/example.blade.php', + $output, + ); + } finally { + unset($_SERVER['VAR_DUMPER_FORMAT']); + VarDumper::setHandler($originalHandler); + + if ($originalFormatExists) { + $_SERVER['VAR_DUMPER_FORMAT'] = $originalFormat; + } + + $this->app->setBasePath($originalBasePath); + $filesystem->deleteDirectory($root); + } + } } diff --git a/tests/Integration/Console/Events/EventListCommandTest.php b/tests/Integration/Console/Events/EventListCommandTest.php index f33d2c98ae..241f714712 100644 --- a/tests/Integration/Console/Events/EventListCommandTest.php +++ b/tests/Integration/Console/Events/EventListCommandTest.php @@ -7,8 +7,10 @@ use Hypervel\Contracts\Broadcasting\ShouldBroadcast; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Events\Dispatcher; +use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Console\EventListCommand; use Hypervel\Support\Facades\Artisan; +use Hypervel\Testing\ParallelTesting; class EventListCommandTest extends \Hypervel\Testbench\TestCase { @@ -49,6 +51,37 @@ public function testDisplayEvents(): void ->expectsOutputToContain('⇂ Closure at: ' . $unixFilePath . ':' . $closureLineNumber); } + public function testClosurePathRemovesOnlyTheLeadingApplicationDirectory(): void + { + $root = ParallelTesting::tempDir('EventListPaths'); + $base = $root . '/app'; + $path = $base . '/nested' . $base . '/listener.php'; + $files = new Filesystem; + $files->deleteDirectory($root); + $originalBasePath = $this->app->basePath(); + + try { + $files->ensureDirectoryExists(dirname($path)); + $files->put($path, ' null;'); + $this->dispatcher->listen('example.event', require $path); + $this->app->setBasePath($base); + + $this->withoutMockingConsoleOutput()->artisan(EventListCommand::class, ['--json' => true]); + + $expected = 'Closure at: nested' . $base . '/listener.php:1'; + $this->assertSame( + $expected, + json_decode(Artisan::output(), true, flags: JSON_THROW_ON_ERROR)[0]['listeners'][0], + ); + + $this->withoutMockingConsoleOutput()->artisan(EventListCommand::class); + $this->assertStringContainsString($expected, Artisan::output()); + } finally { + $this->app->setBasePath($originalBasePath); + $files->deleteDirectory($root); + } + } + public function testDisplayFilteredEvent(): void { $this->dispatcher->subscribe(ExampleSubscriber::class); diff --git a/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php b/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php index f49481520c..a7ae9d162e 100644 --- a/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php +++ b/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php @@ -7,10 +7,12 @@ use Hypervel\Console\Command; use Hypervel\Console\Commands\ScheduleListCommand; use Hypervel\Console\Scheduling\Schedule; +use Hypervel\Filesystem\Filesystem; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Artisan; use Hypervel\Support\ProcessUtils; use Hypervel\Testbench\TestCase; +use Hypervel\Testing\ParallelTesting; class ScheduleListCommandTest extends TestCase { @@ -128,6 +130,33 @@ public function testDisplayScheduleAsJson(): void $this->assertSame('echo hello', $data[9]['command']); } + public function testClosureLocationRemovesOnlyTheLeadingApplicationDirectory(): void + { + $root = ParallelTesting::tempDir('ScheduleListPaths'); + $base = $root . '/app'; + $path = $base . '/nested' . $base . '/task.php'; + $files = new Filesystem; + $files->deleteDirectory($root); + $originalBasePath = $this->app->basePath(); + + try { + $files->ensureDirectoryExists(dirname($path)); + $files->put($path, ' null;'); + $this->schedule->call(require $path)->everyMinute(); + $this->app->setBasePath($base); + + $this->withoutMockingConsoleOutput()->artisan(ScheduleListCommand::class, ['--json' => true]); + + $this->assertSame( + 'Closure at: nested' . $base . '/task.php:1', + json_decode(Artisan::output(), true, flags: JSON_THROW_ON_ERROR)[0]['command'], + ); + } finally { + $this->app->setBasePath($originalBasePath); + $files->deleteDirectory($root); + } + } + public function testDisplayScheduleAsJsonPreservesShellCommands(): void { $this->schedule->exec('grep -c "\" storage/logs/hypervel.log')->everyMinute(); diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php index 5014ccd577..2ed4991f2b 100644 --- a/tests/Integration/Database/EloquentBelongsToManyTest.php +++ b/tests/Integration/Database/EloquentBelongsToManyTest.php @@ -43,6 +43,7 @@ protected function afterRefreshingDatabase(): void $table->increments('id'); $table->string('name'); $table->string('type')->nullable(); + $table->integer('count')->default(0); $table->timestamps(); }); @@ -821,6 +822,36 @@ public function testUpdateOrCreateUnrelatedExisting(): void $this->assertTrue($tag->is($post->tags()->first())); } + public function testIncrementOrCreateMethod(): void + { + $post = Post::create(['title' => Str::random()]); + + $tag1 = $post->tags()->incrementOrCreate(['name' => 'wavez']); + $tag2 = $post->tags()->incrementOrCreate(['name' => 'wavez']); + + $this->assertTrue($tag1->is($tag2)); + $this->assertTrue($tag1->wasRecentlyCreated); + $this->assertFalse($tag2->wasRecentlyCreated); + $this->assertEquals(2, $tag2->fresh()->count); + $this->assertTrue($tag1->is($post->tags()->first())); + $this->assertCount(1, $post->tags()->get()); + } + + public function testIncrementOrCreateMethodWithExtra(): void + { + $post = Post::create(['title' => Str::random()]); + + $tag1 = $post->tags()->incrementOrCreate(['name' => 'wavez'], extra: ['type' => 'featured']); + + $this->assertTrue($tag1->wasRecentlyCreated); + $this->assertSame('featured', $tag1->fresh()->type); + + $tag2 = $post->tags()->incrementOrCreate(['name' => 'wavez'], extra: ['type' => 'pinned']); + + $this->assertSame('pinned', $tag2->fresh()->type); + $this->assertEquals(2, $tag2->fresh()->count); + } + public function testUpdateOrCreateMethodCreate(): void { $post = Post::create(['title' => Str::random()]); @@ -2018,7 +2049,7 @@ class Tag extends Model public bool $timestamps = true; - protected array $fillable = ['name', 'type']; + protected array $fillable = ['name', 'type', 'count']; public function posts() { diff --git a/tests/Integration/Database/EloquentCollectionLoadMissingTest.php b/tests/Integration/Database/EloquentCollectionLoadMissingTest.php index 9763bc047e..6475f42e7e 100644 --- a/tests/Integration/Database/EloquentCollectionLoadMissingTest.php +++ b/tests/Integration/Database/EloquentCollectionLoadMissingTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Integration\Database\EloquentCollectionLoadMissingTest; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\MorphMany; +use Hypervel\Database\Eloquent\Relations\MorphTo; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; @@ -49,6 +51,21 @@ protected function afterRefreshingDatabase(): void $table->unsignedInteger('post_sub_relation_id'); }); + Schema::create('videos', function (Blueprint $table) { + $table->increments('id'); + }); + + Schema::create('images', function (Blueprint $table) { + $table->increments('id'); + $table->morphs('imageable'); + $table->string('name'); + }); + + Schema::create('activities', function (Blueprint $table) { + $table->increments('id'); + $table->morphs('subject'); + }); + User::create(); Post::insert([ @@ -221,6 +238,25 @@ public function testLoadMissingWithMultipleNestedArrays(): void $this->assertInstanceOf(PostSubSubRelation::class, $user->posts[1]->postRelation->postSubRelations[0]->postSubSubRelations[0]); } + public function testLoadMissingWithNestedRelationOnMorphToOfDifferentTypes(): void + { + $post = Post::find(1); + $video = Video::create(); + + $post->images()->create(['name' => 'post image']); + $video->images()->create(['name' => 'video image']); + + Activity::create(['subject_type' => Post::class, 'subject_id' => $post->id]); + Activity::create(['subject_type' => Video::class, 'subject_id' => $video->id]); + + $activities = Activity::get(); + + $activities->loadMissing('subject.images'); + + $this->assertEquals(['post image'], $activities[0]->subject->images->pluck('name')->all()); + $this->assertEquals(['video image'], $activities[1]->subject->images->pluck('name')->all()); + } + public function testLoadMissingWithMultipleNestedArraysCombinedWithDotNotation(): void { $users = User::get(); @@ -280,6 +316,16 @@ public function postRelation() { return $this->hasOne(PostRelation::class); } + + /** + * Get the post's images. + * + * @return MorphMany + */ + public function images(): MorphMany + { + return $this->morphMany(Image::class, 'imageable'); + } } class PostRelation extends Model @@ -331,3 +377,44 @@ public function posts() return $this->hasMany(Post::class); } } + +class Video extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + /** + * Get the video's images. + * + * @return MorphMany + */ + public function images(): MorphMany + { + return $this->morphMany(Image::class, 'imageable'); + } +} + +class Image extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; +} + +class Activity extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + /** + * Get the activity's subject. + * + * @return MorphTo + */ + public function subject(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/tests/Integration/Database/EloquentHasManyTest.php b/tests/Integration/Database/EloquentHasManyTest.php index 450237cc90..0f2ed77622 100644 --- a/tests/Integration/Database/EloquentHasManyTest.php +++ b/tests/Integration/Database/EloquentHasManyTest.php @@ -23,6 +23,7 @@ protected function afterRefreshingDatabase(): void $table->id(); $table->foreignId('eloquent_has_many_test_user_id'); $table->string('title')->unique(); + $table->integer('views')->default(0); $table->timestamps(); }); @@ -89,6 +90,21 @@ public function testFirstOrCreateWithinTransaction(): void $this->assertCount(1, $user->posts()->get()); } + public function testIncrementOrCreate(): void + { + $user = EloquentHasManyTestUser::create(); + + $post1 = $user->posts()->incrementOrCreate(['title' => 'foo'], 'views'); + $post2 = $user->posts()->incrementOrCreate(['title' => 'foo'], 'views'); + + $this->assertTrue($post1->is($post2)); + $this->assertTrue($post1->wasRecentlyCreated); + $this->assertFalse($post2->wasRecentlyCreated); + $this->assertEquals($user->id, $post2->eloquent_has_many_test_user_id); + $this->assertEquals(2, $post2->fresh()->views); + $this->assertCount(1, $user->posts()->get()); + } + public function testCreateOrFirst(): void { $user = EloquentHasManyTestUser::create(); diff --git a/tests/Integration/Database/EloquentHasManyThroughTest.php b/tests/Integration/Database/EloquentHasManyThroughTest.php index bf452d1b6f..d757380dbd 100644 --- a/tests/Integration/Database/EloquentHasManyThroughTest.php +++ b/tests/Integration/Database/EloquentHasManyThroughTest.php @@ -48,10 +48,25 @@ protected function afterRefreshingDatabase(): void $table->id(); $table->foreignId('user_id'); $table->string('title')->unique(); + $table->string('slug')->nullable(); + $table->integer('views')->default(0); $table->timestamps(); }); } + public function testEagerLoadingSpecificColumns(): void + { + $user = User::create(['name' => Str::random()]); + $team = Team::create(['owner_id' => $user->id]); + $mate = User::create(['name' => 'John', 'team_id' => $team->id]); + + $user = User::with('teamMates:id,name')->find($user->id); + + $this->assertCount(1, $user->teamMates); + $this->assertSame($mate->id, $user->teamMates[0]->id); + $this->assertSame('John', $user->teamMates[0]->name); + } + public function testBasicCreateAndRetrieve(): void { $user = User::create(['name' => Str::random()]); @@ -273,6 +288,26 @@ public function testFirstOrCreateWhenModelExists(): void $this->assertSame('adam', $mate->slug); } + public function testFirstOrCreateWithColumnThatAlsoExistsOnIntermediateTable(): void + { + $team = Team::create(); + $user = $team->members()->create(['name' => 'Taylor', 'slug' => 'taylor']); + $article = Article::create(['user_id' => $user->id, 'title' => 'Hypervel', 'slug' => 'hypervel']); + + $this->assertTrue($article->is($team->articles()->firstOrNew(['slug' => 'hypervel']))); + $this->assertTrue($article->is($team->articles()->firstOrCreate(['slug' => 'hypervel']))); + + $updated = $team->articles()->updateOrCreate(['slug' => 'hypervel'], ['views' => 5]); + + $this->assertTrue($article->is($updated)); + $this->assertEquals(5, $article->fresh()->views); + + $created = $team->articles()->firstOrCreate(['slug' => 'eloquent'], ['user_id' => $user->id, 'title' => 'Eloquent']); + + $this->assertTrue($created->wasRecentlyCreated); + $this->assertSame('eloquent', $created->fresh()->slug); + } + public function testFirstOrCreateRegressionIssue(): void { $team1 = Team::create(); @@ -426,6 +461,23 @@ public function testUpdateOrCreateAffectingWrongModelsRegression(): void $this->assertSame('jane-slug', $jane->fresh()->slug); } + public function testIncrementOrCreateIncrementsTheRelatedModel(): void + { + $team = Team::create(); + + $jane = User::create(['name' => 'Jane']); + $john = User::create(['name' => 'John', 'team_id' => $team->id]); + + $johnsArticle = Article::create(['user_id' => $john->id, 'title' => 'foo']); + $janesArticle = Article::create(['user_id' => $jane->id, 'title' => 'bar']); + + $article = $team->articles()->incrementOrCreate(['title' => 'foo'], 'views'); + + $this->assertTrue($article->is($johnsArticle)); + $this->assertEquals(1, $johnsArticle->fresh()->views); + $this->assertEquals(0, $janesArticle->fresh()->views); + } + public function testCanReplicateModelLoadedThroughHasManyThrough(): void { $team = Team::create(); diff --git a/tests/Integration/Database/EloquentHasOneOfManyTest.php b/tests/Integration/Database/EloquentHasOneOfManyTest.php index a7e4ecfc88..b86bc42857 100644 --- a/tests/Integration/Database/EloquentHasOneOfManyTest.php +++ b/tests/Integration/Database/EloquentHasOneOfManyTest.php @@ -55,6 +55,18 @@ public function testItOnlyEagerLoadsRequiredModels(): void $this->assertSame(2, $this->retrievedLogins); } + public function testItEagerLoadsSpecificColumns(): void + { + $user = User::create(); + $user->latest_login()->create(); + $latestLogin = $user->latest_login()->create(); + + $user = User::with('latest_login:id,user_id')->first(); + + $this->assertSame($latestLogin->id, $user->latest_login->id); + $this->assertSame(['id', 'user_id'], array_keys($user->latest_login->getAttributes())); + } + public function testItGetsCorrectResultUsingAtLeastTwoAggregatesDistinctFromId(): void { $user = User::create(); diff --git a/tests/Integration/Database/EloquentWhereHasMorphTest.php b/tests/Integration/Database/EloquentWhereHasMorphTest.php index a18ce3f370..405c56fe4e 100644 --- a/tests/Integration/Database/EloquentWhereHasMorphTest.php +++ b/tests/Integration/Database/EloquentWhereHasMorphTest.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Database\Query\Expression as ExpressionContract; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\MorphTo; use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Query\Expression; @@ -38,6 +39,11 @@ protected function afterRefreshingDatabase(): void $table->softDeletes(); }); + Schema::create('likes', function (Blueprint $table) { + $table->increments('id'); + $table->morphs('likeable'); + }); + $models = []; $models[] = Post::create(['title' => 'foo']); @@ -235,6 +241,16 @@ public function testWhereHasMorphWithOwnerKey(): void $this->assertEquals([1], $comments->pluck('id')->all()); } + public function testWhereHasMorphWithTrashed(): void + { + Like::create(['likeable_type' => Post::class, 'likeable_id' => 1]); + Like::create(['likeable_type' => Post::class, 'likeable_id' => 3]); + + $this->assertEquals([1, 2], Like::has('likeableWithTrashed')->orderBy('id')->pluck('id')->all()); + $this->assertEquals([1, 2], Like::whereHasMorph('likeableWithTrashed', Post::class)->orderBy('id')->pluck('id')->all()); + $this->assertEquals([1], Like::has('likeable')->pluck('id')->all()); + } + public function testHasMorph(): void { $comments = Comment::hasMorph('commentable', Post::class)->orderBy('id')->get(); @@ -372,6 +388,33 @@ public function commentableWithOwnerKey() } } +class Like extends Model +{ + public bool $timestamps = false; + + protected array $guarded = []; + + /** + * Get the liked model. + * + * @return MorphTo + */ + public function likeable(): MorphTo + { + return $this->morphTo(); + } + + /** + * Get the liked model, including soft deleted models. + * + * @return MorphTo + */ + public function likeableWithTrashed(): MorphTo + { + return $this->morphTo('likeable')->withTrashed(); + } +} + class Post extends Model { use SoftDeletes; diff --git a/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php b/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php index 1032ad6281..9a0ac11f10 100644 --- a/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php +++ b/tests/Integration/Database/MySql/DatabaseMySqlConnectionTest.php @@ -10,6 +10,7 @@ use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; +use Hypervel\Testbench\Attributes\RequiresDatabase; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use PHPUnit\Framework\Attributes\RequiresPhpExtension; @@ -258,6 +259,18 @@ public static function jsonContainsKeyDataProvider(): array ]; } + #[RequiresDatabase('mysql', '>=8.0.17')] + public function testWhereJsonOverlapsWithPath(): void + { + DB::table(self::TABLE)->insert([ + [self::JSON_COL => '{"languages":["en","de"]}'], + [self::JSON_COL => '{"languages":["fr"]}'], + ]); + + $this->assertSame(1, DB::table(self::TABLE)->whereJsonOverlaps(self::JSON_COL . '->languages', ['en', 'es'])->count()); + $this->assertSame(1, DB::table(self::TABLE)->whereJsonDoesntOverlap(self::JSON_COL . '->languages', ['en', 'es'])->count()); + } + public function testLastInsertIdIsPreserved(): void { if (! Schema::hasTable('auto_id_table')) { diff --git a/tests/Integration/Foundation/ExceptionHandlerTest.php b/tests/Integration/Foundation/ExceptionHandlerTest.php index 325517c706..21ce657abc 100644 --- a/tests/Integration/Foundation/ExceptionHandlerTest.php +++ b/tests/Integration/Foundation/ExceptionHandlerTest.php @@ -12,6 +12,8 @@ use Hypervel\Contracts\Http\Kernel as HttpKernel; use Hypervel\Contracts\Routing\ResponseFactory as ResponseFactoryContract; use Hypervel\Contracts\Support\Responsable; +use Hypervel\Foundation\Http\Middleware\ConvertEmptyStringsToNull; +use Hypervel\Foundation\Http\Middleware\TrimStrings; use Hypervel\Http\Client\RequestException; use Hypervel\Http\JsonResponse; use Hypervel\Http\Middleware\PrefersJsonResponses; @@ -21,9 +23,13 @@ use Hypervel\Support\Facades\Http; use Hypervel\Support\Facades\Log; use Hypervel\Support\Facades\Route; +use Hypervel\Support\Lottery; use Hypervel\Testbench\TestCase; use Monolog\Handler\TestHandler; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; +use RuntimeException; +use Swoole\Coroutine\CanceledException; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Symfony\Component\Process\PhpProcess; use Throwable; @@ -47,6 +53,55 @@ public function testItRendersAuthorizationExceptions(): void ]); } + #[TestWith([false, true], 'accepted')] + #[TestWith([true, true], 'accepted and mapped')] + #[TestWith([true, false], 'rejected and mapped')] + public function testCancellationCleanupIsReportedOnceAcrossMiddlewareAndKernel(bool $mapped, bool $accepted): void + { + $failure = new RuntimeException('cleanup failed', 0, new CanceledException('canceled')); + $handler = $this->app->make(ExceptionHandler::class); + $reported = []; + $policyEvaluations = 0; + + Lottery::forceResultWithSequence([$accepted, ! $accepted], static fn (): bool => ! $accepted); + $handler->throttleUsing(function (Throwable $exception) use (&$policyEvaluations): Lottery { + ++$policyEvaluations; + + return Lottery::odds(1, 1000); + }); + + if ($mapped) { + $handler->map(RuntimeException::class, static fn (RuntimeException $exception): Exception => new Exception('mapped cleanup', 0, $exception)); + } + + $handler->reportable(function (Throwable $exception) use (&$reported): false { + $reported[] = $exception; + + return false; + }); + + Route::get('canceled', static fn (): never => throw $failure) + ->middleware([TrimStrings::class, ConvertEmptyStringsToNull::class]); + + try { + $this->app->make(HttpKernel::class)->handle(Request::create('/canceled')); + $this->fail('The cancellation was not preserved.'); + } catch (Throwable $exception) { + $this->assertSame($failure, $exception); + } + + // The native server also reports the escaping exception before containing it. + $handler->report($failure); + + $this->assertSame($accepted, $handler->shouldReport($failure)); + $this->assertSame(1, $policyEvaluations); + $this->assertCount((int) $accepted, $reported); + + if ($accepted) { + $this->assertSame($failure, $mapped ? $reported[0]->getPrevious() : $reported[0]); + } + } + public function testItDoesntReportExceptionsWithShouldntReportInterface(): void { Config::set('app.debug', true); diff --git a/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php b/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php index 63d674f01e..c01472673b 100644 --- a/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php +++ b/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php @@ -6,6 +6,7 @@ use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Exceptions\Renderer\Mappers\BladeMapper; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; @@ -13,9 +14,14 @@ use Hypervel\View\Compilers\BladeCompiler; use Hypervel\View\Engines\CompilerEngine; use Hypervel\View\Engines\EngineResolver; +use Hypervel\View\ViewException; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionMethod; +use RuntimeException; use Swoole\Coroutine\CanceledException; +use Symfony\Component\ErrorHandler\Exception\FlattenException; +use Throwable; use function Hypervel\Testbench\after_resolving; use function Hypervel\Testbench\package_path; @@ -28,6 +34,8 @@ class RenderBladeFilesTest extends TestCase */ protected function defineEnvironment(ApplicationContract $app): void { + $app->make('config')->set('view.compiled_extension', 'cache'); + after_resolving($app, 'view.engine.resolver', function (EngineResolver $resolver): void { $resolver->resolve('blade')->getCompiler()->withoutComponentTags(); }); @@ -117,13 +125,64 @@ public function testRoutingTooltipRendersMultilineSafely(): void public function testBladeMapperSkipsMissingOriginalAndCompiledPaths(): void { - CoroutineContext::set(CompilerEngine::COMPILED_PATH_CONTEXT_KEY, [ - ParallelTesting::tempDir('MissingBladeSource') . '/missing.blade.php', - ]); + $compiler = $this->app->make('blade.compiler'); + $path = ParallelTesting::tempDir('MissingBladeSource') . '/missing.blade.php'; + $compiled = $compiler->getCompiledPath($path); + $files = new Filesystem; + $files->delete($compiled); + $mapper = $this->app->make(BladeMapper::class); + + $this->assertNull($mapper->findCompiledView($compiled)); + + try { + $files->ensureDirectoryExists(dirname($compiled)); + $files->put($compiled, '/**PATH ' . $path . ' ENDPATH**/'); + $this->assertNull($mapper->findCompiledView($compiled)); + } finally { + $files->delete($compiled); + } + } + + #[TestWith([false, false], 'default echo format')] + #[TestWith([true, false], 'custom echo format')] + #[TestWith([true, true], 'custom echo format with symlinked cache')] + public function testBladeMapperFindsTheSourceAfterRenderingHasUnwound(bool $customFormat, bool $symlinkedCache): void + { + $files = new Filesystem; + $directory = ParallelTesting::tempDir('RenderBladeFilesTest'); + $files->deleteDirectory($directory); + $files->ensureDirectoryExists($directory); + $path = $directory . '/failing.blade.php'; + $files->put($directory . '/other.blade.php', 'Another view'); + $files->put($path, "/**PATH {$directory}/other.blade.php ENDPATH**/\n{{ missing_blade_mapping_test_function() }}"); - $method = new ReflectionMethod(BladeMapper::class, 'getKnownPaths'); + try { + if ($symlinkedCache) { + $files->ensureDirectoryExists($directory . '/compiled'); + symlink($directory . '/compiled', $directory . '/linked'); + config(['view.compiled' => $directory . '/linked']); + $this->app->forgetInstance('blade.compiler'); + $this->app->make('view.engine.resolver')->forget('blade'); + } - $this->assertSame([], $method->invoke($this->app->make(BladeMapper::class))); + $compiler = $this->app->make('blade.compiler'); + $render = fn (): string => $this->app->make('view')->file($path)->render(); + + try { + $customFormat + ? $compiler->usingEchoFormat('strtoupper(e(%s))', $render) + : $render(); + $this->fail('The view did not fail.'); + } catch (ViewException $exception) { + $this->assertSame([], CoroutineContext::get(CompilerEngine::COMPILED_PATH_CONTEXT_KEY)); + $mapped = $this->app->make(BladeMapper::class)->map(FlattenException::createFromThrowable($exception)); + $frames = array_values(array_filter($mapped->getTrace(), static fn (array $frame): bool => ($frame['file'] ?? null) === $path)); + $this->assertNotEmpty($frames); + $this->assertSame(2, $frames[0]['line']); + } + } finally { + $files->deleteDirectory($directory); + } } public function testBladeMapperPreservesCompiledLineWhenOriginalDisappears(): void @@ -137,9 +196,12 @@ public function testBladeMapperPreservesCompiledLineWhenOriginalDisappears(): vo ); } - public function testBladeMapperPreservesCancellationDuringSourceMapping(): void + #[TestWith([false])] + #[TestWith([true])] + public function testBladeMapperPreservesCancellationDuringSourceMapping(bool $throwingCleanup): void { $cancellation = new CanceledException('canceled'); + $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation; $compiler = m::mock(BladeCompiler::class); $compiler->shouldReceive('compileString')->once()->andThrow($cancellation); $method = new ReflectionMethod(BladeMapper::class, 'compileSourcemap'); @@ -148,7 +210,7 @@ public function testBladeMapperPreservesCancellationDuringSourceMapping(): void $method->invoke(new BladeMapper($compiler), 'plain text'); $this->fail('The cancellation was not preserved.'); - } catch (CanceledException $exception) { + } catch (Throwable $exception) { $this->assertSame($cancellation, $exception); } } diff --git a/tests/Integration/Log/LogManagerTest.php b/tests/Integration/Log/LogManagerTest.php index 5ec1215272..996aa56c29 100644 --- a/tests/Integration/Log/LogManagerTest.php +++ b/tests/Integration/Log/LogManagerTest.php @@ -30,11 +30,14 @@ use Monolog\Processor\PsrLogMessageProcessor; use Monolog\Processor\UidProcessor; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use Psr\Log\LoggerInterface; use Psr\Log\LoggerTrait; use ReflectionProperty; use RuntimeException; use Stringable; +use Swoole\Coroutine\CanceledException; +use Throwable; class LogManagerTest extends TestCase { @@ -387,6 +390,31 @@ protected function createEmergencyLogger(): LoggerInterface } } + #[TestWith([false])] + #[TestWith([true])] + public function testChannelCreationPreservesCancellationWithoutEmergencyFallback(bool $throwingCleanup): void + { + $cancellation = new CanceledException('canceled'); + $cancellation = $throwingCleanup ? new RuntimeException('cleanup failed', 0, $cancellation) : $cancellation; + $manager = new class($this->app) extends LogManager { + protected function createEmergencyLogger(): LoggerInterface + { + throw new RuntimeException('Emergency logger was created.'); + } + }; + config(['logging.channels.canceling' => ['driver' => 'canceling']]); + $manager->extend('canceling', static fn (): never => throw $cancellation); + + try { + $manager->channel('canceling'); + $this->fail('The cancellation was not preserved.'); + } catch (Throwable $exception) { + $this->assertSame($cancellation, $exception); + } + + $this->assertSame([], $manager->getChannels()); + } + public function testLogManagerCreateSingleDriverWithConfiguredFormatter(): void { $manager = new LogManager($this->app); diff --git a/tests/Integration/Mail/SendingMarkdownMailTest.php b/tests/Integration/Mail/SendingMarkdownMailTest.php index f1cb82e5f6..ce36cb040f 100644 --- a/tests/Integration/Mail/SendingMarkdownMailTest.php +++ b/tests/Integration/Mail/SendingMarkdownMailTest.php @@ -10,7 +10,6 @@ use Hypervel\Mail\Mailables\Envelope; use Hypervel\Mail\Markdown; use Hypervel\Support\Facades\Mail; -use Hypervel\Support\Stringable; use Hypervel\Testbench\TestCase; use Symfony\Component\Mime\Email; @@ -96,23 +95,28 @@ public function testEmbed(): void $mailable->assertSeeInText('Embed content: '); $mailable->assertDontSeeInText('Embed content: cid:'); - $email = $this->app->make('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage()->toString(); - - $cid = rtrim(explode(' cid:', (new Stringable($email))->explode("\r\n") - ->filter(fn (string $line): bool => str_contains($line, ' content: cid:')) - ->first())[1], '='); - - $filename = explode('Embed file: ', (new Stringable($email))->explode("\r\n") - ->filter(fn (string $line): bool => str_contains($line, ' file:')) - ->first())[1]; + /** @var Email $email */ + $email = $this->app->make('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage(); - $this->assertStringContainsString(<<\r - EOT, $email); + // The longer HTML filename may be folded or encoded in MIME headers. + $this->assertSame(1, preg_match('/Embed file: ([^\s<]+)/', $email->getHtmlBody(), $filename)); + $this->assertSame(1, preg_match('/Embed content: cid:([^\s<]+)/', $email->getHtmlBody(), $contentId)); + $this->assertSame(1, preg_match('/Embed file: ([^\s<]+)/', $email->getTextBody(), $textFilename)); + $this->assertCount(1, $attachments = $email->getAttachments()); + $attachment = $attachments[0]; + + $this->assertSame($filename[1], $attachment->getFilename()); + $this->assertSame($contentId[1], $attachment->getContentId()); + $this->assertSame('inline', $attachment->getDisposition()); + $this->assertSame('application', $attachment->getMediaType()); + $this->assertSame('x-php', $attachment->getMediaSubtype()); + $this->assertSame('base64', $attachment->getPreparedHeaders()->get('Content-Transfer-Encoding')->getBodyAsString()); + $this->assertStringStartsWith(pathinfo($textFilename[1], PATHINFO_FILENAME) . '_', $filename[1]); + + $raw = $email->toString(); + $this->assertStringContainsString('Content-Type: application/x-php;', $raw); + $this->assertStringContainsString(pathinfo($textFilename[1], PATHINFO_FILENAME), $raw); + $this->assertStringContainsString("Content-ID: <{$contentId[1]}>", $raw); } public function testEmbedData(): void diff --git a/tests/Integration/View/BladeCachedViewsTest.php b/tests/Integration/View/BladeCachedViewsTest.php new file mode 100644 index 0000000000..a12b0fc0c4 --- /dev/null +++ b/tests/Integration/View/BladeCachedViewsTest.php @@ -0,0 +1,89 @@ +directory = ParallelTesting::tempDir('BladeCachedViewsTest'); + (new Filesystem)->deleteDirectory($this->directory); + (new Filesystem)->ensureDirectoryExists($this->directory); + } + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + (new Filesystem)->deleteDirectory($this->directory); + + parent::tearDown(); + } + + public function testCompiledPathDiffersWhenEchoFormatChanges(): void + { + $compiler = Blade::getFacadeRoot(); + $path = $this->directory . '/uses-link.blade.php'; + + $default = $compiler->getCompiledPath($path); + + $custom = $compiler->usingEchoFormat( + 'new \Hypervel\Support\EncodedHtmlString(%s)', + fn (): string => $compiler->getCompiledPath($path) + ); + + $this->assertNotSame($default, $custom); + $this->assertSame($default, $compiler->getCompiledPath($path)); + $this->assertStringEndsWith('.php', $custom); + } + + public function testCachedViewIsNotReusedForADifferentEchoFormat(): void + { + $compiler = Blade::getFacadeRoot(); + $path = $this->directory . '/echo-format.blade.php'; + + file_put_contents($path, '{{ $value }}'); + + try { + $value = '[link](https://evil.example)'; + + // Compile with the default echo format, as "view:cache" does. + $compiler->compile($path); + $this->assertFileExists($default = $compiler->getCompiledPath($path)); + + $rendered = $compiler->usingEchoFormat( + 'new \Hypervel\Support\EncodedHtmlString(%s)', + function () use ($compiler, $path, $default, $value): string { + EncodedHtmlString::encodeUsing(fn (string $string): string => 'SECURED:' . $string); + + $this->assertNotSame($default, $compiler->getCompiledPath($path)); + + return View::file($path, ['value' => $value])->render(); + } + ); + + $this->assertSame('SECURED:' . $value, $rendered); + } finally { + @unlink($path); + } + } +} diff --git a/tests/Sentry/Tracing/BacktraceHelperTest.php b/tests/Sentry/Tracing/BacktraceHelperTest.php index 86cf46aade..8097cb5c49 100644 --- a/tests/Sentry/Tracing/BacktraceHelperTest.php +++ b/tests/Sentry/Tracing/BacktraceHelperTest.php @@ -4,10 +4,13 @@ namespace Hypervel\Tests\Sentry\Tracing; +use Hypervel\Config\Repository; +use Hypervel\Container\Container; use Hypervel\Filesystem\Filesystem; use Hypervel\Sentry\Tracing\BacktraceHelper; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\TestWith; use Sentry\Frame; use Sentry\Options; use Sentry\Serializer\RepresentationSerializer; @@ -26,6 +29,7 @@ public function testUnreadableCompiledViewReturnsNoOriginalPath(): void $this->assertIsResource($socket); try { + Container::getInstance()->instance('config', new Repository(['view' => ['compiled' => $directory]])); $options = new Options(['dsn' => null]); $helper = new BacktraceHelper($options, new RepresentationSerializer($options)); $frame = new Frame(null, '/storage/framework/views/compiled.php', 1, absoluteFilePath: $socketPath); @@ -36,4 +40,34 @@ public function testUnreadableCompiledViewReturnsNoOriginalPath(): void $filesystem->deleteDirectory($directory); } } + + #[TestWith([false])] + #[TestWith([true])] + public function testCustomCompiledViewPathResolvesOriginalSource(bool $symlinked): void + { + $directory = ParallelTesting::tempDir('SentryBacktraceHelperTest'); + $filesystem = new Filesystem; + $filesystem->deleteDirectory($directory); + $filesystem->ensureDirectoryExists($directory . '/compiled'); + + try { + $compiledDirectory = $directory . '/compiled'; + + if ($symlinked) { + symlink($compiledDirectory, $directory . '/linked'); + $compiledDirectory = $directory . '/linked'; + } + + Container::getInstance()->instance('config', new Repository(['view' => ['compiled' => $compiledDirectory]])); + $compiledPath = $directory . '/compiled/example.cache'; + $filesystem->put($compiledPath, '/**PATH /another/view.blade.php ENDPATH**/ /**PATH ' . $directory . '/resources/views/MY PATH example.blade.php ENDPATH**/'); + $options = new Options(['dsn' => null, 'prefixes' => [$directory]]); + $helper = new BacktraceHelper($options, new RepresentationSerializer($options)); + $frame = new Frame(null, '/compiled/example.cache', 1, absoluteFilePath: $compiledPath); + + $this->assertSame('/resources/views/MY PATH example.blade.php', $helper->getOriginalViewPathForFrameOfCompiledViewPath($frame)); + } finally { + $filesystem->deleteDirectory($directory); + } + } } diff --git a/tests/Telescope/Watchers/ExceptionWatcherTest.php b/tests/Telescope/Watchers/ExceptionWatcherTest.php index 0325607433..43a576dfe2 100644 --- a/tests/Telescope/Watchers/ExceptionWatcherTest.php +++ b/tests/Telescope/Watchers/ExceptionWatcherTest.php @@ -10,13 +10,19 @@ use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Filesystem\Filesystem; use Hypervel\Log\Context\Repository as ContextRepository; +use Hypervel\Support\Collection; +use Hypervel\Support\Lottery; use Hypervel\Telescope\EntryType; +use Hypervel\Telescope\IncomingEntry; +use Hypervel\Telescope\Telescope; use Hypervel\Telescope\Watchers\ExceptionWatcher; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\Telescope\FeatureTestCase; use LogicException; use ParseError; +use PHPUnit\Framework\Attributes\TestWith; +use Throwable; #[WithConfig('logging.default', 'null')] #[WithConfig('telescope.watchers', [ @@ -37,6 +43,7 @@ public function testExceptionWatcherRegisterEntries(): void $this->assertSame(EntryType::EXCEPTION, $entry->type); $this->assertSame(BananaException::class, $entry->content['class']); $this->assertSame(__FILE__, $entry->content['file']); + $this->assertSame($exception->getLine(), $entry->content['line']); $this->assertSame('Something went bananas.', $entry->content['message']); $this->assertArrayHasKey('trace', $entry->content); } @@ -54,6 +61,7 @@ public function testExceptionWatcherRegisterThrowableEntries(): void $this->assertSame(EntryType::EXCEPTION, $entry->type); $this->assertSame(BananaError::class, $entry->content['class']); $this->assertSame(__FILE__, $entry->content['file']); + $this->assertSame($exception->getLine(), $entry->content['line']); $this->assertSame('Something went bananas.', $entry->content['message']); $this->assertArrayHasKey('trace', $entry->content); } @@ -85,6 +93,38 @@ public function testExceptionWatcherRegisterEntriesWhenEvalFailed(): void $this->assertArrayHasKey('trace', $entry->content); } + #[TestWith(['filter', false], 'individual filter')] + #[TestWith(['filterBatch', false], 'batch filter')] + #[TestWith(['filter', true], 'individual filter with deduplication')] + #[TestWith(['filterBatch', true], 'batch filter with deduplication')] + public function testReportableFiltersKeepAdmittedMappedExceptions(string $filterMethod, bool $deduplicate): void + { + Telescope::{$filterMethod}(static fn (IncomingEntry|Collection $entries): bool => $entries instanceof IncomingEntry + ? $entries->isReportableException() + : $entries->contains(static fn (IncomingEntry $entry): bool => $entry->isReportableException())); + + $handler = $this->app->make(ExceptionHandler::class); + if ($deduplicate) { + $handler->dontReportDuplicates(); + } + + $handler->map(BananaException::class, static fn (BananaException $exception): BananaException => new BananaException($exception->getMessage(), 0, $exception)); + $evaluations = 0; + $handler->throttleUsing(static function (Throwable $exception) use (&$evaluations): Lottery { + ++$evaluations; + + return Lottery::odds(1, 2); + }); + Lottery::forceResultWithSequence([true, false], static fn (): bool => false); + + $exception = new BananaException('admitted failure'); + $handler->report($exception); + $handler->report($exception); + + $this->assertCount(1, $this->loadTelescopeEntries()); + $this->assertSame($deduplicate ? 1 : 2, $evaluations); + } + public function testExceptionWatcherStoresExtraWhenContextFacadeUsed(): void { ContextRepository::getInstance()->add('tenant_id', 42); diff --git a/tests/Telescope/Watchers/ViewWatcherTest.php b/tests/Telescope/Watchers/ViewWatcherTest.php index 5339e68043..125c0332aa 100644 --- a/tests/Telescope/Watchers/ViewWatcherTest.php +++ b/tests/Telescope/Watchers/ViewWatcherTest.php @@ -55,6 +55,24 @@ public function testViewWatcherRegistersViews(): void $this->assertSame(['name'], $entry->content['data']); } + public function testViewWatcherKeepsExternalPathsWithMatchingApplicationPrefix(): void + { + $files = new Filesystem; + $files->ensureDirectoryExists($this->viewDir . '/app-other'); + $path = $this->viewDir . '/app-other/example.blade.php'; + $files->put($path, 'External view'); + $originalBasePath = $this->app->basePath(); + + try { + $this->app->setBasePath($this->viewDir . '/app'); + $this->app->make(Factory::class)->file($path)->render(); + } finally { + $this->app->setBasePath($originalBasePath); + } + + $this->assertSame($path, $this->loadTelescopeEntries()->first()->content['path']); + } + public function testViewWatcherCapturesViewsWithoutComposers(): void { file_put_contents($this->viewDir . '/simple.blade.php', 'No composers here'); diff --git a/tests/View/ViewBladeCompilerTest.php b/tests/View/ViewBladeCompilerTest.php index 9cb5ae05f4..0e9d64b903 100644 --- a/tests/View/ViewBladeCompilerTest.php +++ b/tests/View/ViewBladeCompilerTest.php @@ -104,6 +104,18 @@ public function testCompilePathIsProperlyCreated(): void $this->assertEquals(__DIR__ . '/' . hash('xxh128', 'v3foo') . '.php', $compiler->getCompiledPath('foo')); } + public function testRelativeCompiledPathsPreserveExternalViewIdentity(): void + { + $compiler = new BladeCompiler($this->getFiles(), __DIR__, '/app'); + $relocated = new BladeCompiler($this->getFiles(), __DIR__, '/another/app/'); + $internal = $compiler->getCompiledPath('/app/views/page.blade.php'); + + $this->assertSame($internal, $relocated->getCompiledPath('/another/app/views/page.blade.php')); + $this->assertNotSame($internal, $compiler->getCompiledPath('/shared/app/views/page.blade.php')); + $this->assertNotSame($internal, $compiler->getCompiledPath('/views/page.blade.php')); + $this->assertNotSame($internal, $compiler->getCompiledPath('views/page.blade.php')); + } + public function testCompileCompilesFileAndReturnsContents(): void { $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); diff --git a/tests/View/ViewCompilerEngineTest.php b/tests/View/ViewCompilerEngineTest.php index c10831069f..7624019739 100755 --- a/tests/View/ViewCompilerEngineTest.php +++ b/tests/View/ViewCompilerEngineTest.php @@ -131,7 +131,7 @@ public function testViewsAreRecompiledWhenCompiledViewIsMissingViaFileNotFoundEx $engine->getCompiler() ->expects('getCompiledPath') - ->times(3) + ->twice() ->with($path) ->andReturn($compiled); @@ -171,7 +171,7 @@ public function testViewsAreRecompiledWhenCompiledViewIsMissingViaRequireExcepti $engine->getCompiler() ->expects('getCompiledPath') - ->times(3) + ->twice() ->with($path) ->andReturn($compiled); @@ -213,7 +213,7 @@ public function testViewsAreRecompiledJustOnceWhenCompiledViewIsMissing(): void $engine->getCompiler() ->expects('getCompiledPath') - ->times(3) + ->twice() ->with($path) ->andReturn($compiled); @@ -307,6 +307,27 @@ public function testExpiredViewsAreCheckedAndCompiledOnEveryRender(): void $engine->get($path); } + public function testEachCompiledVariantHasItsOwnFreshnessCheck(): void + { + $path = '/views/example.blade.php'; + $files = m::mock(Filesystem::class); + $engine = $this->getEngine($files); + + $engine->getCompiler()->expects('getCompiledPath')->twice()->with($path) + ->andReturn('/compiled/default.php', '/compiled/custom.php'); + $engine->getCompiler()->expects('isExpired')->twice()->with($path)->andReturn(false, true); + $engine->getCompiler()->expects('compile')->with($path); + $files->expects('getRequire')->with('/compiled/default.php', [])->andReturnUsing(static function (): void { + echo 'default'; + }); + $files->expects('getRequire')->with('/compiled/custom.php', [])->andReturnUsing(static function (): void { + echo 'custom'; + }); + + $this->assertSame('default', $engine->get($path)); + $this->assertSame('custom', $engine->get($path)); + } + public function testCompiledPathIsPoppedAfterSuccessfulRender(): void { $path = __DIR__ . '/Fixtures/foo.php';