Implement Discord webhook delivery
This commit is contained in:
@@ -59,6 +59,50 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$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.
|
||||
*/
|
||||
@@ -96,6 +140,44 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$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.
|
||||
*/
|
||||
@@ -132,6 +214,43 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$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.
|
||||
*/
|
||||
@@ -168,6 +287,70 @@ class DeliverNotificationActionTest extends TestCase
|
||||
$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.
|
||||
*/
|
||||
@@ -234,8 +417,10 @@ class DeliverNotificationActionTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
private function payload(): TicketStatusChangedPayload
|
||||
{
|
||||
private function payload(
|
||||
?string $actorName = 'Erica Example',
|
||||
string $ticketTitle = 'Fix webhook delivery',
|
||||
): TicketStatusChangedPayload {
|
||||
return new TicketStatusChangedPayload(
|
||||
eventType: NotificationEventType::TicketStatusChanged,
|
||||
projectId: 10,
|
||||
@@ -243,10 +428,10 @@ class DeliverNotificationActionTest extends TestCase
|
||||
projectName: 'Core Platform',
|
||||
ticketId: 42,
|
||||
ticketKey: 'CORE-42',
|
||||
ticketTitle: 'Fix webhook delivery',
|
||||
ticketTitle: $ticketTitle,
|
||||
ticketUrl: 'https://kanbangit.test/projects/core/tickets/42',
|
||||
actorId: 7,
|
||||
actorName: 'Erica Example',
|
||||
actorId: $actorName === null ? null : 7,
|
||||
actorName: $actorName,
|
||||
fromStatusId: 1,
|
||||
fromStatusLabel: 'Backlog',
|
||||
toStatusId: 2,
|
||||
|
||||
Reference in New Issue
Block a user