Compare commits

...

7 Commits

Author SHA1 Message Date
Vehikl
2da5666ce9 Trying to simplify ProcessWebhook for testing without breaking custom discord stuff 2025-09-18 17:21:45 -04:00
Vehikl
e4221bc606 WIP fixing tests to work with new relation 2025-09-04 17:11:25 -04:00
Vehikl
00889a8004 Merge branch 'main' into vehikl/make-webhooks-normalized 2025-08-28 15:44:08 -04:00
Vehikl
c948314e21 wip 2025-08-21 17:24:22 -04:00
Vehikl
dd2fdd15a1 Merge branch 'main' into vehikl/make-webhooks-normalized 2025-08-21 15:51:58 -04:00
Vehikl
59f4679fd1 move webhook tests into unit tests 2025-07-24 16:53:56 -04:00
Vehikl
b838c87af7 fix failing feature webhook tests 2025-07-24 16:52:21 -04:00
11 changed files with 212 additions and 49 deletions

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Admin\Resources;
use App\Filament\Admin\Resources\WebhookResource\Pages;
use App\Filament\Admin\Resources\WebhookResource\Pages\EditWebhookConfiguration;
use App\Filament\Admin\Resources\WebhookResource\RelationManagers\EventsRelationManager;
use App\Livewire\AlertBanner;
use App\Models\WebhookConfiguration;
use App\Traits\Filament\CanCustomizePages;
@@ -23,6 +24,7 @@ use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Form;
use Filament\Resources\Pages\PageRegistration;
use Filament\Forms\Get;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Resources\Resource;
use Filament\Forms\Set;
use Filament\Tables\Actions\DeleteBulkAction;
@@ -161,17 +163,21 @@ class WebhookResource extends Resource
->view('filament.components.webhooksection')
->aside()
->formBefore(),
Section::make(trans('admin/webhook.events'))
/*Section::make(trans('admin/webhook.events'))
->schema([
CheckboxList::make('events')
->live()
->options(fn () => WebhookConfiguration::filamentCheckboxList())
->searchable()
->bulkToggleable()
->columns(3)
->columnSpanFull()
->required(),
]),
CheckboxList::make('webhookEvents')
->relationship('webhookEvents')
->live()
->options(fn () => WebhookConfiguration::filamentCheckboxList())
->searchable()
->bulkToggleable()
->columns(3)
->columnSpanFull()
->required()
->before(function (array $state) {
dd($state);
}),
]),*/
]);
}
@@ -343,4 +349,12 @@ class WebhookResource extends Resource
'edit' => Pages\EditWebhookConfiguration::route('/{record}/edit'),
];
}
/** @return class-string<RelationManager>[] */
public static function getDefaultRelations(): array
{
return [
EventsRelationManager::class,
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Filament\Admin\Resources\WebhookResource\RelationManagers;
use App\Models\WebhookConfiguration;
use App\Models\WebhookEvent;
use Filament\Forms\Components\TextInput;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
/**
* @method WebhookConfiguration getOwnerRecord()
*/
class EventsRelationManager extends RelationManager
{
protected static string $relationship = 'webhookEvents';
public function table(Table $table): Table
{
return $table
->heading('')
->columns([
TextColumn::make('id'),
TextColumn::make('name'),
])
->headerActions([
Action::make('create')
->form(fn () => [
TextInput::make('name')
->inlineLabel()
->live()
->required(),
])
->action(function (array $data) {
$id = WebhookEvent::firstOrCreate([
'name' => $data['name'],
]);
$this->getOwnerRecord()->webhookEvents()->sync([$id]);
}),
])
->actions([
DeleteAction::make()
->authorize(fn (WebhookConfiguration $config) => auth()->user()->can('delete', $config)),
]);
}
}

View File

@@ -18,59 +18,71 @@ class ProcessWebhook implements ShouldQueue
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* @param array<mixed> $data
* @param array<mixed>|string|null $data
*/
public function __construct(
private WebhookConfiguration $webhookConfiguration,
private string $eventName,
private array $data
private array|string|null $data = null
) {}
public function handle(): void
{
$data = $this->data[0] ?? [];
if (count($data) === 1) {
$data = reset($data);
}
$data = is_array($data) ? $data : (json_decode($data, true) ?? []);
$data['event'] = $this->webhookConfiguration->transformClassName($this->eventName);
$payload = is_array($this->data) ? $this->data : (json_decode($this->data, true) ?? []);
$payload['event'] = $this->webhookConfiguration->transformClassName($this->eventName);
if ($this->webhookConfiguration->type === WebhookType::Discord) {
$payload = json_encode($this->webhookConfiguration->payload);
$tmp = $this->webhookConfiguration->replaceVars($data, $payload);
$data = json_decode($tmp, true);
$embeds = data_get($data, 'embeds');
if ($embeds) {
foreach ($embeds as &$embed) {
if (data_get($embed, 'has_timestamp')) {
$embed['timestamp'] = Carbon::now();
unset($embed['has_timestamp']);
}
}
$data['embeds'] = $embeds;
}
$payload = $this->convertToDiscord($payload);
}
try {
$customHeaders = $this->webhookConfiguration->headers;
$customHeaders = $this->webhookConfiguration->headers ?: [];
$headers = [];
foreach ($customHeaders as $key => $value) {
$headers[$key] = $this->webhookConfiguration->replaceVars($data, $value);
$headers[$key] = $this->webhookConfiguration->replaceVars($payload, $value);
}
Http::withHeaders($headers)->post($this->webhookConfiguration->endpoint, $data)->throw();
Http::withHeaders($headers)->post($this->webhookConfiguration->endpoint, $payload)->throw();
$successful = now();
} catch (Exception $exception) {
report($exception->getMessage());
$successful = null;
}
$this->logWebhookCall($payload, $successful);
}
private function logWebhookCall(array $payload, Carbon $success): void
{
$this->webhookConfiguration->webhooks()->create([
'payload' => $data,
'successful_at' => $successful,
'payload' => $payload,
'successful_at' => $success,
'event' => $this->eventName,
'endpoint' => $this->webhookConfiguration->endpoint,
]);
}
/**
* @param mixed $data
* @return array
*/
public function convertToDiscord(mixed $data): array
{
$payload = json_encode($this->webhookConfiguration->payload);
$tmp = $this->webhookConfiguration->replaceVars($data, $payload);
$data = json_decode($tmp, true);
$embeds = data_get($data, 'embeds');
if ($embeds) {
// copied from previous, is the & needed?
foreach ($embeds as &$embed) {
if (data_get($embed, 'has_timestamp')) {
$embed['timestamp'] = Carbon::now();
unset($embed['has_timestamp']);
}
}
$data['embeds'] = $embeds;
}
return $data;
}
}

View File

@@ -18,11 +18,11 @@ use Illuminate\Database\Eloquent\Model;
*/
class Webhook extends Model
{
use HasFactory, MassPrunable;
use MassPrunable;
protected $fillable = ['payload', 'successful_at', 'event', 'endpoint'];
public function casts()
public function casts(): array
{
return [
'payload' => 'array',

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use App\Jobs\ProcessWebhook;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Arr;
@@ -192,7 +193,7 @@ class WebhookConfiguration extends Model
$eventName ??= 'eloquent.created: '.Server::class;
$eventData ??= $this->getWebhookSampleData();
ProcessWebhook::dispatch($this, $eventName, [$eventData]);
ProcessWebhook::dispatch($this, $eventName, $eventData);
}
/**
@@ -231,4 +232,9 @@ class WebhookConfiguration extends Model
'id' => 2,
];
}
public function webhookEvents(): BelongsToMany
{
return $this->belongsToMany(WebhookEvent::class, 'webhook_configurations_events', 'configuration_id', 'event_id');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
/**
* @property string $name
*/
class WebhookEvent extends Model
{
use HasFactory;
public $timestamps = false;
protected $fillable = [
'name',
];
public function configurations(): BelongsToMany
{
return $this->belongsToMany(WebhookConfiguration::class, 'webhook_configurations_events', 'event_id', 'configuration_id');
}
}

View File

@@ -14,7 +14,6 @@ class WebhookConfigurationFactory extends Factory
return [
'endpoint' => fake()->url(),
'description' => fake()->sentence(),
'events' => [],
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Database\Factories;
use App\Models\WebhookEvent;
use Illuminate\Database\Eloquent\Factories\Factory;
class WebhookEventFactory extends Factory
{
protected $model = WebhookEvent::class;
public function definition(): array
{
return [
'name' => fake()->word,
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
/*Schema::table('webhook_configurations', function (Blueprint $table) {
$table->dropColumn('events');
});*/ // TODO: convert old format
Schema::create('webhook_events', function (Blueprint $table) {
$table->id();
$table->string('name');
});
Schema::create('webhook_configurations_events', function (Blueprint $table) {
$table->foreignId('event_id')->references('id')->on('webhook_events')->onDelete('cascade');
$table->foreignId('configuration_id')->references('id')->on('webhook_configurations')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('webhook_events');
Schema::dropIfExists('webhook_configurations_events');
}
};

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Tests\Feature\Webhooks;
namespace App\Tests\Unit\Webhooks;
use App\Jobs\ProcessWebhook;
use App\Models\Server;
@@ -68,7 +68,7 @@ class DispatchWebhooksTest extends TestCase
'events' => ['eloquent.created: '.Server::class],
]);
$webhookConfig->update(['events' => 'eloquent.deleted: '.Server::class]);
$webhookConfig->update(['events' => ['eloquent.deleted: '.Server::class]]);
$this->createServer();

View File

@@ -1,12 +1,13 @@
<?php
namespace App\Tests\Feature\Webhooks;
namespace App\Tests\Unit\Webhooks;
use App\Events\Server\Installed;
use App\Jobs\ProcessWebhook;
use App\Models\Server;
use App\Models\Webhook;
use App\Models\WebhookConfiguration;
use App\Models\WebhookEvent;
use App\Tests\TestCase;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Http\Client\Request;
@@ -26,9 +27,10 @@ class ProcessWebhooksTest extends TestCase
public function test_it_sends_a_single_webhook(): void
{
$webhook = WebhookConfiguration::factory()->create([
'events' => [$eventName = 'eloquent.created: '.Server::class],
]);
$eventName = 'eloquent.created: '.Server::class;
$webhook = WebhookConfiguration::factory()
->has(WebhookEvent::factory()->state(['name' => $eventName]), 'webhookEvents')
->create(['events' => []]);
Http::fake([$webhook->endpoint => Http::response()]);
@@ -64,10 +66,10 @@ class ProcessWebhooksTest extends TestCase
ProcessWebhook::dispatchSync(
$webhook,
'eloquent.created: '.Server::class,
$data,
[$data],
);
$this->assertCount(1, cache()->get("webhooks.$eventName"));
$this->assertCount(1, cache()->get("webhooks.$eventName", []));
$this->assertEquals($webhook->id, cache()->get("webhooks.$eventName")->first()->id);
Http::assertSentCount(1);