From 7db3637985de120bc12f5fe1ed65d0c9f3c2ede3 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 12 Mar 2026 14:26:58 +0000 Subject: [PATCH] =?UTF-8?q?feat(webhook):=20add=20CronTrigger=20scheduled?= =?UTF-8?q?=20action=20=E2=80=94=20replaces=204=20Docker=20cron=20containe?= =?UTF-8?q?rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/Core/Webhook/CronTrigger.php | 56 ++++++++++++++++ tests/Feature/CronTriggerTest.php | 102 ++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 src/Core/Webhook/CronTrigger.php create mode 100644 tests/Feature/CronTriggerTest.php diff --git a/src/Core/Webhook/CronTrigger.php b/src/Core/Webhook/CronTrigger.php new file mode 100644 index 0000000..ea7d111 --- /dev/null +++ b/src/Core/Webhook/CronTrigger.php @@ -0,0 +1,56 @@ + $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()}"); + } + } + } + } +} diff --git a/tests/Feature/CronTriggerTest.php b/tests/Feature/CronTriggerTest.php new file mode 100644 index 0000000..ad0831b --- /dev/null +++ b/tests/Feature/CronTriggerTest.php @@ -0,0 +1,102 @@ +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); + } +}