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', ]; } }