feat(webhook): add CronTrigger scheduled action — replaces 4 Docker cron containers
Some checks failed
CI / PHP 8.3 (push) Failing after 2m9s
CI / PHP 8.4 (push) Failing after 2m14s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-12 14:26:58 +00:00
parent 0e038ff350
commit 7db3637985
2 changed files with 158 additions and 0 deletions

View file

@ -0,0 +1,56 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Webhook;
use Core\Actions\Action;
use Core\Actions\Scheduled;
use Illuminate\Support\Facades\Http;
#[Scheduled(frequency: 'everyMinute', withoutOverlapping: true, runInBackground: true)]
class CronTrigger
{
use Action;
public function handle(): void
{
$triggers = config('webhook.cron_triggers', []);
foreach ($triggers as $product => $config) {
if (empty($config['base_url'])) {
continue;
}
$baseUrl = rtrim($config['base_url'], '/');
$key = $config['key'] ?? '';
$stagger = (int) ($config['stagger_seconds'] ?? 0);
$offset = (int) ($config['offset_seconds'] ?? 0);
if ($offset > 0) {
usleep($offset * 1_000_000);
}
foreach ($config['endpoints'] ?? [] as $i => $endpoint) {
if ($i > 0 && $stagger > 0) {
usleep($stagger * 1_000_000);
}
$url = $baseUrl . $endpoint . '?key=' . $key;
try {
Http::timeout(30)->get($url);
} catch (\Throwable $e) {
logger()->warning("Cron trigger failed for {$product}{$endpoint}: {$e->getMessage()}");
}
}
}
}
}

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace Core\Tests\Feature;
use Core\Actions\Action;
use Core\Actions\Scheduled;
use Core\Tests\TestCase;
use Core\Webhook\CronTrigger;
use Illuminate\Support\Facades\Http;
class CronTriggerTest extends TestCase
{
public function test_has_scheduled_attribute(): void
{
$ref = new \ReflectionClass(CronTrigger::class);
$attrs = $ref->getAttributes(Scheduled::class);
$this->assertCount(1, $attrs);
$this->assertSame('everyMinute', $attrs[0]->newInstance()->frequency);
}
public function test_uses_action_trait(): void
{
$this->assertTrue(
in_array(Action::class, class_uses_recursive(CronTrigger::class), true)
);
}
public function test_hits_configured_endpoints(): void
{
Http::fake();
config(['webhook.cron_triggers' => [
'test-product' => [
'base_url' => 'https://example.com',
'key' => 'secret123',
'endpoints' => ['/cron', '/cron/reports'],
'stagger_seconds' => 0,
'offset_seconds' => 0,
],
]]);
CronTrigger::run();
Http::assertSentCount(2);
Http::assertSent(fn ($request) => str_contains($request->url(), '/cron?key=secret123'));
Http::assertSent(fn ($request) => str_contains($request->url(), '/cron/reports?key=secret123'));
}
public function test_skips_product_with_no_base_url(): void
{
Http::fake();
config(['webhook.cron_triggers' => [
'disabled-product' => [
'base_url' => null,
'key' => 'secret',
'endpoints' => ['/cron'],
'stagger_seconds' => 0,
'offset_seconds' => 0,
],
]]);
CronTrigger::run();
Http::assertSentCount(0);
}
public function test_logs_failures_gracefully(): void
{
Http::fake([
'*' => Http::response('error', 500),
]);
config(['webhook.cron_triggers' => [
'failing-product' => [
'base_url' => 'https://broken.example.com',
'key' => 'key',
'endpoints' => ['/cron'],
'stagger_seconds' => 0,
'offset_seconds' => 0,
],
]]);
// Should not throw
CronTrigger::run();
Http::assertSentCount(1);
}
public function test_handles_empty_config(): void
{
Http::fake();
config(['webhook.cron_triggers' => []]);
CronTrigger::run();
Http::assertSentCount(0);
}
}