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 Discord deliveries render the configured template into the expected payload. */ public function test_discord_delivery_posts_rendered_content_payload_and_marks_the_log_as_succeeded(): void { Http::preventStrayRequests(); Http::fake([ 'https://discord.com/api/webhooks/123456/discord-alerts' => Http::response('', 204), ]); $notificationAction = NotificationAction::factory()->discord()->create([ 'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts', 'message_template' => '$ticket_key moved from $status_from to $status_to by $actor_name: $url ($unknown)', ]); $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://discord.com/api/webhooks/123456/discord-alerts' && $request->hasHeader('Accept', 'application/json') && $request->hasHeader('Content-Type', 'application/json') && $request->data() === [ 'content' => 'CORE-42 moved from Backlog to Doing by Erica Example: https://kanbangit.test/projects/core/tickets/42 ($unknown)', ]; }); $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 retryable Discord webhook responses share the same retry and logging behavior. */ public function test_retryable_discord_responses_mark_the_log_retryable_and_release_the_job(): void { Http::preventStrayRequests(); Http::fake([ 'https://discord.com/api/webhooks/123456/discord-alerts' => Http::response([ 'message' => 'Temporary outage', ], 503), ]); $notificationAction = NotificationAction::factory()->discord()->create([ 'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts', 'message_template' => '$ticket_key moved to $status_to', ]); $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('Discord 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 permanent Discord webhook errors fail immediately without scheduling another retry. */ public function test_permanent_discord_responses_mark_the_log_as_failed(): void { Http::preventStrayRequests(); Http::fake([ 'https://discord.com/api/webhooks/123456/discord-alerts' => Http::response([ 'message' => 'The message payload was invalid.', ], 422), ]); $notificationAction = NotificationAction::factory()->discord()->create([ 'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts', 'message_template' => '$ticket_key moved to $status_to', ]); $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('Discord 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 retryable delivery logs redact stored secret URLs from response excerpts. */ public function test_retryable_response_excerpts_redact_delivery_urls(): void { Http::preventStrayRequests(); $deliveryUrl = 'https://hooks.example.test/kanbangit/secret-token'; Http::fake([ $deliveryUrl => Http::response([ 'message' => "Remote service could not deliver to {$deliveryUrl}.", ], 503), ]); $notificationAction = NotificationAction::factory()->webhook()->create([ 'delivery_url' => $deliveryUrl, ]); $deliveryLog = $this->createPendingDeliveryLog($notificationAction); $job = (new DeliverNotificationAction( notificationActionId: $notificationAction->id, payload: $this->payload(), notificationDeliveryLogId: $deliveryLog->id, ))->withFakeQueueInteractions(); $job->handle(); $job->assertReleased($job->backoff()[0]); $deliveryLog = $deliveryLog->fresh(); $this->assertSame(NotificationDeliveryStatus::Retryable, $deliveryLog->delivery_status); $this->assertStringContainsString('[redacted]', (string) $deliveryLog->response_excerpt); $this->assertStringNotContainsString($deliveryUrl, (string) $deliveryLog->response_excerpt); } /** * Ensure connection failure logs redact stored secret URLs from error excerpts. */ public function test_connection_failure_excerpts_redact_delivery_urls(): void { Http::preventStrayRequests(); $deliveryUrl = 'https://hooks.example.test/kanbangit/secret-token'; Http::fake([ $deliveryUrl => Http::failedConnection( "Connection timed out while contacting {$deliveryUrl}.", ), ]); $notificationAction = NotificationAction::factory()->webhook()->create([ 'delivery_url' => $deliveryUrl, ]); $deliveryLog = $this->createPendingDeliveryLog($notificationAction); $job = (new DeliverNotificationAction( notificationActionId: $notificationAction->id, payload: $this->payload(), notificationDeliveryLogId: $deliveryLog->id, ))->withFakeQueueInteractions(); $job->handle(); $job->assertReleased($job->backoff()[0]); $deliveryLog = $deliveryLog->fresh(); $this->assertSame(NotificationDeliveryStatus::Retryable, $deliveryLog->delivery_status); $this->assertStringContainsString('[redacted]', (string) $deliveryLog->error_excerpt); $this->assertStringNotContainsString($deliveryUrl, (string) $deliveryLog->error_excerpt); } /** * Ensure Discord deliveries reject empty rendered messages before sending the request. */ public function test_empty_rendered_discord_messages_are_rejected_and_logged_as_failed(): void { Http::preventStrayRequests(); $notificationAction = NotificationAction::factory()->discord()->create([ 'message_template' => '$actor_name', ]); $deliveryLog = $this->createPendingDeliveryLog($notificationAction); $job = (new DeliverNotificationAction( notificationActionId: $notificationAction->id, payload: $this->payload(actorName: null), notificationDeliveryLogId: $deliveryLog->id, ))->withFakeQueueInteractions(); $job->handle(); Http::assertNothingSent(); $job->assertFailed(); $job->assertNotReleased(); $deliveryLog = $deliveryLog->fresh(); $this->assertSame(NotificationDeliveryStatus::Failed, $deliveryLog->delivery_status); $this->assertSame(1, $deliveryLog->attempt_count); $this->assertNull($deliveryLog->http_status); $this->assertNull($deliveryLog->response_excerpt); $this->assertSame('Discord message content cannot be empty.', $deliveryLog->error_excerpt); } /** * Ensure Discord deliveries reject rendered content that exceeds Discord's limit. */ public function test_discord_messages_that_exceed_the_content_limit_are_rejected_and_logged_as_failed(): void { Http::preventStrayRequests(); $notificationAction = NotificationAction::factory()->discord()->create([ 'message_template' => '$ticket_title', ]); $deliveryLog = $this->createPendingDeliveryLog($notificationAction); $job = (new DeliverNotificationAction( notificationActionId: $notificationAction->id, payload: $this->payload(ticketTitle: str_repeat('A', 2001)), notificationDeliveryLogId: $deliveryLog->id, ))->withFakeQueueInteractions(); $job->handle(); Http::assertNothingSent(); $job->assertFailed(); $job->assertNotReleased(); $deliveryLog = $deliveryLog->fresh(); $this->assertSame(NotificationDeliveryStatus::Failed, $deliveryLog->delivery_status); $this->assertSame(1, $deliveryLog->attempt_count); $this->assertNull($deliveryLog->http_status); $this->assertNull($deliveryLog->response_excerpt); $this->assertSame('Discord message content exceeds the 2000 character limit.', $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( ?string $actorName = 'Erica Example', string $ticketTitle = 'Fix webhook delivery', ): TicketStatusChangedPayload { return new TicketStatusChangedPayload( eventType: NotificationEventType::TicketStatusChanged, projectId: 10, projectKey: 'CORE', projectName: 'Core Platform', ticketId: 42, ticketKey: 'CORE-42', ticketTitle: $ticketTitle, ticketUrl: 'https://kanbangit.test/projects/core/tickets/42', actorId: $actorName === null ? null : 7, actorName: $actorName, 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', ]; } }