diff --git a/CHANGELOG.md b/CHANGELOG.md index 324b47b..c43f6bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to `pollora/hook` are documented in this file. - `unique()`: merge identical triggers (same hook, same handler, same arguments) while the first one has not started running. The lock is an option that is not autoloaded, taken atomically with `add_option()`, released when the first attempt starts, and expiring after `unique(for: …)` seconds (one day by default) if never released; the daily maintenance task deletes expired locks. A lock that cannot be stored is a queuing failure, never a silent merge. - `action-scheduler` driver, offered when a plugin bundling Action Scheduler is active and its data store is initialised (never a dependency). The payload travels whole as the action argument, the queue name becomes the group (`pollora` by default), and history is visible in Tools › Scheduled Actions. A payload too long for Action Scheduler's arguments (8,000 characters as JSON) is kept in an option that is not autoloaded and the action carries only its identifier. - `Async::setAutoDrivers()`: the drivers `auto` tries, in order. +- `Async::fake()` for tests: records queued handlers instead of queuing them, whatever the driver, with `Async::assertDispatched()` (by class, descriptor or `closure`, with an optional callback receiving the payload and the delay), `assertDispatchedTimes()`, `assertNotDispatched()` and `assertNothingDispatched()`. Arguments are still normalized, so one that cannot travel fails here too; `runDispatched()` runs the recorded handlers. Assertions go through PHPUnit when it is installed. - Closures as asynchronous handlers, when `laravel/serializable-closure` is installed (suggested, not required). The closure is serialized with it and signed with an HMAC-SHA256 whose key derives from `wp_salt('auth')`, or is set with `Async::useClosureKey()`; the signature is checked before anything is unserialized, and an altered payload or a changed key fails with an explicit report. The closure is checked at registration but signed when its hook first fires, since `wp_salt()` is pluggable and not loaded while plugins load. A closure made from a named function or method (`strlen(...)`, `$object->method(...)`) travels by that name. - Payloads record the driver that queued them, the queue, `asUser`, the backoff and the unique lock. These fields are optional, so payloads queued by 1.2.0 still run. diff --git a/src/Async/Async.php b/src/Async/Async.php index 19c11c5..e1acc34 100644 --- a/src/Async/Async.php +++ b/src/Async/Async.php @@ -56,6 +56,8 @@ final class Async private static ?string $closureKey = null; + private static ?AsyncFake $fake = null; + private static ?ArgumentNormalizer $normalizer = null; private static ?AsyncDispatcher $dispatcher = null; @@ -87,6 +89,10 @@ public static function extend(string $name, callable $factory): void */ public static function driver(?string $name = null): AsyncDriver { + if (self::$fake instanceof AsyncFake) { + return self::$fake; + } + $name ??= self::defaultDriver(); $factories = self::factories(); @@ -103,6 +109,40 @@ public static function driver(?string $name = null): AsyncDriver return $driver; } + /** + * Record queued handlers instead of queuing them, whatever the driver. For tests. + */ + public static function fake(): AsyncFake + { + return self::$fake = new AsyncFake; + } + + /** + * @param (callable(AsyncPayload, int): bool)|null $callback Receives the payload and its delay + */ + public static function assertDispatched(string $handler, ?callable $callback = null): void + { + self::faked()->assertDispatched($handler, $callback); + } + + public static function assertDispatchedTimes(string $handler, int $times): void + { + self::faked()->assertDispatchedTimes($handler, $times); + } + + /** + * @param (callable(AsyncPayload, int): bool)|null $callback + */ + public static function assertNotDispatched(string $handler, ?callable $callback = null): void + { + self::faked()->assertNotDispatched($handler, $callback); + } + + public static function assertNothingDispatched(): void + { + self::faked()->assertNothingDispatched(); + } + /** * Name of the default driver, from the first of: setDefaultDriver(), the * POLLORA_ASYNC_DRIVER constant, the 'pollora/hook/async_driver' filter, @@ -315,6 +355,7 @@ public static function flush(): void self::$autoDrivers = ['action-scheduler', 'wp-cron']; self::$resolver = null; self::$closureKey = null; + self::$fake = null; self::$normalizer = null; self::$dispatcher = null; self::$runner = null; @@ -322,6 +363,11 @@ public static function flush(): void self::$reporter = null; } + private static function faked(): AsyncFake + { + return self::$fake ?? throw new \LogicException('Call Async::fake() before asserting on dispatched handlers.'); + } + /** * The first available auto driver, WP-Cron when none is. */ diff --git a/src/Async/AsyncFake.php b/src/Async/AsyncFake.php new file mode 100644 index 0000000..bfcf266 --- /dev/null +++ b/src/Async/AsyncFake.php @@ -0,0 +1,124 @@ + $event->ID, 'post_title' => 'New title']); + * Async::assertDispatched(SyncEventToCrm::class, fn (AsyncPayload $payload) => $payload->captured['status'] === 'publish'); + * + * Arguments and captured values are normalized as for a real driver, so one + * that cannot travel fails here too. A handler is matched by its class name, + * its descriptor ('Class@method', a function name), or 'closure' for closures. + */ +final class AsyncFake implements AsyncDriver +{ + /** @var list */ + private array $dispatched = []; + + public function available(): bool + { + return true; + } + + public function dispatch(AsyncPayload $payload, int $delay = 0): void + { + $this->dispatched[] = ['payload' => $payload, 'delay' => $delay]; + } + + /** + * Payloads recorded for a handler, or all of them. + * + * @param (callable(AsyncPayload, int): bool)|null $callback Receives the payload and its delay + * @return list + */ + public function dispatched(?string $handler = null, ?callable $callback = null): array + { + $payloads = []; + + foreach ($this->dispatched as $entry) { + if ($handler !== null && ! self::matches($entry['payload'], $handler)) { + continue; + } + + if ($callback !== null && $callback($entry['payload'], $entry['delay']) !== true) { + continue; + } + + $payloads[] = $entry['payload']; + } + + return $payloads; + } + + /** + * @param (callable(AsyncPayload, int): bool)|null $callback + */ + public function assertDispatched(string $handler, ?callable $callback = null): void + { + self::assert( + $this->dispatched($handler, $callback) !== [], + sprintf('The asynchronous handler [%s] was not dispatched%s.', $handler, $callback === null ? '' : ' with the expected payload'), + ); + } + + public function assertDispatchedTimes(string $handler, int $times): void + { + $count = count($this->dispatched($handler)); + + self::assert($count === $times, sprintf('The asynchronous handler [%s] was dispatched %d time(s) instead of %d.', $handler, $count, $times)); + } + + /** + * @param (callable(AsyncPayload, int): bool)|null $callback + */ + public function assertNotDispatched(string $handler, ?callable $callback = null): void + { + self::assert($this->dispatched($handler, $callback) === [], sprintf('The asynchronous handler [%s] was dispatched.', $handler)); + } + + public function assertNothingDispatched(): void + { + self::assert($this->dispatched === [], sprintf('%d asynchronous handler(s) were dispatched.', count($this->dispatched))); + } + + /** + * Run the recorded handlers, as a driver would, then forget them. + */ + public function runDispatched(): void + { + while ($this->dispatched !== []) { + $entry = array_shift($this->dispatched); + Async::receive($entry['payload']->toJson()); + } + } + + private static function matches(AsyncPayload $payload, string $handler): bool + { + $handler = ltrim($handler, '\\'); + + return $payload->handler === $handler + || str_starts_with($payload->handler, $handler.'@') + || ($handler === 'closure' && str_starts_with($payload->handler, 'closure:')); + } + + private static function assert(bool $condition, string $message): void + { + if (class_exists(Assert::class)) { + Assert::assertTrue($condition, $message); + + return; + } + + if (! $condition) { + throw new \AssertionError($message); + } + } +} diff --git a/tests/Unit/Async/FakeTest.php b/tests/Unit/Async/FakeTest.php new file mode 100644 index 0000000..33dd34a --- /dev/null +++ b/tests/Unit/Async/FakeTest.php @@ -0,0 +1,129 @@ + 5, 'blog' => 1, 'locale' => 'fr_FR', 'multisite' => false]; + $GLOBALS['wp_objects'] = []; + RecordingHandler::$calls = []; + RecordingHandler::$failuresLeft = 0; + + Async::flush(); + Async::setDebug(true); + $this->fake = Async::fake(); + $this->action = new Action; +}); + +afterEach(function (): void { + Async::flush(); +}); + +it('records queued handlers instead of queuing them, whatever the driver', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async(); + $this->action->add('edit_post', [RecordingHandler::class, 'handle'])->async()->via('rabbitmq'); + + wp_stub_fire('save_post', 1); + wp_stub_fire('edit_post', 2); + + expect($this->fake)->toBeInstanceOf(AsyncFake::class) + ->and($this->fake->dispatched())->toHaveCount(2) + ->and($GLOBALS['wp_cron_events'])->toBe([]) + ->and(RecordingHandler::$calls)->toBe([]); +}); + +it('asserts on a handler by class, descriptor or payload, as in the design document', function (): void { + $GLOBALS['wp_objects']['post'][12] = new WP_Post(12, 'publish'); + $this->action->add('save_post', [RecordingHandler::class, 'post'])->async() + ->capture(fn (WP_Post $post): array => ['status' => $post->post_status]); + + wp_stub_fire('save_post', new WP_Post(12, 'publish')); + + Async::assertDispatched(RecordingHandler::class); + Async::assertDispatched(RecordingHandler::class.'@post'); + Async::assertDispatched(RecordingHandler::class, fn (AsyncPayload $payload): bool => $payload->captured['status'] === 'publish'); + Async::assertDispatchedTimes(RecordingHandler::class, 1); + Async::assertNotDispatched(RecordingHandler::class.'@handle'); +}); + +it('fails an assertion that does not hold, with a readable message', function (Closure $assertion, string $message): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async(); + wp_stub_fire('save_post', 1); + + expect($assertion)->toThrow(AssertionFailedError::class, $message); +})->with([ + 'not dispatched' => [fn () => Async::assertDispatched('App\Missing'), 'The asynchronous handler [App\Missing] was not dispatched.'], + 'wrong payload' => [fn () => Async::assertDispatched(RecordingHandler::class, fn (): bool => false), 'was not dispatched with the expected payload'], + 'wrong count' => [fn () => Async::assertDispatchedTimes(RecordingHandler::class, 2), 'dispatched 1 time(s) instead of 2'], + 'dispatched' => [fn () => Async::assertNotDispatched(RecordingHandler::class), 'was dispatched'], + 'something dispatched' => [fn () => Async::assertNothingDispatched(), '1 asynchronous handler(s) were dispatched'], +]); + +it('hands the delay to the callback', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->delay(90); + + wp_stub_fire('save_post', 1); + + Async::assertDispatched(RecordingHandler::class, fn (AsyncPayload $payload, int $delay): bool => $delay === 90); +}); + +it('matches closures', function (): void { + Async::useClosureKey('test-signing-key'); + $this->action->add('save_post', static function (int $postId): void {})->async(); + + wp_stub_fire('save_post', 1); + + Async::assertDispatched('closure'); +}); + +it('does not record a trigger that when() skips', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async()->when(fn (): bool => false); + + wp_stub_fire('save_post', 1); + + Async::assertNothingDispatched(); +}); + +it('still rejects an argument that cannot travel', function (): void { + $this->action->add('save_post', [RecordingHandler::class, 'objects'])->async(); + + expect(fn () => wp_stub_fire('save_post', new stdClass))->toThrow(UnsupportedArgument::class); +}); + +it('runs the recorded handlers on demand, retries included', function (): void { + RecordingHandler::$failuresLeft = 1; + $this->action->add('save_post', [RecordingHandler::class, 'flaky'])->async()->tries(2); + wp_stub_fire('save_post', 4); + + $this->fake->runDispatched(); + + expect(array_column(RecordingHandler::$calls, 'arguments'))->toBe([[4, 1], [4, 2]]) + ->and($this->fake->dispatched())->toBe([]); +}); + +it('requires fake() before asserting', function (): void { + Async::flush(); + + expect(fn () => Async::assertNothingDispatched())->toThrow(LogicException::class, 'Call Async::fake()'); +}); + +it('is forgotten by flush()', function (): void { + Async::flush(); + Async::setDefaultDriver('sync'); + $this->action->add('save_post', [RecordingHandler::class, 'handle'])->async(); + + wp_stub_fire('save_post', 1); + + expect(RecordingHandler::$calls)->toHaveCount(1); +}); diff --git a/tests/WordPress/AsyncWpCronTest.php b/tests/WordPress/AsyncWpCronTest.php index 0626047..e3134af 100644 --- a/tests/WordPress/AsyncWpCronTest.php +++ b/tests/WordPress/AsyncWpCronTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Pollora\Hook\Async\Async; +use Pollora\Hook\Async\AsyncPayload; use Pollora\Hook\Async\Drivers\WpCronDriver; /* @@ -224,3 +225,16 @@ function fixtureRuns(): array expect(array_column($runs, 'key'))->toBe(['failed']) ->and($runs[0]['message'])->toContain('signature of a queued closure is invalid'); }); + +it('records handlers with Async::fake() instead of queuing them', function (): void { + Async::fake(); + + try { + do_action('pollora_fixture_event', $this->postId, get_post($this->postId)); + + Async::assertDispatched(PolloraHookFixture::class.'@event', fn (AsyncPayload $payload): bool => $payload->arguments[0] === $this->postId); + expect(scheduledExecutions())->toBe([]); + } finally { + Async::flush(); + } +});