diff --git a/CHANGELOG.md b/CHANGELOG.md index 6224987946d6..9c91a1f8db53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,30 @@ # Release Notes for 12.x -## [Unreleased](https://github.com/laravel/framework/compare/v12.14.1...12.x) +## [Unreleased](https://github.com/laravel/framework/compare/v12.15.0...12.x) + +## [v12.15.0](https://github.com/laravel/framework/compare/v12.14.1...v12.15.0) - 2025-05-20 + +* [12.x] Add locale-aware number parsing methods to Number class by [@informagenie](https://github.com/informagenie) in https://github.com/laravel/framework/pull/55725 +* [12.x] Add a default option when retrieving an enum from data by [@elbojoloco](https://github.com/elbojoloco) in https://github.com/laravel/framework/pull/55735 +* Revert "[12.x] Update "Number::fileSize" to use correct prefix and add prefix param" by [@ziadoz](https://github.com/ziadoz) in https://github.com/laravel/framework/pull/55741 +* [12.x] Remove apc by [@AhmedAlaa4611](https://github.com/AhmedAlaa4611) in https://github.com/laravel/framework/pull/55745 +* [12.x] Add param type for `assertJsonStructure` & `assertExactJsonStructure` methods by [@milwad-dev](https://github.com/milwad-dev) in https://github.com/laravel/framework/pull/55743 +* [12.x] Fix type casting for environment variables in config files by [@adamwhp](https://github.com/adamwhp) in https://github.com/laravel/framework/pull/55737 +* [12.x] Preserve "previous" model state by [@crynobone](https://github.com/crynobone) in https://github.com/laravel/framework/pull/55729 +* [12.x] Passthru `getCountForPagination` on an Eloquent\Builder by [@cosmastech](https://github.com/cosmastech) in https://github.com/laravel/framework/pull/55752 +* [12.x] Add `assertClientError` method to `TestResponse` by [@shane-zeng](https://github.com/shane-zeng) in https://github.com/laravel/framework/pull/55750 +* Install Broadcasting Command Fix for Livewire Starter Kit by [@joshcirre](https://github.com/joshcirre) in https://github.com/laravel/framework/pull/55774 +* Clarify units for benchmark value for IDE accessibility by [@mike-healy](https://github.com/mike-healy) in https://github.com/laravel/framework/pull/55781 +* Improved PHPDoc Return Types for Eloquent's Original Attribute Methods by [@clementbirkle](https://github.com/clementbirkle) in https://github.com/laravel/framework/pull/55779 +* [12.x] Prevent `preventsLazyLoading` exception when using `automaticallyEagerLoadRelationships` by [@devajmeireles](https://github.com/devajmeireles) in https://github.com/laravel/framework/pull/55771 +* [12.x] Add `hash` string helper by [@istiak-tridip](https://github.com/istiak-tridip) in https://github.com/laravel/framework/pull/55767 +* [12.x] Update `assertSessionMissing()` signature to match `assertSessionHas()` by [@nexxai](https://github.com/nexxai) in https://github.com/laravel/framework/pull/55763 +* Fix: php artisan db command if no password by [@mr-chetan](https://github.com/mr-chetan) in https://github.com/laravel/framework/pull/55761 +* [12.x] Types: InteractsWithPivotTable::sync by [@liamduckett](https://github.com/liamduckett) in https://github.com/laravel/framework/pull/55762 +* [12.x] feat: Add `current_page_url` to Paginator by [@mariomka](https://github.com/mariomka) in https://github.com/laravel/framework/pull/55789 +* Correct return type in PhpDoc for command fail method by [@Muetze42](https://github.com/Muetze42) in https://github.com/laravel/framework/pull/55783 +* [12.x] Add `assertRedirectToAction` method to test redirection to controller actions by [@xurshudyan](https://github.com/xurshudyan) in https://github.com/laravel/framework/pull/55788 +* [12.x] Add Context contextual attribute by [@martinbean](https://github.com/martinbean) in https://github.com/laravel/framework/pull/55760 ## [v12.14.1](https://github.com/laravel/framework/compare/v12.14.0...v12.14.1) - 2025-05-13 diff --git a/config/auth.php b/config/auth.php index 0ba5d5d8f10c..7d1eb0de5f7b 100644 --- a/config/auth.php +++ b/config/auth.php @@ -104,7 +104,7 @@ | Password Confirmation Timeout |-------------------------------------------------------------------------- | - | Here you may define the amount of seconds before a password confirmation + | Here you may define the number of seconds before a password confirmation | window expires and users are asked to re-enter their password via the | confirmation screen. By default, the timeout lasts for three hours. | diff --git a/src/Illuminate/Auth/AuthManager.php b/src/Illuminate/Auth/AuthManager.php index 131959148b06..8c12db570ae4 100755 --- a/src/Illuminate/Auth/AuthManager.php +++ b/src/Illuminate/Auth/AuthManager.php @@ -66,7 +66,7 @@ public function guard($name = null) { $name = $name ?: $this->getDefaultDriver(); - return $this->guards[$name] ?? $this->guards[$name] = $this->resolve($name); + return $this->guards[$name] ??= $this->resolve($name); } /** diff --git a/src/Illuminate/Cache/Events/CacheFlushed.php b/src/Illuminate/Cache/Events/CacheFlushed.php index 5f942afdd1af..aacabf5e9e10 100644 --- a/src/Illuminate/Cache/Events/CacheFlushed.php +++ b/src/Illuminate/Cache/Events/CacheFlushed.php @@ -22,7 +22,6 @@ class CacheFlushed * Create a new event instance. * * @param string|null $storeName - * @return void */ public function __construct($storeName, array $tags = []) { diff --git a/src/Illuminate/Cache/RedisStore.php b/src/Illuminate/Cache/RedisStore.php index 33cdf87307c7..399f4ac78ea0 100755 --- a/src/Illuminate/Cache/RedisStore.php +++ b/src/Illuminate/Cache/RedisStore.php @@ -140,7 +140,7 @@ public function putMany(array $values, $seconds) $serializedValues = []; foreach ($values as $key => $value) { - $serializedValues[$this->prefix.$key] = $this->serialize($value); + $serializedValues[$this->prefix.$key] = $this->connectionAwareSerialize($value, $connection); } $connection->multi(); diff --git a/src/Illuminate/Cache/Repository.php b/src/Illuminate/Cache/Repository.php index 3eb6f700ed01..5b55da8e3008 100755 --- a/src/Illuminate/Cache/Repository.php +++ b/src/Illuminate/Cache/Repository.php @@ -483,9 +483,10 @@ public function rememberForever($key, Closure $callback) * @param array{ 0: \DateTimeInterface|\DateInterval|int, 1: \DateTimeInterface|\DateInterval|int } $ttl * @param (callable(): TCacheValue) $callback * @param array{ seconds?: int, owner?: string }|null $lock + * @param bool $alwaysDefer * @return TCacheValue */ - public function flexible($key, $ttl, $callback, $lock = null) + public function flexible($key, $ttl, $callback, $lock = null, $alwaysDefer = false) { [ $key => $value, @@ -520,7 +521,7 @@ public function flexible($key, $ttl, $callback, $lock = null) }); }; - defer($refresh, "illuminate:cache:flexible:{$key}"); + defer($refresh, "illuminate:cache:flexible:{$key}", $alwaysDefer); return $value; } diff --git a/src/Illuminate/Collections/Arr.php b/src/Illuminate/Collections/Arr.php index bea43ce76c26..267991ad00f0 100644 --- a/src/Illuminate/Collections/Arr.php +++ b/src/Illuminate/Collections/Arr.php @@ -495,6 +495,30 @@ public static function has($array, $keys) return true; } + /** + * Determine if all keys exist in an array using "dot" notation. + * + * @param \ArrayAccess|array $array + * @param string|array $keys + * @return bool + */ + public static function hasAll($array, $keys) + { + $keys = (array) $keys; + + if (! $array || $keys === []) { + return false; + } + + foreach ($keys as $key) { + if (! static::has($array, $key)) { + return false; + } + } + + return true; + } + /** * Determine if any of the keys exist in an array using "dot" notation. * @@ -534,7 +558,7 @@ public static function integer(ArrayAccess|array $array, string|int|null $key, ? { $value = Arr::get($array, $key, $default); - if (! is_integer($value)) { + if (! is_int($value)) { throw new InvalidArgumentException( sprintf('Array value for key [%s] must be an integer, %s found.', $key, gettype($value)) ); diff --git a/src/Illuminate/Container/Attributes/Context.php b/src/Illuminate/Container/Attributes/Context.php index 34516ea3afc5..1c858074646d 100644 --- a/src/Illuminate/Container/Attributes/Context.php +++ b/src/Illuminate/Container/Attributes/Context.php @@ -13,7 +13,7 @@ class Context implements ContextualAttribute /** * Create a new attribute instance. */ - public function __construct(public string $key, public mixed $default = null) + public function __construct(public string $key, public mixed $default = null, public bool $hidden = false) { } @@ -26,6 +26,11 @@ public function __construct(public string $key, public mixed $default = null) */ public static function resolve(self $attribute, Container $container): mixed { - return $container->make(Repository::class)->get($attribute->key, $attribute->default); + $repository = $container->make(Repository::class); + + return match ($attribute->hidden) { + true => $repository->getHidden($attribute->key, $attribute->default), + false => $repository->get($attribute->key, $attribute->default), + }; } } diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 5cf2c20dfafb..d5b6fdd23a00 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -562,7 +562,7 @@ public function getRelationValue($key) return $this->relations[$key]; } - if ($this->preventsLazyLoading && ! self::isAutomaticallyEagerLoadingRelationships()) { + if ($this->preventsLazyLoading) { $this->handleLazyLoadingViolation($key); } diff --git a/src/Illuminate/Database/Eloquent/Scope.php b/src/Illuminate/Database/Eloquent/Scope.php index 63cba6a51717..cfb1d9b97bc1 100644 --- a/src/Illuminate/Database/Eloquent/Scope.php +++ b/src/Illuminate/Database/Eloquent/Scope.php @@ -7,8 +7,10 @@ interface Scope /** * Apply the scope to a given Eloquent query builder. * - * @param \Illuminate\Database\Eloquent\Builder $builder - * @param \Illuminate\Database\Eloquent\Model $model + * @template TModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @param TModel $model * @return void */ public function apply(Builder $builder, Model $model); diff --git a/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index d2b97d5d121e..29e4cf764f20 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -3898,11 +3898,9 @@ public function updateOrInsert(array $attributes, array|callable $values = []) /** * Insert new records or update the existing ones. * - * @param array|string $uniqueBy - * @param array|null $update * @return int */ - public function upsert(array $values, $uniqueBy, $update = null) + public function upsert(array $values, array|string $uniqueBy, ?array $update = null) { if (empty($values)) { return 0; diff --git a/src/Illuminate/Foundation/Application.php b/src/Illuminate/Foundation/Application.php index 5e1181b3da0c..c212f1af11e6 100755 --- a/src/Illuminate/Foundation/Application.php +++ b/src/Illuminate/Foundation/Application.php @@ -45,7 +45,7 @@ class Application extends Container implements ApplicationContract, CachesConfig * * @var string */ - const VERSION = '12.15.0'; + const VERSION = '12.16.0'; /** * The base path for the Laravel installation. diff --git a/src/Illuminate/Foundation/Console/OptimizeClearCommand.php b/src/Illuminate/Foundation/Console/OptimizeClearCommand.php index 974af84f947c..414a0d57dac1 100644 --- a/src/Illuminate/Foundation/Console/OptimizeClearCommand.php +++ b/src/Illuminate/Foundation/Console/OptimizeClearCommand.php @@ -59,9 +59,9 @@ public function handle() public function getOptimizeClearTasks() { return [ + 'config' => 'config:clear', 'cache' => 'cache:clear', 'compiled' => 'clear-compiled', - 'config' => 'config:clear', 'events' => 'event:clear', 'routes' => 'route:clear', 'views' => 'view:clear', diff --git a/src/Illuminate/Mail/Transport/ResendTransport.php b/src/Illuminate/Mail/Transport/ResendTransport.php index 0e690bf30b5a..9693eaf3a476 100644 --- a/src/Illuminate/Mail/Transport/ResendTransport.php +++ b/src/Illuminate/Mail/Transport/ResendTransport.php @@ -72,12 +72,19 @@ protected function doSend(SentMessage $message): void if ($email->getAttachments()) { foreach ($email->getAttachments() as $attachment) { $attachmentHeaders = $attachment->getPreparedHeaders(); + $contentType = $attachmentHeaders->get('Content-Type')->getBody(); $filename = $attachmentHeaders->getHeaderParameter('Content-Disposition', 'filename'); + if ($contentType == 'text/calendar') { + $content = $attachment->getBody(); + } else { + $content = str_replace("\r\n", '', $attachment->bodyToString()); + } + $item = [ - 'content_type' => $attachmentHeaders->get('Content-Type')->getBody(), - 'content' => str_replace("\r\n", '', $attachment->bodyToString()), + 'content_type' => $contentType, + 'content' => $content, 'filename' => $filename, ]; diff --git a/src/Illuminate/Queue/Console/MonitorCommand.php b/src/Illuminate/Queue/Console/MonitorCommand.php index 466a09501bc0..08ab98c5b8ae 100644 --- a/src/Illuminate/Queue/Console/MonitorCommand.php +++ b/src/Illuminate/Queue/Console/MonitorCommand.php @@ -19,7 +19,8 @@ class MonitorCommand extends Command */ protected $signature = 'queue:monitor {queues : The names of the queues to monitor} - {--max=1000 : The maximum number of jobs that can be on the queue before an event is dispatched}'; + {--max=1000 : The maximum number of jobs that can be on the queue before an event is dispatched} + {--json : Output the queue size as JSON}'; /** * The console command description. @@ -65,7 +66,15 @@ public function handle() { $queues = $this->parseQueues($this->argument('queues')); - $this->displaySizes($queues); + if ($this->option('json')) { + $this->output->writeln((new Collection($queues))->map(function ($queue) { + return array_merge($queue, [ + 'status' => str_contains($queue['status'], 'ALERT') ? 'ALERT' : 'OK', + ]); + })->toJson()); + } else { + $this->displaySizes($queues); + } $this->dispatchEvents($queues); } diff --git a/src/Illuminate/Routing/ResponseFactory.php b/src/Illuminate/Routing/ResponseFactory.php index c8ec8f958b07..b0df52488a65 100644 --- a/src/Illuminate/Routing/ResponseFactory.php +++ b/src/Illuminate/Routing/ResponseFactory.php @@ -186,14 +186,14 @@ public function eventStream(Closure $callback, array $headers = [], StreamedEven /** * Create a new streamed response instance. * - * @param callable $callback + * @param callable|null $callback * @param int $status * @param array $headers * @return \Symfony\Component\HttpFoundation\StreamedResponse */ public function stream($callback, $status = 200, array $headers = []) { - if ((new ReflectionFunction($callback))->isGenerator()) { + if (! is_null($callback) && (new ReflectionFunction($callback))->isGenerator()) { return new StreamedResponse(function () use ($callback) { foreach ($callback() as $chunk) { echo $chunk; diff --git a/src/Illuminate/Support/EncodedHtmlString.php b/src/Illuminate/Support/EncodedHtmlString.php index a25115740277..36b29cc33ecf 100644 --- a/src/Illuminate/Support/EncodedHtmlString.php +++ b/src/Illuminate/Support/EncodedHtmlString.php @@ -27,7 +27,6 @@ class EncodedHtmlString extends HtmlString * * @param \Illuminate\Contracts\Support\DeferringDisplayableValue|\Illuminate\Contracts\Support\Htmlable|\BackedEnum|string|int|float|null $html * @param bool $doubleEncode - * @return void */ public function __construct($html = '', protected bool $doubleEncode = true) { diff --git a/src/Illuminate/Support/Facades/Cache.php b/src/Illuminate/Support/Facades/Cache.php index 07553d8bb812..8c153e26ee2b 100755 --- a/src/Illuminate/Support/Facades/Cache.php +++ b/src/Illuminate/Support/Facades/Cache.php @@ -33,7 +33,7 @@ * @method static mixed remember(string $key, \Closure|\DateTimeInterface|\DateInterval|int|null $ttl, \Closure $callback) * @method static mixed sear(string $key, \Closure $callback) * @method static mixed rememberForever(string $key, \Closure $callback) - * @method static mixed flexible(string $key, array $ttl, callable $callback, array|null $lock = null) + * @method static mixed flexible(string $key, array $ttl, callable $callback, array|null $lock = null, bool $alwaysDefer = false) * @method static bool forget(string $key) * @method static bool delete(string $key) * @method static bool deleteMultiple(iterable $keys) diff --git a/src/Illuminate/Support/Facades/Response.php b/src/Illuminate/Support/Facades/Response.php index f2dc641b77c5..7c61bde14e40 100755 --- a/src/Illuminate/Support/Facades/Response.php +++ b/src/Illuminate/Support/Facades/Response.php @@ -11,7 +11,7 @@ * @method static \Illuminate\Http\JsonResponse json(mixed $data = [], int $status = 200, array $headers = [], int $options = 0) * @method static \Illuminate\Http\JsonResponse jsonp(string $callback, mixed $data = [], int $status = 200, array $headers = [], int $options = 0) * @method static \Symfony\Component\HttpFoundation\StreamedResponse eventStream(\Closure $callback, array $headers = [], \Illuminate\Http\StreamedEvent|string|null $endStreamWith = '') - * @method static \Symfony\Component\HttpFoundation\StreamedResponse stream(callable $callback, int $status = 200, array $headers = []) + * @method static \Symfony\Component\HttpFoundation\StreamedResponse stream(callable|null $callback, int $status = 200, array $headers = []) * @method static \Symfony\Component\HttpFoundation\StreamedJsonResponse streamJson(array $data, int $status = 200, array $headers = [], int $encodingOptions = 15) * @method static \Symfony\Component\HttpFoundation\StreamedResponse streamDownload(callable $callback, string|null $name = null, array $headers = [], string|null $disposition = 'attachment') * @method static \Symfony\Component\HttpFoundation\BinaryFileResponse download(\SplFileInfo|string $file, string|null $name = null, array $headers = [], string|null $disposition = 'attachment') diff --git a/src/Illuminate/Support/Stringable.php b/src/Illuminate/Support/Stringable.php index c5a57a226247..c04d89d7afa9 100644 --- a/src/Illuminate/Support/Stringable.php +++ b/src/Illuminate/Support/Stringable.php @@ -1430,6 +1430,16 @@ public function toDate($format = null, $tz = null) return Date::createFromFormat($format, $this->value, $tz); } + /** + * Get the underlying string value as a Uri instance. + * + * @return \Illuminate\Support\Uri + */ + public function toUri() + { + return Uri::of($this->value); + } + /** * Convert the object to a string when JSON encoded. * diff --git a/src/Illuminate/Testing/TestResponse.php b/src/Illuminate/Testing/TestResponse.php index 6cf3e9b75b33..3795fefec4da 100644 --- a/src/Illuminate/Testing/TestResponse.php +++ b/src/Illuminate/Testing/TestResponse.php @@ -1670,6 +1670,8 @@ public function assertSessionMissing($key, $value = null) foreach ($key as $value) { $this->assertSessionMissing($value); } + + return $this; } if (is_null($value)) { diff --git a/src/Illuminate/Translation/lang/en/validation.php b/src/Illuminate/Translation/lang/en/validation.php index a57a95ed9858..9e92832b575e 100644 --- a/src/Illuminate/Translation/lang/en/validation.php +++ b/src/Illuminate/Translation/lang/en/validation.php @@ -73,6 +73,7 @@ 'image' => 'The :attribute field must be an image.', 'in' => 'The selected :attribute is invalid.', 'in_array' => 'The :attribute field must exist in :other.', + 'in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.', 'integer' => 'The :attribute field must be an integer.', 'ip' => 'The :attribute field must be a valid IP address.', 'ipv4' => 'The :attribute field must be a valid IPv4 address.', diff --git a/src/Illuminate/Validation/Concerns/ReplacesAttributes.php b/src/Illuminate/Validation/Concerns/ReplacesAttributes.php index 202398f33195..975f131b0757 100644 --- a/src/Illuminate/Validation/Concerns/ReplacesAttributes.php +++ b/src/Illuminate/Validation/Concerns/ReplacesAttributes.php @@ -325,6 +325,24 @@ protected function replaceInArray($message, $attribute, $rule, $parameters) return str_replace(':other', $this->getDisplayableAttribute($parameters[0]), $message); } + /** + * Replace all place-holders for the in_array_keys rule. + * + * @param string $message + * @param string $attribute + * @param string $rule + * @param array $parameters + * @return string + */ + protected function replaceInArrayKeys($message, $attribute, $rule, $parameters) + { + foreach ($parameters as &$parameter) { + $parameter = $this->getDisplayableValue($attribute, $parameter); + } + + return str_replace(':values', implode(', ', $parameters), $message); + } + /** * Replace all place-holders for the required_array_keys rule. * diff --git a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php index 21577ce4eb87..dd567b1afe65 100644 --- a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php +++ b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php @@ -1450,6 +1450,33 @@ public function validateInArray($attribute, $value, $parameters) return in_array($value, $otherValues); } + /** + * Validate that an array has at least one of the given keys. + * + * @param string $attribute + * @param mixed $value + * @param array $parameters + * @return bool + */ + public function validateInArrayKeys($attribute, $value, $parameters) + { + if (! is_array($value)) { + return false; + } + + if (empty($parameters)) { + return false; + } + + foreach ($parameters as $param) { + if (Arr::exists($value, $param)) { + return true; + } + } + + return false; + } + /** * Validate that an attribute is an integer. * diff --git a/src/Illuminate/Validation/Rule.php b/src/Illuminate/Validation/Rule.php index 170f4d04a1ea..57e5bc52d4a9 100644 --- a/src/Illuminate/Validation/Rule.php +++ b/src/Illuminate/Validation/Rule.php @@ -260,6 +260,21 @@ public static function anyOf($rules) return new AnyOf($rules); } + /** + * Get a contains rule builder instance. + * + * @param \Illuminate\Contracts\Support\Arrayable|\BackedEnum|\UnitEnum|array|string $values + * @return \Illuminate\Validation\Rules\Contains + */ + public static function contains($values) + { + if ($values instanceof Arrayable) { + $values = $values->toArray(); + } + + return new Rules\Contains(is_array($values) ? $values : func_get_args()); + } + /** * Compile a set of rules for an attribute. * diff --git a/src/Illuminate/Validation/Rules/Contains.php b/src/Illuminate/Validation/Rules/Contains.php new file mode 100644 index 000000000000..c42b9b474250 --- /dev/null +++ b/src/Illuminate/Validation/Rules/Contains.php @@ -0,0 +1,49 @@ +toArray(); + } + + $this->values = is_array($values) ? $values : func_get_args(); + } + + /** + * Convert the rule to a validation string. + * + * @return string + */ + public function __toString() + { + $values = array_map(function ($value) { + $value = enum_value($value); + + return '"'.str_replace('"', '""', $value).'"'; + }, $this->values); + + return 'contains:'.implode(',', $values); + } +} diff --git a/tests/Container/ContextualAttributeBindingTest.php b/tests/Container/ContextualAttributeBindingTest.php index 610e4a1d1cd8..4eb73ad12d7d 100644 --- a/tests/Container/ContextualAttributeBindingTest.php +++ b/tests/Container/ContextualAttributeBindingTest.php @@ -231,6 +231,21 @@ public function testContextAttribute(): void $container->make(ContextTest::class); } + public function testContextAttributeInteractingWithHidden(): void + { + $container = new Container; + + $container->singleton(ContextRepository::class, function () { + $context = m::mock(ContextRepository::class); + $context->shouldReceive('getHidden')->once()->with('bar', null)->andReturn('bar'); + $context->shouldNotReceive('get'); + + return $context; + }); + + $container->make(ContextHiddenTest::class); + } + public function testStorageAttribute() { $container = new Container; @@ -448,6 +463,13 @@ public function __construct(#[Context('foo')] string $foo) } } +final class ContextHiddenTest +{ + public function __construct(#[Context('bar', hidden: true)] string $foo) + { + } +} + final class DatabaseTest { public function __construct(#[Database('foo')] Connection $foo, #[Database('bar')] Connection $bar) diff --git a/tests/Integration/Cache/RepositoryTest.php b/tests/Integration/Cache/RepositoryTest.php index 8b13595cd5c5..ea340418b298 100644 --- a/tests/Integration/Cache/RepositoryTest.php +++ b/tests/Integration/Cache/RepositoryTest.php @@ -238,6 +238,31 @@ public function testItImplicitlyClearsTtlKeysFromFileDriver() $this->assertTrue($cache->missing('illuminate:cache:flexible:created:count')); } + public function testItCanAlwaysDefer() + { + $this->freezeTime(); + $cache = Cache::driver('array'); + $count = 0; + + // Cache is empty. The value should be populated... + $cache->flexible('foo', [10, 20], function () use (&$count) { + return ++$count; + }, alwaysDefer: true); + + // First call to flexible() should not defer + $this->assertCount(0, defer()); + + Carbon::setTestNow(now()->addSeconds(11)); + + // Second callback should defer with always now true + $cache->flexible('foo', [10, 20], function () use (&$count) { + return ++$count; + }, alwaysDefer: true); + + $this->assertCount(1, defer()); + $this->assertTrue(defer()->first()->always); + } + public function testItRoundsDateTimeValuesToAccountForTimePassedDuringScriptExecution() { // do not freeze time as this test depends on time progressing duration execution. diff --git a/tests/Support/SupportArrTest.php b/tests/Support/SupportArrTest.php index a81e6eb79bee..94fb488ec3c7 100644 --- a/tests/Support/SupportArrTest.php +++ b/tests/Support/SupportArrTest.php @@ -702,6 +702,29 @@ public function testHas() $this->assertFalse(Arr::has([], [''])); } + public function testHasAllMethod() + { + $array = ['name' => 'Taylor', 'age' => '', 'city' => null]; + $this->assertTrue(Arr::hasAll($array, 'name')); + $this->assertTrue(Arr::hasAll($array, 'age')); + $this->assertFalse(Arr::hasAll($array, ['age', 'car'])); + $this->assertTrue(Arr::hasAll($array, 'city')); + $this->assertFalse(Arr::hasAll($array, ['city', 'some'])); + $this->assertTrue(Arr::hasAll($array, ['name', 'age', 'city'])); + $this->assertFalse(Arr::hasAll($array, ['name', 'age', 'city', 'country'])); + + $array = ['user' => ['name' => 'Taylor']]; + $this->assertTrue(Arr::hasAll($array, 'user.name')); + $this->assertFalse(Arr::hasAll($array, 'user.age')); + + $array = ['name' => 'Taylor', 'age' => '', 'city' => null]; + $this->assertFalse(Arr::hasAll($array, 'foo')); + $this->assertFalse(Arr::hasAll($array, 'bar')); + $this->assertFalse(Arr::hasAll($array, 'baz')); + $this->assertFalse(Arr::hasAll($array, 'bah')); + $this->assertFalse(Arr::hasAll($array, ['foo', 'bar', 'baz', 'bar'])); + } + public function testHasAnyMethod() { $array = ['name' => 'Taylor', 'age' => '', 'city' => null]; diff --git a/tests/Support/SupportStringableTest.php b/tests/Support/SupportStringableTest.php index 7f1257b8acb1..2cfd4c4cd4d3 100644 --- a/tests/Support/SupportStringableTest.php +++ b/tests/Support/SupportStringableTest.php @@ -6,6 +6,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\HtmlString; use Illuminate\Support\Stringable; +use Illuminate\Support\Uri; use League\CommonMark\Environment\EnvironmentBuilderInterface; use League\CommonMark\Extension\ExtensionInterface; use PHPUnit\Framework\TestCase; @@ -1397,6 +1398,17 @@ public function testToDateThrowsException() $this->stringable('not a date')->toDate(); } + public function testToUri() + { + $sentence = 'Laravel is a PHP framework. You can access the docs in: {https://laravel.com/docs}'; + + $uri = $this->stringable($sentence)->between('{', '}')->toUri(); + + $this->assertInstanceOf(Uri::class, $uri); + $this->assertSame('https://laravel.com/docs', (string) $uri); + $this->assertSame('https://laravel.com/docs', $uri->toHtml()); + } + public function testArrayAccess() { $str = $this->stringable('my string'); diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php index b0b30a26388a..08882b92df0e 100644 --- a/tests/Testing/TestResponseTest.php +++ b/tests/Testing/TestResponseTest.php @@ -27,6 +27,7 @@ use JsonSerializable; use Mockery as m; use PHPUnit\Framework\AssertionFailedError; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\ExpectationFailedException; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\BinaryFileResponse; @@ -2807,7 +2808,10 @@ public function testAssertSessionMissing() $response->assertSessionMissing('foo'); } - public function testAssertSessionMissingValue() + #[TestWith(['foo', 'badvalue'])] + #[TestWith(['foo', null])] + #[TestWith([['foo', 'bar'], null])] + public function testAssertSessionMissingValue(array|string $key, mixed $value) { $this->expectException(AssertionFailedError::class); @@ -2816,7 +2820,7 @@ public function testAssertSessionMissingValue() $store->put('foo', 'goodvalue'); $response = TestResponse::fromBaseResponse(new Response()); - $response->assertSessionMissing('foo', 'badvalue'); + $response->assertSessionMissing($key, $value); } public function testAssertSessionHasInput() diff --git a/tests/Validation/ValidationInArrayKeysTest.php b/tests/Validation/ValidationInArrayKeysTest.php new file mode 100644 index 000000000000..dec89209e398 --- /dev/null +++ b/tests/Validation/ValidationInArrayKeysTest.php @@ -0,0 +1,80 @@ +getIlluminateArrayTranslator(); + + // Test passes when array has at least one of the specified keys + $v = new Validator($trans, ['foo' => ['first_key' => 'bar', 'second_key' => 'baz']], ['foo' => 'in_array_keys:first_key,third_key']); + $this->assertTrue($v->passes()); + + // Test passes when array has multiple of the specified keys + $v = new Validator($trans, ['foo' => ['first_key' => 'bar', 'second_key' => 'baz']], ['foo' => 'in_array_keys:first_key,second_key']); + $this->assertTrue($v->passes()); + + // Test fails when array doesn't have any of the specified keys + $v = new Validator($trans, ['foo' => ['first_key' => 'bar', 'second_key' => 'baz']], ['foo' => 'in_array_keys:third_key,fourth_key']); + $this->assertTrue($v->fails()); + + // Test fails when value is not an array + $v = new Validator($trans, ['foo' => 'not-an-array'], ['foo' => 'in_array_keys:first_key']); + $this->assertTrue($v->fails()); + + // Test fails when no keys are specified + $v = new Validator($trans, ['foo' => ['first_key' => 'bar']], ['foo' => 'in_array_keys:']); + $this->assertTrue($v->fails()); + } + + public function testInArrayKeysValidationWithNestedArrays() + { + $trans = $this->getIlluminateArrayTranslator(); + + // Test passes with nested arrays + $v = new Validator($trans, [ + 'foo' => [ + 'first_key' => ['nested' => 'value'], + 'second_key' => 'baz', + ], + ], ['foo' => 'in_array_keys:first_key,third_key']); + $this->assertTrue($v->passes()); + + // Test with dot notation for nested arrays + $v = new Validator($trans, [ + 'foo' => [ + 'first' => [ + 'nested_key' => 'value', + ], + ], + ], ['foo.first' => 'in_array_keys:nested_key']); + $this->assertTrue($v->passes()); + } + + public function testInArrayKeysValidationErrorMessage() + { + $trans = $this->getIlluminateArrayTranslator(); + $trans->addLines([ + 'validation.in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.', + ], 'en'); + + $v = new Validator($trans, ['foo' => ['wrong_key' => 'bar']], ['foo' => 'in_array_keys:first_key,second_key']); + $this->assertFalse($v->passes()); + $this->assertEquals( + 'The foo field must contain at least one of the following keys: first_key, second_key.', + $v->messages()->first('foo') + ); + } + + protected function getIlluminateArrayTranslator() + { + return new Translator(new ArrayLoader, 'en'); + } +} diff --git a/tests/Validation/ValidationRuleContainsTest.php b/tests/Validation/ValidationRuleContainsTest.php new file mode 100644 index 000000000000..9b3009d1bff5 --- /dev/null +++ b/tests/Validation/ValidationRuleContainsTest.php @@ -0,0 +1,89 @@ +assertSame('contains:"Taylor"', (string) $rule); + + $rule = Rule::contains('Taylor', 'Abigail'); + $this->assertSame('contains:"Taylor","Abigail"', (string) $rule); + + $rule = Rule::contains(['Taylor', 'Abigail']); + $this->assertSame('contains:"Taylor","Abigail"', (string) $rule); + + $rule = Rule::contains(collect(['Taylor', 'Abigail'])); + $this->assertSame('contains:"Taylor","Abigail"', (string) $rule); + + $rule = Rule::contains([ArrayKeys::key_1, ArrayKeys::key_2]); + $this->assertSame('contains:"key_1","key_2"', (string) $rule); + + $rule = Rule::contains([ArrayKeysBacked::key_1, ArrayKeysBacked::key_2]); + $this->assertSame('contains:"key_1","key_2"', (string) $rule); + + $rule = Rule::contains(['Taylor', 'Taylor']); + $this->assertSame('contains:"Taylor","Taylor"', (string) $rule); + + $rule = Rule::contains([1, 2, 3]); + $this->assertSame('contains:"1","2","3"', (string) $rule); + + $rule = Rule::contains(['"foo"', '"bar"', '"baz"']); + $this->assertSame('contains:"""foo""","""bar""","""baz"""', (string) $rule); + } + + public function testContainsValidation() + { + $trans = new Translator(new ArrayLoader, 'en'); + + // Test fails when value is string + $v = new Validator($trans, ['roles' => 'admin'], ['roles' => Rule::contains('editor')]); + $this->assertTrue($v->fails()); + + // Test passes when array contains the value + $v = new Validator($trans, ['roles' => ['admin', 'user']], ['roles' => Rule::contains('admin')]); + $this->assertTrue($v->passes()); + + // Test fails when array doesn't contain all the values + $v = new Validator($trans, ['roles' => ['admin', 'user']], ['roles' => Rule::contains(['admin', 'editor'])]); + $this->assertTrue($v->fails()); + + // Test fails when array doesn't contain all the values (using multiple arguments) + $v = new Validator($trans, ['roles' => ['admin', 'user']], ['roles' => Rule::contains('admin', 'editor')]); + $this->assertTrue($v->fails()); + + // Test passes when array contains all the values + $v = new Validator($trans, ['roles' => ['admin', 'user', 'editor']], ['roles' => Rule::contains(['admin', 'editor'])]); + $this->assertTrue($v->passes()); + + // Test passes when array contains all the values (using multiple arguments) + $v = new Validator($trans, ['roles' => ['admin', 'user', 'editor']], ['roles' => Rule::contains('admin', 'editor')]); + $this->assertTrue($v->passes()); + + // Test fails when array doesn't contain the value + $v = new Validator($trans, ['roles' => ['admin', 'user']], ['roles' => Rule::contains('editor')]); + $this->assertTrue($v->fails()); + + // Test fails when array doesn't contain any of the values + $v = new Validator($trans, ['roles' => ['admin', 'user']], ['roles' => Rule::contains(['editor', 'manager'])]); + $this->assertTrue($v->fails()); + + // Test with empty array + $v = new Validator($trans, ['roles' => []], ['roles' => Rule::contains('admin')]); + $this->assertTrue($v->fails()); + + // Test with nullable field + $v = new Validator($trans, ['roles' => null], ['roles' => ['nullable', Rule::contains('admin')]]); + $this->assertTrue($v->passes()); + } +}