feat(webhook): add WebhookCall model, migration, event, verifier interface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a0a0727c88
commit
39ede84d0e
5 changed files with 226 additions and 0 deletions
|
|
@ -0,0 +1,31 @@
|
|||
<?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');
|
||||
}
|
||||
};
|
||||
58
src/Core/Webhook/WebhookCall.php
Normal file
58
src/Core/Webhook/WebhookCall.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?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()]);
|
||||
}
|
||||
}
|
||||
20
src/Core/Webhook/WebhookReceived.php
Normal file
20
src/Core/Webhook/WebhookReceived.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?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,
|
||||
) {}
|
||||
}
|
||||
24
src/Core/Webhook/WebhookVerifier.php
Normal file
24
src/Core/Webhook/WebhookVerifier.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?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.
|
||||
*/
|
||||
public function verify(Request $request, string $secret): bool;
|
||||
}
|
||||
93
tests/Feature/WebhookCallTest.php
Normal file
93
tests/Feature/WebhookCallTest.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Tests\Feature;
|
||||
|
||||
use Core\Tests\TestCase;
|
||||
use Core\Webhook\WebhookCall;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
class WebhookCallTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function defineDatabaseMigrations(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue