From 86fe8d3317ea5274c2414c1c11eea37d41df05eb Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 15:05:23 +0200 Subject: [PATCH] feat: retry failed async handlers and run them as the original user --- CHANGELOG.md | 4 + src/Async/AsyncDispatcher.php | 37 ++-- src/Async/AsyncPayload.php | 57 ++++++ src/Async/AsyncRunner.php | 48 ++++- src/Async/PendingAsync.php | 103 +++++++++++ tests/Fixtures/Async/RecordingDriver.php | 12 +- tests/Fixtures/Async/RecordingHandler.php | 17 +- tests/Stubs/wordpress.php | 10 ++ tests/Unit/Async/AsyncPayloadTest.php | 24 +++ tests/Unit/Async/RetryAndUserTest.php | 208 ++++++++++++++++++++++ 10 files changed, 497 insertions(+), 23 deletions(-) create mode 100644 tests/Unit/Async/RetryAndUserTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d06132d..5bcefb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to `pollora/hook` are documented in this file. - `capture()` on an asynchronous registration: a callback run at trigger time, in the original request, with the hook arguments; the array it returns is read back at execution through `AsyncContext::get()`. Captured values travel like arguments. A handler class's public `capture()` method is used when `capture()` is not called. - `when()`: queue only when the callback, given the hook arguments, returns `true`. It runs before the capture, and an exception it throws goes through, as it would synchronously. +- `tries()`: a handler that throws is queued again, through the driver that queued it, until it has used its attempts; each failed attempt is reported, the last one is fired as `pollora/async/failed`. `backoff()` sets the seconds before each retry (default 10, 60, then 300; the last value repeats). A handler that no longer exists is not retried. +- `asUser()`: run as the user who fired the hook, then remove that user. Without it the handler runs without a current user. +- `onQueue()`: queue name, used as the Action Scheduler group and the Laravel queue; WP-Cron ignores it. +- Payloads record the driver that queued them, the queue, `asUser` and the backoff. These fields are optional, so payloads queued by 1.2.0 still run. ## [1.2.0] - 2026-10-07 diff --git a/src/Async/AsyncDispatcher.php b/src/Async/AsyncDispatcher.php index 1fe992d..48adf1d 100644 --- a/src/Async/AsyncDispatcher.php +++ b/src/Async/AsyncDispatcher.php @@ -38,6 +38,8 @@ public function dispatch(QueuedHandler $handler, array $arguments): void } try { + [$driverName, $driver] = $this->driverFor($handler); + $payload = new AsyncPayload( id: AsyncPayload::newId(), hook: $handler->hook, @@ -46,10 +48,15 @@ public function dispatch(QueuedHandler $handler, array $arguments): void arguments: $this->normalizer->normalize($arguments), origin: self::origin(), captured: $this->normalizer->normalize($this->capture($handler, $arguments), 'captured value'), + tries: $handler->options->attempts(), keepMissing: $handler->options->keepsMissing(), + driver: $driverName, + queue: $handler->options->queue(), + asUser: $handler->options->runsAsUser(), + backoff: $handler->options->backoffDelays(), ); - $this->driverFor($handler)->dispatch($payload, $handler->options->delayInSeconds()); + $driver->dispatch($payload, $handler->options->delayInSeconds()); } catch (\Throwable $throwable) { if (Async::isDebug()) { throw $throwable; @@ -107,28 +114,30 @@ private function capture(QueuedHandler $handler, array $arguments): array } /** - * The driver requested by the registration, or the default one. + * The driver requested by the registration, or the default one, with its name. + * + * @return array{0: string, 1: AsyncDriver} * * @throws DriverUnavailable */ - private function driverFor(QueuedHandler $handler): AsyncDriver + private function driverFor(QueuedHandler $handler): array { $requested = $handler->options->driver(); - if ($requested === null) { - return Async::driver(); - } + if ($requested !== null) { + try { + return [$requested, Async::driver($requested)]; + } catch (DriverUnavailable $driverUnavailable) { + if (Async::isDebug()) { + throw $driverUnavailable; + } - try { - return Async::driver($requested); - } catch (DriverUnavailable $driverUnavailable) { - if (Async::isDebug()) { - throw $driverUnavailable; + Async::report($driverUnavailable, ['hook' => $handler->hook, 'handler' => $handler->descriptor]); } + } - Async::report($driverUnavailable, ['hook' => $handler->hook, 'handler' => $handler->descriptor]); + $default = Async::defaultDriver(); - return Async::driver(); - } + return [$default, Async::driver($default)]; } } diff --git a/src/Async/AsyncPayload.php b/src/Async/AsyncPayload.php index 25b0001..1f3b3dc 100644 --- a/src/Async/AsyncPayload.php +++ b/src/Async/AsyncPayload.php @@ -29,6 +29,10 @@ * @param int $attempt Attempt number, from 1 * @param int $tries Number of attempts allowed * @param bool $keepMissing Run with null in place of a referenced object that no longer exists + * @param string|null $driver Driver that queued the payload, used again to retry it + * @param string|null $queue Queue name: the group with Action Scheduler, ignored by WP-Cron + * @param bool $asUser Run as the user who fired the hook + * @param list $backoff Seconds before each retry; the last value repeats */ public function __construct( public string $id, @@ -41,6 +45,10 @@ public function __construct( public int $attempt = 1, public int $tries = 1, public bool $keepMissing = false, + public ?string $driver = null, + public ?string $queue = null, + public bool $asUser = false, + public array $backoff = [], ) {} /** @@ -94,6 +102,11 @@ public static function fromJson(string $json): self attempt: self::field($data, 'attempt', 'is_int'), tries: self::field($data, 'tries', 'is_int'), keepMissing: self::field($data, 'keepMissing', 'is_bool'), + // Fields added after 1.2.0: optional, so payloads queued by 1.2.0 still run + driver: self::optionalField($data, 'driver', 'is_string', null), + queue: self::optionalField($data, 'queue', 'is_string', null), + asUser: self::optionalField($data, 'asUser', 'is_bool', false), + backoff: self::backoffField($data), ); } @@ -116,6 +129,10 @@ public function toJson(): string 'attempt' => $this->attempt, 'tries' => $this->tries, 'keepMissing' => $this->keepMissing, + 'driver' => $this->driver, + 'queue' => $this->queue, + 'asUser' => $this->asUser, + 'backoff' => $this->backoff, ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION); } @@ -127,6 +144,18 @@ public function withAttempt(int $attempt): self return new self(...[...get_object_vars($this), 'attempt' => $attempt]); } + /** + * Seconds to wait before retrying after the given failed attempt. + */ + public function retryDelay(int $failedAttempt): int + { + if ($this->backoff === []) { + return 0; + } + + return $this->backoff[min(max($failedAttempt, 1), count($this->backoff)) - 1]; + } + /** * The context handed to the handler, with the captured values rebuilt. * @@ -145,6 +174,34 @@ public function context(array $captured = []): AsyncContext ); } + /** + * @param array $data + * @param callable(mixed): bool $check + */ + private static function optionalField(array $data, string $field, callable $check, mixed $default): mixed + { + if (! array_key_exists($field, $data) || $data[$field] === null) { + return $default; + } + + return self::field($data, $field, $check); + } + + /** + * @param array $data + * @return list + */ + private static function backoffField(array $data): array + { + $backoff = self::optionalField($data, 'backoff', 'is_array', []); + + if (! array_is_list($backoff) || array_filter($backoff, fn (mixed $delay): bool => ! is_int($delay) || $delay < 0) !== []) { + throw InvalidPayload::invalidField('backoff'); + } + + return $backoff; + } + /** * @param array $data * @param callable(mixed): bool $check diff --git a/src/Async/AsyncRunner.php b/src/Async/AsyncRunner.php index e3155f7..72f79ba 100644 --- a/src/Async/AsyncRunner.php +++ b/src/Async/AsyncRunner.php @@ -10,9 +10,10 @@ * Runs a queued handler: resolves it, rebuilds its arguments, restores the * site and locale of the original request, calls it. * - * A failure is reported and announced through the 'pollora/async/failed' - * action; it never escapes, so one failing handler does not stop the others - * a WP-Cron run holds. + * A handler that throws is queued again, through the driver that queued it, + * until it has used its attempts; the last failure is reported and announced + * through the 'pollora/async/failed' action. Nothing escapes, so one failing + * handler does not stop the others a WP-Cron run holds. * * @internal */ @@ -47,7 +48,7 @@ public function run(AsyncPayload $payload): void try { $this->call($callable, $arguments, $payload->context($captured)); } catch (\Throwable $throwable) { - $this->fail($payload, $throwable); + $this->retryOrFail($payload, $throwable); } finally { $leaveContext(); @@ -80,32 +81,67 @@ public function isRunning(string $key): bool } /** - * Switch to the site and locale of the original request. + * Switch to the site, the user (with asUser) and the locale of the original request. * * @return \Closure(): void Switches back */ private function enterContext(AsyncPayload $payload): \Closure { $blogId = $payload->origin['blogId']; + $userId = $payload->origin['userId']; $locale = $payload->origin['locale']; $switchedBlog = function_exists('is_multisite') && is_multisite() && $blogId !== get_current_blog_id() && switch_to_blog($blogId); + $previousUser = null; + if ($payload->asUser && $userId > 0 && function_exists('wp_set_current_user')) { + $previousUser = get_current_user_id(); + wp_set_current_user($userId); + } + $switchedLocale = function_exists('switch_to_locale') && determine_locale() !== $locale && switch_to_locale($locale); - return static function () use ($switchedBlog, $switchedLocale): void { + return static function () use ($switchedBlog, $previousUser, $switchedLocale): void { if ($switchedLocale) { restore_previous_locale(); } + if ($previousUser !== null) { + wp_set_current_user($previousUser); + } + if ($switchedBlog) { restore_current_blog(); } }; } + /** + * Queue the handler again when it has attempts left, report the failure otherwise. + */ + private function retryOrFail(AsyncPayload $payload, \Throwable $throwable): void + { + if ($payload->attempt < $payload->tries) { + $delay = $payload->retryDelay($payload->attempt); + + try { + Async::driver($payload->driver ?? Async::defaultDriver())->dispatch($payload->withAttempt($payload->attempt + 1), $delay); + Async::report( + sprintf('Attempt %d of %d failed, retried in %d s: %s', $payload->attempt, $payload->tries, $delay, $throwable->getMessage()), + ['hook' => $payload->hook, 'handler' => $payload->handler, 'payload' => $payload->id, 'exception' => $throwable], + ); + + return; + } catch (\Throwable $retryFailure) { + Async::report($retryFailure, ['hook' => $payload->hook, 'handler' => $payload->handler, 'payload' => $payload->id]); + } + } + + $this->fail($payload, $throwable); + } + private function fail(AsyncPayload $payload, \Throwable $throwable): void { Async::report($throwable, ['hook' => $payload->hook, 'handler' => $payload->handler, 'payload' => $payload->id]); diff --git a/src/Async/PendingAsync.php b/src/Async/PendingAsync.php index 4d8620d..46df956 100644 --- a/src/Async/PendingAsync.php +++ b/src/Async/PendingAsync.php @@ -21,6 +21,15 @@ final class PendingAsync private bool $keepMissing = false; + private int $tries = 1; + + /** @var list */ + private array $backoff = [10, 60, 300]; + + private bool $asUser = false; + + private ?string $queue = null; + /** @var (\Closure(mixed...): array)|null */ private ?\Closure $capture = null; @@ -101,6 +110,66 @@ public function keepMissing(bool $keepMissing = true): self return $this; } + /** + * Number of attempts when the handler throws: it is queued again after each + * failure but the last, which is reported and fired as 'pollora/async/failed'. + * + * @throws \InvalidArgumentException When $tries is lower than 1 + */ + 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; + + return $this; + } + + /** + * Seconds to wait before each retry; the last value repeats. Default: 10, 60, then 300. + * + * @param int|array $seconds Validated: integers of 0 or more + * + * @throws \InvalidArgumentException When a delay is negative or the list is empty + */ + 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; + + return $this; + } + + /** + * Run the handler as the user who fired the hook, then remove that user. + * + * Without it the handler runs without a current user, so a capability + * check fails there, which is the safe default. + */ + public function asUser(bool $asUser = true): self + { + $this->asUser = $asUser; + + return $this; + } + + /** + * Queue name: the group with Action Scheduler, the queue with a Laravel queue. WP-Cron ignores it. + */ + public function onQueue(string $queue): self + { + $this->queue = $queue; + + return $this; + } + /** * Record values at trigger time, read back at execution through AsyncContext::get(). * @@ -181,6 +250,40 @@ public function keepsMissing(): bool return $this->keepMissing; } + /** + * @internal + */ + public function attempts(): int + { + return $this->tries; + } + + /** + * @internal + * + * @return list + */ + public function backoffDelays(): array + { + return $this->backoff; + } + + /** + * @internal + */ + public function runsAsUser(): bool + { + return $this->asUser; + } + + /** + * @internal + */ + public function queue(): ?string + { + return $this->queue; + } + /** * @internal * diff --git a/tests/Fixtures/Async/RecordingDriver.php b/tests/Fixtures/Async/RecordingDriver.php index c737e98..f9f3511 100644 --- a/tests/Fixtures/Async/RecordingDriver.php +++ b/tests/Fixtures/Async/RecordingDriver.php @@ -26,10 +26,18 @@ public function dispatch(AsyncPayload $payload, int $delay = 0): void $this->queued[] = ['payload' => $payload, 'delay' => $delay]; } + /** + * Run every queued payload, including those queued while running (retries). + */ public function runAll(): void { - foreach ($this->queued as $entry) { - Async::receive($entry['payload']->toJson()); + for ($index = 0; $index < count($this->queued); $index++) { + $this->run($index); } } + + public function run(int $index): void + { + Async::receive($this->queued[$index]['payload']->toJson()); + } } diff --git a/tests/Fixtures/Async/RecordingHandler.php b/tests/Fixtures/Async/RecordingHandler.php index 3083117..f3a4d04 100644 --- a/tests/Fixtures/Async/RecordingHandler.php +++ b/tests/Fixtures/Async/RecordingHandler.php @@ -11,9 +11,12 @@ */ final class RecordingHandler { - /** @var list, blog: int|null, locale: string|null}> */ + /** @var list, blog: int|null, locale: string|null, user: int|null}> */ public static array $calls = []; + /** Failures flaky() still has to throw */ + public static int $failuresLeft = 0; + public function __construct(public string $source = 'direct') {} public function handle(int $postId, AsyncContext $context): void @@ -56,6 +59,17 @@ public function fails(int $postId): void throw new \RuntimeException('CRM unreachable'); } + public function flaky(int $postId, AsyncContext $context): void + { + $this->record(__FUNCTION__, [$postId, $context->attempt]); + + if (self::$failuresLeft > 0) { + self::$failuresLeft--; + + throw new \RuntimeException('Temporary failure'); + } + } + public function refires(int $postId): void { $this->record(__FUNCTION__, func_get_args()); @@ -77,6 +91,7 @@ private function record(string $method, array $arguments): void 'arguments' => $arguments, 'blog' => $GLOBALS['wp_state']['blog'] ?? null, 'locale' => $GLOBALS['wp_state']['locale'] ?? null, + 'user' => $GLOBALS['wp_state']['user'] ?? null, ]; } } diff --git a/tests/Stubs/wordpress.php b/tests/Stubs/wordpress.php index 27a14b0..f2edfc5 100644 --- a/tests/Stubs/wordpress.php +++ b/tests/Stubs/wordpress.php @@ -162,6 +162,16 @@ function get_current_user_id(): int } } +if (! function_exists('wp_set_current_user')) { + function wp_set_current_user(int $userId): WP_User + { + $GLOBALS['wp_switches'][] = ['user', $userId]; + $GLOBALS['wp_state']['user'] = $userId; + + return new WP_User($userId); + } +} + if (! function_exists('get_current_blog_id')) { function get_current_blog_id(): int { diff --git a/tests/Unit/Async/AsyncPayloadTest.php b/tests/Unit/Async/AsyncPayloadTest.php index a564853..9d3e350 100644 --- a/tests/Unit/Async/AsyncPayloadTest.php +++ b/tests/Unit/Async/AsyncPayloadTest.php @@ -86,3 +86,27 @@ function makeAsyncPayload(array $overrides = []): AsyncPayload ['origin.userId', '3', 'origin.userId'], ['origin.locale', null, 'origin.locale'], ]); + +it('reads a payload queued by 1.2.0, without the fields added since', function (): void { + $data = json_decode(makeAsyncPayload()->toJson(), true); + unset($data['driver'], $data['queue'], $data['asUser'], $data['backoff']); + + $payload = AsyncPayload::fromJson(json_encode($data)); + + expect($payload->driver)->toBeNull() + ->and($payload->queue)->toBeNull() + ->and($payload->asUser)->toBeFalse() + ->and($payload->backoff)->toBe([]) + ->and($payload->retryDelay(1))->toBe(0); +}); + +it('refuses an invalid backoff', function (mixed $backoff): void { + $data = json_decode(makeAsyncPayload()->toJson(), true); + $data['backoff'] = $backoff; + + expect(fn () => AsyncPayload::fromJson(json_encode($data)))->toThrow(InvalidPayload::class, "'backoff'"); +})->with([ + 'negative' => [[10, -5]], + 'not a list' => [['a' => 10]], + 'not an integer' => [['10']], +]); diff --git a/tests/Unit/Async/RetryAndUserTest.php b/tests/Unit/Async/RetryAndUserTest.php new file mode 100644 index 0000000..ab04948 --- /dev/null +++ b/tests/Unit/Async/RetryAndUserTest.php @@ -0,0 +1,208 @@ + 5, 'blog' => 1, 'locale' => 'fr_FR', 'multisite' => false]; + $GLOBALS['incidents'] = []; + RecordingHandler::$calls = []; + RecordingHandler::$failuresLeft = 0; + + 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(); +}); + +function failedAnnouncements(): array +{ + return array_values(array_filter($GLOBALS['wp_actions_done'], fn (array $call): bool => $call['hook'] === 'pollora/async/failed')); +} + +describe('tries', function (): void { + it('queues the handler again after a failure, with the default backoff, until it succeeds', function (): void { + RecordingHandler::$failuresLeft = 2; + $this->action->add('save_post', [RecordingHandler::class, 'flaky'])->async()->tries(3); + + wp_stub_fire('save_post', 8); + $this->driver->runAll(); + + expect(array_column(RecordingHandler::$calls, 'arguments'))->toBe([[8, 1], [8, 2], [8, 3]]) + ->and(array_column($this->driver->queued, 'delay'))->toBe([0, 10, 60]) + ->and(array_map(fn (array $entry): int => $entry['payload']->attempt, $this->driver->queued))->toBe([1, 2, 3]) + ->and($GLOBALS['incidents'])->toBe([ + 'Attempt 1 of 3 failed, retried in 10 s: Temporary failure', + 'Attempt 2 of 3 failed, retried in 60 s: Temporary failure', + ]) + ->and(failedAnnouncements())->toBe([]); + }); + + it('reports and announces the last failure once the attempts are used', function (): void { + RecordingHandler::$failuresLeft = 5; + $this->action->add('save_post', [RecordingHandler::class, 'flaky'])->async()->tries(2); + + wp_stub_fire('save_post', 8); + $this->driver->runAll(); + + expect(RecordingHandler::$calls)->toHaveCount(2) + ->and(end($GLOBALS['incidents']))->toBe('Temporary failure') + ->and(failedAnnouncements())->toHaveCount(1) + ->and(failedAnnouncements()[0]['args'][0]->attempt)->toBe(2); + }); + + it('does not retry by default', function (): void { + RecordingHandler::$failuresLeft = 1; + $this->action->add('save_post', [RecordingHandler::class, 'flaky'])->async(); + + wp_stub_fire('save_post', 8); + $this->driver->runAll(); + + expect($this->driver->queued)->toHaveCount(1) + ->and(failedAnnouncements())->toHaveCount(1); + }); + + it('repeats the last backoff delay', function (): void { + RecordingHandler::$failuresLeft = 3; + $this->action->add('save_post', [RecordingHandler::class, 'flaky'])->async()->tries(4)->backoff([5, 30]); + + wp_stub_fire('save_post', 8); + $this->driver->runAll(); + + expect(array_column($this->driver->queued, 'delay'))->toBe([0, 5, 30, 30]); + }); + + it('retries through the driver that queued the handler', function (): void { + $other = new RecordingDriver; + Async::extend('other', fn (): AsyncDriver => $other); + RecordingHandler::$failuresLeft = 1; + $this->action->add('save_post', [RecordingHandler::class, 'flaky'])->async()->via('other')->tries(2); + + wp_stub_fire('save_post', 8); + $other->runAll(); + + expect($this->driver->queued)->toBe([]) + ->and($other->queued)->toHaveCount(2) + ->and($other->queued[0]['payload']->driver)->toBe('other'); + }); + + it('retries at once through the sync driver', function (): void { + RecordingHandler::$failuresLeft = 1; + $this->action->add('save_post', [RecordingHandler::class, 'flaky'])->async()->via('sync')->tries(2); + + wp_stub_fire('save_post', 8); + + expect(array_column(RecordingHandler::$calls, 'arguments'))->toBe([[8, 1], [8, 2]]); + }); + + it('gives up and announces the failure when the retry cannot be queued', function (): void { + RecordingHandler::$failuresLeft = 1; + $this->action->add('save_post', [RecordingHandler::class, 'flaky'])->async()->tries(2); + wp_stub_fire('save_post', 8); + Async::extend('recording', fn (): AsyncDriver => new class implements AsyncDriver + { + public function available(): bool + { + return false; + } + + public function dispatch(AsyncPayload $payload, int $delay = 0): void {} + }); + + $this->driver->run(0); + + expect($GLOBALS['incidents'])->toBe(["The asynchronous driver 'recording' is not available in this request.", 'Temporary failure']) + ->and(failedAnnouncements())->toHaveCount(1); + }); + + it('does not retry a handler that no longer exists', function (): void { + Async::receive((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'], + tries: 3, + driver: 'recording', + ))->toJson()); + + expect($this->driver->queued)->toBe([]) + ->and(failedAnnouncements())->toHaveCount(1); + }); + + it('rejects invalid attempts and delays', function (Closure $configure, string $message): void { + $pending = $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async(); + + expect(fn () => $configure($pending))->toThrow(InvalidArgumentException::class, $message); + })->with([ + 'no attempt' => [fn ($pending) => $pending->tries(0), 'at least 1 attempt'], + 'empty backoff' => [fn ($pending) => $pending->backoff([]), 'one or more delays'], + 'negative delay' => [fn ($pending) => $pending->backoff([10, -1]), 'one or more delays'], + ]); +}); + +describe('asUser', function (): void { + it('runs as the user who fired the hook, then removes that user', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->asUser(); + wp_stub_fire('save_post', 1); + $GLOBALS['wp_state']['user'] = 0; + + $this->driver->runAll(); + + expect(RecordingHandler::$calls[0]['user'])->toBe(5) + ->and($GLOBALS['wp_state']['user'])->toBe(0) + ->and($GLOBALS['wp_switches'])->toBe([['user', 5], ['user', 0]]); + }); + + it('runs without a user by default', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async(); + wp_stub_fire('save_post', 1); + $GLOBALS['wp_state']['user'] = 0; + + $this->driver->runAll(); + + expect(RecordingHandler::$calls[0]['user'])->toBe(0) + ->and(RecordingHandler::$calls[0]['arguments'][1]->userId)->toBe(5) + ->and($GLOBALS['wp_switches'])->toBe([]); + }); + + it('does not switch when nobody was logged in', function (): void { + $GLOBALS['wp_state']['user'] = 0; + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->asUser(); + wp_stub_fire('save_post', 1); + + $this->driver->runAll(); + + expect($GLOBALS['wp_switches'])->toBe([]); + }); +}); + +it('records the queue name and the driver in the payload', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->onQueue('integrations'); + + wp_stub_fire('save_post', 1); + + expect($this->driver->queued[0]['payload']->queue)->toBe('integrations') + ->and($this->driver->queued[0]['payload']->driver)->toBe('recording'); +});