From 7d9ed4506ec5de29383ab4d17aa1c7f155359bfc Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 17:57:45 +0200 Subject: [PATCH 1/2] feat: queue async actions through Laravel and configure them from config/hooks.php --- composer.json | 2 +- config/hooks.php | 44 ++++++ .../Async/EloquentModelReference.php | 53 +++++++ src/Hook/Infrastructure/Async/QueueDriver.php | 50 +++++++ .../Infrastructure/Jobs/RunAsyncAction.php | 58 ++++++++ .../Providers/AsyncServiceProvider.php | 75 ++++++++++ src/Providers/PolloraServiceProvider.php | 2 + .../Hook/Async/AsyncServiceProviderTest.php | 139 ++++++++++++++++++ .../Hook/Async/EloquentModelReferenceTest.php | 64 ++++++++ tests/Feature/Hook/Async/QueueDriverTest.php | 85 +++++++++++ .../Feature/Hook/Async/RunAsyncActionTest.php | 88 +++++++++++ 11 files changed, 659 insertions(+), 1 deletion(-) create mode 100644 config/hooks.php create mode 100644 src/Hook/Infrastructure/Async/EloquentModelReference.php create mode 100644 src/Hook/Infrastructure/Async/QueueDriver.php create mode 100644 src/Hook/Infrastructure/Jobs/RunAsyncAction.php create mode 100644 src/Hook/Infrastructure/Providers/AsyncServiceProvider.php create mode 100644 tests/Feature/Hook/Async/AsyncServiceProviderTest.php create mode 100644 tests/Feature/Hook/Async/EloquentModelReferenceTest.php create mode 100644 tests/Feature/Hook/Async/QueueDriverTest.php create mode 100644 tests/Feature/Hook/Async/RunAsyncActionTest.php diff --git a/composer.json b/composer.json index 039de3f5..a215a651 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "pollora/abilities": "^1.0", "pollora/ajax": "^1.1", "pollora/entity": "^1.2", - "pollora/hook": "^1.1", + "pollora/hook": "^1.4", "pollora/option": "^1.1", "watson/rememberable": "^7.1", "cweagans/composer-patches": "^2.0", diff --git a/config/hooks.php b/config/hooks.php new file mode 100644 index 00000000..0ebc8b3f --- /dev/null +++ b/config/hooks.php @@ -0,0 +1,44 @@ +async() and #[Async] queue a handler instead of running + | it inside the request that fires the hook. + | + | default: auto picks the Laravel queue when its connection is not "sync", + | then Action Scheduler when a plugin bundling it is active, then WP-Cron. + | Set HOOKS_ASYNC_DRIVER=sync in a developer's .env to run every handler + | at once, without touching the code. + | + | Supported: "auto", "queue", "action-scheduler", "wp-cron", "sync" + | + */ + + 'async' => [ + + 'default' => env('HOOKS_ASYNC_DRIVER', 'auto'), + + // Attempts when a handler throws, and seconds before each retry (the last value repeats) + 'tries' => 1, + 'backoff' => [10, 60, 300], + + // Run handlers as the user who fired the hook. Off by default: without a user, a capability check fails, which is the safe behaviour + 'as_user' => false, + + 'queue' => [ + // null: the default queue connection + 'connection' => env('HOOKS_ASYNC_CONNECTION'), + // Overridden per action by onQueue() or #[Async(onQueue: ...)] + 'queue' => env('HOOKS_ASYNC_QUEUE', 'default'), + ], + + ], + +]; diff --git a/src/Hook/Infrastructure/Async/EloquentModelReference.php b/src/Hook/Infrastructure/Async/EloquentModelReference.php new file mode 100644 index 00000000..86afd59f --- /dev/null +++ b/src/Hook/Infrastructure/Async/EloquentModelReference.php @@ -0,0 +1,53 @@ +exists; + } + + public function reference(object $object): array + { + /** @var Model $object */ + $reference = ['class' => $object::class, 'key' => $object->getKey()]; + + if ($object->getConnectionName() !== null) { + $reference['connection'] = $object->getConnectionName(); + } + + return $reference; + } + + public function resolve(array $reference): ?object + { + $class = $reference['class'] ?? null; + + if (! is_string($class) || ! is_a($class, Model::class, true)) { + return null; + } + + $query = isset($reference['connection']) && is_string($reference['connection']) + ? $class::on($reference['connection']) + : $class::query(); + + return $query->find($reference['key'] ?? null); + } +} diff --git a/src/Hook/Infrastructure/Async/QueueDriver.php b/src/Hook/Infrastructure/Async/QueueDriver.php new file mode 100644 index 00000000..7ae7dab4 --- /dev/null +++ b/src/Hook/Infrastructure/Async/QueueDriver.php @@ -0,0 +1,50 @@ +config->get(sprintf('queue.connections.%s.driver', $this->connection())); + + return is_string($driver) && ! in_array($driver, ['sync', 'null'], true); + } + + public function dispatch(AsyncPayload $payload, int $delay = 0): void + { + $job = (new RunAsyncAction($payload->toJson())) + ->onConnection($this->connection()) + ->onQueue($payload->queue ?? $this->config->get('hooks.async.queue.queue', 'default')); + + if ($delay > 0) { + $job->delay($delay); + } + + $this->dispatcher->dispatch($job); + } + + private function connection(): string + { + return (string) ($this->config->get('hooks.async.queue.connection') ?? $this->config->get('queue.default', 'sync')); + } +} diff --git a/src/Hook/Infrastructure/Jobs/RunAsyncAction.php b/src/Hook/Infrastructure/Jobs/RunAsyncAction.php new file mode 100644 index 00000000..7ac92f33 --- /dev/null +++ b/src/Hook/Infrastructure/Jobs/RunAsyncAction.php @@ -0,0 +1,58 @@ +payload, throwOnFinalFailure: true); + } + + /** + * Name shown by Horizon and in the failed jobs table: the hook and the handler. + */ + public function displayName(): string + { + $payload = json_decode($this->payload, true); + + if (! is_array($payload) || ! is_string($payload['hook'] ?? null) || ! is_string($payload['handler'] ?? null)) { + return self::class; + } + + $handler = str_starts_with($payload['handler'], 'closure:') ? 'closure' : $payload['handler']; + + return sprintf('pollora/async %s %s', $payload['hook'], $handler); + } +} diff --git a/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php new file mode 100644 index 00000000..2d03fcb5 --- /dev/null +++ b/src/Hook/Infrastructure/Providers/AsyncServiceProvider.php @@ -0,0 +1,75 @@ +mergeConfigFrom(__DIR__.'/../../../../config/hooks.php', 'hooks'); + } + + public function boot(): void + { + $this->publishes([ + __DIR__.'/../../../../config/hooks.php' => config_path('hooks.php'), + ], 'pollora-hooks'); + + $this->configureAsync($this->app->make(Repository::class)); + } + + private function configureAsync(Repository $config): void + { + $container = $this->app; + + Async::extend('queue', static fn (): AsyncDriver => new QueueDriver($container->make(Dispatcher::class), $config)); + Async::setAutoDrivers(['queue', 'action-scheduler', 'wp-cron']); + + $default = $config->get('hooks.async.default', 'auto'); + Async::setDefaultDriver(is_string($default) && $default !== '' && $default !== 'auto' ? $default : null); + + Async::setDefaults( + tries: (int) $config->get('hooks.async.tries', 1), + backoff: (array) $config->get('hooks.async.backoff', [10, 60, 300]), + asUser: (bool) $config->get('hooks.async.as_user', false), + ); + + Async::setDebug((bool) $config->get('app.debug', false)); + Async::useClosureKey(is_string($key = $config->get('app.key')) ? $key : null); + Async::reference(new EloquentModelReference); + Async::injectParametersUsing(static fn (\ReflectionParameter $parameter): mixed => self::resolveParameter($container, $parameter)); + + Async::reportUsing(static function (string $message, array $context) use ($container): void { + $container->make(LoggerInterface::class)->log(isset($context['exception']) ? 'error' : 'warning', $message, $context); + }); + } + + private static function resolveParameter(Container $container, \ReflectionParameter $parameter): mixed + { + $type = $parameter->getType(); + + return $type instanceof \ReflectionNamedType ? $container->make($type->getName()) : null; + } +} diff --git a/src/Providers/PolloraServiceProvider.php b/src/Providers/PolloraServiceProvider.php index 48ce82aa..b83b4b7a 100755 --- a/src/Providers/PolloraServiceProvider.php +++ b/src/Providers/PolloraServiceProvider.php @@ -23,6 +23,7 @@ use Pollora\Exceptions\Infrastructure\Providers\ExceptionServiceProvider; use Pollora\Foundation\Providers\ArtisanServiceProvider; use Pollora\Hashing\HashServiceProvider; +use Pollora\Hook\Infrastructure\Providers\AsyncServiceProvider; use Pollora\Hook\Infrastructure\Providers\HookServiceProvider; use Pollora\Logging\Infrastructure\Providers\LoggingServiceProvider; use Pollora\Login\Infrastructure\Providers\LoginServiceProvider; @@ -106,6 +107,7 @@ public function register(): void $this->app->register(WordPressMailServiceProvider::class); $this->app->register(HookServiceProvider::class); + $this->app->register(AsyncServiceProvider::class); $this->app->register(PermalinkServiceProvider::class); $this->app->register(ThemeServiceProvider::class); diff --git a/tests/Feature/Hook/Async/AsyncServiceProviderTest.php b/tests/Feature/Hook/Async/AsyncServiceProviderTest.php new file mode 100644 index 00000000..ac81bd12 --- /dev/null +++ b/tests/Feature/Hook/Async/AsyncServiceProviderTest.php @@ -0,0 +1,139 @@ + 7]; +} + +beforeEach(function (): void { + Async::flush(); +}); + +afterEach(function (): void { + Async::flush(); +}); + +function bootAsyncProvider(array $config = []): void +{ + config(['queue.default' => 'sync', 'queue.connections.sync' => ['driver' => 'sync'], 'queue.connections.database' => ['driver' => 'database'], ...$config]); + app()->register(AsyncServiceProvider::class, force: true); +} + +it('merges config/hooks.php and publishes it under the pollora-hooks tag', function (): void { + bootAsyncProvider(); + + expect(config('hooks.async.default'))->toBe('auto') + ->and(config('hooks.async.tries'))->toBe(1) + ->and(array_values(ServiceProvider::pathsToPublish(AsyncServiceProvider::class, 'pollora-hooks')))->toBe([config_path('hooks.php')]); +}); + +it('makes the Laravel queue the first driver auto tries, skipped on a sync connection', function (): void { + bootAsyncProvider(); + + expect(Async::defaultDriver())->toBe('wp-cron'); + + config(['queue.default' => 'database']); + + expect(Async::defaultDriver())->toBe('queue'); +}); + +it('sets the default driver from the configuration', function (): void { + bootAsyncProvider(['hooks.async.default' => 'sync']); + + expect(Async::defaultDriver())->toBe('sync'); +}); + +it('starts registrations from the configured attempts, backoff and user', function (): void { + bootAsyncProvider(['hooks.async.tries' => 3, 'hooks.async.backoff' => [5, 50], 'hooks.async.as_user' => true]); + + expect(Async::defaults())->toBe(['tries' => 3, 'backoff' => [5, 50], 'asUser' => true]); +}); + +it('follows app.debug', function (bool $debug): void { + bootAsyncProvider(['app.debug' => $debug]); + + expect(Async::isDebug())->toBe($debug); +})->with([true, false]); + +it('signs closures with the application key', function (): void { + bootAsyncProvider(['app.key' => 'base64:application-key']); + + expect(Async::closureKey())->toBe('base64:application-key'); +}); + +it('does not sign closures with an empty application key', function (): void { + bootAsyncProvider(['app.key' => '']); + + expect(fn () => Async::closureKey())->toThrow(UnresolvableHandler::class, 'without a signing key'); +}); + +it('sends incidents to the Laravel log, as errors when they carry an exception', function (): void { + $logger = new class extends AbstractLogger + { + /** @var list}> */ + public array $records = []; + + public function log($level, string|Stringable $message, array $context = []): void + { + $this->records[] = [$level, $message, $context]; + } + }; + $exception = new RuntimeException('CRM unreachable'); + app()->instance(LoggerInterface::class, $logger); + bootAsyncProvider(); + + Async::report('Lock released', ['hook' => 'save_post']); + Async::report($exception); + + expect($logger->records)->toBe([ + ['warning', 'Lock released', ['hook' => 'save_post']], + ['error', 'CRM unreachable', ['exception' => $exception]], + ]); +}); + +it('resolves the handler parameters that are not hook arguments from the container', function (): void { + bootAsyncProvider(); + $parameter = (new ReflectionFunction(fn (int $id, AsyncProviderService $service): null => null))->getParameters()[1]; + + expect(Async::isInjectable($parameter))->toBeTrue() + ->and(Async::inject($parameter)->name)->toBe('from the container'); +}); + +it('injects nothing for a parameter without a named type', function (): void { + $resolve = (new ReflectionClass(AsyncServiceProvider::class))->getMethod('resolveParameter'); + $parameter = (new ReflectionFunction(fn (int|string $id): null => null))->getParameters()[0]; + + expect($resolve->invoke(null, app(), $parameter))->toBeNull(); +}); + +it('carries Eloquent models by reference', function (): void { + bootAsyncProvider(); + $fake = Async::fake(); + $handler = new QueuedHandler('save_post', 10, 'App\Hooks\Sync@handle', 'App\Hooks\Sync@handle', 1, 1, new PendingAsync(static fn (): null => null)); + + Async::dispatcher()->dispatch($handler, [new AsyncProviderModel]); + + expect($fake)->toBeInstanceOf(AsyncFake::class) + ->and($fake->dispatched()[0]->arguments[0])->toBe(['@type' => 'ref', 'kind' => 'eloquent', 'ref' => ['class' => AsyncProviderModel::class, 'key' => 7]]); +}); diff --git a/tests/Feature/Hook/Async/EloquentModelReferenceTest.php b/tests/Feature/Hook/Async/EloquentModelReferenceTest.php new file mode 100644 index 00000000..b3ac0760 --- /dev/null +++ b/tests/Feature/Hook/Async/EloquentModelReferenceTest.php @@ -0,0 +1,64 @@ + 'testing', 'database.connections.testing' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]); + Schema::create('async_reference_orders', function (Blueprint $table): void { + $table->id(); + $table->string('status'); + }); + + $this->reference = new EloquentModelReference; +}); + +it('carries saved models only', function (): void { + expect($this->reference->name())->toBe('eloquent') + ->and($this->reference->supports(AsyncReferenceOrder::create(['status' => 'paid'])))->toBeTrue() + ->and($this->reference->supports(new AsyncReferenceOrder(['status' => 'draft'])))->toBeFalse() + ->and($this->reference->supports(new stdClass))->toBeFalse(); +}); + +it('identifies a model by class and key, with its connection when it has one', function (): void { + $order = (new AsyncReferenceOrder)->forceFill(['id' => 5]); + $order->exists = true; + + expect($this->reference->reference($order))->toBe(['class' => AsyncReferenceOrder::class, 'key' => 5]); + + $order->setConnection('testing'); + + expect($this->reference->reference($order))->toBe(['class' => AsyncReferenceOrder::class, 'key' => 5, 'connection' => 'testing']); +}); + +it('reloads the model in its current state, on its connection', function (?string $connection): void { + $order = AsyncReferenceOrder::create(['status' => 'paid']); + $identifier = $this->reference->reference($connection === null ? $order : $order->setConnection($connection)); + $order->update(['status' => 'refunded']); + + $reloaded = $this->reference->resolve($identifier); + + expect($reloaded)->toBeInstanceOf(AsyncReferenceOrder::class) + ->and($reloaded->status)->toBe('refunded'); +})->with(['default connection' => [null], 'named connection' => ['testing']]); + +it('returns null for a deleted model or a class that is not a model', function (array $identifier): void { + expect($this->reference->resolve($identifier))->toBeNull(); +})->with([ + 'deleted' => [['class' => AsyncReferenceOrder::class, 'key' => 999]], + 'not a model' => [['class' => stdClass::class, 'key' => 1]], + 'missing class' => [['key' => 1]], +]); diff --git a/tests/Feature/Hook/Async/QueueDriverTest.php b/tests/Feature/Hook/Async/QueueDriverTest.php new file mode 100644 index 00000000..4ac4d2f3 --- /dev/null +++ b/tests/Feature/Hook/Async/QueueDriverTest.php @@ -0,0 +1,85 @@ + 'database', + 'queue.connections.database' => ['driver' => 'database'], + 'queue.connections.redis' => ['driver' => 'redis'], + 'queue.connections.sync' => ['driver' => 'sync'], + 'queue.connections.null' => ['driver' => 'null'], + 'hooks.async.queue' => ['connection' => null, 'queue' => 'default'], + ]); +}); + +function queueDriver(): QueueDriver +{ + return new QueueDriver(app(Dispatcher::class), app('config')); +} + +function queuePayload(?string $queue = null): AsyncPayload +{ + return new AsyncPayload( + id: AsyncPayload::newId(), + hook: 'save_post', + handler: 'App\Hooks\Sync@handle', + priority: 10, + arguments: [42], + origin: ['userId' => 1, 'blogId' => 1, 'locale' => 'fr_FR', 'dispatchedAt' => '2026-10-07T10:00:00+00:00'], + queue: $queue, + ); +} + +describe('available()', function (): void { + it('is available on a real queue connection', function (string $connection): void { + config(['queue.default' => $connection]); + + expect(queueDriver()->available())->toBeTrue(); + })->with(['database', 'redis']); + + it('is never available on a connection that would run the handler in the request, or drop it', function (string $connection): void { + config(['queue.default' => $connection]); + + expect(queueDriver()->available())->toBeFalse(); + })->with(['sync', 'null', 'missing']); + + it('follows the connection set for async actions before the default one', function (): void { + config(['queue.default' => 'sync', 'hooks.async.queue.connection' => 'redis']); + + expect(queueDriver()->available())->toBeTrue(); + }); +}); + +describe('dispatch()', function (): void { + beforeEach(function (): void { + Bus::fake(); + }); + + it('dispatches a RunAsyncAction job carrying the payload, on the configured connection and queue', function (): void { + $payload = queuePayload(); + + queueDriver()->dispatch($payload); + + Bus::assertDispatched(RunAsyncAction::class, fn (RunAsyncAction $job): bool => $job->payload === $payload->toJson() + && $job->connection === 'database' + && $job->queue === 'default' + && $job->delay === null); + }); + + it('uses the queue name of the action and its delay', function (): void { + config(['hooks.async.queue.connection' => 'redis']); + + queueDriver()->dispatch(queuePayload('integrations'), 30); + + Bus::assertDispatched(RunAsyncAction::class, fn (RunAsyncAction $job): bool => $job->connection === 'redis' + && $job->queue === 'integrations' + && $job->delay === 30); + }); +}); diff --git a/tests/Feature/Hook/Async/RunAsyncActionTest.php b/tests/Feature/Hook/Async/RunAsyncActionTest.php new file mode 100644 index 00000000..f63ca9c2 --- /dev/null +++ b/tests/Feature/Hook/Async/RunAsyncActionTest.php @@ -0,0 +1,88 @@ +> */ + public static array $calls = []; + + public static bool $fails = false; + + public function handle(int $postId): void + { + if (self::$fails) { + throw new RuntimeException('CRM unreachable'); + } + + self::$calls[] = [$postId]; + } +} + +beforeEach(function (): void { + Functions\when('wp_cache_flush_runtime')->justReturn(true); + Async::flush(); + RunAsyncActionFixture::$calls = []; + RunAsyncActionFixture::$fails = false; + $this->incidents = []; + Async::reportUsing(function (string $message): void { + $this->incidents[] = $message; + }); +}); + +afterEach(function (): void { + Async::flush(); +}); + +function runAsyncActionPayload(string $handler = 'RunAsyncActionFixture@handle'): string +{ + return (new AsyncPayload( + id: AsyncPayload::newId(), + hook: 'save_post', + handler: $handler, + priority: 10, + arguments: [42], + origin: ['userId' => 1, 'blogId' => 1, 'locale' => 'fr_FR', 'dispatchedAt' => '2026-10-07T10:00:00+00:00'], + ))->toJson(); +} + +it('runs the handler, after forgetting what WordPress cached in memory', function (): void { + $flushes = 0; + Functions\when('wp_cache_flush_runtime')->alias(function () use (&$flushes): bool { + $flushes++; + + return true; + }); + + (new RunAsyncAction(runAsyncActionPayload()))->handle(); + + expect(RunAsyncActionFixture::$calls)->toBe([[42]]) + ->and($flushes)->toBe(1); +}); + +it('throws the last failure, so the job lands in the failed jobs table, without reporting it twice', function (): void { + RunAsyncActionFixture::$fails = true; + + expect(fn () => (new RunAsyncAction(runAsyncActionPayload()))->handle())->toThrow(RuntimeException::class, 'CRM unreachable'); + expect($this->incidents)->toBe([]); +}); + +it('is tried once: retries are new jobs, queued by the package after the backoff', function (): void { + expect((new RunAsyncAction(runAsyncActionPayload()))->tries)->toBe(1); +}); + +it('names itself after the hook and the handler', function (string $handler, string $name): void { + expect((new RunAsyncAction(runAsyncActionPayload($handler)))->displayName())->toBe($name); +})->with([ + 'class method' => ['RunAsyncActionFixture@handle', 'pollora/async save_post RunAsyncActionFixture@handle'], + 'closure' => ['closure:'.str_repeat('a', 64).':abc', 'pollora/async save_post closure'], +]); + +it('falls back to its class name for a payload it cannot read', function (): void { + expect((new RunAsyncAction('not json'))->displayName())->toBe(RunAsyncAction::class); +}); From 8a0fee9c27c8df11bdeec7d03d88c68d795bf138 Mon Sep 17 00:00:00 2001 From: Olivier Gorzalka Date: Wed, 7 Oct 2026 18:03:30 +0200 Subject: [PATCH 2/2] test: cover the async queue driver, job, model reference and provider --- CHANGELOG.md | 5 +++++ src/Hook/Infrastructure/Jobs/RunAsyncAction.php | 4 ++-- .../Hook/Async/AsyncServiceProviderTest.php | 3 +-- .../Hook/Async/EloquentModelReferenceTest.php | 15 +++++++-------- tests/Feature/Hook/Async/QueueDriverTest.php | 2 +- tests/Feature/Hook/Async/RunAsyncActionTest.php | 5 ++++- 6 files changed, 20 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb3955bd..015eb606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased](https://github.com/Pollora/framework/compare/v13.35.1...develop) +### Added +- Asynchronous actions in the framework (pollora/hook 1.4): `Action::add(...)->async()` can queue handlers as Laravel jobs. The `queue` driver dispatches a `RunAsyncAction` job on `hooks.async.queue.connection` (the default connection when null) and on the action's `onQueue()` or `hooks.async.queue.queue`. `auto`, the default, now tries the Laravel queue first, then Action Scheduler, then WP-Cron; a `sync` or `null` connection is always skipped. The job is tried once: retries are new jobs queued after the backoff, and the last failure lands in the failed jobs table. It also clears WordPress's in-memory cache before each handler, since a worker lives across jobs +- `config/hooks.php`, published with `php artisan vendor:publish --tag=pollora-hooks`: default driver (`HOOKS_ASYNC_DRIVER`, `sync` in a developer's `.env` runs every handler at once), attempts, backoff, `as_user`, queue connection and name +- Async handlers in the framework: debug mode follows `app.debug`, incidents go to the Laravel log, closures are signed with the application key, Eloquent models travel by class and key and are reloaded at execution, and parameters typed with a service are resolved from the container + ### Fixed - Login screen: the language switcher's label ("Language" and its icon, shown when a second language is installed) sat at the left edge of the window, far from its dropdown. The card style given to the login form also reached the switcher's form and made it a full-width block; it is an inline block again, as in WordPress's own sheet - Login screen: the rules for the buttons were written `.pollora-login .wp-core-ui …`, but both classes are on ``, so they never matched. The language switcher's button kept wp-admin's blue instead of the theme's primary colour, and the submit button kept WordPress's `button-large` padding diff --git a/src/Hook/Infrastructure/Jobs/RunAsyncAction.php b/src/Hook/Infrastructure/Jobs/RunAsyncAction.php index 7ac92f33..62f58fad 100644 --- a/src/Hook/Infrastructure/Jobs/RunAsyncAction.php +++ b/src/Hook/Infrastructure/Jobs/RunAsyncAction.php @@ -6,6 +6,7 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Queue\Attributes\Tries; use Illuminate\Queue\InteractsWithQueue; use Pollora\Hook\Async\Async; @@ -16,13 +17,12 @@ * the job itself is tried once; the last failure is thrown, which lands it in * the failed jobs table. */ +#[Tries(1)] final class RunAsyncAction implements ShouldQueue { use InteractsWithQueue; use Queueable; - public int $tries = 1; - /** * @param string $payload The payload, as JSON */ diff --git a/tests/Feature/Hook/Async/AsyncServiceProviderTest.php b/tests/Feature/Hook/Async/AsyncServiceProviderTest.php index ac81bd12..5dae74fd 100644 --- a/tests/Feature/Hook/Async/AsyncServiceProviderTest.php +++ b/tests/Feature/Hook/Async/AsyncServiceProviderTest.php @@ -6,7 +6,6 @@ use Illuminate\Support\ServiceProvider; use Pollora\Hook\Async\Async; use Pollora\Hook\Async\AsyncFake; -use Pollora\Hook\Async\AsyncPayload; use Pollora\Hook\Async\Exceptions\UnresolvableHandler; use Pollora\Hook\Async\PendingAsync; use Pollora\Hook\Async\QueuedHandler; @@ -85,7 +84,7 @@ function bootAsyncProvider(array $config = []): void it('does not sign closures with an empty application key', function (): void { bootAsyncProvider(['app.key' => '']); - expect(fn () => Async::closureKey())->toThrow(UnresolvableHandler::class, 'without a signing key'); + expect(fn (): string => Async::closureKey())->toThrow(UnresolvableHandler::class, 'without a signing key'); }); it('sends incidents to the Laravel log, as errors when they carry an exception', function (): void { diff --git a/tests/Feature/Hook/Async/EloquentModelReferenceTest.php b/tests/Feature/Hook/Async/EloquentModelReferenceTest.php index b3ac0760..fd97d6b3 100644 --- a/tests/Feature/Hook/Async/EloquentModelReferenceTest.php +++ b/tests/Feature/Hook/Async/EloquentModelReferenceTest.php @@ -2,19 +2,18 @@ declare(strict_types=1); +use Illuminate\Database\Eloquent\Attributes\Table; +use Illuminate\Database\Eloquent\Attributes\Unguarded; +use Illuminate\Database\Eloquent\Attributes\WithoutTimestamps; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; use Pollora\Hook\Infrastructure\Async\EloquentModelReference; -final class AsyncReferenceOrder extends Model -{ - public $timestamps = false; - - protected $table = 'async_reference_orders'; - - protected $guarded = []; -} +#[Unguarded] +#[Table(name: 'async_reference_orders')] +#[WithoutTimestamps] +final class AsyncReferenceOrder extends Model {} beforeEach(function (): void { config(['database.default' => 'testing', 'database.connections.testing' => ['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => '']]); diff --git a/tests/Feature/Hook/Async/QueueDriverTest.php b/tests/Feature/Hook/Async/QueueDriverTest.php index 4ac4d2f3..14e48fba 100644 --- a/tests/Feature/Hook/Async/QueueDriverTest.php +++ b/tests/Feature/Hook/Async/QueueDriverTest.php @@ -21,7 +21,7 @@ function queueDriver(): QueueDriver { - return new QueueDriver(app(Dispatcher::class), app('config')); + return new QueueDriver(resolve(Dispatcher::class), resolve('config')); } function queuePayload(?string $queue = null): AsyncPayload diff --git a/tests/Feature/Hook/Async/RunAsyncActionTest.php b/tests/Feature/Hook/Async/RunAsyncActionTest.php index f63ca9c2..f53cafb7 100644 --- a/tests/Feature/Hook/Async/RunAsyncActionTest.php +++ b/tests/Feature/Hook/Async/RunAsyncActionTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Brain\Monkey\Functions; +use Illuminate\Queue\Attributes\Tries; use Pollora\Hook\Async\Async; use Pollora\Hook\Async\AsyncPayload; use Pollora\Hook\Infrastructure\Jobs\RunAsyncAction; @@ -73,7 +74,9 @@ function runAsyncActionPayload(string $handler = 'RunAsyncActionFixture@handle') }); it('is tried once: retries are new jobs, queued by the package after the backoff', function (): void { - expect((new RunAsyncAction(runAsyncActionPayload()))->tries)->toBe(1); + $tries = (new ReflectionClass(RunAsyncAction::class))->getAttributes(Tries::class)[0]->newInstance(); + + expect($tries->tries)->toBe(1); }); it('names itself after the hook and the handler', function (string $handler, string $name): void {