docs: add Core\Webhook implementation plan (8 tasks)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0a12a6f32a
commit
bdc617d48e
1 changed files with 948 additions and 0 deletions
948
docs/plans/2026-03-12-core-webhook-plan.md
Normal file
948
docs/plans/2026-03-12-core-webhook-plan.md
Normal file
|
|
@ -0,0 +1,948 @@
|
|||
# Core\Webhook Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add framework-level webhook infrastructure to core/php — append-only inbound log, config-driven outbound cron triggers, replaces 4 AltumCode Docker cron containers.
|
||||
|
||||
**Architecture:** `Core\Webhook` namespace in `src/Core/Webhook/`. One migration, one model, one controller, one lifecycle event, one scheduled action for cron triggers, one verifier interface. Modules subscribe to `WebhookReceived` for awareness; they query the table for processing on their own schedule. No inline processing — ever.
|
||||
|
||||
**Tech Stack:** Laravel 12, Pest testing, Eloquent (ULID keys), `#[Scheduled]` attribute, lifecycle events (`ApiRoutesRegistering`), HTTP client for outbound triggers.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Migration — `webhook_calls` table
|
||||
|
||||
**Files:**
|
||||
- Create: `database/migrations/2026_03_12_000001_create_webhook_calls_table.php`
|
||||
|
||||
**Step 1: Create the migration**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('webhook_calls', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('source', 64)->index();
|
||||
$table->string('event_type', 128)->nullable();
|
||||
$table->json('headers');
|
||||
$table->json('payload');
|
||||
$table->boolean('signature_valid')->nullable();
|
||||
$table->timestamp('processed_at')->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['source', 'processed_at', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('webhook_calls');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Step 2: Verify**
|
||||
|
||||
```bash
|
||||
cd /Users/snider/Code/core/php
|
||||
head -5 database/migrations/2026_03_12_000001_create_webhook_calls_table.php
|
||||
```
|
||||
|
||||
Expected: `declare(strict_types=1)` and `use Illuminate\Database\Migrations\Migration`.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add database/migrations/2026_03_12_000001_create_webhook_calls_table.php
|
||||
git commit -m "feat(webhook): add webhook_calls migration — append-only inbound log"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Model — `WebhookCall`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/Webhook/WebhookCall.php`
|
||||
- Test: `tests/Feature/WebhookCallTest.php`
|
||||
|
||||
**Step 1: Write the test**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Tests\Feature;
|
||||
|
||||
use Core\Webhook\WebhookCall;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Core\Tests\TestCase;
|
||||
|
||||
class WebhookCallTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_create_webhook_call(): void
|
||||
{
|
||||
$call = WebhookCall::create([
|
||||
'source' => 'altum-biolinks',
|
||||
'event_type' => 'link.created',
|
||||
'headers' => ['webhook-id' => 'abc123'],
|
||||
'payload' => ['type' => 'link.created', 'data' => ['id' => 1]],
|
||||
]);
|
||||
|
||||
$this->assertNotNull($call->id);
|
||||
$this->assertSame('altum-biolinks', $call->source);
|
||||
$this->assertSame('link.created', $call->event_type);
|
||||
$this->assertIsArray($call->headers);
|
||||
$this->assertIsArray($call->payload);
|
||||
$this->assertNull($call->signature_valid);
|
||||
$this->assertNull($call->processed_at);
|
||||
}
|
||||
|
||||
public function test_unprocessed_scope(): void
|
||||
{
|
||||
WebhookCall::create([
|
||||
'source' => 'stripe',
|
||||
'headers' => [],
|
||||
'payload' => ['type' => 'invoice.paid'],
|
||||
]);
|
||||
|
||||
WebhookCall::create([
|
||||
'source' => 'stripe',
|
||||
'headers' => [],
|
||||
'payload' => ['type' => 'invoice.created'],
|
||||
'processed_at' => now(),
|
||||
]);
|
||||
|
||||
$unprocessed = WebhookCall::unprocessed()->get();
|
||||
$this->assertCount(1, $unprocessed);
|
||||
$this->assertSame('invoice.paid', $unprocessed->first()->payload['type']);
|
||||
}
|
||||
|
||||
public function test_for_source_scope(): void
|
||||
{
|
||||
WebhookCall::create(['source' => 'stripe', 'headers' => [], 'payload' => []]);
|
||||
WebhookCall::create(['source' => 'altum-biolinks', 'headers' => [], 'payload' => []]);
|
||||
|
||||
$this->assertCount(1, WebhookCall::forSource('stripe')->get());
|
||||
$this->assertCount(1, WebhookCall::forSource('altum-biolinks')->get());
|
||||
}
|
||||
|
||||
public function test_mark_processed(): void
|
||||
{
|
||||
$call = WebhookCall::create([
|
||||
'source' => 'test',
|
||||
'headers' => [],
|
||||
'payload' => [],
|
||||
]);
|
||||
|
||||
$this->assertNull($call->processed_at);
|
||||
|
||||
$call->markProcessed();
|
||||
|
||||
$this->assertNotNull($call->fresh()->processed_at);
|
||||
}
|
||||
|
||||
public function test_signature_valid_is_nullable_boolean(): void
|
||||
{
|
||||
$call = WebhookCall::create([
|
||||
'source' => 'test',
|
||||
'headers' => [],
|
||||
'payload' => [],
|
||||
'signature_valid' => false,
|
||||
]);
|
||||
|
||||
$this->assertFalse($call->signature_valid);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
cd /Users/snider/Code/core/php
|
||||
vendor/bin/phpunit tests/Feature/WebhookCallTest.php
|
||||
```
|
||||
|
||||
Expected: FAIL — class `Core\Webhook\WebhookCall` not found.
|
||||
|
||||
**Step 3: Create the model**
|
||||
|
||||
```php
|
||||
<?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 Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class WebhookCall extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'source',
|
||||
'event_type',
|
||||
'headers',
|
||||
'payload',
|
||||
'signature_valid',
|
||||
'processed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'headers' => 'array',
|
||||
'payload' => 'array',
|
||||
'signature_valid' => 'boolean',
|
||||
'processed_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function scopeUnprocessed(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNull('processed_at');
|
||||
}
|
||||
|
||||
public function scopeForSource(Builder $query, string $source): Builder
|
||||
{
|
||||
return $query->where('source', $source);
|
||||
}
|
||||
|
||||
public function markProcessed(): void
|
||||
{
|
||||
$this->update(['processed_at' => now()]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
```bash
|
||||
vendor/bin/phpunit tests/Feature/WebhookCallTest.php
|
||||
```
|
||||
|
||||
Expected: All 5 tests pass.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Core/Webhook/WebhookCall.php tests/Feature/WebhookCallTest.php
|
||||
git commit -m "feat(webhook): add WebhookCall model — ULID, scopes, markProcessed"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Lifecycle event — `WebhookReceived`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/Webhook/WebhookReceived.php`
|
||||
|
||||
**Step 1: Create the event**
|
||||
|
||||
This is a simple value object. It carries only the source tag and call ID — never the payload. Modules subscribe to it for lightweight awareness only.
|
||||
|
||||
```php
|
||||
<?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;
|
||||
|
||||
class WebhookReceived
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $source,
|
||||
public readonly string $callId,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Core/Webhook/WebhookReceived.php
|
||||
git commit -m "feat(webhook): add WebhookReceived event — source + callId only"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Verifier interface — `WebhookVerifier`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/Webhook/WebhookVerifier.php`
|
||||
|
||||
**Step 1: Create the interface**
|
||||
|
||||
```php
|
||||
<?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 Illuminate\Http\Request;
|
||||
|
||||
interface WebhookVerifier
|
||||
{
|
||||
/**
|
||||
* Verify the webhook signature.
|
||||
*
|
||||
* Returns true if valid, false if invalid.
|
||||
* Implementations should check headers like webhook-signature against
|
||||
* the raw request body using the provided secret.
|
||||
*/
|
||||
public function verify(Request $request, string $secret): bool;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Core/Webhook/WebhookVerifier.php
|
||||
git commit -m "feat(webhook): add WebhookVerifier interface — per-source signature check"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Controller — `WebhookController`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/Webhook/WebhookController.php`
|
||||
- Test: `tests/Feature/WebhookControllerTest.php`
|
||||
|
||||
**Step 1: Write the test**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Tests\Feature;
|
||||
|
||||
use Core\Webhook\WebhookCall;
|
||||
use Core\Webhook\WebhookReceived;
|
||||
use Core\Webhook\WebhookVerifier;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Core\Tests\TestCase;
|
||||
|
||||
class WebhookControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function defineRoutes($router): void
|
||||
{
|
||||
$router->post('/webhooks/{source}', [\Core\Webhook\WebhookController::class, 'handle']);
|
||||
}
|
||||
|
||||
public function test_stores_webhook_call(): void
|
||||
{
|
||||
$response = $this->postJson('/webhooks/altum-biolinks', [
|
||||
'type' => 'link.created',
|
||||
'data' => ['id' => 42],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$call = WebhookCall::first();
|
||||
$this->assertNotNull($call);
|
||||
$this->assertSame('altum-biolinks', $call->source);
|
||||
$this->assertSame(['type' => 'link.created', 'data' => ['id' => 42]], $call->payload);
|
||||
$this->assertNull($call->processed_at);
|
||||
}
|
||||
|
||||
public function test_captures_headers(): void
|
||||
{
|
||||
$this->postJson('/webhooks/stripe', ['type' => 'invoice.paid'], [
|
||||
'Webhook-Id' => 'msg_abc123',
|
||||
'Webhook-Timestamp' => '1234567890',
|
||||
]);
|
||||
|
||||
$call = WebhookCall::first();
|
||||
$this->assertArrayHasKey('webhook-id', $call->headers);
|
||||
}
|
||||
|
||||
public function test_fires_webhook_received_event(): void
|
||||
{
|
||||
Event::fake([WebhookReceived::class]);
|
||||
|
||||
$this->postJson('/webhooks/altum-biolinks', ['type' => 'test']);
|
||||
|
||||
Event::assertDispatched(WebhookReceived::class, function ($event) {
|
||||
return $event->source === 'altum-biolinks' && ! empty($event->callId);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_extracts_event_type_from_payload(): void
|
||||
{
|
||||
$this->postJson('/webhooks/stripe', ['type' => 'invoice.paid']);
|
||||
|
||||
$this->assertSame('invoice.paid', WebhookCall::first()->event_type);
|
||||
}
|
||||
|
||||
public function test_handles_empty_payload(): void
|
||||
{
|
||||
$response = $this->postJson('/webhooks/test', []);
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(1, WebhookCall::all());
|
||||
}
|
||||
|
||||
public function test_signature_valid_null_when_no_verifier(): void
|
||||
{
|
||||
$this->postJson('/webhooks/unknown-source', ['data' => 1]);
|
||||
|
||||
$this->assertNull(WebhookCall::first()->signature_valid);
|
||||
}
|
||||
|
||||
public function test_signature_verified_when_verifier_registered(): void
|
||||
{
|
||||
$verifier = new class implements WebhookVerifier {
|
||||
public function verify(Request $request, string $secret): bool
|
||||
{
|
||||
return $request->header('webhook-signature') === 'valid';
|
||||
}
|
||||
};
|
||||
|
||||
$this->app->instance('webhook.verifier.test-source', $verifier);
|
||||
$this->app['config']->set('webhook.secrets.test-source', 'test-secret');
|
||||
|
||||
$this->postJson('/webhooks/test-source', ['data' => 1], [
|
||||
'Webhook-Signature' => 'valid',
|
||||
]);
|
||||
|
||||
$this->assertTrue(WebhookCall::first()->signature_valid);
|
||||
}
|
||||
|
||||
public function test_signature_invalid_still_stores_call(): void
|
||||
{
|
||||
$verifier = new class implements WebhookVerifier {
|
||||
public function verify(Request $request, string $secret): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
$this->app->instance('webhook.verifier.test-source', $verifier);
|
||||
$this->app['config']->set('webhook.secrets.test-source', 'test-secret');
|
||||
|
||||
$this->postJson('/webhooks/test-source', ['data' => 1]);
|
||||
|
||||
$call = WebhookCall::first();
|
||||
$this->assertNotNull($call);
|
||||
$this->assertFalse($call->signature_valid);
|
||||
}
|
||||
|
||||
public function test_source_is_sanitised(): void
|
||||
{
|
||||
$response = $this->postJson('/webhooks/valid-source-123', ['data' => 1]);
|
||||
$response->assertOk();
|
||||
|
||||
$response = $this->postJson('/webhooks/invalid source!', ['data' => 1]);
|
||||
$response->assertStatus(404);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
vendor/bin/phpunit tests/Feature/WebhookControllerTest.php
|
||||
```
|
||||
|
||||
Expected: FAIL — class `Core\Webhook\WebhookController` not found.
|
||||
|
||||
**Step 3: Create the controller**
|
||||
|
||||
```php
|
||||
<?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 Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WebhookController
|
||||
{
|
||||
public function handle(Request $request, string $source): JsonResponse
|
||||
{
|
||||
$signatureValid = null;
|
||||
|
||||
// Check for registered verifier
|
||||
$verifier = app()->bound("webhook.verifier.{$source}")
|
||||
? app("webhook.verifier.{$source}")
|
||||
: null;
|
||||
|
||||
if ($verifier instanceof WebhookVerifier) {
|
||||
$secret = config("webhook.secrets.{$source}", '');
|
||||
$signatureValid = $verifier->verify($request, $secret);
|
||||
}
|
||||
|
||||
// Extract event type from common payload patterns
|
||||
$payload = $request->json()->all();
|
||||
$eventType = $payload['type'] ?? $payload['event_type'] ?? $payload['event'] ?? null;
|
||||
|
||||
$call = WebhookCall::create([
|
||||
'source' => $source,
|
||||
'event_type' => is_string($eventType) ? $eventType : null,
|
||||
'headers' => $request->headers->all(),
|
||||
'payload' => $payload,
|
||||
'signature_valid' => $signatureValid,
|
||||
]);
|
||||
|
||||
event(new WebhookReceived($source, $call->id));
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
```bash
|
||||
vendor/bin/phpunit tests/Feature/WebhookControllerTest.php
|
||||
```
|
||||
|
||||
Expected: All 9 tests pass.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Core/Webhook/WebhookController.php tests/Feature/WebhookControllerTest.php
|
||||
git commit -m "feat(webhook): add WebhookController — store, verify, fire event, return 200"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Config + route registration
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/Webhook/config.php`
|
||||
- Create: `src/Core/Webhook/Boot.php`
|
||||
|
||||
**Step 1: Create the config**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Webhook Secrets
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Per-source signing secrets for signature verification.
|
||||
| Modules register WebhookVerifier implementations per source.
|
||||
|
|
||||
*/
|
||||
'secrets' => [
|
||||
'altum-biolinks' => env('ALTUM_BIOLINKS_WEBHOOK_SECRET'),
|
||||
'altum-analytics' => env('ALTUM_ANALYTICS_WEBHOOK_SECRET'),
|
||||
'altum-pusher' => env('ALTUM_PUSHER_WEBHOOK_SECRET'),
|
||||
'altum-socialproof' => env('ALTUM_SOCIALPROOF_WEBHOOK_SECRET'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cron Triggers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Outbound HTTP triggers that replace Docker cron containers.
|
||||
| The CronTrigger action hits these endpoints every minute.
|
||||
|
|
||||
*/
|
||||
'cron_triggers' => [
|
||||
'altum-biolinks' => [
|
||||
'base_url' => env('ALTUM_BIOLINKS_URL'),
|
||||
'key' => env('ALTUM_BIOLINKS_CRON_KEY'),
|
||||
'endpoints' => ['/cron', '/cron/email_reports', '/cron/broadcasts', '/cron/push_notifications'],
|
||||
'stagger_seconds' => 15,
|
||||
'offset_seconds' => 5,
|
||||
],
|
||||
'altum-analytics' => [
|
||||
'base_url' => env('ALTUM_ANALYTICS_URL'),
|
||||
'key' => env('ALTUM_ANALYTICS_CRON_KEY'),
|
||||
'endpoints' => ['/cron', '/cron/email_reports', '/cron/broadcasts', '/cron/push_notifications'],
|
||||
'stagger_seconds' => 15,
|
||||
'offset_seconds' => 0,
|
||||
],
|
||||
'altum-pusher' => [
|
||||
'base_url' => env('ALTUM_PUSHER_URL'),
|
||||
'key' => env('ALTUM_PUSHER_CRON_KEY'),
|
||||
'endpoints' => [
|
||||
'/cron/reset', '/cron/broadcasts', '/cron/campaigns',
|
||||
'/cron/flows', '/cron/flows_notifications', '/cron/personal_notifications',
|
||||
'/cron/rss_automations', '/cron/recurring_campaigns', '/cron/push_notifications',
|
||||
],
|
||||
'stagger_seconds' => 7,
|
||||
'offset_seconds' => 7,
|
||||
],
|
||||
'altum-socialproof' => [
|
||||
'base_url' => env('ALTUM_SOCIALPROOF_URL'),
|
||||
'key' => env('ALTUM_SOCIALPROOF_CRON_KEY'),
|
||||
'endpoints' => ['/cron', '/cron/email_reports', '/cron/broadcasts', '/cron/push_notifications'],
|
||||
'stagger_seconds' => 15,
|
||||
'offset_seconds' => 10,
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
**Step 2: Create Boot.php**
|
||||
|
||||
```php
|
||||
<?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\Events\ApiRoutesRegistering;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class Boot extends ServiceProvider
|
||||
{
|
||||
public static array $listens = [
|
||||
ApiRoutesRegistering::class => 'onApiRoutes',
|
||||
];
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(__DIR__.'/config.php', 'webhook');
|
||||
}
|
||||
|
||||
public function onApiRoutes(ApiRoutesRegistering $event): void
|
||||
{
|
||||
$event->routes(fn () => Route::post(
|
||||
'/webhooks/{source}',
|
||||
[WebhookController::class, 'handle']
|
||||
)->where('source', '[a-z0-9\-]+'));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Core/Webhook/config.php src/Core/Webhook/Boot.php
|
||||
git commit -m "feat(webhook): add config + Boot — route registration, cron trigger config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: CronTrigger scheduled action
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Core/Webhook/CronTrigger.php`
|
||||
- Test: `tests/Feature/CronTriggerTest.php`
|
||||
|
||||
**Step 1: Write the test**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Tests\Feature;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Actions\Scheduled;
|
||||
use Core\Webhook\CronTrigger;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Core\Tests\TestCase;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
vendor/bin/phpunit tests/Feature/CronTriggerTest.php
|
||||
```
|
||||
|
||||
Expected: FAIL — class `Core\Webhook\CronTrigger` not found.
|
||||
|
||||
**Step 3: Create the action**
|
||||
|
||||
```php
|
||||
<?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()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests**
|
||||
|
||||
```bash
|
||||
vendor/bin/phpunit tests/Feature/CronTriggerTest.php
|
||||
```
|
||||
|
||||
Expected: All 6 tests pass.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Core/Webhook/CronTrigger.php tests/Feature/CronTriggerTest.php
|
||||
git commit -m "feat(webhook): add CronTrigger action — replaces 4 Docker cron containers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final verification
|
||||
|
||||
**Step 1: Verify file structure**
|
||||
|
||||
```bash
|
||||
cd /Users/snider/Code/core/php
|
||||
find src/Core/Webhook/ -type f | sort
|
||||
find database/migrations/ -name '*webhook*' | sort
|
||||
find tests/Feature/ -name '*Webhook*' -o -name '*CronTrigger*' | sort
|
||||
```
|
||||
|
||||
Expected:
|
||||
```
|
||||
src/Core/Webhook/Boot.php
|
||||
src/Core/Webhook/CronTrigger.php
|
||||
src/Core/Webhook/WebhookCall.php
|
||||
src/Core/Webhook/WebhookController.php
|
||||
src/Core/Webhook/WebhookReceived.php
|
||||
src/Core/Webhook/WebhookVerifier.php
|
||||
src/Core/Webhook/config.php
|
||||
database/migrations/2026_03_12_000001_create_webhook_calls_table.php
|
||||
tests/Feature/WebhookCallTest.php
|
||||
tests/Feature/WebhookControllerTest.php
|
||||
tests/Feature/CronTriggerTest.php
|
||||
```
|
||||
|
||||
**Step 2: Run all webhook tests**
|
||||
|
||||
```bash
|
||||
vendor/bin/phpunit tests/Feature/WebhookCallTest.php tests/Feature/WebhookControllerTest.php tests/Feature/CronTriggerTest.php
|
||||
```
|
||||
|
||||
Expected: All tests pass (5 + 9 + 6 = 20 tests).
|
||||
|
||||
**Step 3: Run lint**
|
||||
|
||||
```bash
|
||||
./vendor/bin/pint --test src/Core/Webhook/ tests/Feature/WebhookCallTest.php tests/Feature/WebhookControllerTest.php tests/Feature/CronTriggerTest.php
|
||||
```
|
||||
|
||||
Expected: Clean.
|
||||
|
||||
**Step 4: Verify strict types in all files**
|
||||
|
||||
```bash
|
||||
grep -rL 'declare(strict_types=1)' src/Core/Webhook/ database/migrations/*webhook*
|
||||
```
|
||||
|
||||
Expected: No output (all files have strict types).
|
||||
|
||||
**Step 5: Final commit if any lint fixes needed**
|
||||
|
||||
```bash
|
||||
git add -A src/Core/Webhook/ tests/Feature/ database/migrations/
|
||||
git status
|
||||
```
|
||||
|
||||
If clean, done. If fixes needed, commit them.
|
||||
Loading…
Add table
Reference in a new issue