From 39ede84d0ef9a1e6e47e625c373ff477e1ba95c4 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 12 Mar 2026 14:21:20 +0000 Subject: [PATCH] feat(webhook): add WebhookCall model, migration, event, verifier interface Co-Authored-By: Claude Opus 4.6 --- ...3_12_000001_create_webhook_calls_table.php | 31 +++++++ src/Core/Webhook/WebhookCall.php | 58 ++++++++++++ src/Core/Webhook/WebhookReceived.php | 20 ++++ src/Core/Webhook/WebhookVerifier.php | 24 +++++ tests/Feature/WebhookCallTest.php | 93 +++++++++++++++++++ 5 files changed, 226 insertions(+) create mode 100644 database/migrations/2026_03_12_000001_create_webhook_calls_table.php create mode 100644 src/Core/Webhook/WebhookCall.php create mode 100644 src/Core/Webhook/WebhookReceived.php create mode 100644 src/Core/Webhook/WebhookVerifier.php create mode 100644 tests/Feature/WebhookCallTest.php diff --git a/database/migrations/2026_03_12_000001_create_webhook_calls_table.php b/database/migrations/2026_03_12_000001_create_webhook_calls_table.php new file mode 100644 index 0000000..f203b1c --- /dev/null +++ b/database/migrations/2026_03_12_000001_create_webhook_calls_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/src/Core/Webhook/WebhookCall.php b/src/Core/Webhook/WebhookCall.php new file mode 100644 index 0000000..20922ad --- /dev/null +++ b/src/Core/Webhook/WebhookCall.php @@ -0,0 +1,58 @@ + '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()]); + } +} diff --git a/src/Core/Webhook/WebhookReceived.php b/src/Core/Webhook/WebhookReceived.php new file mode 100644 index 0000000..b61228b --- /dev/null +++ b/src/Core/Webhook/WebhookReceived.php @@ -0,0 +1,20 @@ +loadMigrationsFrom(__DIR__.'/../../database/migrations'); + } + + 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); + } +}