Implement generic webhook delivery
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Notifications;
|
||||
|
||||
use App\Actions\Notifications\DeliverNotificationAction;
|
||||
use App\Actions\Notifications\TicketStatusChangedPayload;
|
||||
use App\Enums\NotificationDeliveryStatus;
|
||||
use App\Enums\NotificationEventType;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\NotificationDeliveryLog;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DeliverNotificationActionTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Ensure generic webhook deliveries send JSON payloads and mark successful log entries.
|
||||
*/
|
||||
public function test_webhook_delivery_posts_structured_json_payload_and_marks_the_log_as_succeeded(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://hooks.example.test/kanbangit/ticket-status' => Http::response('', 204),
|
||||
]);
|
||||
|
||||
$notificationAction = NotificationAction::factory()->webhook()->create([
|
||||
'delivery_url' => 'https://hooks.example.test/kanbangit/ticket-status',
|
||||
]);
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
|
||||
(new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->handle();
|
||||
|
||||
Http::assertSentCount(1);
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://hooks.example.test/kanbangit/ticket-status'
|
||||
&& $request->hasHeader('Accept', 'application/json')
|
||||
&& $request->hasHeader('Content-Type', 'application/json')
|
||||
&& $request->data() === $this->expectedPayload();
|
||||
});
|
||||
|
||||
$deliveryLog = $deliveryLog->fresh();
|
||||
|
||||
$this->assertSame(NotificationDeliveryStatus::Succeeded, $deliveryLog->delivery_status);
|
||||
$this->assertSame(1, $deliveryLog->attempt_count);
|
||||
$this->assertSame(204, $deliveryLog->http_status);
|
||||
$this->assertSame('No Content', $deliveryLog->response_excerpt);
|
||||
$this->assertNull($deliveryLog->error_excerpt);
|
||||
$this->assertNotNull($deliveryLog->last_attempted_at);
|
||||
$this->assertNotNull($deliveryLog->delivered_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure retryable webhook responses release the job and update the delivery log.
|
||||
*/
|
||||
public function test_retryable_webhook_responses_mark_the_log_retryable_and_release_the_job(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://hooks.example.test/kanbangit/ticket-status' => Http::response([
|
||||
'message' => 'Temporary outage',
|
||||
], 503),
|
||||
]);
|
||||
|
||||
$notificationAction = NotificationAction::factory()->webhook()->create([
|
||||
'delivery_url' => 'https://hooks.example.test/kanbangit/ticket-status',
|
||||
]);
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
$job = (new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->withFakeQueueInteractions();
|
||||
|
||||
$job->handle();
|
||||
|
||||
$job->assertReleased($job->backoff()[0]);
|
||||
$job->assertNotFailed();
|
||||
|
||||
$deliveryLog = $deliveryLog->fresh();
|
||||
|
||||
$this->assertSame(NotificationDeliveryStatus::Retryable, $deliveryLog->delivery_status);
|
||||
$this->assertSame(1, $deliveryLog->attempt_count);
|
||||
$this->assertSame(503, $deliveryLog->http_status);
|
||||
$this->assertStringContainsString('Temporary outage', (string) $deliveryLog->response_excerpt);
|
||||
$this->assertSame('Webhook returned HTTP 503 Service Unavailable.', $deliveryLog->error_excerpt);
|
||||
$this->assertNull($deliveryLog->delivered_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure permanent webhook errors fail immediately without scheduling another retry.
|
||||
*/
|
||||
public function test_permanent_webhook_responses_mark_the_log_as_failed(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://hooks.example.test/kanbangit/ticket-status' => Http::response([
|
||||
'message' => 'The ticket payload was invalid.',
|
||||
], 422),
|
||||
]);
|
||||
|
||||
$notificationAction = NotificationAction::factory()->webhook()->create([
|
||||
'delivery_url' => 'https://hooks.example.test/kanbangit/ticket-status',
|
||||
]);
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
$job = (new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->withFakeQueueInteractions();
|
||||
|
||||
$job->handle();
|
||||
|
||||
$job->assertFailed();
|
||||
$job->assertNotReleased();
|
||||
|
||||
$deliveryLog = $deliveryLog->fresh();
|
||||
|
||||
$this->assertSame(NotificationDeliveryStatus::Failed, $deliveryLog->delivery_status);
|
||||
$this->assertSame(1, $deliveryLog->attempt_count);
|
||||
$this->assertSame(422, $deliveryLog->http_status);
|
||||
$this->assertStringContainsString('invalid', (string) $deliveryLog->response_excerpt);
|
||||
$this->assertStringContainsString('Webhook returned HTTP 422', (string) $deliveryLog->error_excerpt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure connection failures are marked retryable while attempts remain.
|
||||
*/
|
||||
public function test_connection_failures_mark_the_log_retryable_and_release_the_job(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://hooks.example.test/kanbangit/ticket-status' => Http::failedConnection(
|
||||
'Connection timed out while contacting the remote endpoint.',
|
||||
),
|
||||
]);
|
||||
|
||||
$notificationAction = NotificationAction::factory()->webhook()->create([
|
||||
'delivery_url' => 'https://hooks.example.test/kanbangit/ticket-status',
|
||||
]);
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
$job = (new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->withFakeQueueInteractions();
|
||||
|
||||
$job->handle();
|
||||
|
||||
$job->assertReleased($job->backoff()[0]);
|
||||
$job->assertNotFailed();
|
||||
|
||||
$deliveryLog = $deliveryLog->fresh();
|
||||
|
||||
$this->assertSame(NotificationDeliveryStatus::Retryable, $deliveryLog->delivery_status);
|
||||
$this->assertSame(1, $deliveryLog->attempt_count);
|
||||
$this->assertNull($deliveryLog->http_status);
|
||||
$this->assertNull($deliveryLog->response_excerpt);
|
||||
$this->assertSame('Connection timed out while contacting the remote endpoint.', $deliveryLog->error_excerpt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the last retryable attempt is marked failed once the job exhausts its retries.
|
||||
*/
|
||||
public function test_final_retryable_failures_mark_the_log_as_failed(): void
|
||||
{
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://hooks.example.test/kanbangit/ticket-status' => Http::response([
|
||||
'message' => 'Webhook service unavailable',
|
||||
], 503),
|
||||
]);
|
||||
|
||||
$notificationAction = NotificationAction::factory()->webhook()->create([
|
||||
'delivery_url' => 'https://hooks.example.test/kanbangit/ticket-status',
|
||||
]);
|
||||
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
|
||||
$job = (new DeliverNotificationAction(
|
||||
notificationActionId: $notificationAction->id,
|
||||
payload: $this->payload(),
|
||||
notificationDeliveryLogId: $deliveryLog->id,
|
||||
))->withFakeQueueInteractions();
|
||||
|
||||
$job->job->attempts = $job->tries;
|
||||
|
||||
$job->handle();
|
||||
|
||||
$job->assertFailed();
|
||||
$job->assertNotReleased();
|
||||
|
||||
$deliveryLog = $deliveryLog->fresh();
|
||||
|
||||
$this->assertSame(NotificationDeliveryStatus::Failed, $deliveryLog->delivery_status);
|
||||
$this->assertSame($job->tries, $deliveryLog->attempt_count);
|
||||
$this->assertSame(503, $deliveryLog->http_status);
|
||||
$this->assertSame('Webhook returned HTTP 503 Service Unavailable.', $deliveryLog->error_excerpt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure webhook delivery jobs expose the retry and timeout configuration required by the ticket.
|
||||
*/
|
||||
public function test_webhook_delivery_jobs_use_the_expected_retry_and_backoff_configuration(): void
|
||||
{
|
||||
$job = new DeliverNotificationAction(
|
||||
notificationActionId: 1,
|
||||
payload: $this->payload(),
|
||||
notificationDeliveryLogId: 1,
|
||||
);
|
||||
|
||||
$this->assertSame(3, $job->tries);
|
||||
$this->assertSame(15, $job->timeout);
|
||||
$this->assertSame([15, 60, 300], $job->backoff());
|
||||
}
|
||||
|
||||
private function createPendingDeliveryLog(NotificationAction $notificationAction): NotificationDeliveryLog
|
||||
{
|
||||
return NotificationDeliveryLog::factory()
|
||||
->pending()
|
||||
->forNotificationAction($notificationAction)
|
||||
->create([
|
||||
'payload_metadata' => [
|
||||
'project_key' => 'CORE',
|
||||
'ticket_key' => 'CORE-42',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function payload(): TicketStatusChangedPayload
|
||||
{
|
||||
return new TicketStatusChangedPayload(
|
||||
eventType: NotificationEventType::TicketStatusChanged,
|
||||
projectId: 10,
|
||||
projectKey: 'CORE',
|
||||
projectName: 'Core Platform',
|
||||
ticketId: 42,
|
||||
ticketKey: 'CORE-42',
|
||||
ticketTitle: 'Fix webhook delivery',
|
||||
ticketUrl: 'https://kanbangit.test/projects/core/tickets/42',
|
||||
actorId: 7,
|
||||
actorName: 'Erica Example',
|
||||
fromStatusId: 1,
|
||||
fromStatusLabel: 'Backlog',
|
||||
toStatusId: 2,
|
||||
toStatusLabel: 'Doing',
|
||||
occurredAt: CarbonImmutable::parse('2026-04-24T09:15:30+00:00'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* event_type: string,
|
||||
* project: array{id: int, key: string, name: string},
|
||||
* ticket: array{id: int, key: string, title: string},
|
||||
* actor: array{id: int, name: string},
|
||||
* from: array{id: int, label: string},
|
||||
* to: array{id: int, label: string},
|
||||
* ticket_url: string,
|
||||
* occurred_at: string
|
||||
* }
|
||||
*/
|
||||
private function expectedPayload(): array
|
||||
{
|
||||
return [
|
||||
'event_type' => 'ticket_status_changed',
|
||||
'project' => [
|
||||
'id' => 10,
|
||||
'key' => 'CORE',
|
||||
'name' => 'Core Platform',
|
||||
],
|
||||
'ticket' => [
|
||||
'id' => 42,
|
||||
'key' => 'CORE-42',
|
||||
'title' => 'Fix webhook delivery',
|
||||
],
|
||||
'actor' => [
|
||||
'id' => 7,
|
||||
'name' => 'Erica Example',
|
||||
],
|
||||
'from' => [
|
||||
'id' => 1,
|
||||
'label' => 'Backlog',
|
||||
],
|
||||
'to' => [
|
||||
'id' => 2,
|
||||
'label' => 'Doing',
|
||||
],
|
||||
'ticket_url' => 'https://kanbangit.test/projects/core/tickets/42',
|
||||
'occurred_at' => '2026-04-24T09:15:30.000000Z',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user