Files
KanbanGit/tests/Feature/Notifications/DeliverNotificationActionTest.php
T

489 lines
19 KiB
PHP

<?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 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)',
'allowed_mentions' => [
'parse' => [],
],
];
});
$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 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',
];
}
}