diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d4365b7927cb..cd988a7d4a30 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ on: jobs: linux_tests: - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 services: memcached: diff --git a/CHANGELOG-6.x.md b/CHANGELOG-6.x.md index ee1b7ced29b4..b041da96c128 100644 --- a/CHANGELOG-6.x.md +++ b/CHANGELOG-6.x.md @@ -1,6 +1,21 @@ # Release Notes for 6.x -## [Unreleased](https://github.com/laravel/framework/compare/v6.20.16...6.x) +## [Unreleased](https://github.com/laravel/framework/compare/v6.20.18...6.x) + + +## [v6.20.18 (2021-03-09)](https://github.com/laravel/framework/compare/v6.20.17...v6.20.18) + +### Fixed +- Fix validator treating null as true for (required|exclude)_(if|unless) due to loose `in_array()` check ([#36504](https://github.com/laravel/framework/pull/36504)) + +### Changed +- Delete existing links that are broken in `Illuminate\Foundation\Console\StorageLinkCommand` ([#36470](https://github.com/laravel/framework/pull/36470)) + + +## [v6.20.17 (2021-03-02)](https://github.com/laravel/framework/compare/v6.20.16...v6.20.17) + +### Added +- Added new line to `DetectsLostConnections` ([#36373](https://github.com/laravel/framework/pull/36373)) ## [v6.20.16 (2021-02-02)](https://github.com/laravel/framework/compare/v6.20.15...v6.20.16) @@ -253,6 +268,8 @@ ### Changed - Improve cookie encryption ([#33662](https://github.com/laravel/framework/pull/33662)) +This change will invalidate all existing cookies. Please see [this security bulletin](https://blog.laravel.com/laravel-cookie-security-releases) for more information. + ## [v6.18.26 (2020-07-21)](https://github.com/laravel/framework/compare/v6.18.25...v6.18.26) diff --git a/CHANGELOG-8.x.md b/CHANGELOG-8.x.md index 4d383e7fc191..399b85419411 100644 --- a/CHANGELOG-8.x.md +++ b/CHANGELOG-8.x.md @@ -1,6 +1,91 @@ # Release Notes for 8.x -## [Unreleased](https://github.com/laravel/framework/compare/v8.29.0...8.x) +## [Unreleased](https://github.com/laravel/framework/compare/v8.33.0...8.x) + + +## [v8.33.0 (2021-03-16)](https://github.com/laravel/framework/compare/v8.32.1...v8.33.0) + +### Added +- Added broken pipe exception as lost connection error ([#36601](https://github.com/laravel/framework/pull/36601)) +- Added missing option to resource ([#36562](https://github.com/laravel/framework/pull/36562)) +- Introduce StringEncrypter interface ([#36578](https://github.com/laravel/framework/pull/36578)) + +### Fixed +- Fixed returns with Mail & Notification components ([#36559](https://github.com/laravel/framework/pull/36559)) +- Stack driver fix: respect the defined processors in LogManager ([#36591](https://github.com/laravel/framework/pull/36591)) +- Require the correct password to rehash it when logging out other devices ([#36608](https://github.com/laravel/framework/pull/36608), [1e61612](https://github.com/laravel/framework/commit/1e6161250074b8106c1fcf153eeaef7c0bf74c6c)) + +### Changed +- Allow nullable columns for `AsArrayObject/AsCollection` casts ([#36526](https://github.com/laravel/framework/pull/36526)) +- Accept callable class for reportable and renderable in exception handler ([#36551](https://github.com/laravel/framework/pull/36551)) +- Container - detect circular dependencies ([dd7274d](https://github.com/laravel/framework/commit/dd7274d23a9ee58cc1abdf7107403169a3994b68), [a712f72](https://github.com/laravel/framework/commit/a712f72ca88f709335576530b31635738abd4c89), [6f9bb4c](https://github.com/laravel/framework/commit/6f9bb4cdd84295cbcf7908cc4b4684f47f38b8cf)) +- Initialize CronExpression class using new keyword ([#36600](https://github.com/laravel/framework/pull/36600)) +- Use different config key for overriding temporary url host in AwsTemporaryUrl method ([#36612](https://github.com/laravel/framework/pull/36612)) + + +## [v8.32.1 (2021-03-09)](https://github.com/laravel/framework/compare/v8.32.0...v8.32.1) + +### Changed +- Changed `Illuminate\Queue\Middleware\ThrottlesExceptions` ([b8a70e9](https://github.com/laravel/framework/commit/b8a70e9a3685871ed46a24fc03c0267849d2d7c8)) + + +## [v8.32.0 (2021-03-09)](https://github.com/laravel/framework/compare/v8.31.0...v8.32.0) + +Added +- Phpredis lock serialization and compression support ([#36412](https://github.com/laravel/framework/pull/36412), [10f1a93](https://github.com/laravel/framework/commit/10f1a935205340ba8954e7075c1d9b67943db27d)) +- Added Fluent JSON Assertions ([#36454](https://github.com/laravel/framework/pull/36454)) +- Added methods to dump requests of the Laravel HTTP client ([#36466](https://github.com/laravel/framework/pull/36466)) +- Added `ThrottlesExceptions` and `ThrottlesExceptionsWithRedis` job middlewares for unstable services ([#36473](https://github.com/laravel/framework/pull/36473), [21fee76](https://github.com/laravel/framework/commit/21fee7649e1b48a7701b8ba860218741c2c3bcef), [36518](https://github.com/laravel/framework/pull/36518), [37e48ba](https://github.com/laravel/framework/commit/37e48ba864e2f463517429d41cefd94e88136c1c)) +- Added support to Eloquent Collection on `Model::destroy()` ([#36497](https://github.com/laravel/framework/pull/36497)) +- Added `rest` option to `php artisan queue:work` command ([#36521](https://github.com/laravel/framework/pull/36521), [c6ea49c](https://github.com/laravel/framework/commit/c6ea49c80a2ac93aebb8fdf2360161b73cec26af)) +- Added `prohibited_if` and `prohibited_unless` validation rules ([#36516](https://github.com/laravel/framework/pull/36516)) +- Added class `argument` to `Illuminate\Database\Console\Seeds\SeedCommand` ([#36513](https://github.com/laravel/framework/pull/36513)) + +### Fixed +- Fix validator treating null as true for (required|exclude)_(if|unless) due to loose `in_array()` check ([#36504](https://github.com/laravel/framework/pull/36504)) + +### Changed +- Delete existing links that are broken in `Illuminate\Foundation\Console\StorageLinkCommand` ([#36470](https://github.com/laravel/framework/pull/36470)) +- Use user provided url in AwsTemporaryUrl method ([#36480](https://github.com/laravel/framework/pull/36480)) +- Allow to override discover events base path ([#36515](https://github.com/laravel/framework/pull/36515)) + + +## [v8.31.0 (2021-03-04)](https://github.com/laravel/framework/compare/v8.30.1...v8.31.0) + +### Added +- Added new `VendorTagPublished` event ([#36458](https://github.com/laravel/framework/pull/36458)) +- Added new `Stringable::test()` method ([#36462](https://github.com/laravel/framework/pull/36462)) + +### Reverted +- Reverted [Fixed `formatWheres()` methods in `DatabaseRule`](https://github.com/laravel/framework/pull/36441) ([#36452](https://github.com/laravel/framework/pull/36452)) + +### Changed +- Make user policy command fix (Windows) ([#36464](https://github.com/laravel/framework/pull/36464)) + + +## [v8.30.1 (2021-03-03)](https://github.com/laravel/framework/compare/v8.30.0...v8.30.1) + +### Reverted +- Reverted [Respect custom route key with explicit route model binding](https://github.com/laravel/framework/pull/36375) ([#36449](https://github.com/laravel/framework/pull/36449)) + +### Fixed +- Fixed `formatWheres()` methods in `DatabaseRule` ([#36441](https://github.com/laravel/framework/pull/36441)) + + +## [v8.30.0 (2021-03-02)](https://github.com/laravel/framework/compare/v8.29.0...v8.30.0) + +### Added +- Added new line to `DetectsLostConnections` ([#36373](https://github.com/laravel/framework/pull/36373)) +- Added `Illuminate\Cache\RateLimiting\Limit::perMinutes()` ([#36352](https://github.com/laravel/framework/pull/36352), [86d0a5c](https://github.com/laravel/framework/commit/86d0a5c733b3f22ae2353df538e07605963c3052)) +- Make Database Factory macroable ([#36380](https://github.com/laravel/framework/pull/36380)) +- Added stop on first failure for Validators ([39e1f84](https://github.com/laravel/framework/commit/39e1f84a48fec024859d4e80948aca9bd7878658)) +- Added `containsOneItem()` method to Collections ([#36428](https://github.com/laravel/framework/pull/36428), [5b7ffc2](https://github.com/laravel/framework/commit/5b7ffc2b54dec803bd12541ab9c3d6bf3d4666ca)) + +### Changed +- Respect custom route key with explicit route model binding ([#36375](https://github.com/laravel/framework/pull/36375)) +- Add Buffered Console Output ([#36404](https://github.com/laravel/framework/pull/36404)) +- Don't flash 'current_password' input ([#36415](https://github.com/laravel/framework/pull/36415)) +- Check for context method in Exception Handler ([#36424](https://github.com/laravel/framework/pull/36424)) ## [v8.29.0 (2021-02-23)](https://github.com/laravel/framework/compare/v8.28.1...v8.29.0) diff --git a/src/Illuminate/Auth/SessionGuard.php b/src/Illuminate/Auth/SessionGuard.php index 1f6863141c4c..4bb3fd4b6f73 100644 --- a/src/Illuminate/Auth/SessionGuard.php +++ b/src/Illuminate/Auth/SessionGuard.php @@ -20,6 +20,7 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; +use InvalidArgumentException; use RuntimeException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; @@ -581,6 +582,8 @@ protected function cycleRememberToken(AuthenticatableContract $user) * @param string $password * @param string $attribute * @return bool|null + * + * @throws \Illuminate\Auth\AuthenticationException */ public function logoutOtherDevices($password, $attribute = 'password') { @@ -588,9 +591,7 @@ public function logoutOtherDevices($password, $attribute = 'password') return; } - $result = tap($this->user()->forceFill([ - $attribute => Hash::make($password), - ]))->save(); + $result = $this->rehashUserPassword($password, $attribute); if ($this->recaller() || $this->getCookieJar()->hasQueued($this->getRecallerName())) { @@ -602,6 +603,26 @@ public function logoutOtherDevices($password, $attribute = 'password') return $result; } + /** + * Rehash the current user's password. + * + * @param string $password + * @param string $attribute + * @return bool|null + * + * @throws \InvalidArgumentException + */ + protected function rehashUserPassword($password, $attribute) + { + if (! Hash::check($password, $this->user()->{$attribute})) { + throw new InvalidArgumentException('The given password does not match the current password.'); + } + + return tap($this->user()->forceFill([ + $attribute => Hash::make($password), + ]))->save(); + } + /** * Register an authentication attempt event listener. * diff --git a/src/Illuminate/Cache/PhpRedisLock.php b/src/Illuminate/Cache/PhpRedisLock.php new file mode 100644 index 000000000000..9d0215f37d6d --- /dev/null +++ b/src/Illuminate/Cache/PhpRedisLock.php @@ -0,0 +1,110 @@ +redis->eval( + LuaScripts::releaseLock(), + 1, + $this->name, + $this->serializedAndCompressedOwner() + ); + } + + /** + * Get the owner key, serialized and compressed. + * + * @return string + */ + protected function serializedAndCompressedOwner(): string + { + $client = $this->redis->client(); + + $owner = $client->_serialize($this->owner); + + // https://github.com/phpredis/phpredis/issues/1938 + if ($this->compressed()) { + if ($this->lzfCompressed()) { + $owner = \lzf_compress($owner); + } elseif ($this->zstdCompressed()) { + $owner = \zstd_compress($owner, $client->getOption(Redis::OPT_COMPRESSION_LEVEL)); + } elseif ($this->lz4Compressed()) { + $owner = \lz4_compress($owner, $client->getOption(Redis::OPT_COMPRESSION_LEVEL)); + } else { + throw new UnexpectedValueException(sprintf( + 'Unknown phpredis compression in use [%d]. Unable to release lock.', + $client->getOption(Redis::OPT_COMPRESSION) + )); + } + } + + return $owner; + } + + /** + * Determine if compression is enabled. + * + * @return bool + */ + protected function compressed(): bool + { + return $this->redis->client()->getOption(Redis::OPT_COMPRESSION) !== Redis::COMPRESSION_NONE; + } + + /** + * Determine if LZF compression is enabled. + * + * @return bool + */ + protected function lzfCompressed(): bool + { + return defined('Redis::COMPRESSION_LZF') && + $this->redis->client()->getOption(Redis::OPT_COMPRESSION) === Redis::COMPRESSION_LZF; + } + + /** + * Determine if ZSTD compression is enabled. + * + * @return bool + */ + protected function zstdCompressed(): bool + { + return defined('Redis::COMPRESSION_ZSTD') && + $this->redis->client()->getOption(Redis::OPT_COMPRESSION) === Redis::COMPRESSION_ZSTD; + } + + /** + * Determine if LZ4 compression is enabled. + * + * @return bool + */ + protected function lz4Compressed(): bool + { + return defined('Redis::COMPRESSION_LZ4') && + $this->redis->client()->getOption(Redis::OPT_COMPRESSION) === Redis::COMPRESSION_LZ4; + } +} diff --git a/src/Illuminate/Cache/RedisStore.php b/src/Illuminate/Cache/RedisStore.php index cdf1c8fca094..4896c9183d03 100755 --- a/src/Illuminate/Cache/RedisStore.php +++ b/src/Illuminate/Cache/RedisStore.php @@ -4,6 +4,7 @@ use Illuminate\Contracts\Cache\LockProvider; use Illuminate\Contracts\Redis\Factory as Redis; +use Illuminate\Redis\Connections\PhpRedisConnection; class RedisStore extends TaggableStore implements LockProvider { @@ -188,7 +189,15 @@ public function forever($key, $value) */ public function lock($name, $seconds = 0, $owner = null) { - return new RedisLock($this->lockConnection(), $this->prefix.$name, $seconds, $owner); + $lockName = $this->prefix.$name; + + $lockConnection = $this->lockConnection(); + + if ($lockConnection instanceof PhpRedisConnection) { + return new PhpRedisLock($lockConnection, $lockName, $seconds, $owner); + } + + return new RedisLock($lockConnection, $lockName, $seconds, $owner); } /** diff --git a/src/Illuminate/Collections/helpers.php b/src/Illuminate/Collections/helpers.php index 6ae6dfe68a9b..67669e5ce1c6 100644 --- a/src/Illuminate/Collections/helpers.php +++ b/src/Illuminate/Collections/helpers.php @@ -179,8 +179,8 @@ function last($array) * @param mixed $value * @return mixed */ - function value($value) + function value($value, ...$args) { - return $value instanceof Closure ? $value() : $value; + return $value instanceof Closure ? $value(...$args) : $value; } } diff --git a/src/Illuminate/Console/Scheduling/Event.php b/src/Illuminate/Console/Scheduling/Event.php index a680c1a64cb0..b3ab9b2db567 100644 --- a/src/Illuminate/Console/Scheduling/Event.php +++ b/src/Illuminate/Console/Scheduling/Event.php @@ -328,7 +328,7 @@ protected function expressionPasses() $date->setTimezone($this->timezone); } - return CronExpression::factory($this->expression)->isDue($date->toDateTimeString()); + return (new CronExpression($this->expression))->isDue($date->toDateTimeString()); } /** @@ -890,9 +890,8 @@ public function getSummaryForDisplay() */ public function nextRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false) { - return Date::instance(CronExpression::factory( - $this->getExpression() - )->getNextRunDate($currentTime, $nth, $allowCurrentDate, $this->timezone)); + return Date::instance((new CronExpression($this->getExpression())) + ->getNextRunDate($currentTime, $nth, $allowCurrentDate, $this->timezone)); } /** diff --git a/src/Illuminate/Container/Container.php b/src/Illuminate/Container/Container.php index 2cfa72f51bb7..642d1e082e6d 100755 --- a/src/Illuminate/Container/Container.php +++ b/src/Illuminate/Container/Container.php @@ -6,6 +6,7 @@ use Closure; use Exception; use Illuminate\Contracts\Container\BindingResolutionException; +use Illuminate\Contracts\Container\CircularDependencyException; use Illuminate\Contracts\Container\Container as ContainerContract; use LogicException; use ReflectionClass; @@ -659,7 +660,7 @@ public function get($id) try { return $this->resolve($id); } catch (Exception $e) { - if ($this->has($id)) { + if ($this->has($id) || $e instanceof CircularDependencyException) { throw $e; } @@ -676,6 +677,7 @@ public function get($id) * @return mixed * * @throws \Illuminate\Contracts\Container\BindingResolutionException + * @throws \Illuminate\Contracts\Container\CircularDependencyException */ protected function resolve($abstract, $parameters = [], $raiseEvents = true) { @@ -816,6 +818,7 @@ protected function isBuildable($concrete, $abstract) * @return mixed * * @throws \Illuminate\Contracts\Container\BindingResolutionException + * @throws \Illuminate\Contracts\Container\CircularDependencyException */ public function build($concrete) { @@ -839,6 +842,10 @@ public function build($concrete) return $this->notInstantiable($concrete); } + // if (in_array($concrete, $this->buildStack)) { + // throw new CircularDependencyException("Circular dependency detected while resolving [{$concrete}]."); + // } + $this->buildStack[] = $concrete; $constructor = $reflector->getConstructor(); diff --git a/src/Illuminate/Contracts/Container/CircularDependencyException.php b/src/Illuminate/Contracts/Container/CircularDependencyException.php new file mode 100644 index 000000000000..6c90381cc0cd --- /dev/null +++ b/src/Illuminate/Contracts/Container/CircularDependencyException.php @@ -0,0 +1,11 @@ +recordsModified = false; + } + /** * Is Doctrine available? * diff --git a/src/Illuminate/Database/Console/Seeds/SeedCommand.php b/src/Illuminate/Database/Console/Seeds/SeedCommand.php index ccca6fd5eeda..058e545c234f 100644 --- a/src/Illuminate/Database/Console/Seeds/SeedCommand.php +++ b/src/Illuminate/Database/Console/Seeds/SeedCommand.php @@ -6,6 +6,7 @@ use Illuminate\Console\ConfirmableTrait; use Illuminate\Database\ConnectionResolverInterface as Resolver; use Illuminate\Database\Eloquent\Model; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; class SeedCommand extends Command @@ -81,7 +82,7 @@ public function handle() */ protected function getSeeder() { - $class = $this->input->getOption('class'); + $class = $this->input->getArgument('class') ?? $this->input->getOption('class'); if (strpos($class, '\\') === false) { $class = 'Database\\Seeders\\'.$class; @@ -109,6 +110,18 @@ protected function getDatabase() return $database ?: $this->laravel['config']['database.default']; } + /** + * Get the console command arguments. + * + * @return array + */ + protected function getArguments() + { + return [ + ['class', InputArgument::OPTIONAL, 'The class name of the root seeder', null], + ]; + } + /** * Get the console command options. * diff --git a/src/Illuminate/Database/DetectsLostConnections.php b/src/Illuminate/Database/DetectsLostConnections.php index 1ecfc96140f4..a0bad6718017 100644 --- a/src/Illuminate/Database/DetectsLostConnections.php +++ b/src/Illuminate/Database/DetectsLostConnections.php @@ -50,6 +50,7 @@ protected function causedByLostConnection(Throwable $e) 'SSL: Connection timed out', 'SQLSTATE[HY000]: General error: 1105 The last transaction was aborted due to Seamless Scaling. Please retry.', 'Temporary failure in name resolution', + 'SSL: Broken pipe', ]); } } diff --git a/src/Illuminate/Database/Eloquent/Casts/AsArrayObject.php b/src/Illuminate/Database/Eloquent/Casts/AsArrayObject.php index 8d950e2daff4..a939e8acdbdb 100644 --- a/src/Illuminate/Database/Eloquent/Casts/AsArrayObject.php +++ b/src/Illuminate/Database/Eloquent/Casts/AsArrayObject.php @@ -18,7 +18,7 @@ public static function castUsing(array $arguments) return new class implements CastsAttributes { public function get($model, $key, $value, $attributes) { - return new ArrayObject(json_decode($attributes[$key], true)); + return isset($attributes[$key]) ? new ArrayObject(json_decode($attributes[$key], true)) : null; } public function set($model, $key, $value, $attributes) diff --git a/src/Illuminate/Database/Eloquent/Casts/AsCollection.php b/src/Illuminate/Database/Eloquent/Casts/AsCollection.php index e96834f6e4d3..c2d567b504f7 100644 --- a/src/Illuminate/Database/Eloquent/Casts/AsCollection.php +++ b/src/Illuminate/Database/Eloquent/Casts/AsCollection.php @@ -19,7 +19,7 @@ public static function castUsing(array $arguments) return new class implements CastsAttributes { public function get($model, $key, $value, $attributes) { - return new Collection(json_decode($attributes[$key], true)); + return isset($attributes[$key]) ? new Collection(json_decode($attributes[$key], true)) : null; } public function set($model, $key, $value, $attributes) diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 575148909050..62fcd058e258 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -10,6 +10,7 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Contracts\Support\Jsonable; use Illuminate\Database\ConnectionResolverInterface as Resolver; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\Concerns\AsPivot; use Illuminate\Database\Eloquent\Relations\HasManyThrough; @@ -1061,6 +1062,10 @@ protected function insertAndSetId(Builder $query, $attributes) */ public static function destroy($ids) { + if ($ids instanceof EloquentCollection) { + $ids = $ids->modelKeys(); + } + if ($ids instanceof BaseCollection) { $ids = $ids->all(); } diff --git a/src/Illuminate/Encryption/Encrypter.php b/src/Illuminate/Encryption/Encrypter.php index 8159b2bf6a9e..c0e5e50b8092 100755 --- a/src/Illuminate/Encryption/Encrypter.php +++ b/src/Illuminate/Encryption/Encrypter.php @@ -5,9 +5,10 @@ use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Contracts\Encryption\Encrypter as EncrypterContract; use Illuminate\Contracts\Encryption\EncryptException; +use Illuminate\Contracts\Encryption\StringEncrypter; use RuntimeException; -class Encrypter implements EncrypterContract +class Encrypter implements EncrypterContract, StringEncrypter { /** * The encryption key. diff --git a/src/Illuminate/Events/NullDispatcher.php b/src/Illuminate/Events/NullDispatcher.php index 5c020cfdd732..5c539d53a361 100644 --- a/src/Illuminate/Events/NullDispatcher.php +++ b/src/Illuminate/Events/NullDispatcher.php @@ -37,6 +37,7 @@ public function __construct(DispatcherContract $dispatcher) */ public function dispatch($event, $payload = [], $halt = false) { + // } /** @@ -48,6 +49,7 @@ public function dispatch($event, $payload = [], $halt = false) */ public function push($event, $payload = []) { + // } /** @@ -59,6 +61,7 @@ public function push($event, $payload = []) */ public function until($event, $payload = []) { + // } /** diff --git a/src/Illuminate/Filesystem/FilesystemAdapter.php b/src/Illuminate/Filesystem/FilesystemAdapter.php index 896bfbd6f35a..935c413cd6c0 100644 --- a/src/Illuminate/Filesystem/FilesystemAdapter.php +++ b/src/Illuminate/Filesystem/FilesystemAdapter.php @@ -23,6 +23,7 @@ use League\Flysystem\Sftp\SftpAdapter as Sftp; use PHPUnit\Framework\Assert as PHPUnit; use Psr\Http\Message\StreamInterface; +use Psr\Http\Message\UriInterface; use RuntimeException; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -593,9 +594,18 @@ public function getAwsTemporaryUrl($adapter, $path, $expiration, $options) 'Key' => $adapter->getPathPrefix().$path, ], $options)); - return (string) $client->createPresignedRequest( + $uri = $client->createPresignedRequest( $command, $expiration )->getUri(); + + // If an explicit base URL has been set on the disk configuration then we will use + // it as the base URL instead of the default path. This allows the developer to + // have full control over the base path for this filesystem's generated URLs. + if (! is_null($url = $this->driver->getConfig()->get('temporary_url'))) { + $uri = $this->replaceBaseUrl($uri, $url); + } + + return (string) $uri; } /** @@ -610,6 +620,20 @@ protected function concatPathToUrl($url, $path) return rtrim($url, '/').'/'.ltrim($path, '/'); } + /** + * Replace the scheme and host of the given UriInterface with values from the given URL. + * + * @param \Psr\Http\Message\UriInterface $uri + * @param string $url + * @return \Psr\Http\Message\UriInterface + */ + protected function replaceBaseUrl($uri, $url) + { + $parsed = parse_url($url); + + return $uri->withScheme($parsed['scheme'])->withHost($parsed['host']); + } + /** * Get an array of all files in a directory. * diff --git a/src/Illuminate/Foundation/Application.php b/src/Illuminate/Foundation/Application.php index b61e3db92acf..cbd1cba90c73 100755 --- a/src/Illuminate/Foundation/Application.php +++ b/src/Illuminate/Foundation/Application.php @@ -33,7 +33,7 @@ class Application extends Container implements ApplicationContract, CachesConfig * * @var string */ - const VERSION = '8.30.0'; + const VERSION = '8.33.1'; /** * The base path for the Laravel installation. @@ -1302,9 +1302,9 @@ public function registerCoreContainerAliases() 'cache.psr6' => [\Symfony\Component\Cache\Adapter\Psr16Adapter::class, \Symfony\Component\Cache\Adapter\AdapterInterface::class, \Psr\Cache\CacheItemPoolInterface::class], 'config' => [\Illuminate\Config\Repository::class, \Illuminate\Contracts\Config\Repository::class], 'cookie' => [\Illuminate\Cookie\CookieJar::class, \Illuminate\Contracts\Cookie\Factory::class, \Illuminate\Contracts\Cookie\QueueingFactory::class], - 'encrypter' => [\Illuminate\Encryption\Encrypter::class, \Illuminate\Contracts\Encryption\Encrypter::class], 'db' => [\Illuminate\Database\DatabaseManager::class, \Illuminate\Database\ConnectionResolverInterface::class], 'db.connection' => [\Illuminate\Database\Connection::class, \Illuminate\Database\ConnectionInterface::class], + 'encrypter' => [\Illuminate\Encryption\Encrypter::class, \Illuminate\Contracts\Encryption\Encrypter::class, \Illuminate\Contracts\Encryption\StringEncrypter::class], 'events' => [\Illuminate\Events\Dispatcher::class, \Illuminate\Contracts\Events\Dispatcher::class], 'files' => [\Illuminate\Filesystem\Filesystem::class], 'filesystem' => [\Illuminate\Filesystem\FilesystemManager::class, \Illuminate\Contracts\Filesystem\Factory::class], diff --git a/src/Illuminate/Foundation/Console/PolicyMakeCommand.php b/src/Illuminate/Foundation/Console/PolicyMakeCommand.php index 0fe6964d5940..78873de065ee 100644 --- a/src/Illuminate/Foundation/Console/PolicyMakeCommand.php +++ b/src/Illuminate/Foundation/Console/PolicyMakeCommand.php @@ -131,8 +131,13 @@ protected function replaceModel($stub, $model) array_keys($replace), array_values($replace), $stub ); - return str_replace( - "use {$namespacedModel};\nuse {$namespacedModel};", "use {$namespacedModel};", $stub + return preg_replace( + vsprintf('/use %s;[\r\n]+use %s;/', [ + preg_quote($namespacedModel, '/'), + preg_quote($namespacedModel, '/'), + ]), + "use {$namespacedModel};", + $stub ); } diff --git a/src/Illuminate/Foundation/Console/StorageLinkCommand.php b/src/Illuminate/Foundation/Console/StorageLinkCommand.php index a0419bf6c077..0d47ddae7294 100644 --- a/src/Illuminate/Foundation/Console/StorageLinkCommand.php +++ b/src/Illuminate/Foundation/Console/StorageLinkCommand.php @@ -35,6 +35,10 @@ public function handle() continue; } + if (is_link($link)) { + $this->laravel->make('files')->delete($link); + } + if ($relative) { $this->laravel->make('files')->relativeLink($target, $link); } else { diff --git a/src/Illuminate/Foundation/Console/VendorPublishCommand.php b/src/Illuminate/Foundation/Console/VendorPublishCommand.php index 17a459e72834..501142f0d63c 100644 --- a/src/Illuminate/Foundation/Console/VendorPublishCommand.php +++ b/src/Illuminate/Foundation/Console/VendorPublishCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Filesystem\Filesystem; +use Illuminate\Foundation\Events\VendorTagPublished; use Illuminate\Support\Arr; use Illuminate\Support\ServiceProvider; use League\Flysystem\Adapter\Local as LocalAdapter; @@ -159,7 +160,9 @@ protected function publishTag($tag) { $published = false; - foreach ($this->pathsToPublish($tag) as $from => $to) { + $pathsToPublish = $this->pathsToPublish($tag); + + foreach ($pathsToPublish as $from => $to) { $this->publishItem($from, $to); $published = true; @@ -167,6 +170,8 @@ protected function publishTag($tag) if ($published === false) { $this->error('Unable to locate publishable resources.'); + } else { + $this->laravel['events']->dispatch(new VendorTagPublished($tag, $pathsToPublish)); } } diff --git a/src/Illuminate/Foundation/Console/stubs/view-component.stub b/src/Illuminate/Foundation/Console/stubs/view-component.stub index 5c6ecc586ec4..22eae518c1a4 100644 --- a/src/Illuminate/Foundation/Console/stubs/view-component.stub +++ b/src/Illuminate/Foundation/Console/stubs/view-component.stub @@ -19,7 +19,7 @@ class DummyClass extends Component /** * Get the view / contents that represent the component. * - * @return \Illuminate\Contracts\View\View|string + * @return \Illuminate\Contracts\View\View|\Closure|string */ public function render() { diff --git a/src/Illuminate/Foundation/Events/VendorTagPublished.php b/src/Illuminate/Foundation/Events/VendorTagPublished.php new file mode 100644 index 000000000000..084c1293fcfd --- /dev/null +++ b/src/Illuminate/Foundation/Events/VendorTagPublished.php @@ -0,0 +1,33 @@ +tag = $tag; + $this->paths = $paths; + } +} diff --git a/src/Illuminate/Foundation/Exceptions/Handler.php b/src/Illuminate/Foundation/Exceptions/Handler.php index 38c29a3996c7..bedb1fca3be4 100644 --- a/src/Illuminate/Foundation/Exceptions/Handler.php +++ b/src/Illuminate/Foundation/Exceptions/Handler.php @@ -139,6 +139,10 @@ public function register() */ public function reportable(callable $reportUsing) { + if (! $reportUsing instanceof Closure) { + $reportUsing = Closure::fromCallable($reportUsing); + } + return tap(new ReportableHandler($reportUsing), function ($callback) { $this->reportCallbacks[] = $callback; }); @@ -152,6 +156,10 @@ public function reportable(callable $reportUsing) */ public function renderable(callable $renderUsing) { + if (! $renderUsing instanceof Closure) { + $renderUsing = Closure::fromCallable($renderUsing); + } + $this->renderCallbacks[] = $renderUsing; return $this; diff --git a/src/Illuminate/Foundation/Support/Providers/EventServiceProvider.php b/src/Illuminate/Foundation/Support/Providers/EventServiceProvider.php index 0573563cf5ac..70ea3086efe9 100644 --- a/src/Illuminate/Foundation/Support/Providers/EventServiceProvider.php +++ b/src/Illuminate/Foundation/Support/Providers/EventServiceProvider.php @@ -119,7 +119,7 @@ public function discoverEvents() ->reduce(function ($discovered, $directory) { return array_merge_recursive( $discovered, - DiscoverEvents::within($directory, base_path()) + DiscoverEvents::within($directory, $this->eventDiscoveryBasePath()) ); }, []); } @@ -135,4 +135,14 @@ protected function discoverEventsWithin() $this->app->path('Listeners'), ]; } + + /** + * Get the base path to be used during event discovery. + * + * @return string + */ + protected function eventDiscoveryBasePath() + { + return base_path(); + } } diff --git a/src/Illuminate/Http/Client/Factory.php b/src/Illuminate/Http/Client/Factory.php index 0f182b6a65cb..4db3e1fa98a0 100644 --- a/src/Illuminate/Http/Client/Factory.php +++ b/src/Illuminate/Http/Client/Factory.php @@ -32,6 +32,8 @@ * @method \Illuminate\Http\Client\PendingRequest withToken(string $token, string $type = 'Bearer') * @method \Illuminate\Http\Client\PendingRequest withoutRedirecting() * @method \Illuminate\Http\Client\PendingRequest withoutVerifying() + * @method \Illuminate\Http\Client\PendingRequest dump() + * @method \Illuminate\Http\Client\PendingRequest dd() * @method \Illuminate\Http\Client\Response delete(string $url, array $data = []) * @method \Illuminate\Http\Client\Response get(string $url, array $query = []) * @method \Illuminate\Http\Client\Response head(string $url, array $query = []) diff --git a/src/Illuminate/Http/Client/PendingRequest.php b/src/Illuminate/Http/Client/PendingRequest.php index 836e8b39fe50..5801bc0b70f0 100644 --- a/src/Illuminate/Http/Client/PendingRequest.php +++ b/src/Illuminate/Http/Client/PendingRequest.php @@ -9,6 +9,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; +use Symfony\Component\VarDumper\VarDumper; class PendingRequest { @@ -452,6 +453,40 @@ public function beforeSending($callback) }); } + /** + * Dump the request before sending. + * + * @return $this + */ + public function dump() + { + $values = func_get_args(); + + return $this->beforeSending(function (Request $request, array $options) use ($values) { + foreach (array_merge($values, [$request, $options]) as $value) { + VarDumper::dump($value); + } + }); + } + + /** + * Dump the request before sending and end the script. + * + * @return $this + */ + public function dd() + { + $values = func_get_args(); + + return $this->beforeSending(function (Request $request, array $options) use ($values) { + foreach (array_merge($values, [$request, $options]) as $value) { + VarDumper::dump($value); + } + + exit(1); + }); + } + /** * Issue a GET request to the given URL. * diff --git a/src/Illuminate/Log/LogManager.php b/src/Illuminate/Log/LogManager.php index 0e0abcd67c33..f5d0ac486e2b 100644 --- a/src/Illuminate/Log/LogManager.php +++ b/src/Illuminate/Log/LogManager.php @@ -245,11 +245,15 @@ protected function createStackDriver(array $config) return $this->channel($channel)->getHandlers(); })->all(); + $processors = collect($config['channels'])->flatMap(function ($channel) { + return $this->channel($channel)->getProcessors(); + })->all(); + if ($config['ignore_exceptions'] ?? false) { $handlers = [new WhatFailureGroupHandler($handlers)]; } - return new Monolog($this->parseChannel($config), $handlers); + return new Monolog($this->parseChannel($config), $handlers, $processors); } /** diff --git a/src/Illuminate/Mail/MailManager.php b/src/Illuminate/Mail/MailManager.php index 97fcda7827c5..86acdde3e3aa 100644 --- a/src/Illuminate/Mail/MailManager.php +++ b/src/Illuminate/Mail/MailManager.php @@ -63,7 +63,7 @@ public function __construct($app) * Get a mailer instance by name. * * @param string|null $name - * @return \Illuminate\Mail\Mailer + * @return \Illuminate\Contracts\Mail\Mailer */ public function mailer($name = null) { diff --git a/src/Illuminate/Mail/Mailable.php b/src/Illuminate/Mail/Mailable.php index 1fe0b7ea3d20..903bd5f5f41b 100644 --- a/src/Illuminate/Mail/Mailable.php +++ b/src/Illuminate/Mail/Mailable.php @@ -170,7 +170,7 @@ class Mailable implements MailableContract, Renderable */ public function send($mailer) { - return $this->withLocale($this->locale, function () use ($mailer) { + $this->withLocale($this->locale, function () use ($mailer) { Container::getInstance()->call([$this, 'build']); $mailer = $mailer instanceof MailFactory diff --git a/src/Illuminate/Mail/Mailer.php b/src/Illuminate/Mail/Mailer.php index 668d68baaf2a..128f211f7651 100755 --- a/src/Illuminate/Mail/Mailer.php +++ b/src/Illuminate/Mail/Mailer.php @@ -197,7 +197,7 @@ public function bcc($users) */ public function html($html, $callback) { - return $this->send(['html' => new HtmlString($html)], [], $callback); + $this->send(['html' => new HtmlString($html)], [], $callback); } /** @@ -209,7 +209,7 @@ public function html($html, $callback) */ public function raw($text, $callback) { - return $this->send(['raw' => $text], [], $callback); + $this->send(['raw' => $text], [], $callback); } /** @@ -222,7 +222,7 @@ public function raw($text, $callback) */ public function plain($view, array $data, $callback) { - return $this->send(['text' => $view], $data, $callback); + $this->send(['text' => $view], $data, $callback); } /** diff --git a/src/Illuminate/Mail/PendingMail.php b/src/Illuminate/Mail/PendingMail.php index 43c30961f90b..10d76cb6aa9b 100644 --- a/src/Illuminate/Mail/PendingMail.php +++ b/src/Illuminate/Mail/PendingMail.php @@ -114,11 +114,11 @@ public function bcc($users) * Send a new mailable message instance. * * @param \Illuminate\Contracts\Mail\Mailable $mailable - * @return mixed + * @return void */ public function send(MailableContract $mailable) { - return $this->mailer->send($this->fill($mailable)); + $this->mailer->send($this->fill($mailable)); } /** diff --git a/src/Illuminate/Notifications/ChannelManager.php b/src/Illuminate/Notifications/ChannelManager.php index d2344ab68acc..8eb9c251024d 100644 --- a/src/Illuminate/Notifications/ChannelManager.php +++ b/src/Illuminate/Notifications/ChannelManager.php @@ -34,7 +34,7 @@ class ChannelManager extends Manager implements DispatcherContract, FactoryContr */ public function send($notifiables, $notification) { - return (new NotificationSender( + (new NotificationSender( $this, $this->container->make(Bus::class), $this->container->make(Dispatcher::class), $this->locale) )->send($notifiables, $notification); } @@ -49,7 +49,7 @@ public function send($notifiables, $notification) */ public function sendNow($notifiables, $notification, array $channels = null) { - return (new NotificationSender( + (new NotificationSender( $this, $this->container->make(Bus::class), $this->container->make(Dispatcher::class), $this->locale) )->sendNow($notifiables, $notification, $channels); } diff --git a/src/Illuminate/Notifications/NotificationSender.php b/src/Illuminate/Notifications/NotificationSender.php index 39be0e598796..aff36c7a5b0f 100644 --- a/src/Illuminate/Notifications/NotificationSender.php +++ b/src/Illuminate/Notifications/NotificationSender.php @@ -76,7 +76,7 @@ public function send($notifiables, $notification) return $this->queueNotification($notifiables, $notification); } - return $this->sendNow($notifiables, $notification); + $this->sendNow($notifiables, $notification); } /** diff --git a/src/Illuminate/Queue/Console/WorkCommand.php b/src/Illuminate/Queue/Console/WorkCommand.php index ff092197f53a..da9176be4063 100644 --- a/src/Illuminate/Queue/Console/WorkCommand.php +++ b/src/Illuminate/Queue/Console/WorkCommand.php @@ -33,6 +33,7 @@ class WorkCommand extends Command {--force : Force the worker to run even in maintenance mode} {--memory=128 : The memory limit in megabytes} {--sleep=3 : Number of seconds to sleep when no job is available} + {--rest=0 : Number of seconds to rest between jobs} {--timeout=60 : The number of seconds a child process can run} {--tries=1 : Number of times to attempt a job before logging it failed}'; @@ -134,7 +135,8 @@ protected function gatherWorkerOptions() $this->option('force'), $this->option('stop-when-empty'), $this->option('max-jobs'), - $this->option('max-time') + $this->option('max-time'), + $this->option('rest') ); } diff --git a/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php b/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php new file mode 100644 index 000000000000..6364b1d6381a --- /dev/null +++ b/src/Illuminate/Queue/Middleware/ThrottlesExceptions.php @@ -0,0 +1,202 @@ +maxAttempts = $maxAttempts; + $this->decayMinutes = $decayMinutes; + } + + /** + * Process the job. + * + * @param mixed $job + * @param callable $next + * @return mixed + */ + public function handle($job, $next) + { + $this->limiter = Container::getInstance()->make(RateLimiter::class); + + if ($this->limiter->tooManyAttempts($jobKey = $this->getKey($job), $this->maxAttempts)) { + return $job->release($this->getTimeUntilNextRetry($jobKey)); + } + + try { + $next($job); + + $this->limiter->clear($jobKey); + } catch (Throwable $throwable) { + if ($this->whenCallback && ! call_user_func($this->whenCallback, $throwable)) { + throw $throwable; + } + + $this->limiter->hit($jobKey, $this->decayMinutes * 60); + + return $job->release($this->retryAfterMinutes * 60); + } + } + + /** + * Specify a callback that should determine if rate limiting behavior should apply. + * + * @param callable $callback + * @return $this + */ + public function when(callable $callback) + { + $this->whenCallback = $callback; + + return $this; + } + + /** + * Set the prefix of the rate limiter key. + * + * @param string $prefix + * @return $this + */ + public function withPrefix(string $prefix) + { + $this->prefix = $prefix; + + return $this; + } + + /** + * Specify the number of seconds a job should be delayed when it is released (before it has reached its max exceptions). + * + * @param int $backoff + * @return $this + */ + public function backoff($backoff) + { + $this->retryAfterMinutes = $backoff; + + return $this; + } + + /** + * Get the cache key associated for the rate limiter. + * + * @param mixed $job + * @return string + */ + protected function getKey($job) + { + if ($this->key) { + return $this->prefix.$this->key; + } elseif ($this->byJob) { + return $this->prefix.$job->job->uuid(); + } + + return $this->prefix.md5(get_class($job)); + } + + /** + * Set the value that the rate limiter should be keyed by. + * + * @param string $key + * @return $this + */ + public function by($key) + { + $this->key = $key; + + return $this; + } + + /** + * Indicate that the throttle key should use the job's UUID. + * + * @return $this + */ + public function byJob() + { + $this->byJob = true; + + return $this; + } + + /** + * Get the number of seconds that should elapse before the job is retried. + * + * @param string $key + * @return int + */ + protected function getTimeUntilNextRetry($key) + { + return $this->limiter->availableIn($key) + 3; + } +} diff --git a/src/Illuminate/Queue/Middleware/ThrottlesExceptionsWithRedis.php b/src/Illuminate/Queue/Middleware/ThrottlesExceptionsWithRedis.php new file mode 100644 index 000000000000..38790e353e2d --- /dev/null +++ b/src/Illuminate/Queue/Middleware/ThrottlesExceptionsWithRedis.php @@ -0,0 +1,62 @@ +redis = Container::getInstance()->make(Redis::class); + + $this->limiter = new DurationLimiter( + $this->redis, $this->getKey($job), $this->maxAttempts, $this->decayMinutes * 60 + ); + + if ($this->limiter->tooManyAttempts()) { + return $job->release($this->limiter->decaysAt - $this->currentTime()); + } + + try { + $next($job); + + $this->limiter->clear(); + } catch (Throwable $throwable) { + if ($this->whenCallback && ! call_user_func($this->whenCallback, $throwable)) { + throw $throwable; + } + + $this->limiter->acquire(); + + return $job->release($this->retryAfterMinutes * 60); + } + } +} diff --git a/src/Illuminate/Queue/Worker.php b/src/Illuminate/Queue/Worker.php index f2ba7b1a01ad..4229fe701691 100644 --- a/src/Illuminate/Queue/Worker.php +++ b/src/Illuminate/Queue/Worker.php @@ -156,6 +156,10 @@ public function daemon($connectionName, $queue, WorkerOptions $options) $jobsProcessed++; $this->runJob($job, $connectionName, $options); + + if ($options->rest > 0) { + $this->sleep($options->rest); + } } else { $this->sleep($options->sleep); } diff --git a/src/Illuminate/Queue/WorkerOptions.php b/src/Illuminate/Queue/WorkerOptions.php index 766f4676029a..7b8d8dfeea3b 100644 --- a/src/Illuminate/Queue/WorkerOptions.php +++ b/src/Illuminate/Queue/WorkerOptions.php @@ -39,6 +39,13 @@ class WorkerOptions */ public $sleep; + /** + * The number of seconds to rest between jobs. + * + * @var int + */ + public $rest; + /** * The maximum amount of times a job may be attempted. * @@ -87,14 +94,16 @@ class WorkerOptions * @param bool $stopWhenEmpty * @param int $maxJobs * @param int $maxTime + * @param int $rest * @return void */ public function __construct($name = 'default', $backoff = 0, $memory = 128, $timeout = 60, $sleep = 3, $maxTries = 1, - $force = false, $stopWhenEmpty = false, $maxJobs = 0, $maxTime = 0) + $force = false, $stopWhenEmpty = false, $maxJobs = 0, $maxTime = 0, $rest = 0) { $this->name = $name; $this->backoff = $backoff; $this->sleep = $sleep; + $this->rest = $rest; $this->force = $force; $this->memory = $memory; $this->timeout = $timeout; diff --git a/src/Illuminate/Redis/Limiters/DurationLimiter.php b/src/Illuminate/Redis/Limiters/DurationLimiter.php index 9aa594fb41f4..56dbba505435 100644 --- a/src/Illuminate/Redis/Limiters/DurationLimiter.php +++ b/src/Illuminate/Redis/Limiters/DurationLimiter.php @@ -111,6 +111,30 @@ public function acquire() return (bool) $results[0]; } + /** + * Determine if the key has been "accessed" too many times. + * + * @return bool + */ + public function tooManyAttempts() + { + [$this->decaysAt, $this->remaining] = $this->redis->eval( + $this->tooManyAttemptsLuaScript(), 1, $this->name, microtime(true), time(), $this->decay, $this->maxLocks + ); + + return $this->remaining <= 0; + } + + /** + * Clear the limiter. + * + * @return void + */ + public function clear() + { + $this->redis->del($this->name); + } + /** * Get the Lua script for acquiring a lock. * @@ -143,6 +167,36 @@ protected function luaScript() end return {reset(), ARGV[2] + ARGV[3], ARGV[4] - 1} +LUA; + } + + /** + * Get the Lua script to determine if the key has been "accessed" too many times. + * + * KEYS[1] - The limiter name + * ARGV[1] - Current time in microseconds + * ARGV[2] - Current time in seconds + * ARGV[3] - Duration of the bucket + * ARGV[4] - Allowed number of tasks + * + * @return string + */ + protected function tooManyAttemptsLuaScript() + { + return <<<'LUA' + +if redis.call('EXISTS', KEYS[1]) == 0 then + return {0, ARGV[2] + ARGV[3]} +end + +if ARGV[1] >= redis.call('HGET', KEYS[1], 'start') and ARGV[1] <= redis.call('HGET', KEYS[1], 'end') then + return { + redis.call('HGET', KEYS[1], 'end'), + ARGV[4] - redis.call('HGET', KEYS[1], 'count') + } +end + +return {0, ARGV[2] + ARGV[3]} LUA; } } diff --git a/src/Illuminate/Routing/PendingResourceRegistration.php b/src/Illuminate/Routing/PendingResourceRegistration.php index 3b6c97e2042d..59e4b8f0b78f 100644 --- a/src/Illuminate/Routing/PendingResourceRegistration.php +++ b/src/Illuminate/Routing/PendingResourceRegistration.php @@ -195,6 +195,19 @@ public function shallow($shallow = true) return $this; } + /** + * Define the callable that should be invoked on a missing model exception. + * + * @param callable $callback + * @return $this + */ + public function missing($callback) + { + $this->options['missing'] = $callback; + + return $this; + } + /** * Indicate that the resource routes should be scoped using the given binding fields. * diff --git a/src/Illuminate/Routing/ResourceRegistrar.php b/src/Illuminate/Routing/ResourceRegistrar.php index c32aa023b4bd..c32de58c291f 100644 --- a/src/Illuminate/Routing/ResourceRegistrar.php +++ b/src/Illuminate/Routing/ResourceRegistrar.php @@ -184,6 +184,8 @@ protected function addResourceIndex($name, $base, $controller, $options) { $uri = $this->getResourceUri($name); + unset($options['missing']); + $action = $this->getResourceAction($name, $controller, 'index', $options); return $this->router->get($uri, $action); @@ -202,6 +204,8 @@ protected function addResourceCreate($name, $base, $controller, $options) { $uri = $this->getResourceUri($name).'/'.static::$verbs['create']; + unset($options['missing']); + $action = $this->getResourceAction($name, $controller, 'create', $options); return $this->router->get($uri, $action); @@ -220,6 +224,8 @@ protected function addResourceStore($name, $base, $controller, $options) { $uri = $this->getResourceUri($name); + unset($options['missing']); + $action = $this->getResourceAction($name, $controller, 'store', $options); return $this->router->post($uri, $action); @@ -421,6 +427,10 @@ protected function getResourceAction($resource, $controller, $method, $options) $action['where'] = $options['wheres']; } + if (isset($options['missing'])) { + $action['missing'] = $options['missing']; + } + return $action; } diff --git a/src/Illuminate/Routing/RouteBinding.php b/src/Illuminate/Routing/RouteBinding.php index 45b2e8ec8d05..133a84a40b07 100644 --- a/src/Illuminate/Routing/RouteBinding.php +++ b/src/Illuminate/Routing/RouteBinding.php @@ -33,7 +33,7 @@ public static function forCallback($container, $binder) */ protected static function createClassBinding($container, $binding) { - return function ($value, $route, $key) use ($container, $binding) { + return function ($value, $route) use ($container, $binding) { // If the binding has an @ sign, we will assume it's being used to delimit // the class name from the bind method name. This allows for bindings // to run multiple bind methods in a single class for convenience. @@ -41,7 +41,7 @@ protected static function createClassBinding($container, $binding) $callable = [$container->make($class), $method]; - return $callable($value, $route, $key); + return $callable($value, $route); }; } @@ -57,7 +57,7 @@ protected static function createClassBinding($container, $binding) */ public static function forModel($container, $class, $callback = null) { - return function ($value, $route, $key) use ($container, $class, $callback) { + return function ($value) use ($container, $class, $callback) { if (is_null($value)) { return; } @@ -67,7 +67,7 @@ public static function forModel($container, $class, $callback = null) // throw a not found exception otherwise we will return the instance. $instance = $container->make($class); - if ($model = $instance->resolveRouteBinding($value, $route->bindingFieldFor($key))) { + if ($model = $instance->resolveRouteBinding($value)) { return $model; } diff --git a/src/Illuminate/Routing/Router.php b/src/Illuminate/Routing/Router.php index 4e57ffc6ec9d..dfdb7ae7332e 100644 --- a/src/Illuminate/Routing/Router.php +++ b/src/Illuminate/Routing/Router.php @@ -840,7 +840,7 @@ public function substituteImplicitBindings($route) */ protected function performBinding($key, $value, $route) { - return call_user_func($this->binders[$key], $value, $route, $key); + return call_user_func($this->binders[$key], $value, $route); } /** diff --git a/src/Illuminate/Support/Facades/DB.php b/src/Illuminate/Support/Facades/DB.php index 0923a3d7aa77..997f790c0dd3 100755 --- a/src/Illuminate/Support/Facades/DB.php +++ b/src/Illuminate/Support/Facades/DB.php @@ -7,10 +7,12 @@ * @method static \Illuminate\Database\ConnectionInterface connection(string $name = null) * @method static \Illuminate\Database\Query\Builder table(string $table, string $as = null) * @method static \Illuminate\Database\Query\Expression raw($value) + * @method static array getQueryLog() * @method static array prepareBindings(array $bindings) * @method static array pretend(\Closure $callback) * @method static array select(string $query, array $bindings = [], bool $useReadPdo = true) * @method static bool insert(string $query, array $bindings = []) + * @method static bool logging() * @method static bool statement(string $query, array $bindings = []) * @method static bool unprepared(string $query) * @method static int affectingStatement(string $query, array $bindings = []) @@ -20,16 +22,14 @@ * @method static mixed selectOne(string $query, array $bindings = [], bool $useReadPdo = true) * @method static mixed transaction(\Closure $callback, int $attempts = 1) * @method static string getDefaultConnection() + * @method static void afterCommit(\Closure $callback) * @method static void beginTransaction() * @method static void commit() - * @method static void afterCommit(\Closure $callback) - * @method static void listen(\Closure $callback) - * @method static void rollBack(int $toLevel = null) * @method static void enableQueryLog() * @method static void disableQueryLog() - * @method static bool logging() - * @method static array getQueryLog() * @method static void flushQueryLog() + * @method static void listen(\Closure $callback) + * @method static void rollBack(int $toLevel = null) * @method static void setDefaultConnection(string $name) * * @see \Illuminate\Database\DatabaseManager diff --git a/src/Illuminate/Support/Facades/Http.php b/src/Illuminate/Support/Facades/Http.php index c6a26b2108cd..426d574789c5 100644 --- a/src/Illuminate/Support/Facades/Http.php +++ b/src/Illuminate/Support/Facades/Http.php @@ -30,6 +30,8 @@ * @method static \Illuminate\Http\Client\PendingRequest withToken(string $token, string $type = 'Bearer') * @method static \Illuminate\Http\Client\PendingRequest withoutRedirecting() * @method static \Illuminate\Http\Client\PendingRequest withoutVerifying() + * @method static \Illuminate\Http\Client\PendingRequest dump() + * @method static \Illuminate\Http\Client\PendingRequest dd() * @method static \Illuminate\Http\Client\Response delete(string $url, array $data = []) * @method static \Illuminate\Http\Client\Response get(string $url, array $query = []) * @method static \Illuminate\Http\Client\Response head(string $url, array $query = []) diff --git a/src/Illuminate/Support/Stringable.php b/src/Illuminate/Support/Stringable.php index ff5f44c5fded..d834260e11cf 100644 --- a/src/Illuminate/Support/Stringable.php +++ b/src/Illuminate/Support/Stringable.php @@ -335,7 +335,7 @@ public function markdown(array $options = []) * Get the string matching the given pattern. * * @param string $pattern - * @return static|null + * @return static */ public function match($pattern) { @@ -365,6 +365,17 @@ public function matchAll($pattern) return collect($matches[1] ?? $matches[0]); } + /** + * Determine if the string matches the given pattern. + * + * @param string $pattern + * @return bool + */ + public function test($pattern) + { + return $this->match($pattern)->isNotEmpty(); + } + /** * Pad both sides of the string with another. * diff --git a/src/Illuminate/Support/Testing/Fakes/BatchRepositoryFake.php b/src/Illuminate/Support/Testing/Fakes/BatchRepositoryFake.php index 55681f4d5534..d9661334ce01 100644 --- a/src/Illuminate/Support/Testing/Fakes/BatchRepositoryFake.php +++ b/src/Illuminate/Support/Testing/Fakes/BatchRepositoryFake.php @@ -33,6 +33,7 @@ public function get($limit, $before) */ public function find(string $batchId) { + // } /** @@ -68,6 +69,7 @@ public function store(PendingBatch $batch) */ public function incrementTotalJobs(string $batchId, int $amount) { + // } /** @@ -102,6 +104,7 @@ public function incrementFailedJobs(string $batchId, string $jobId) */ public function markAsFinished(string $batchId) { + // } /** @@ -112,6 +115,7 @@ public function markAsFinished(string $batchId) */ public function cancel(string $batchId) { + // } /** @@ -122,6 +126,7 @@ public function cancel(string $batchId) */ public function delete(string $batchId) { + // } /** diff --git a/src/Illuminate/Support/Testing/Fakes/BusFake.php b/src/Illuminate/Support/Testing/Fakes/BusFake.php index 5b5b68534583..82f3dda5d129 100644 --- a/src/Illuminate/Support/Testing/Fakes/BusFake.php +++ b/src/Illuminate/Support/Testing/Fakes/BusFake.php @@ -517,6 +517,7 @@ public function chain($jobs) */ public function findBatch(string $batchId) { + // } /** diff --git a/src/Illuminate/Support/Testing/Fakes/EventFake.php b/src/Illuminate/Support/Testing/Fakes/EventFake.php index 90f30212ef7b..88fcb84cea48 100644 --- a/src/Illuminate/Support/Testing/Fakes/EventFake.php +++ b/src/Illuminate/Support/Testing/Fakes/EventFake.php @@ -278,7 +278,7 @@ public function forgetPushed() * * @param string|object $event * @param mixed $payload - * @return void + * @return array|null */ public function until($event, $payload = []) { diff --git a/src/Illuminate/Support/Testing/Fakes/MailFake.php b/src/Illuminate/Support/Testing/Fakes/MailFake.php index eb548b6910b3..a42fe341f40e 100644 --- a/src/Illuminate/Support/Testing/Fakes/MailFake.php +++ b/src/Illuminate/Support/Testing/Fakes/MailFake.php @@ -276,7 +276,7 @@ protected function queuedMailablesOf($type) * Get a mailer instance by name. * * @param string|null $name - * @return \Illuminate\Mail\Mailer + * @return \Illuminate\Contracts\Mail\Mailer */ public function mailer($name = null) { diff --git a/src/Illuminate/Support/Testing/Fakes/NotificationFake.php b/src/Illuminate/Support/Testing/Fakes/NotificationFake.php index cf3a25afd696..28526d592556 100644 --- a/src/Illuminate/Support/Testing/Fakes/NotificationFake.php +++ b/src/Illuminate/Support/Testing/Fakes/NotificationFake.php @@ -210,7 +210,7 @@ protected function notificationsFor($notifiable, $notification) */ public function send($notifiables, $notification) { - return $this->sendNow($notifiables, $notification); + $this->sendNow($notifiables, $notification); } /** diff --git a/src/Illuminate/Support/Testing/Fakes/PendingMailFake.php b/src/Illuminate/Support/Testing/Fakes/PendingMailFake.php index c39012501ae6..52251301ceb9 100644 --- a/src/Illuminate/Support/Testing/Fakes/PendingMailFake.php +++ b/src/Illuminate/Support/Testing/Fakes/PendingMailFake.php @@ -22,11 +22,11 @@ public function __construct($mailer) * Send a new mailable message instance. * * @param \Illuminate\Contracts\Mail\Mailable $mailable - * @return mixed + * @return void */ public function send(Mailable $mailable) { - return $this->mailer->send($this->fill($mailable)); + $this->mailer->send($this->fill($mailable)); } /** diff --git a/src/Illuminate/Support/Testing/Fakes/QueueFake.php b/src/Illuminate/Support/Testing/Fakes/QueueFake.php index e83408fc0c74..64d6414fd81b 100644 --- a/src/Illuminate/Support/Testing/Fakes/QueueFake.php +++ b/src/Illuminate/Support/Testing/Fakes/QueueFake.php @@ -74,7 +74,7 @@ public function assertPushedOn($queue, $job, $callback = null) [$job, $callback] = [$this->firstClosureParameterType($job), $job]; } - return $this->assertPushed($job, function ($job, $pushedQueue) use ($callback, $queue) { + $this->assertPushed($job, function ($job, $pushedQueue) use ($callback, $queue) { if ($pushedQueue !== $queue) { return false; } diff --git a/src/Illuminate/Testing/Fluent/AssertableJson.php b/src/Illuminate/Testing/Fluent/AssertableJson.php new file mode 100644 index 000000000000..07104e114990 --- /dev/null +++ b/src/Illuminate/Testing/Fluent/AssertableJson.php @@ -0,0 +1,127 @@ +path = $path; + $this->props = $props; + } + + /** + * Compose the absolute "dot" path to the given key. + * + * @param string $key + * @return string + */ + protected function dotPath(string $key): string + { + if (is_null($this->path)) { + return $key; + } + + return implode('.', [$this->path, $key]); + } + + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ + protected function prop(string $key = null) + { + return Arr::get($this->props, $key); + } + + /** + * Instantiate a new "scope" at the path of the given key. + * + * @param string $key + * @param \Closure $callback + * @return $this + */ + protected function scope(string $key, Closure $callback): self + { + $props = $this->prop($key); + $path = $this->dotPath($key); + + PHPUnit::assertIsArray($props, sprintf('Property [%s] is not scopeable.', $path)); + + $scope = new self($props, $path); + $callback($scope); + $scope->interacted(); + + return $this; + } + + /** + * Create a new instance from an array. + * + * @param array $data + * @return static + */ + public static function fromArray(array $data): self + { + return new self($data); + } + + /** + * Create a new instance from a AssertableJsonString. + * + * @param \Illuminate\Testing\AssertableJsonString $json + * @return static + */ + public static function fromAssertableJsonString(AssertableJsonString $json): self + { + return self::fromArray($json->json()); + } + + /** + * Get the instance as an array. + * + * @return array + */ + public function toArray() + { + return $this->props; + } +} diff --git a/src/Illuminate/Testing/Fluent/Concerns/Debugging.php b/src/Illuminate/Testing/Fluent/Concerns/Debugging.php new file mode 100644 index 000000000000..f51d119074ae --- /dev/null +++ b/src/Illuminate/Testing/Fluent/Concerns/Debugging.php @@ -0,0 +1,38 @@ +prop($prop)); + + return $this; + } + + /** + * Dumps the given props and exits. + * + * @param string|null $prop + * @return void + */ + public function dd(string $prop = null): void + { + dd($this->prop($prop)); + } + + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ + abstract protected function prop(string $key = null); +} diff --git a/src/Illuminate/Testing/Fluent/Concerns/Has.php b/src/Illuminate/Testing/Fluent/Concerns/Has.php new file mode 100644 index 000000000000..dd91ee618790 --- /dev/null +++ b/src/Illuminate/Testing/Fluent/Concerns/Has.php @@ -0,0 +1,158 @@ +prop($key), + sprintf('Property [%s] does not have the expected size.', $this->dotPath($key)) + ); + + return $this; + } + + /** + * Ensure that the given prop exists. + * + * @param string $key + * @param null $value + * @param \Closure|null $scope + * @return $this + */ + public function has(string $key, $value = null, Closure $scope = null): self + { + $prop = $this->prop(); + + PHPUnit::assertTrue( + Arr::has($prop, $key), + sprintf('Property [%s] does not exist.', $this->dotPath($key)) + ); + + $this->interactsWith($key); + + // When all three arguments are provided this indicates a short-hand expression + // that combines both a `count`-assertion, followed by directly creating the + // `scope` on the first element. We can simply handle this correctly here. + if (is_int($value) && ! is_null($scope)) { + $prop = $this->prop($key); + $path = $this->dotPath($key); + + PHPUnit::assertTrue($value > 0, sprintf('Cannot scope directly onto the first entry of property [%s] when asserting that it has a size of 0.', $path)); + PHPUnit::assertIsArray($prop, sprintf('Direct scoping is unsupported for non-array like properties such as [%s].', $path)); + + $this->count($key, $value); + + return $this->scope($key.'.'.array_keys($prop)[0], $scope); + } + + if (is_callable($value)) { + $this->scope($key, $value); + } elseif (! is_null($value)) { + $this->count($key, $value); + } + + return $this; + } + + /** + * Assert that all of the given props exist. + * + * @param array|string $key + * @return $this + */ + public function hasAll($key): self + { + $keys = is_array($key) ? $key : func_get_args(); + + foreach ($keys as $prop => $count) { + if (is_int($prop)) { + $this->has($count); + } else { + $this->has($prop, $count); + } + } + + return $this; + } + + /** + * Assert that none of the given props exist. + * + * @param array|string $key + * @return $this + */ + public function missingAll($key): self + { + $keys = is_array($key) ? $key : func_get_args(); + + foreach ($keys as $prop) { + $this->missing($prop); + } + + return $this; + } + + /** + * Assert that the given prop does not exist. + * + * @param string $key + * @return $this + */ + public function missing(string $key): self + { + PHPUnit::assertNotTrue( + Arr::has($this->prop(), $key), + sprintf('Property [%s] was found while it was expected to be missing.', $this->dotPath($key)) + ); + + return $this; + } + + /** + * Compose the absolute "dot" path to the given key. + * + * @param string $key + * @return string + */ + abstract protected function dotPath(string $key): string; + + /** + * Marks the property as interacted. + * + * @param string $key + * @return void + */ + abstract protected function interactsWith(string $key): void; + + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ + abstract protected function prop(string $key = null); + + /** + * Instantiate a new "scope" at the path of the given key. + * + * @param string $key + * @param \Closure $callback + * @return $this + */ + abstract protected function scope(string $key, Closure $callback); +} diff --git a/src/Illuminate/Testing/Fluent/Concerns/Interaction.php b/src/Illuminate/Testing/Fluent/Concerns/Interaction.php new file mode 100644 index 000000000000..15e7e9508f55 --- /dev/null +++ b/src/Illuminate/Testing/Fluent/Concerns/Interaction.php @@ -0,0 +1,67 @@ +interacted, true)) { + $this->interacted[] = $prop; + } + } + + /** + * Asserts that all properties have been interacted with. + * + * @return void + */ + public function interacted(): void + { + PHPUnit::assertSame( + [], + array_diff(array_keys($this->prop()), $this->interacted), + $this->path + ? sprintf('Unexpected properties were found in scope [%s].', $this->path) + : 'Unexpected properties were found on the root level.' + ); + } + + /** + * Disables the interaction check. + * + * @return $this + */ + public function etc(): self + { + $this->interacted = array_keys($this->prop()); + + return $this; + } + + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ + abstract protected function prop(string $key = null); +} diff --git a/src/Illuminate/Testing/Fluent/Concerns/Matching.php b/src/Illuminate/Testing/Fluent/Concerns/Matching.php new file mode 100644 index 000000000000..3cf1f82c471c --- /dev/null +++ b/src/Illuminate/Testing/Fluent/Concerns/Matching.php @@ -0,0 +1,109 @@ +has($key); + + $actual = $this->prop($key); + + if ($expected instanceof Closure) { + PHPUnit::assertTrue( + $expected(is_array($actual) ? Collection::make($actual) : $actual), + sprintf('Property [%s] was marked as invalid using a closure.', $this->dotPath($key)) + ); + + return $this; + } + + if ($expected instanceof Arrayable) { + $expected = $expected->toArray(); + } + + $this->ensureSorted($expected); + $this->ensureSorted($actual); + + PHPUnit::assertSame( + $expected, + $actual, + sprintf('Property [%s] does not match the expected value.', $this->dotPath($key)) + ); + + return $this; + } + + /** + * Asserts that all properties match their expected values. + * + * @param array $bindings + * @return $this + */ + public function whereAll(array $bindings): self + { + foreach ($bindings as $key => $value) { + $this->where($key, $value); + } + + return $this; + } + + /** + * Ensures that all properties are sorted the same way, recursively. + * + * @param mixed $value + * @return void + */ + protected function ensureSorted(&$value): void + { + if (! is_array($value)) { + return; + } + + foreach ($value as &$arg) { + $this->ensureSorted($arg); + } + + ksort($value); + } + + /** + * Compose the absolute "dot" path to the given key. + * + * @param string $key + * @return string + */ + abstract protected function dotPath(string $key): string; + + /** + * Ensure that the given prop exists. + * + * @param string $key + * @param null $value + * @param \Closure|null $scope + * @return $this + */ + abstract public function has(string $key, $value = null, Closure $scope = null); + + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ + abstract protected function prop(string $key = null); +} diff --git a/src/Illuminate/Testing/TestResponse.php b/src/Illuminate/Testing/TestResponse.php index 1bfc75285518..aeee4fe59c7e 100644 --- a/src/Illuminate/Testing/TestResponse.php +++ b/src/Illuminate/Testing/TestResponse.php @@ -14,6 +14,7 @@ use Illuminate\Support\Traits\Tappable; use Illuminate\Testing\Assert as PHPUnit; use Illuminate\Testing\Constraints\SeeInOrder; +use Illuminate\Testing\Fluent\AssertableJson; use LogicException; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -507,13 +508,25 @@ public function assertDontSeeText($value, $escape = true) /** * Assert that the response is a superset of the given JSON. * - * @param array $data + * @param array|callable $value * @param bool $strict * @return $this */ - public function assertJson(array $data, $strict = false) + public function assertJson($value, $strict = false) { - $this->decodeResponseJson()->assertSubset($data, $strict); + $json = $this->decodeResponseJson(); + + if (is_array($value)) { + $json->assertSubset($value, $strict); + } else { + $assert = AssertableJson::fromAssertableJsonString($json); + + $value($assert); + + if ($strict) { + $assert->interacted(); + } + } return $this; } diff --git a/src/Illuminate/Validation/Concerns/ReplacesAttributes.php b/src/Illuminate/Validation/Concerns/ReplacesAttributes.php index d645dbd6d5a6..d4a47af146c4 100644 --- a/src/Illuminate/Validation/Concerns/ReplacesAttributes.php +++ b/src/Illuminate/Validation/Concerns/ReplacesAttributes.php @@ -374,6 +374,46 @@ protected function replaceRequiredUnless($message, $attribute, $rule, $parameter return str_replace([':other', ':values'], [$other, implode(', ', $values)], $message); } + /** + * Replace all place-holders for the prohibited_if rule. + * + * @param string $message + * @param string $attribute + * @param string $rule + * @param array $parameters + * @return string + */ + protected function replaceProhibitedIf($message, $attribute, $rule, $parameters) + { + $parameters[1] = $this->getDisplayableValue($parameters[0], Arr::get($this->data, $parameters[0])); + + $parameters[0] = $this->getDisplayableAttribute($parameters[0]); + + return str_replace([':other', ':value'], $parameters, $message); + } + + /** + * Replace all place-holders for the prohibited_unless rule. + * + * @param string $message + * @param string $attribute + * @param string $rule + * @param array $parameters + * @return string + */ + protected function replaceProhibitedUnless($message, $attribute, $rule, $parameters) + { + $other = $this->getDisplayableAttribute($parameters[0]); + + $values = []; + + foreach (array_slice($parameters, 1) as $value) { + $values[] = $this->getDisplayableValue($parameters[0], $value); + } + + return str_replace([':other', ':values'], [$other, implode(', ', $values)], $message); + } + /** * Replace all place-holders for the same rule. * diff --git a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php index 46fcc8441a17..447585d25f5c 100644 --- a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php +++ b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php @@ -1425,15 +1425,57 @@ public function validateRequiredIf($attribute, $value, $parameters) { $this->requireParameterCount(2, $parameters, 'required_if'); - [$values, $other] = $this->prepareValuesAndOther($parameters); + [$values, $other] = $this->parseDependentRuleParameters($parameters); - if (in_array($other, $values)) { + if (in_array($other, $values, is_bool($other))) { return $this->validateRequired($attribute, $value); } return true; } + /** + * Validate that an attribute does not exist when another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateProhibitedIf($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'prohibited_if'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (in_array($other, $values, is_bool($other))) { + return ! $this->validateRequired($attribute, $value); + } + + return true; + } + + /** + * Validate that an attribute does not exist unless another attribute has a given value. + * + * @param string $attribute + * @param mixed $value + * @param mixed $parameters + * @return bool + */ + public function validateProhibitedUnless($attribute, $value, $parameters) + { + $this->requireParameterCount(2, $parameters, 'prohibited_unless'); + + [$values, $other] = $this->parseDependentRuleParameters($parameters); + + if (! in_array($other, $values, is_bool($other))) { + return ! $this->validateRequired($attribute, $value); + } + + return true; + } + /** * Indicate that an attribute should be excluded when another attribute has a given value. * @@ -1446,9 +1488,9 @@ public function validateExcludeIf($attribute, $value, $parameters) { $this->requireParameterCount(2, $parameters, 'exclude_if'); - [$values, $other] = $this->prepareValuesAndOther($parameters); + [$values, $other] = $this->parseDependentRuleParameters($parameters); - return ! in_array($other, $values); + return ! in_array($other, $values, is_bool($other)); } /** @@ -1463,9 +1505,9 @@ public function validateExcludeUnless($attribute, $value, $parameters) { $this->requireParameterCount(2, $parameters, 'exclude_unless'); - [$values, $other] = $this->prepareValuesAndOther($parameters); + [$values, $other] = $this->parseDependentRuleParameters($parameters); - return in_array($other, $values); + return in_array($other, $values, is_bool($other)); } /** @@ -1493,7 +1535,7 @@ public function validateExcludeWithout($attribute, $value, $parameters) * @param array $parameters * @return array */ - protected function prepareValuesAndOther($parameters) + public function parseDependentRuleParameters($parameters) { $other = Arr::get($this->data, $parameters[0]); @@ -1552,9 +1594,9 @@ public function validateRequiredUnless($attribute, $value, $parameters) { $this->requireParameterCount(2, $parameters, 'required_unless'); - [$values, $other] = $this->prepareValuesAndOther($parameters); + [$values, $other] = $this->parseDependentRuleParameters($parameters); - if (! in_array($other, $values)) { + if (! in_array($other, $values, is_bool($other))) { return $this->validateRequired($attribute, $value); } diff --git a/src/Illuminate/Validation/Validator.php b/src/Illuminate/Validation/Validator.php index 48d9946386d6..0aa44d6c197e 100755 --- a/src/Illuminate/Validation/Validator.php +++ b/src/Illuminate/Validation/Validator.php @@ -227,6 +227,8 @@ class Validator implements ValidatorContract 'RequiredWithAll', 'RequiredWithout', 'RequiredWithoutAll', + 'ProhibitedIf', + 'ProhibitedUnless', 'Same', 'Unique', ]; diff --git a/tests/Container/ContainerTest.php b/tests/Container/ContainerTest.php index 8f4b01a2bfd5..eefe9366cbfc 100755 --- a/tests/Container/ContainerTest.php +++ b/tests/Container/ContainerTest.php @@ -5,6 +5,7 @@ use Illuminate\Container\Container; use Illuminate\Container\EntryNotFoundException; use Illuminate\Contracts\Container\BindingResolutionException; +use Illuminate\Contracts\Container\CircularDependencyException; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerExceptionInterface; use stdClass; @@ -562,6 +563,38 @@ public function testContainerCanResolveClasses() $this->assertInstanceOf(ContainerConcreteStub::class, $class); } + + // public function testContainerCanCatchCircularDependency() + // { + // $this->expectException(CircularDependencyException::class); + + // $container = new Container; + // $container->get(CircularAStub::class); + // } +} + +class CircularAStub +{ + public function __construct(CircularBStub $b) + { + // + } +} + +class CircularBStub +{ + public function __construct(CircularCStub $c) + { + // + } +} + +class CircularCStub +{ + public function __construct(CircularAStub $a) + { + // + } } class ContainerConcreteStub diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 42f3b6563927..b5049151adab 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -290,7 +290,16 @@ public function testDestroyMethodCallsQueryBuilderCorrectly() public function testDestroyMethodCallsQueryBuilderCorrectlyWithCollection() { - EloquentModelDestroyStub::destroy(new Collection([1, 2, 3])); + EloquentModelDestroyStub::destroy(new BaseCollection([1, 2, 3])); + } + + public function testDestroyMethodCallsQueryBuilderCorrectlyWithEloquentCollection() + { + EloquentModelDestroyStub::destroy(new Collection([ + new EloquentModelDestroyStub(['id' => 1]), + new EloquentModelDestroyStub(['id' => 2]), + new EloquentModelDestroyStub(['id' => 3]), + ])); } public function testDestroyMethodCallsQueryBuilderCorrectlyWithMultipleArgs() @@ -2383,6 +2392,10 @@ public function newQuery() class EloquentModelDestroyStub extends Model { + protected $fillable = [ + 'id', + ]; + public function newQuery() { $mock = m::mock(Builder::class); diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php index 55b2d54982a5..755a01d9f31e 100644 --- a/tests/Foundation/FoundationExceptionsHandlerTest.php +++ b/tests/Foundation/FoundationExceptionsHandlerTest.php @@ -107,6 +107,20 @@ public function testHandlerCallsReportMethodWithDependencies() $this->handler->report(new ReportableException('Exception message')); } + public function testHandlerReportsExceptionUsingCallableClass() + { + $reporter = m::mock(ReportingService::class); + $reporter->shouldReceive('send')->withArgs(['Exception message'])->once(); + + $logger = m::mock(LoggerInterface::class); + $this->container->instance(LoggerInterface::class, $logger); + $logger->shouldNotReceive('error'); + + $this->handler->reportable(new CustomReporter($reporter)); + + $this->handler->report(new CustomException('Exception message')); + } + public function testReturnsJsonWithStackTraceWhenAjaxRequestAndDebugTrue() { $this->config->shouldReceive('get')->with('app.debug', null)->once()->andReturn(true); @@ -134,6 +148,15 @@ public function testReturnsCustomResponseFromRenderableCallback() $this->assertSame('{"response":"My custom exception response"}', $response); } + public function testReturnsCustomResponseFromCallableClass() + { + $this->handler->renderable(new CustomRenderer()); + + $response = $this->handler->render($this->request, new CustomException)->getContent(); + + $this->assertSame('{"response":"The CustomRenderer response"}', $response); + } + public function testReturnsCustomResponseWhenExceptionImplementsResponsable() { $response = $this->handler->render($this->request, new ResponsableException)->getContent(); @@ -302,6 +325,31 @@ public function context() } } +class CustomReporter +{ + private $service; + + public function __construct(ReportingService $service) + { + $this->service = $service; + } + + public function __invoke(CustomException $e) + { + $this->service->send($e->getMessage()); + + return false; + } +} + +class CustomRenderer +{ + public function __invoke(CustomException $e, $request) + { + return response()->json(['response' => 'The CustomRenderer response']); + } +} + interface ReportingService { public function send($message); diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index b407d6fb3f0e..95f08521babb 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -11,6 +11,7 @@ use Illuminate\Support\Str; use OutOfBoundsException; use PHPUnit\Framework\TestCase; +use Symfony\Component\VarDumper\VarDumper; class HttpClientTest extends TestCase { @@ -781,4 +782,23 @@ function (Request $request) { $this->factory->assertSentInOrder($executionOrder); } + + public function testCanDump() + { + $dumped = []; + + VarDumper::setHandler(function ($value) use (&$dumped) { + $dumped[] = $value; + }); + + $this->factory->fake()->dump(1, 2, 3)->withOptions(['delay' => 1000])->get('http://foo.com'); + + $this->assertSame(1, $dumped[0]); + $this->assertSame(2, $dumped[1]); + $this->assertSame(3, $dumped[2]); + $this->assertInstanceOf(Request::class, $dumped[3]); + $this->assertSame(1000, $dumped[4]['delay']); + + VarDumper::setHandler(null); + } } diff --git a/tests/Integration/Auth/AuthenticationTest.php b/tests/Integration/Auth/AuthenticationTest.php index ec23afba3d18..e2ec79050d18 100644 --- a/tests/Integration/Auth/AuthenticationTest.php +++ b/tests/Integration/Auth/AuthenticationTest.php @@ -19,6 +19,7 @@ use Illuminate\Support\Str; use Illuminate\Support\Testing\Fakes\EventFake; use Illuminate\Tests\Integration\Auth\Fixtures\AuthenticationTestUser; +use InvalidArgumentException; use Orchestra\Testbench\TestCase; /** @@ -211,7 +212,7 @@ public function testLoggingOutOtherDevices() $this->assertEquals(1, $user->id); - $this->app['auth']->logoutOtherDevices('adifferentpassword'); + $this->app['auth']->logoutOtherDevices('password'); $this->assertEquals(1, $user->id); Event::assertDispatched(OtherDeviceLogout::class, function ($event) { @@ -222,6 +223,20 @@ public function testLoggingOutOtherDevices() }); } + public function testPasswordMustBeValidToLogOutOtherDevices() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('current password'); + + $this->app['auth']->loginUsingId(1); + + $user = $this->app['auth']->user(); + + $this->assertEquals(1, $user->id); + + $this->app['auth']->logoutOtherDevices('adifferentpassword'); + } + public function testLoggingInOutViaAttemptRemembering() { $this->assertTrue( diff --git a/tests/Integration/Cache/PhpRedisCacheLockTest.php b/tests/Integration/Cache/PhpRedisCacheLockTest.php new file mode 100644 index 000000000000..de1048eb30c4 --- /dev/null +++ b/tests/Integration/Cache/PhpRedisCacheLockTest.php @@ -0,0 +1,306 @@ +setUpRedis(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->tearDownRedis(); + } + + public function testRedisLockCanBeAcquiredAndReleasedWithoutSerializationAndCompression() + { + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + public function testRedisLockCanBeAcquiredAndReleasedWithPhpSerialization() + { + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + public function testRedisLockCanBeAcquiredAndReleasedWithJsonSerialization() + { + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_JSON); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + public function testRedisLockCanBeAcquiredAndReleasedWithIgbinarySerialization() + { + if (! defined('Redis::SERIALIZER_IGBINARY')) { + $this->markTestSkipped('Redis extension is not configured to support the igbinary serializer.'); + } + + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_IGBINARY); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + public function testRedisLockCanBeAcquiredAndReleasedWithMsgpackSerialization() + { + if (! defined('Redis::SERIALIZER_MSGPACK')) { + $this->markTestSkipped('Redis extension is not configured to support the msgpack serializer.'); + } + + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_MSGPACK); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + public function testRedisLockCanBeAcquiredAndReleasedWithLzfCompression() + { + if (! defined('Redis::COMPRESSION_LZF')) { + $this->markTestSkipped('Redis extension is not configured to support the lzf compression.'); + } + + if (! extension_loaded('lzf')) { + $this->markTestSkipped('Lzf extension is not installed.'); + } + + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE); + $client->setOption(Redis::OPT_COMPRESSION, Redis::COMPRESSION_LZF); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + public function testRedisLockCanBeAcquiredAndReleasedWithZstdCompression() + { + if (! defined('Redis::COMPRESSION_ZSTD')) { + $this->markTestSkipped('Redis extension is not configured to support the zstd compression.'); + } + + if (! extension_loaded('zstd')) { + $this->markTestSkipped('Zstd extension is not installed.'); + } + + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE); + $client->setOption(Redis::OPT_COMPRESSION, Redis::COMPRESSION_ZSTD); + $client->setOption(Redis::OPT_COMPRESSION_LEVEL, Redis::COMPRESSION_ZSTD_DEFAULT); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + + $client->setOption(Redis::OPT_COMPRESSION_LEVEL, Redis::COMPRESSION_ZSTD_MIN); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + + $client->setOption(Redis::OPT_COMPRESSION_LEVEL, Redis::COMPRESSION_ZSTD_MAX); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + public function testRedisLockCanBeAcquiredAndReleasedWithLz4Compression() + { + if (! defined('Redis::COMPRESSION_LZ4')) { + $this->markTestSkipped('Redis extension is not configured to support the lz4 compression.'); + } + + if (! extension_loaded('lz4')) { + $this->markTestSkipped('Lz4 extension is not installed.'); + } + + $this->markTestIncomplete( + 'phpredis extension does not compress consistently with the php '. + 'extension lz4. See: https://github.com/phpredis/phpredis/issues/1939' + ); + + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_NONE); + $client->setOption(Redis::OPT_COMPRESSION, Redis::COMPRESSION_LZ4); + $client->setOption(Redis::OPT_COMPRESSION_LEVEL, 1); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + + $client->setOption(Redis::OPT_COMPRESSION_LEVEL, 3); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + + $client->setOption(Redis::OPT_COMPRESSION_LEVEL, 12); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } + + public function testRedisLockCanBeAcquiredAndReleasedWithSerializationAndCompression() + { + if (! defined('Redis::COMPRESSION_LZF')) { + $this->markTestSkipped('Redis extension is not configured to support the lzf compression.'); + } + + if (! extension_loaded('lzf')) { + $this->markTestSkipped('Lzf extension is not installed.'); + } + + $this->app['config']->set('database.redis.client', 'phpredis'); + $this->app['config']->set('cache.stores.redis.connection', 'default'); + $this->app['config']->set('cache.stores.redis.lock_connection', 'default'); + + /** @var \Illuminate\Cache\RedisStore $store */ + $store = Cache::store('redis'); + /** @var \Redis $client */ + $client = $store->lockConnection()->client(); + + $client->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP); + $client->setOption(Redis::OPT_COMPRESSION, Redis::COMPRESSION_LZF); + $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertFalse($store->lock('foo', 10)->get()); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix().'foo')); + } +} diff --git a/tests/Integration/Queue/CustomPayloadTest.php b/tests/Integration/Queue/CustomPayloadTest.php index 3d1088b52c4d..2ce39544be34 100644 --- a/tests/Integration/Queue/CustomPayloadTest.php +++ b/tests/Integration/Queue/CustomPayloadTest.php @@ -60,5 +60,6 @@ class MyJob implements ShouldQueue public function handle() { + // } } diff --git a/tests/Integration/Queue/ThrottlesExceptionsTest.php b/tests/Integration/Queue/ThrottlesExceptionsTest.php new file mode 100644 index 000000000000..002acc30c661 --- /dev/null +++ b/tests/Integration/Queue/ThrottlesExceptionsTest.php @@ -0,0 +1,147 @@ +assertJobWasReleasedImmediately(CircuitBreakerTestJob::class); + $this->assertJobWasReleasedImmediately(CircuitBreakerTestJob::class); + $this->assertJobWasReleasedWithDelay(CircuitBreakerTestJob::class); + } + + public function testCircuitStaysClosedForSuccessfulJobs() + { + $this->assertJobRanSuccessfully(CircuitBreakerSuccessfulJob::class); + $this->assertJobRanSuccessfully(CircuitBreakerSuccessfulJob::class); + $this->assertJobRanSuccessfully(CircuitBreakerSuccessfulJob::class); + } + + public function testCircuitResetsAfterSuccess() + { + $this->assertJobWasReleasedImmediately(CircuitBreakerTestJob::class); + $this->assertJobRanSuccessfully(CircuitBreakerSuccessfulJob::class); + $this->assertJobWasReleasedImmediately(CircuitBreakerTestJob::class); + $this->assertJobWasReleasedImmediately(CircuitBreakerTestJob::class); + $this->assertJobWasReleasedWithDelay(CircuitBreakerTestJob::class); + } + + protected function assertJobWasReleasedImmediately($class) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('release')->with(0)->once(); + $job->shouldReceive('isReleased')->andReturn(true); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(true); + $job->shouldReceive('uuid')->andReturn('simple-test-uuid'); + + $instance->call($job, [ + 'command' => serialize($command = new $class), + ]); + + $this->assertTrue($class::$handled); + } + + protected function assertJobWasReleasedWithDelay($class) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('release')->withArgs(function ($delay) { + return $delay >= 600; + })->once(); + $job->shouldReceive('isReleased')->andReturn(true); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(true); + $job->shouldReceive('uuid')->andReturn('simple-test-uuid'); + + $instance->call($job, [ + 'command' => serialize($command = new $class), + ]); + + $this->assertFalse($class::$handled); + } + + protected function assertJobRanSuccessfully($class) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('isReleased')->andReturn(false); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(false); + $job->shouldReceive('delete')->once(); + $job->shouldReceive('uuid')->andReturn('simple-test-uuid'); + + $instance->call($job, [ + 'command' => serialize($command = new $class), + ]); + + $this->assertTrue($class::$handled); + } +} + +class CircuitBreakerTestJob +{ + use InteractsWithQueue, Queueable; + + public static $handled = false; + + public function handle() + { + static::$handled = true; + + throw new Exception; + } + + public function middleware() + { + return [(new ThrottlesExceptions(2, 10))->by('test')]; + } +} + +class CircuitBreakerSuccessfulJob +{ + use InteractsWithQueue, Queueable; + + public static $handled = false; + + public function handle() + { + static::$handled = true; + } + + public function middleware() + { + return [(new ThrottlesExceptions(2, 10))->by('test')]; + } +} diff --git a/tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php b/tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php new file mode 100644 index 000000000000..c789d5d523f6 --- /dev/null +++ b/tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php @@ -0,0 +1,167 @@ +setUpRedis(); + } + + protected function tearDown(): void + { + parent::tearDown(); + + $this->tearDownRedis(); + + m::close(); + } + + public function testCircuitIsOpenedForJobErrors() + { + $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key = Str::random()); + $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key); + $this->assertJobWasReleasedWithDelay(CircuitBreakerWithRedisTestJob::class, $key); + } + + public function testCircuitStaysClosedForSuccessfulJobs() + { + $this->assertJobRanSuccessfully(CircuitBreakerWithRedisSuccessfulJob::class, $key = Str::random()); + $this->assertJobRanSuccessfully(CircuitBreakerWithRedisSuccessfulJob::class, $key); + $this->assertJobRanSuccessfully(CircuitBreakerWithRedisSuccessfulJob::class, $key); + } + + public function testCircuitResetsAfterSuccess() + { + $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key = Str::random()); + $this->assertJobRanSuccessfully(CircuitBreakerWithRedisSuccessfulJob::class, $key); + $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key); + $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key); + $this->assertJobWasReleasedWithDelay(CircuitBreakerWithRedisTestJob::class, $key); + } + + protected function assertJobWasReleasedImmediately($class, $key) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('release')->with(0)->once(); + $job->shouldReceive('isReleased')->andReturn(true); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(true); + + $instance->call($job, [ + 'command' => serialize($command = new $class($key)), + ]); + + $this->assertTrue($class::$handled); + } + + protected function assertJobWasReleasedWithDelay($class, $key) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('release')->withArgs(function ($delay) { + return $delay >= 600; + })->once(); + $job->shouldReceive('isReleased')->andReturn(true); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(true); + + $instance->call($job, [ + 'command' => serialize($command = new $class($key)), + ]); + + $this->assertFalse($class::$handled); + } + + protected function assertJobRanSuccessfully($class, $key) + { + $class::$handled = false; + $instance = new CallQueuedHandler(new Dispatcher($this->app), $this->app); + + $job = m::mock(Job::class); + + $job->shouldReceive('hasFailed')->once()->andReturn(false); + $job->shouldReceive('isReleased')->andReturn(false); + $job->shouldReceive('isDeletedOrReleased')->once()->andReturn(false); + $job->shouldReceive('delete')->once(); + + $instance->call($job, [ + 'command' => serialize($command = new $class($key)), + ]); + + $this->assertTrue($class::$handled); + } +} + +class CircuitBreakerWithRedisTestJob +{ + use InteractsWithQueue, Queueable; + + public static $handled = false; + + public function __construct($key) + { + $this->key = $key; + } + + public function handle() + { + static::$handled = true; + + throw new Exception; + } + + public function middleware() + { + return [(new ThrottlesExceptionsWithRedis(2, 10))->by($this->key)]; + } +} + +class CircuitBreakerWithRedisSuccessfulJob +{ + use InteractsWithQueue, Queueable; + + public static $handled = false; + + public function __construct($key) + { + $this->key = $key; + } + + public function handle() + { + static::$handled = true; + } + + public function middleware() + { + return [(new ThrottlesExceptionsWithRedis(2, 10))->by($this->key)]; + } +} diff --git a/tests/Routing/RouteRegistrarTest.php b/tests/Routing/RouteRegistrarTest.php index 47fcb2aa31e5..9802ad742a61 100644 --- a/tests/Routing/RouteRegistrarTest.php +++ b/tests/Routing/RouteRegistrarTest.php @@ -334,6 +334,24 @@ public function testCanRegisterResourcesWithoutOption() } } + public function testCanRegisterResourceWithMissingOption() + { + $this->router->middleware('resource-middleware') + ->resource('users', RouteRegistrarControllerStub::class) + ->missing(function () { + return 'missing'; + }); + + $this->assertIsCallable($this->router->getRoutes()->getByName('users.show')->getMissing()); + $this->assertIsCallable($this->router->getRoutes()->getByName('users.edit')->getMissing()); + $this->assertIsCallable($this->router->getRoutes()->getByName('users.update')->getMissing()); + $this->assertIsCallable($this->router->getRoutes()->getByName('users.destroy')->getMissing()); + + $this->assertNull($this->router->getRoutes()->getByName('users.index')->getMissing()); + $this->assertNull($this->router->getRoutes()->getByName('users.create')->getMissing()); + $this->assertNull($this->router->getRoutes()->getByName('users.store')->getMissing()); + } + public function testCanAccessRegisteredResourceRoutesAsRouteCollection() { $resource = $this->router->middleware('resource-middleware') diff --git a/tests/Routing/RoutingRouteTest.php b/tests/Routing/RoutingRouteTest.php index f4244a3bbb3e..a0d4548dc06e 100644 --- a/tests/Routing/RoutingRouteTest.php +++ b/tests/Routing/RoutingRouteTest.php @@ -29,7 +29,6 @@ use Illuminate\Routing\UrlGenerator; use Illuminate\Support\Str; use LogicException; -use Mockery; use PHPUnit\Framework\TestCase; use stdClass; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; @@ -939,36 +938,6 @@ public function testModelBinding() $this->assertSame('TAYLOR', $router->dispatch(Request::create('foo/taylor', 'GET'))->getContent()); } - public function testModelBindingWithCustomKey() - { - // Create the router. - $container = new Container(); - $router = new Router(new Dispatcher(), $container); - $container->singleton(Registrar::class, function () use ($router) { - return $router; - }); - - $router->get('foo/{bar:custom}', ['middleware' => SubstituteBindings::class, 'uses' => function ($name) { - return $name; - }]); - $router->model('bar', RouteModelBindingStub::class); - - // Mock the stub so we can verify that the method is called with custom key. - $mock = $container->instance( - RouteModelBindingStub::class, - Mockery::mock(RouteModelBindingStub::class), - ); - - $mock->shouldReceive('resolveRouteBinding') - ->with('taylor', 'custom') - ->once() - ->andReturn('TAYLOR'); - - $this->assertSame('TAYLOR', $router->dispatch(Request::create('foo/taylor', 'GET'))->getContent()); - - Mockery::close(); - } - public function testModelBindingWithNullReturn() { $this->expectException(ModelNotFoundException::class); diff --git a/tests/Support/SupportHelpersTest.php b/tests/Support/SupportHelpersTest.php index af7de6b40f56..c286809fbe23 100755 --- a/tests/Support/SupportHelpersTest.php +++ b/tests/Support/SupportHelpersTest.php @@ -40,6 +40,9 @@ public function testValue() $this->assertSame('foo', value(function () { return 'foo'; })); + $this->assertSame('foo', value(function ($arg) { + return $arg; + }, 'foo')); } public function testObjectGet() diff --git a/tests/Support/SupportReflectorTest.php b/tests/Support/SupportReflectorTest.php index df5b3e414e46..deebed5aabc7 100644 --- a/tests/Support/SupportReflectorTest.php +++ b/tests/Support/SupportReflectorTest.php @@ -81,6 +81,7 @@ class B extends A { public function f(parent $x) { + // } } @@ -92,6 +93,7 @@ class C { public function f(A|Model $x) { + // } }' ); @@ -101,6 +103,7 @@ class TestClassWithCall { public function __call($method, $parameters) { + // } } @@ -108,5 +111,6 @@ class TestClassWithCallStatic { public static function __callStatic($method, $parameters) { + // } } diff --git a/tests/Support/SupportStringableTest.php b/tests/Support/SupportStringableTest.php index 15ab04726cb1..d6c010fd20fd 100644 --- a/tests/Support/SupportStringableTest.php +++ b/tests/Support/SupportStringableTest.php @@ -62,6 +62,14 @@ public function testMatch() $this->assertTrue($stringable->matchAll('/nothing/')->isEmpty()); } + public function testTest() + { + $stringable = $this->stringable('foo bar'); + + $this->assertTrue($stringable->test('/bar/')); + $this->assertTrue($stringable->test('/foo (.*)/')); + } + public function testTrim() { $this->assertSame('foo', (string) $this->stringable(' foo ')->trim()); diff --git a/tests/Testing/Fluent/AssertTest.php b/tests/Testing/Fluent/AssertTest.php new file mode 100644 index 000000000000..acafd07589d2 --- /dev/null +++ b/tests/Testing/Fluent/AssertTest.php @@ -0,0 +1,688 @@ + 'value', + ]); + + $assert->has('prop'); + } + + public function testAssertHasFailsWhenPropMissing() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'value', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [prop] does not exist.'); + + $assert->has('prop'); + } + + public function testAssertHasNestedProp() + { + $assert = AssertableJson::fromArray([ + 'example' => [ + 'nested' => 'nested-value', + ], + ]); + + $assert->has('example.nested'); + } + + public function testAssertHasFailsWhenNestedPropMissing() + { + $assert = AssertableJson::fromArray([ + 'example' => [ + 'nested' => 'nested-value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [example.another] does not exist.'); + + $assert->has('example.another'); + } + + public function testAssertCountItemsInProp() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $assert->has('bar', 2); + } + + public function testAssertCountFailsWhenAmountOfItemsDoesNotMatch() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not have the expected size.'); + + $assert->has('bar', 1); + } + + public function testAssertCountFailsWhenPropMissing() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] does not exist.'); + + $assert->has('baz', 1); + } + + public function testAssertHasFailsWhenSecondArgumentUnsupportedType() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'baz', + ]); + + $this->expectException(TypeError::class); + + $assert->has('bar', 'invalid'); + } + + public function testAssertMissing() + { + $assert = AssertableJson::fromArray([ + 'foo' => [ + 'bar' => true, + ], + ]); + + $assert->missing('foo.baz'); + } + + public function testAssertMissingFailsWhenPropExists() + { + $assert = AssertableJson::fromArray([ + 'prop' => 'value', + 'foo' => [ + 'bar' => true, + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo.bar] was found while it was expected to be missing.'); + + $assert->missing('foo.bar'); + } + + public function testAssertMissingAll() + { + $assert = AssertableJson::fromArray([ + 'baz' => 'foo', + ]); + + $assert->missingAll([ + 'foo', + 'bar', + ]); + } + + public function testAssertMissingAllFailsWhenAtLeastOnePropExists() + { + $assert = AssertableJson::fromArray([ + 'baz' => 'foo', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] was found while it was expected to be missing.'); + + $assert->missingAll([ + 'bar', + 'baz', + ]); + } + + public function testAssertMissingAllAcceptsMultipleArgumentsInsteadOfArray() + { + $assert = AssertableJson::fromArray([ + 'baz' => 'foo', + ]); + + $assert->missingAll('foo', 'bar'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] was found while it was expected to be missing.'); + + $assert->missingAll('bar', 'baz'); + } + + public function testAssertWhereMatchesValue() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'value', + ]); + + $assert->where('bar', 'value'); + } + + public function testAssertWhereFailsWhenDoesNotMatchValue() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'value', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not match the expected value.'); + + $assert->where('bar', 'invalid'); + } + + public function testAssertWhereFailsWhenMissing() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'value', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] does not exist.'); + + $assert->where('baz', 'invalid'); + } + + public function testAssertWhereFailsWhenMachingLoosely() + { + $assert = AssertableJson::fromArray([ + 'bar' => 1, + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not match the expected value.'); + + $assert->where('bar', true); + } + + public function testAssertWhereUsingClosure() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'baz', + ]); + + $assert->where('bar', function ($value) { + return $value === 'baz'; + }); + } + + public function testAssertWhereFailsWhenDoesNotMatchValueUsingClosure() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'baz', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] was marked as invalid using a closure.'); + + $assert->where('bar', function ($value) { + return $value === 'invalid'; + }); + } + + public function testAssertWhereClosureArrayValuesAreAutomaticallyCastedToCollections() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'foo', + 'example' => 'value', + ], + ]); + + $assert->where('bar', function ($value) { + $this->assertInstanceOf(Collection::class, $value); + + return $value->count() === 2; + }); + } + + public function testAssertWhereMatchesValueUsingArrayable() + { + $stub = ArrayableStubObject::make(['foo' => 'bar']); + + $assert = AssertableJson::fromArray([ + 'bar' => $stub->toArray(), + ]); + + $assert->where('bar', $stub); + } + + public function testAssertWhereMatchesValueUsingArrayableWhenSortedDifferently() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'foo', + 'example' => 'value', + ], + ]); + + $assert->where('bar', function ($value) { + $this->assertInstanceOf(Collection::class, $value); + + return $value->count() === 2; + }); + } + + public function testAssertWhereFailsWhenDoesNotMatchValueUsingArrayable() + { + $assert = AssertableJson::fromArray([ + 'bar' => ['id' => 1, 'name' => 'Example'], + 'baz' => [ + 'id' => 1, + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'email_verified_at' => '2021-01-22T10:34:42.000000Z', + 'created_at' => '2021-01-22T10:34:42.000000Z', + 'updated_at' => '2021-01-22T10:34:42.000000Z', + ], + ]); + + $assert + ->where('bar', ArrayableStubObject::make(['name' => 'Example', 'id' => 1])) + ->where('baz', [ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'id' => 1, + 'email_verified_at' => '2021-01-22T10:34:42.000000Z', + 'updated_at' => '2021-01-22T10:34:42.000000Z', + 'created_at' => '2021-01-22T10:34:42.000000Z', + ]); + } + + public function testAssertNestedWhereMatchesValue() + { + $assert = AssertableJson::fromArray([ + 'example' => [ + 'nested' => 'nested-value', + ], + ]); + + $assert->where('example.nested', 'nested-value'); + } + + public function testAssertNestedWhereFailsWhenDoesNotMatchValue() + { + $assert = AssertableJson::fromArray([ + 'example' => [ + 'nested' => 'nested-value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [example.nested] does not match the expected value.'); + + $assert->where('example.nested', 'another-value'); + } + + public function testScope() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $called = false; + $assert->has('bar', function (AssertableJson $assert) use (&$called) { + $called = true; + $assert + ->where('baz', 'example') + ->where('prop', 'value'); + }); + + $this->assertTrue($called, 'The scoped query was never actually called.'); + } + + public function testScopeFailsWhenPropMissing() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] does not exist.'); + + $assert->has('baz', function (AssertableJson $item) { + $item->where('baz', 'example'); + }); + } + + public function testScopeFailsWhenPropSingleValue() + { + $assert = AssertableJson::fromArray([ + 'bar' => 'value', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] is not scopeable.'); + + $assert->has('bar', function (AssertableJson $item) { + // + }); + } + + public function testScopeShorthand() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + ['key' => 'first'], + ['key' => 'second'], + ], + ]); + + $called = false; + $assert->has('bar', 2, function (AssertableJson $item) use (&$called) { + $item->where('key', 'first'); + $called = true; + }); + + $this->assertTrue($called, 'The scoped query was never actually called.'); + } + + public function testScopeShorthandFailsWhenAssertingZeroItems() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + ['key' => 'first'], + ['key' => 'second'], + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Cannot scope directly onto the first entry of property [bar] when asserting that it has a size of 0.'); + + $assert->has('bar', 0, function (AssertableJson $item) { + $item->where('key', 'first'); + }); + } + + public function testScopeShorthandFailsWhenAmountOfItemsDoesNotMatch() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + ['key' => 'first'], + ['key' => 'second'], + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not have the expected size.'); + + $assert->has('bar', 1, function (AssertableJson $item) { + $item->where('key', 'first'); + }); + } + + public function testFailsWhenNotInteractingWithAllPropsInScope() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected properties were found in scope [bar].'); + + $assert->has('bar', function (AssertableJson $item) { + $item->where('baz', 'example'); + }); + } + + public function testDisableInteractionCheckForCurrentScope() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $assert->has('bar', function (AssertableJson $item) { + $item->etc(); + }); + } + + public function testCannotDisableInteractionCheckForDifferentScopes() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => [ + 'foo' => 'bar', + 'example' => 'value', + ], + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected properties were found in scope [bar.baz].'); + + $assert->has('bar', function (AssertableJson $item) { + $item + ->etc() + ->has('baz', function (AssertableJson $item) { + // + }); + }); + } + + public function testTopLevelPropInteractionDisabledByDefault() + { + $assert = AssertableJson::fromArray([ + 'foo' => 'bar', + 'bar' => 'baz', + ]); + + $assert->has('foo'); + } + + public function testTopLevelInteractionEnabledWhenInteractedFlagSet() + { + $assert = AssertableJson::fromArray([ + 'foo' => 'bar', + 'bar' => 'baz', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected properties were found on the root level.'); + + $assert + ->has('foo') + ->interacted(); + } + + public function testAssertWhereAllMatchesValues() + { + $assert = AssertableJson::fromArray([ + 'foo' => [ + 'bar' => 'value', + 'example' => ['hello' => 'world'], + ], + 'baz' => 'another', + ]); + + $assert->whereAll([ + 'foo.bar' => 'value', + 'foo.example' => ArrayableStubObject::make(['hello' => 'world']), + 'baz' => function ($value) { + return $value === 'another'; + }, + ]); + } + + public function testAssertWhereAllFailsWhenAtLeastOnePropDoesNotMatchValue() + { + $assert = AssertableJson::fromArray([ + 'foo' => 'bar', + 'baz' => 'example', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] was marked as invalid using a closure.'); + + $assert->whereAll([ + 'foo' => 'bar', + 'baz' => function ($value) { + return $value === 'foo'; + }, + ]); + } + + public function testAssertHasAll() + { + $assert = AssertableJson::fromArray([ + 'foo' => [ + 'bar' => 'value', + 'example' => ['hello' => 'world'], + ], + 'baz' => 'another', + ]); + + $assert->hasAll([ + 'foo.bar', + 'foo.example', + 'baz', + ]); + } + + public function testAssertHasAllFailsWhenAtLeastOnePropMissing() + { + $assert = AssertableJson::fromArray([ + 'foo' => [ + 'bar' => 'value', + 'example' => ['hello' => 'world'], + ], + 'baz' => 'another', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo.baz] does not exist.'); + + $assert->hasAll([ + 'foo.bar', + 'foo.baz', + 'baz', + ]); + } + + public function testAssertHasAllAcceptsMultipleArgumentsInsteadOfArray() + { + $assert = AssertableJson::fromArray([ + 'foo' => [ + 'bar' => 'value', + 'example' => ['hello' => 'world'], + ], + 'baz' => 'another', + ]); + + $assert->hasAll('foo.bar', 'foo.example', 'baz'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo.baz] does not exist.'); + + $assert->hasAll('foo.bar', 'foo.baz', 'baz'); + } + + public function testAssertCountMultipleProps() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'key' => 'value', + 'prop' => 'example', + ], + 'baz' => [ + 'another' => 'value', + ], + ]); + + $assert->hasAll([ + 'bar' => 2, + 'baz' => 1, + ]); + } + + public function testAssertCountMultiplePropsFailsWhenPropMissing() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'key' => 'value', + 'prop' => 'example', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] does not exist.'); + + $assert->hasAll([ + 'bar' => 2, + 'baz' => 1, + ]); + } + + public function testMacroable() + { + AssertableJson::macro('myCustomMacro', function () { + throw new RuntimeException('My Custom Macro was called!'); + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('My Custom Macro was called!'); + + $assert = AssertableJson::fromArray(['foo' => 'bar']); + $assert->myCustomMacro(); + } + + public function testTappable() + { + $assert = AssertableJson::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $called = false; + $assert->has('bar', function (AssertableJson $assert) use (&$called) { + $assert->etc(); + $assert->tap(function (AssertableJson $assert) use (&$called) { + $called = true; + }); + }); + + $this->assertTrue($called, 'The scoped query was never actually called.'); + } +} diff --git a/tests/Testing/Stubs/ArrayableStubObject.php b/tests/Testing/Stubs/ArrayableStubObject.php new file mode 100644 index 000000000000..021440e0b287 --- /dev/null +++ b/tests/Testing/Stubs/ArrayableStubObject.php @@ -0,0 +1,25 @@ +data = $data; + } + + public static function make($data = []) + { + return new self($data); + } + + public function toArray() + { + return $this->data; + } +} diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php index 42d16e72d3fa..b6a1e5531a54 100644 --- a/tests/Testing/TestResponseTest.php +++ b/tests/Testing/TestResponseTest.php @@ -9,6 +9,7 @@ use Illuminate\Encryption\Encrypter; use Illuminate\Filesystem\Filesystem; use Illuminate\Http\Response; +use Illuminate\Testing\Fluent\AssertableJson; use Illuminate\Testing\TestResponse; use JsonSerializable; use Mockery as m; @@ -577,6 +578,27 @@ public function testAssertJsonWithNull() $response->assertJson($resource->jsonSerialize()); } + public function testAssertJsonWithFluent() + { + $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableSingleResourceStub)); + + $response->assertJson(function (AssertableJson $json) { + $json->where('0.foo', 'foo 0'); + }); + } + + public function testAssertJsonWithFluentStrict() + { + $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableSingleResourceStub)); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected properties were found on the root level.'); + + $response->assertJson(function (AssertableJson $json) { + $json->where('0.foo', 'foo 0'); + }, true); + } + public function testAssertSimilarJsonWithMixed() { $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub)); diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index a60b93b9abf4..1772582d18ba 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -1096,10 +1096,34 @@ public function testRequiredIf() $v = new Validator($trans, ['foo' => true], ['bar' => 'required_if:foo,false']); $this->assertTrue($v->passes()); + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => true], ['bar' => 'required_if:foo,null']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => 0], ['bar' => 'required_if:foo,0']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => '0'], ['bar' => 'required_if:foo,0']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => 1], ['bar' => 'required_if:foo,1']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => '1'], ['bar' => 'required_if:foo,1']); + $this->assertTrue($v->fails()); + $trans = $this->getIlluminateArrayTranslator(); $v = new Validator($trans, ['foo' => true], ['bar' => 'required_if:foo,true']); $this->assertTrue($v->fails()); + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => false], ['bar' => 'required_if:foo,false']); + $this->assertTrue($v->fails()); + // error message when passed multiple values (required_if:foo,bar,baz) $trans = $this->getIlluminateArrayTranslator(); $trans->addLines(['validation.required_if' => 'The :attribute field is required when :other is :value.'], 'en'); @@ -1138,6 +1162,26 @@ public function testRequiredUnless() $v = new Validator($trans, ['foo' => false], ['bar' => 'required_unless:foo,true']); $this->assertTrue($v->fails()); + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => true], ['bar' => 'required_unless:foo,null']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => '0'], ['bar' => 'required_unless:foo,0']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => 0], ['bar' => 'required_unless:foo,0']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => '1'], ['bar' => 'required_unless:foo,1']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => 1], ['bar' => 'required_unless:foo,1']); + $this->assertTrue($v->passes()); + // error message when passed multiple values (required_unless:foo,bar,baz) $trans = $this->getIlluminateArrayTranslator(); $trans->addLines(['validation.required_unless' => 'The :attribute field is required unless :other is in :values.'], 'en'); @@ -1146,6 +1190,78 @@ public function testRequiredUnless() $this->assertSame('The last field is required unless first is in taylor, sven.', $v->messages()->first('last')); } + public function testProhibitedIf() + { + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['first' => 'taylor', 'last' => 'otwell'], ['last' => 'prohibited_if:first,taylor']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['first' => 'taylor'], ['last' => 'prohibited_if:first,taylor']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['first' => 'taylor', 'last' => 'otwell'], ['last' => 'prohibited_if:first,taylor,jess']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['first' => 'taylor'], ['last' => 'prohibited_if:first,taylor,jess']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => true, 'bar' => 'baz'], ['bar' => 'prohibited_if:foo,false']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => true, 'bar' => 'baz'], ['bar' => 'prohibited_if:foo,true']); + $this->assertTrue($v->fails()); + + // error message when passed multiple values (prohibited_if:foo,bar,baz) + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.prohibited_if' => 'The :attribute field is prohibited when :other is :value.'], 'en'); + $v = new Validator($trans, ['first' => 'jess', 'last' => 'archer'], ['last' => 'prohibited_if:first,taylor,jess']); + $this->assertFalse($v->passes()); + $this->assertSame('The last field is prohibited when first is jess.', $v->messages()->first('last')); + } + + public function testProhibitedUnless() + { + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['first' => 'jess', 'last' => 'archer'], ['last' => 'prohibited_unless:first,taylor']); + $this->assertTrue($v->fails()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['first' => 'taylor', 'last' => 'otwell'], ['last' => 'prohibited_unless:first,taylor']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['first' => 'jess'], ['last' => 'prohibited_unless:first,taylor']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['first' => 'taylor', 'last' => 'otwell'], ['last' => 'prohibited_unless:first,taylor,jess']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['first' => 'jess', 'last' => 'archer'], ['last' => 'prohibited_unless:first,taylor,jess']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => false, 'bar' => 'baz'], ['bar' => 'prohibited_unless:foo,false']); + $this->assertTrue($v->passes()); + + $trans = $this->getIlluminateArrayTranslator(); + $v = new Validator($trans, ['foo' => false, 'bar' => 'baz'], ['bar' => 'prohibited_unless:foo,true']); + $this->assertTrue($v->fails()); + + // error message when passed multiple values (prohibited_unless:foo,bar,baz) + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines(['validation.prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.'], 'en'); + $v = new Validator($trans, ['first' => 'tim', 'last' => 'macdonald'], ['last' => 'prohibitedUnless:first,taylor,jess']); + $this->assertFalse($v->passes()); + $this->assertSame('The last field is prohibited unless first is in taylor, jess.', $v->messages()->first('last')); + } + public function testFailedFileUploads() { $trans = $this->getIlluminateArrayTranslator(); @@ -5334,6 +5450,20 @@ public function providesPassingExcludeIfData() 'has_appointment' => false, ], ], + [ + [ + 'has_appointment' => ['nullable', 'bool'], + 'appointment_date' => ['exclude_if:has_appointment,null', 'required', 'date'], + ], + [ + 'has_appointment' => true, + 'appointment_date' => '2021-03-08', + ], + [ + 'has_appointment' => true, + 'appointment_date' => '2021-03-08', + ], + ], [ [ 'has_appointment' => ['required', 'bool'], @@ -5668,6 +5798,14 @@ public function testExcludeUnless() ); $this->assertTrue($validator->fails()); $this->assertSame(['mouse' => ['validation.required']], $validator->messages()->toArray()); + + $validator = new Validator( + $this->getIlluminateArrayTranslator(), + ['foo' => true, 'bar' => 'baz'], + ['foo' => 'nullable', 'bar' => 'exclude_unless:foo,null'] + ); + $this->assertTrue($validator->passes()); + $this->assertSame(['foo' => true], $validator->validated()); } public function testExcludeWithout() diff --git a/tests/View/ViewFactoryTest.php b/tests/View/ViewFactoryTest.php index ab003c3b9f9d..1419f502706c 100755 --- a/tests/View/ViewFactoryTest.php +++ b/tests/View/ViewFactoryTest.php @@ -443,6 +443,27 @@ public function testMultipleStackPush() $this->assertSame('hi, Hello!', $factory->yieldPushContent('foo')); } + public function testSingleStackPrepend() + { + $factory = $this->getFactory(); + $factory->startPrepend('foo'); + echo 'hi'; + $factory->stopPrepend(); + $this->assertSame('hi', $factory->yieldPushContent('foo')); + } + + public function testMultipleStackPrepend() + { + $factory = $this->getFactory(); + $factory->startPrepend('foo'); + echo ', Hello!'; + $factory->stopPrepend(); + $factory->startPrepend('foo'); + echo 'hi'; + $factory->stopPrepend(); + $this->assertSame('hi, Hello!', $factory->yieldPushContent('foo')); + } + public function testSessionAppending() { $factory = $this->getFactory();