From 8c3531781fa5f1e1aabb9ccb2f9feed6e58b69d6 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 17:33:23 +0200 Subject: [PATCH] feat: defaults, parameter injection and final-failure throwing for the framework --- CHANGELOG.md | 15 +- README.md | 2 + src/Async/Async.php | 112 ++++++++++++- src/Async/AsyncRunner.php | 23 ++- src/Async/HandlerArguments.php | 29 +++- src/Async/PendingAsync.php | 63 +++++-- tests/Fixtures/Async/InjectedHandler.php | 18 ++ tests/Fixtures/Async/Mailer.php | 13 ++ tests/Unit/Async/FrameworkHooksTest.php | 194 ++++++++++++++++++++++ tests/Unit/Async/HandlerArgumentsTest.php | 8 +- 10 files changed, 443 insertions(+), 34 deletions(-) create mode 100644 tests/Fixtures/Async/InjectedHandler.php create mode 100644 tests/Fixtures/Async/Mailer.php create mode 100644 tests/Unit/Async/FrameworkHooksTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d7bb1..4e589e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to `pollora/hook` are documented in this file. ## [Unreleased] +## [1.4.0] - 2026-10-07 + +### Added + +- `Async::setDefaults()`: the attempts, backoff and `asUser` every asynchronous registration starts from. The framework sets them from `config/hooks.php`. +- `Async::injectParametersUsing()`: handler parameters that are not hook arguments are injected at execution. By default, a parameter is injected when its type is a class or interface that does not travel as a hook argument (WordPress objects, enums, dates, `JsonSerializable` and `AsyncContext` do). These parameters are not requested from WordPress, so they can sit anywhere in the signature. The framework resolves them from its container. +- `Async::receive(..., throwOnFinalFailure: true)`: the last failure is announced through `pollora/async/failed`, then thrown instead of reported, for a driver whose queue records failures itself (a Laravel job then lands in `failed_jobs`). Retries still happen while attempts are left. + +### Changed + +- A handler parameter with no hook argument left takes its default value, so a later `AsyncContext` or injected parameter still gets its own. + ## [1.3.0] - 2026-10-07 ### Changed @@ -52,6 +64,7 @@ All notable changes to `pollora/hook` are documented in this file. - A `[ClassName::class, 'method']` callback naming an instance method is now instantiated at registration, through the callback resolver when one is set, as a class name is. WordPress used to receive it as a static call and threw a `TypeError` when the hook fired. Static methods and classes not loaded yet are unchanged. - `remove()` and `exists()` accept that same `[ClassName::class, 'method']` form and find the instance it was registered as. `exists()` now accepts a non-callable array or string as its callback. -[Unreleased]: https://github.com/Pollora/hook/compare/v1.3.0...HEAD +[Unreleased]: https://github.com/Pollora/hook/compare/v1.4.0...HEAD +[1.4.0]: https://github.com/Pollora/hook/compare/v1.3.0...v1.4.0 [1.3.0]: https://github.com/Pollora/hook/compare/v1.2.0...v1.3.0 [1.2.0]: https://github.com/Pollora/hook/compare/v1.1.1...v1.2.0 diff --git a/README.md b/README.md index 2961205..31ec9b1 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,8 @@ Action::add('save_post_event', [CrmSync::class, 'push']) Choose the default with the `POLLORA_ASYNC_DRIVER` constant in `wp-config.php`, or the `pollora/hook/async_driver` filter. More drivers register through `Async::extend()`. +**Dependencies.** `Async::injectParametersUsing($resolver)` injects, at execution, the handler parameters typed with a class that does not travel as a hook argument, such as a mailer or an API client. Pollora resolves them from its container. + **Closures.** With `laravel/serializable-closure` installed, a closure can be queued. It is serialized and signed with a key derived from the WordPress salts, and the signature is checked before anything is unserialized. Declare it `static` and let it use IDs rather than objects. **Failures.** At execution, the original site and locale are restored, and a handler that fires its own hook does not queue itself again. A handler that throws is retried while it has attempts left; the last failure is reported and announced through the `pollora/async/failed` action. When an action cannot be queued, `WP_DEBUG` throws; otherwise the incident goes to the PHP error log (or `Async::reportUsing()`) and the handler runs in place, so the work always happens. diff --git a/src/Async/Async.php b/src/Async/Async.php index e1acc34..b06b134 100644 --- a/src/Async/Async.php +++ b/src/Async/Async.php @@ -41,6 +41,8 @@ final class Async */ public const string DRIVER_FILTER = 'pollora/hook/async_driver'; + private const array DEFAULTS = ['tries' => 1, 'backoff' => [10, 60, 300], 'asUser' => false]; + /** @var array */ private static array $factories = []; @@ -58,6 +60,15 @@ final class Async private static ?AsyncFake $fake = null; + /** @var array{tries: int, backoff: list, asUser: bool} */ + private static array $defaults = self::DEFAULTS; + + /** @var (\Closure(\ReflectionParameter): mixed)|null */ + private static ?\Closure $injector = null; + + /** @var (\Closure(\ReflectionParameter): bool)|null */ + private static ?\Closure $injectable = null; + private static ?ArgumentNormalizer $normalizer = null; private static ?AsyncDispatcher $dispatcher = null; @@ -109,6 +120,94 @@ public static function driver(?string $name = null): AsyncDriver return $driver; } + /** + * Options every asynchronous registration starts from. The framework sets them from config/hooks.php. + * + * @param int|null $tries Attempts, 1 by default + * @param int|list|null $backoff Seconds before each retry, [10, 60, 300] by default + * @param bool|null $asUser Run as the user who fired the hook, false by default + * + * @throws \InvalidArgumentException When a value is invalid + */ + public static function setDefaults(?int $tries = null, int|array|null $backoff = null, ?bool $asUser = null): void + { + self::$defaults = [ + 'tries' => $tries === null ? self::$defaults['tries'] : PendingAsync::validTries($tries), + 'backoff' => $backoff === null ? self::$defaults['backoff'] : PendingAsync::validBackoff($backoff), + 'asUser' => $asUser ?? self::$defaults['asUser'], + ]; + } + + /** + * @return array{tries: int, backoff: list, asUser: bool} + */ + public static function defaults(): array + { + return self::$defaults; + } + + /** + * Inject the handler parameters that are not hook arguments, at execution. + * + * A parameter is injected when $isInjectable says so; by default, when it is + * typed with a class or interface that does not travel as a hook argument + * (WordPress objects, enums, dates, JsonSerializable and AsyncContext do). + * Injected parameters are not taken from the hook. The framework resolves + * them from its container. + * + * Async::injectParametersUsing(fn (ReflectionParameter $parameter) => $container->make($parameter->getType()->getName())); + * + * @param (callable(\ReflectionParameter): mixed)|null $resolver Null to stop injecting + * @param (callable(\ReflectionParameter): bool)|null $isInjectable + */ + public static function injectParametersUsing(?callable $resolver, ?callable $isInjectable = null): void + { + self::$injector = $resolver === null ? null : $resolver(...); + self::$injectable = $isInjectable === null ? null : $isInjectable(...); + } + + /** + * @internal + */ + public static function isInjectable(\ReflectionParameter $parameter): bool + { + if (! self::$injector instanceof \Closure) { + return false; + } + + if (self::$injectable instanceof \Closure) { + return (self::$injectable)($parameter) === true; + } + + $type = $parameter->getType(); + + if (! $type instanceof \ReflectionNamedType || $type->isBuiltin()) { + return false; + } + + $name = $type->getName(); + + if (! class_exists($name) && ! interface_exists($name)) { + return false; + } + + foreach ([AsyncContext::class, \UnitEnum::class, \DateTimeInterface::class, \JsonSerializable::class, 'WP_Post', 'WP_Term', 'WP_User', 'WP_Comment'] as $travelling) { + if (is_a($name, $travelling, true)) { + return false; + } + } + + return true; + } + + /** + * @internal + */ + public static function inject(\ReflectionParameter $parameter): mixed + { + return self::$injector instanceof \Closure ? (self::$injector)($parameter) : null; + } + /** * Record queued handlers instead of queuing them, whatever the driver. For tests. */ @@ -255,8 +354,10 @@ public static function listen(): void * Run a queued handler, from the message a driver hands back. * * @param string $message The payload as JSON, or the identifier of a payload kept by PayloadStore + * @param bool $throwOnFinalFailure Throw the last failure instead of reporting it, for a driver whose + * queue records failures itself (a Laravel job lands in failed_jobs) */ - public static function receive(string $message): void + public static function receive(string $message, bool $throwOnFinalFailure = false): void { if (PayloadStore::handles($message)) { $message = PayloadStore::claim($message); @@ -269,12 +370,16 @@ public static function receive(string $message): void try { $payload = AsyncPayload::fromJson($message); } catch (\Throwable $throwable) { + if ($throwOnFinalFailure) { + throw $throwable; + } + self::report($throwable); return; } - self::runner()->run($payload); + self::runner()->run($payload, $throwOnFinalFailure); } /** @@ -356,6 +461,9 @@ public static function flush(): void self::$resolver = null; self::$closureKey = null; self::$fake = null; + self::$defaults = self::DEFAULTS; + self::$injector = null; + self::$injectable = null; self::$normalizer = null; self::$dispatcher = null; self::$runner = null; diff --git a/src/Async/AsyncRunner.php b/src/Async/AsyncRunner.php index e2ff9ce..1faaf38 100644 --- a/src/Async/AsyncRunner.php +++ b/src/Async/AsyncRunner.php @@ -27,7 +27,10 @@ public function __construct( private readonly ArgumentNormalizer $normalizer, ) {} - public function run(AsyncPayload $payload): void + /** + * @param bool $throwOnFinalFailure Announce the last failure, then throw it instead of reporting it + */ + public function run(AsyncPayload $payload, bool $throwOnFinalFailure = false): void { // Identical triggers queue again from the moment the first attempt starts if ($payload->uniqueKey !== null && $payload->attempt === 1) { @@ -41,7 +44,7 @@ public function run(AsyncPayload $payload): void } catch (MissingReferencedObject) { return; } catch (\Throwable $throwable) { - $this->fail($payload, $throwable); + $this->fail($payload, $throwable, $throwOnFinalFailure); return; } @@ -53,7 +56,7 @@ public function run(AsyncPayload $payload): void try { $this->call($callable, $arguments, $payload->context($captured)); } catch (\Throwable $throwable) { - $this->retryOrFail($payload, $throwable); + $this->retryOrFail($payload, $throwable, $throwOnFinalFailure); } finally { $leaveContext(); @@ -126,7 +129,7 @@ private function enterContext(AsyncPayload $payload): \Closure /** * Queue the handler again when it has attempts left, report the failure otherwise. */ - private function retryOrFail(AsyncPayload $payload, \Throwable $throwable): void + private function retryOrFail(AsyncPayload $payload, \Throwable $throwable, bool $throwOnFinalFailure): void { if ($payload->attempt < $payload->tries) { $delay = $payload->retryDelay($payload->attempt); @@ -144,15 +147,21 @@ private function retryOrFail(AsyncPayload $payload, \Throwable $throwable): void } } - $this->fail($payload, $throwable); + $this->fail($payload, $throwable, $throwOnFinalFailure); } - private function fail(AsyncPayload $payload, \Throwable $throwable): void + private function fail(AsyncPayload $payload, \Throwable $throwable, bool $throw = false): void { - Async::report($throwable, ['hook' => $payload->hook, 'handler' => $payload->handler, 'payload' => $payload->id]); + if (! $throw) { + Async::report($throwable, ['hook' => $payload->hook, 'handler' => $payload->handler, 'payload' => $payload->id]); + } if (function_exists('do_action')) { do_action('pollora/async/failed', $payload, $throwable); } + + if ($throw) { + throw $throwable; + } } } diff --git a/src/Async/HandlerArguments.php b/src/Async/HandlerArguments.php index df4ec5e..e145386 100644 --- a/src/Async/HandlerArguments.php +++ b/src/Async/HandlerArguments.php @@ -7,15 +7,16 @@ /** * Maps hook arguments onto a handler's signature. * - * A parameter typed AsyncContext receives the context, wherever it sits; the - * other parameters receive the hook arguments, in order. + * A parameter typed AsyncContext receives the context, and an injectable + * parameter (see Async::injectParametersUsing()) its resolved value, wherever + * they sit; the other parameters receive the hook arguments, in order. * * @internal */ final class HandlerArguments { /** - * How many hook arguments a handler takes, its AsyncContext parameters left out. + * How many hook arguments a handler takes, its AsyncContext and injected parameters left out. * * @param callable|string|array $callback The registered callback * @param int $registeredArgs The argument count it was registered with @@ -28,9 +29,12 @@ public static function hookArgumentCount(callable|string|array $callback, int $r return $registeredArgs; } - $contextParameters = count(array_filter($reflection->getParameters(), self::isContextParameter(...))); + $hookParameters = count(array_filter( + $reflection->getParameters(), + fn (\ReflectionParameter $parameter): bool => ! self::isContextParameter($parameter) && ! Async::isInjectable($parameter), + )); - return max(0, $registeredArgs - $contextParameters); + return max(0, min($registeredArgs, $hookParameters)); } /** @@ -57,12 +61,25 @@ public static function for(callable $callback, array $hookArguments, AsyncContex continue; } + if (Async::isInjectable($parameter)) { + $arguments[] = Async::inject($parameter); + + continue; + } + if ($parameter->isVariadic()) { return [...$arguments, ...$hookArguments]; } if ($hookArguments === []) { - break; + // Use the default value, so a later context or injected parameter still gets its own + if (! $parameter->isDefaultValueAvailable()) { + break; + } + + $arguments[] = $parameter->getDefaultValue(); + + continue; } $arguments[] = array_shift($hookArguments); diff --git a/src/Async/PendingAsync.php b/src/Async/PendingAsync.php index b2a76ef..d4641bf 100644 --- a/src/Async/PendingAsync.php +++ b/src/Async/PendingAsync.php @@ -21,12 +21,12 @@ final class PendingAsync private bool $keepMissing = false; - private int $tries = 1; + private int $tries; /** @var list */ - private array $backoff = [10, 60, 300]; + private array $backoff; - private bool $asUser = false; + private bool $asUser; private ?string $queue = null; @@ -49,7 +49,46 @@ final class PendingAsync */ public function __construct( private readonly \Closure $restore, - ) {} + ) { + $defaults = Async::defaults(); + $this->tries = $defaults['tries']; + $this->backoff = $defaults['backoff']; + $this->asUser = $defaults['asUser']; + } + + /** + * @internal + * + * @throws \InvalidArgumentException When $tries is lower than 1 + */ + public static function validTries(int $tries): int + { + if ($tries < 1) { + throw new \InvalidArgumentException(sprintf('tries() expects at least 1 attempt, %d given.', $tries)); + } + + return $tries; + } + + /** + * @internal + * + * @param int|array $seconds + * @return list + * + * @throws \InvalidArgumentException When a delay is negative or the list is empty + */ + public static function validBackoff(int|array $seconds): array + { + $seconds = array_values((array) $seconds); + + if ($seconds === [] || array_filter($seconds, fn (mixed $delay): bool => ! is_int($delay) || $delay < 0) !== []) { + throw new \InvalidArgumentException('backoff() expects one or more delays of 0 seconds or more.'); + } + + /** @var list $seconds */ + return $seconds; + } /** * Keep some hooks of the registration synchronous. @@ -120,17 +159,13 @@ public function keepMissing(bool $keepMissing = true): self */ public function tries(int $tries): self { - if ($tries < 1) { - throw new \InvalidArgumentException(sprintf('tries() expects at least 1 attempt, %d given.', $tries)); - } - - $this->tries = $tries; + $this->tries = self::validTries($tries); return $this; } /** - * Seconds to wait before each retry; the last value repeats. Default: 10, 60, then 300. + * Seconds to wait before each retry; the last value repeats. Default: 10, 60, then 300, or Async::setDefaults(). * * @param int|array $seconds Validated: integers of 0 or more * @@ -138,13 +173,7 @@ public function tries(int $tries): self */ public function backoff(int|array $seconds): self { - $seconds = array_values((array) $seconds); - - if ($seconds === [] || array_filter($seconds, fn (mixed $delay): bool => ! is_int($delay) || $delay < 0) !== []) { - throw new \InvalidArgumentException('backoff() expects one or more delays of 0 seconds or more.'); - } - - $this->backoff = $seconds; + $this->backoff = self::validBackoff($seconds); return $this; } diff --git a/tests/Fixtures/Async/InjectedHandler.php b/tests/Fixtures/Async/InjectedHandler.php new file mode 100644 index 0000000..28be2c9 --- /dev/null +++ b/tests/Fixtures/Async/InjectedHandler.php @@ -0,0 +1,18 @@ +> */ + public static array $calls = []; + + public function send(int $postId, Mailer $mailer, AsyncContext $context, ?\WP_Post $post = null): void + { + self::$calls[] = ['postId' => $postId, 'transport' => $mailer->transport, 'hook' => $context->hook, 'post' => $post?->ID]; + } +} diff --git a/tests/Fixtures/Async/Mailer.php b/tests/Fixtures/Async/Mailer.php new file mode 100644 index 0000000..f9bd4cd --- /dev/null +++ b/tests/Fixtures/Async/Mailer.php @@ -0,0 +1,13 @@ + 5, 'blog' => 1, 'locale' => 'fr_FR', 'multisite' => false]; + $GLOBALS['incidents'] = []; + RecordingHandler::$calls = []; + RecordingHandler::$failuresLeft = 0; + InjectedHandler::$calls = []; + + Async::flush(); + Async::setDebug(true); + Async::reportUsing(function (string $message): void { + $GLOBALS['incidents'][] = $message; + }); + $this->driver = new RecordingDriver; + Async::extend('recording', fn (): AsyncDriver => $this->driver); + Async::setDefaultDriver('recording'); + + $this->action = new Action; +}); + +afterEach(function (): void { + Async::flush(); +}); + +describe('Defaults', function (): void { + it('starts every registration from the defaults', function (): void { + Async::setDefaults(tries: 4, backoff: [1, 2], asUser: true); + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async(); + + wp_stub_fire('save_post', 1); + + $payload = $this->driver->queued[0]['payload']; + expect($payload->tries)->toBe(4) + ->and($payload->backoff)->toBe([1, 2]) + ->and($payload->asUser)->toBeTrue(); + }); + + it('lets a registration override them', function (): void { + Async::setDefaults(tries: 4, asUser: true); + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->tries(1)->asUser(false); + + wp_stub_fire('save_post', 1); + + expect($this->driver->queued[0]['payload']->tries)->toBe(1) + ->and($this->driver->queued[0]['payload']->asUser)->toBeFalse() + ->and($this->driver->queued[0]['payload']->backoff)->toBe([10, 60, 300]); + }); + + it('keeps the values not given', function (): void { + Async::setDefaults(tries: 3); + Async::setDefaults(asUser: true); + + expect(Async::defaults())->toBe(['tries' => 3, 'backoff' => [10, 60, 300], 'asUser' => true]); + }); + + it('rejects invalid defaults', function (Closure $set, string $message): void { + expect($set)->toThrow(InvalidArgumentException::class, $message); + })->with([ + 'no attempt' => [fn () => Async::setDefaults(tries: 0), 'at least 1 attempt'], + 'negative delay' => [fn () => Async::setDefaults(backoff: [-1]), 'one or more delays'], + ]); + + it('is reset by flush()', function (): void { + Async::setDefaults(tries: 9); + Async::flush(); + + expect(Async::defaults()['tries'])->toBe(1); + }); +}); + +describe('Parameter injection', function (): void { + beforeEach(function (): void { + $this->resolved = []; + Async::injectParametersUsing(function (ReflectionParameter $parameter): object { + $this->resolved[] = $parameter->getName(); + + return new Mailer('queue'); + }); + }); + + it('injects the parameters that are not hook arguments, and does not ask WordPress for them', function (): void { + $GLOBALS['wp_objects']['post'][3] = new WP_Post(3); + $this->action->add('save_post', [InjectedHandler::class, 'send'])->async(); + + expect($GLOBALS['wp_actions'][0]['args'])->toBe(2); + + wp_stub_fire('save_post', 3, new WP_Post(3)); + $this->driver->runAll(); + + expect(InjectedHandler::$calls)->toBe([['postId' => 3, 'transport' => 'queue', 'hook' => 'save_post', 'post' => 3]]) + ->and($this->resolved)->toBe(['mailer']); + }); + + it('takes WordPress objects, enums, dates and JsonSerializable objects from the hook', function (Closure $handler): void { + $parameter = (new ReflectionFunction($handler))->getParameters()[0]; + + expect(Async::isInjectable($parameter))->toBeFalse(); + })->with([ + 'post' => [fn (WP_Post $post): null => null], + 'enum' => [fn (OrderStatus $status): null => null], + 'date' => [fn (DateTimeImmutable $date): null => null], + 'JsonSerializable' => [fn (JsonSerializable $value): null => null], + 'scalar' => [fn (int $id): null => null], + 'union' => [fn (Mailer|int $value): null => null], + 'unknown class' => [eval('return fn (\\App\\Missing $value) => null;')], + ]); + + it('follows a predicate of its own when one is given', function (): void { + Async::injectParametersUsing(fn (ReflectionParameter $parameter): string => 'injected', fn (ReflectionParameter $parameter): bool => $parameter->getName() === 'token'); + $handler = fn (int $id, string $token): null => null; + $context = new AsyncContext(1, 1, 'fr_FR', 'save_post', new DateTimeImmutable); + + expect(HandlerArguments::hookArgumentCount($handler, 2))->toBe(1) + ->and(HandlerArguments::for($handler, [7], $context))->toBe([7, 'injected']); + }); + + it('injects nothing without a resolver', function (): void { + Async::injectParametersUsing(null); + $this->action->add('save_post', [InjectedHandler::class, 'send'])->async(); + + expect($GLOBALS['wp_actions'][0]['args'])->toBe(3); + }); +}); + +describe('Throwing the final failure', function (): void { + function failedAnnouncementsCount(): int + { + return count(array_filter($GLOBALS['wp_actions_done'], fn (array $call): bool => $call['hook'] === 'pollora/async/failed')); + } + + it('announces the last failure, then throws it instead of reporting it', function (): void { + RecordingHandler::$failuresLeft = 1; + $this->action->add('save_post', [RecordingHandler::class, 'flaky'])->async(); + wp_stub_fire('save_post', 1); + + expect(fn () => Async::receive($this->driver->queued[0]['payload']->toJson(), throwOnFinalFailure: true)) + ->toThrow(RuntimeException::class, 'Temporary failure'); + expect($GLOBALS['incidents'])->toBe([]) + ->and(failedAnnouncementsCount())->toBe(1); + }); + + it('still retries while attempts are left', function (): void { + RecordingHandler::$failuresLeft = 1; + $this->action->add('save_post', [RecordingHandler::class, 'flaky'])->async()->tries(2); + wp_stub_fire('save_post', 1); + + Async::receive($this->driver->queued[0]['payload']->toJson(), throwOnFinalFailure: true); + + expect($this->driver->queued)->toHaveCount(2) + ->and(failedAnnouncementsCount())->toBe(0); + }); + + it('throws for a handler that no longer exists and for an unreadable payload', function (): void { + $missing = (new AsyncPayload( + id: AsyncPayload::newId(), + hook: 'save_post', + handler: 'App\Removed@handle', + priority: 10, + arguments: [], + origin: ['userId' => 0, 'blogId' => 1, 'locale' => 'fr_FR', 'dispatchedAt' => '2026-10-07T10:00:00+00:00'], + ))->toJson(); + + expect(fn () => Async::receive($missing, throwOnFinalFailure: true))->toThrow(RuntimeException::class, 'no longer exists') + ->and(fn () => Async::receive('{"v":99}', throwOnFinalFailure: true))->toThrow(RuntimeException::class, 'version 99'); + }); + + it('drops a handler whose referenced object was deleted, without throwing', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'post'])->async(); + wp_stub_fire('save_post', new WP_Post(12)); + + Async::receive($this->driver->queued[0]['payload']->toJson(), throwOnFinalFailure: true); + + expect(RecordingHandler::$calls)->toBe([]); + }); +}); diff --git a/tests/Unit/Async/HandlerArgumentsTest.php b/tests/Unit/Async/HandlerArgumentsTest.php index 6374cb2..df64b5f 100644 --- a/tests/Unit/Async/HandlerArgumentsTest.php +++ b/tests/Unit/Async/HandlerArgumentsTest.php @@ -25,9 +25,15 @@ expect(HandlerArguments::for($handler, [7, 'publish'], $this->context))->toBe([7, $this->context, 'publish']); }); -it('stops at the arguments the hook passed', function (): void { +it('fills a missing hook argument with its default value, so a later context still arrives', function (): void { $handler = fn (int $id, string $status = 'draft', ?AsyncContext $context = null): null => null; + expect(HandlerArguments::for($handler, [7], $this->context))->toBe([7, 'draft', $this->context]); +}); + +it('stops at the first missing hook argument without a default value', function (): void { + $handler = fn (int $id, string $status, ?AsyncContext $context = null): null => null; + expect(HandlerArguments::for($handler, [7], $this->context))->toBe([7]); });