Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
46 changes: 46 additions & 0 deletions src/Async/Async.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand All @@ -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,
Expand Down Expand Up @@ -315,13 +355,19 @@ 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;
self::$debug = null;
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.
*/
Expand Down
124 changes: 124 additions & 0 deletions src/Async/AsyncFake.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

declare(strict_types=1);

namespace Pollora\Hook\Async;

use PHPUnit\Framework\Assert;
use Pollora\Hook\Async\Contracts\AsyncDriver;

/**
* Records queued handlers instead of queuing them, for tests.
*
* Async::fake();
* wp_update_post(['ID' => $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<array{payload: AsyncPayload, delay: int}> */
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<AsyncPayload>
*/
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);
}
}
}
129 changes: 129 additions & 0 deletions tests/Unit/Async/FakeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

use PHPUnit\Framework\AssertionFailedError;
use Pollora\Hook\Adapter\Out\WordPress\Action;
use Pollora\Hook\Async\Async;
use Pollora\Hook\Async\AsyncFake;
use Pollora\Hook\Async\AsyncPayload;
use Pollora\Hook\Async\Exceptions\UnsupportedArgument;
use Pollora\Hook\Tests\Fixtures\Async\RecordingHandler;

beforeEach(function (): void {
$GLOBALS['wp_actions'] = [];
$GLOBALS['wp_async_listeners'] = [];
$GLOBALS['wp_options'] = [];
$GLOBALS['wp_cron_events'] = [];
$GLOBALS['wp_state'] = ['user' => 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);
});
14 changes: 14 additions & 0 deletions tests/WordPress/AsyncWpCronTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
declare(strict_types=1);

use Pollora\Hook\Async\Async;
use Pollora\Hook\Async\AsyncPayload;
use Pollora\Hook\Async\Drivers\WpCronDriver;

/*
Expand Down Expand Up @@ -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();
}
});
Loading