Merge main into notification filters
linter / quality (pull_request) Successful in 3m31s
tests / ci (8.4) (pull_request) Successful in 2m0s

This commit is contained in:
2026-04-29 10:41:51 +00:00
28 changed files with 700 additions and 192 deletions
+2 -2
View File
@@ -233,8 +233,8 @@ class TicketApiTest extends TestCase
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($notificationAction): bool {
return $job->notificationActionId === $notificationAction->id
&& $job->payload->ticketKey === 'CORE-1'
&& $job->payload->toStatusLabel === 'Doing'
&& $job->event->payload()->ticketKey === 'CORE-1'
&& $job->event->payload()->toStatusLabel === 'Doing'
&& $job->notificationDeliveryLogId !== null;
});
+2 -2
View File
@@ -359,8 +359,8 @@ class KanbanGitMcpTest extends TestCase
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($notificationAction): bool {
return $job->notificationActionId === $notificationAction->id
&& $job->payload->ticketKey === 'CORE-1'
&& $job->payload->toStatusLabel === 'Doing'
&& $job->event->payload()->ticketKey === 'CORE-1'
&& $job->event->payload()->toStatusLabel === 'Doing'
&& $job->notificationDeliveryLogId !== null;
});
@@ -3,9 +3,9 @@
namespace Tests\Feature\Notifications;
use App\Actions\Notifications\DeliverNotificationAction;
use App\Actions\Notifications\TicketStatusChanged;
use App\Actions\Notifications\TicketStatusChangedPayload;
use App\Enums\NotificationDeliveryStatus;
use App\Enums\NotificationEventType;
use App\Models\NotificationAction;
use App\Models\NotificationDeliveryLog;
use Carbon\CarbonImmutable;
@@ -35,7 +35,7 @@ class DeliverNotificationActionTest extends TestCase
(new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
payload: $this->payload(),
event: $this->event(),
notificationDeliveryLogId: $deliveryLog->id,
))->handle();
@@ -77,7 +77,7 @@ class DeliverNotificationActionTest extends TestCase
(new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
payload: $this->payload(),
event: $this->event(),
notificationDeliveryLogId: $deliveryLog->id,
))->handle();
@@ -106,6 +106,44 @@ class DeliverNotificationActionTest extends TestCase
$this->assertNotNull($deliveryLog->delivered_at);
}
/**
* Ensure Discord deliveries include configured allowed mention parse values.
*/
public function test_discord_delivery_includes_configured_allowed_mentions(): 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',
'channel_configuration' => [
'allowed_mentions' => ['users', 'roles', 'users', 'EVERYONE', 'invalid'],
],
'message_template' => '$ticket_key moved to $status_to',
]);
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
(new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
event: $this->event(),
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->data() === [
'content' => 'CORE-42 moved to Doing',
'allowed_mentions' => [
'parse' => ['users', 'roles', 'everyone'],
],
];
});
}
/**
* Ensure retryable webhook responses release the job and update the delivery log.
*/
@@ -124,7 +162,7 @@ class DeliverNotificationActionTest extends TestCase
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
$job = (new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
payload: $this->payload(),
event: $this->event(),
notificationDeliveryLogId: $deliveryLog->id,
))->withFakeQueueInteractions();
@@ -162,7 +200,7 @@ class DeliverNotificationActionTest extends TestCase
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
$job = (new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
payload: $this->payload(),
event: $this->event(),
notificationDeliveryLogId: $deliveryLog->id,
))->withFakeQueueInteractions();
@@ -199,7 +237,7 @@ class DeliverNotificationActionTest extends TestCase
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
$job = (new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
payload: $this->payload(),
event: $this->event(),
notificationDeliveryLogId: $deliveryLog->id,
))->withFakeQueueInteractions();
@@ -236,7 +274,7 @@ class DeliverNotificationActionTest extends TestCase
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
$job = (new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
payload: $this->payload(),
event: $this->event(),
notificationDeliveryLogId: $deliveryLog->id,
))->withFakeQueueInteractions();
@@ -272,7 +310,7 @@ class DeliverNotificationActionTest extends TestCase
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
$job = (new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
payload: $this->payload(),
event: $this->event(),
notificationDeliveryLogId: $deliveryLog->id,
))->withFakeQueueInteractions();
@@ -311,7 +349,7 @@ class DeliverNotificationActionTest extends TestCase
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
$job = (new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
payload: $this->payload(),
event: $this->event(),
notificationDeliveryLogId: $deliveryLog->id,
))->withFakeQueueInteractions();
@@ -347,7 +385,7 @@ class DeliverNotificationActionTest extends TestCase
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
$job = (new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
payload: $this->payload(),
event: $this->event(),
notificationDeliveryLogId: $deliveryLog->id,
))->withFakeQueueInteractions();
@@ -375,7 +413,7 @@ class DeliverNotificationActionTest extends TestCase
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
$job = (new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
payload: $this->payload(actorName: null),
event: $this->event(actorName: null),
notificationDeliveryLogId: $deliveryLog->id,
))->withFakeQueueInteractions();
@@ -407,7 +445,7 @@ class DeliverNotificationActionTest extends TestCase
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
$job = (new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
payload: $this->payload(ticketTitle: str_repeat('A', 2001)),
event: $this->event(ticketTitle: str_repeat('A', 2001)),
notificationDeliveryLogId: $deliveryLog->id,
))->withFakeQueueInteractions();
@@ -444,7 +482,7 @@ class DeliverNotificationActionTest extends TestCase
$deliveryLog = $this->createPendingDeliveryLog($notificationAction);
$job = (new DeliverNotificationAction(
notificationActionId: $notificationAction->id,
payload: $this->payload(),
event: $this->event(),
notificationDeliveryLogId: $deliveryLog->id,
))->withFakeQueueInteractions();
@@ -470,7 +508,7 @@ class DeliverNotificationActionTest extends TestCase
{
$job = new DeliverNotificationAction(
notificationActionId: 1,
payload: $this->payload(),
event: $this->event(),
notificationDeliveryLogId: 1,
);
@@ -492,12 +530,11 @@ class DeliverNotificationActionTest extends TestCase
]);
}
private function payload(
private function event(
?string $actorName = 'Erica Example',
string $ticketTitle = 'Fix webhook delivery',
): TicketStatusChangedPayload {
return new TicketStatusChangedPayload(
eventType: NotificationEventType::TicketStatusChanged,
): TicketStatusChanged {
return new TicketStatusChanged(new TicketStatusChangedPayload(
projectId: 10,
projectKey: 'CORE',
projectName: 'Core Platform',
@@ -512,7 +549,7 @@ class DeliverNotificationActionTest extends TestCase
toStatusId: 2,
toStatusLabel: 'Doing',
occurredAt: CarbonImmutable::parse('2026-04-24T09:15:30+00:00'),
);
));
}
/**
@@ -3,8 +3,8 @@
namespace Tests\Feature\Notifications;
use App\Actions\Notifications\RenderDiscordMessageTemplate;
use App\Actions\Notifications\TicketStatusChanged;
use App\Actions\Notifications\TicketStatusChangedPayload;
use App\Enums\NotificationEventType;
use Carbon\CarbonImmutable;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
@@ -54,7 +54,7 @@ class RenderDiscordMessageTemplateTest extends TestCase
#[DataProvider('supportedVariableProvider')]
public function test_it_replaces_supported_template_variables(string $template, string $expected): void
{
$renderedMessage = app(RenderDiscordMessageTemplate::class)->handle($template, $this->payload());
$renderedMessage = app(RenderDiscordMessageTemplate::class)->handle($template, $this->event());
$this->assertSame($expected, $renderedMessage);
}
@@ -66,16 +66,15 @@ class RenderDiscordMessageTemplateTest extends TestCase
{
$renderedMessage = app(RenderDiscordMessageTemplate::class)->handle(
'[$ticket_key] $unknown moved to $status_to',
$this->payload(),
$this->event(),
);
$this->assertSame('[CORE-42] $unknown moved to Doing', $renderedMessage);
}
private function payload(): TicketStatusChangedPayload
private function event(): TicketStatusChanged
{
return new TicketStatusChangedPayload(
eventType: NotificationEventType::TicketStatusChanged,
return new TicketStatusChanged(new TicketStatusChangedPayload(
projectId: 10,
projectKey: 'CORE',
projectName: 'Core Platform',
@@ -90,6 +89,6 @@ class RenderDiscordMessageTemplateTest extends TestCase
toStatusId: 2,
toStatusLabel: 'Doing',
occurredAt: CarbonImmutable::parse('2026-04-24T09:15:30+00:00'),
);
));
}
}
@@ -131,27 +131,27 @@ class TicketCreatedNotificationDispatchTest extends TestCase
Queue::assertPushed(DeliverNotificationAction::class, 3);
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus): bool {
return $job->notificationActionId === $webhookAction->id
&& $job->payload->projectId === $project->id
&& $job->payload->projectKey === 'CORE'
&& $job->payload->projectName === 'Core Platform'
&& $job->payload->ticketId === $ticket->id
&& $job->payload->ticketKey === $ticket->ticket_key
&& $job->payload->ticketTitle === 'Trigger ticket created notification'
&& $job->payload->ticketUrl === route('projects.tickets.show', [$project, $ticket])
&& $job->payload->actorId === $user->id
&& $job->payload->actorName === 'Erica Example'
&& $job->payload->fromStatusId === null
&& $job->payload->fromStatusLabel === null
&& $job->payload->toStatusId === $backlogStatus->id
&& $job->payload->toStatusLabel === 'Backlog'
&& $job->payload->eventType === NotificationEventType::TicketCreate
&& $job->event->payload()->projectId === $project->id
&& $job->event->payload()->projectKey === 'CORE'
&& $job->event->payload()->projectName === 'Core Platform'
&& $job->event->payload()->ticketId === $ticket->id
&& $job->event->payload()->ticketKey === $ticket->ticket_key
&& $job->event->payload()->ticketTitle === 'Trigger ticket created notification'
&& $job->event->payload()->ticketUrl === route('projects.tickets.show', [$project, $ticket])
&& $job->event->payload()->actorId === $user->id
&& $job->event->payload()->actorName === 'Erica Example'
&& $job->event->payload()->fromStatusId === null
&& $job->event->payload()->fromStatusLabel === null
&& $job->event->payload()->toStatusId === $backlogStatus->id
&& $job->event->payload()->toStatusLabel === 'Backlog'
&& $job->event->eventType() === NotificationEventType::TicketCreate
&& $job->notificationDeliveryLogId !== null;
});
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id
&& $job->payload->eventType === NotificationEventType::TicketCreate
&& $job->event->eventType() === NotificationEventType::TicketCreate
&& $job->notificationDeliveryLogId !== null);
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterNotExistsAction->id
&& $job->payload->eventType === NotificationEventType::TicketCreate
&& $job->event->eventType() === NotificationEventType::TicketCreate
&& $job->notificationDeliveryLogId !== null);
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $disabledAction->id);
Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $statusChangedAction->id);
@@ -147,7 +147,7 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase
'project_id' => $project->id,
'name' => 'Wrong event',
'enabled' => true,
'event_type' => 'ticket_created',
'event_type' => NotificationEventType::TicketCreate->value,
'channel' => NotificationChannel::Webhook->value,
'channel_configuration' => json_encode([]),
'message_template' => null,
@@ -169,20 +169,20 @@ class TicketStatusChangedNotificationDispatchTest extends TestCase
Queue::assertPushed(DeliverNotificationAction::class, 5);
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus, $doingStatus): bool {
return $job->notificationActionId === $webhookAction->id
&& $job->payload->projectId === $project->id
&& $job->payload->projectKey === 'CORE'
&& $job->payload->projectName === 'Core Platform'
&& $job->payload->ticketId === $ticket->id
&& $job->payload->ticketKey === 'CORE-1'
&& $job->payload->ticketTitle === 'Fix webhook dispatch'
&& $job->payload->ticketUrl === route('projects.tickets.show', [$project, $ticket])
&& $job->payload->actorId === $user->id
&& $job->payload->actorName === 'Erica Example'
&& $job->payload->fromStatusId === $backlogStatus->id
&& $job->payload->fromStatusLabel === 'Backlog'
&& $job->payload->toStatusId === $doingStatus->id
&& $job->payload->toStatusLabel === 'Doing'
&& $job->payload->eventType === NotificationEventType::TicketStatusChanged
&& $job->event->payload()->projectId === $project->id
&& $job->event->payload()->projectKey === 'CORE'
&& $job->event->payload()->projectName === 'Core Platform'
&& $job->event->payload()->ticketId === $ticket->id
&& $job->event->payload()->ticketKey === 'CORE-1'
&& $job->event->payload()->ticketTitle === 'Fix webhook dispatch'
&& $job->event->payload()->ticketUrl === route('projects.tickets.show', [$project, $ticket])
&& $job->event->payload()->actorId === $user->id
&& $job->event->payload()->actorName === 'Erica Example'
&& $job->event->payload()->fromStatusId === $backlogStatus->id
&& $job->event->payload()->fromStatusLabel === 'Backlog'
&& $job->event->payload()->toStatusId === $doingStatus->id
&& $job->event->payload()->toStatusLabel === 'Doing'
&& $job->event->eventType() === NotificationEventType::TicketStatusChanged
&& $job->notificationDeliveryLogId !== null;
});
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id
@@ -36,6 +36,9 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'value' => 'Doing',
],
],
'channel_configuration' => [
'allowed_mentions' => ['users', 'roles'],
],
]);
$this->syncProjectPermissions($project, $user, ['edit']);
@@ -48,6 +51,8 @@ class ProjectNotificationSettingsControllerTest extends TestCase
->where('project.id', $project->id)
->where('notificationActions.0.name', 'Discord alerts')
->where('notificationActions.0.channel', NotificationChannel::Discord->value)
->where('notificationActions.0.channel_configuration.allowed_mentions.0', 'users')
->where('notificationActions.0.channel_configuration.allowed_mentions.1', 'roles')
->where('notificationActions.0.event_type', NotificationEventType::TicketStatusChanged->value)
->where('notificationActions.0.enabled', true)
->where('notificationActions.0.has_delivery_url', true)
@@ -58,6 +63,8 @@ class ProjectNotificationSettingsControllerTest extends TestCase
->where('channelOptions.0.value', NotificationChannel::Discord->value)
->where('eventTypeOptions.0.value', NotificationEventType::TicketStatusChanged->value)
->where('eventTypeOptions.1.value', NotificationEventType::TicketCreate->value)
->where('discordAllowedMentionOptions.0.value', 'everyone')
->where('discordAllowedMentionOptions.2.value', 'users')
->where('filterAttributeOptions.0.value', 'project.id')
->where('filterOperatorOptions.0.value', 'equals')
->where('templateVariables.0.value', '$url')
@@ -96,6 +103,9 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'name' => 'Discord alerts',
'event_type' => NotificationEventType::TicketStatusChanged->value,
'channel' => NotificationChannel::Discord->value,
'channel_configuration' => [
'allowed_mentions' => ['users', 'roles'],
],
'enabled' => '1',
'delivery_url' => 'https://discord.com/api/webhooks/123456/discord-alerts',
'filters' => json_encode([
@@ -115,6 +125,10 @@ class ProjectNotificationSettingsControllerTest extends TestCase
$this->assertSame($project->id, $notificationAction->project_id);
$this->assertSame(NotificationChannel::Discord, $notificationAction->channel);
$this->assertSame(NotificationEventType::TicketStatusChanged, $notificationAction->event_type);
$this->assertSame(
['allowed_mentions' => ['users', 'roles']],
$notificationAction->channel_configuration,
);
$this->assertTrue($notificationAction->enabled);
$this->assertSame(
'https://discord.com/api/webhooks/123456/discord-alerts',
@@ -151,6 +165,9 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'name' => 'Ops escalation alerts',
'event_type' => NotificationEventType::TicketStatusChanged->value,
'channel' => NotificationChannel::Discord->value,
'channel_configuration' => [
'allowed_mentions' => ['users', 'everyone'],
],
'enabled' => '1',
'delivery_url' => '',
'filters' => json_encode([
@@ -179,6 +196,17 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'value' => ['CORE-1', 'CORE-2'],
],
], $notificationAction->filters);
$this->assertSame(
['allowed_mentions' => ['users', 'everyone']],
$notificationAction->channel_configuration,
);
$this->assertSame([
[
'attribute' => 'ticket.key',
'operator' => 'in',
'value' => ['CORE-1', 'CORE-2'],
],
], $notificationAction->filters);
$this->assertSame('$ticket_key moved to $status_to: $url', $notificationAction->message_template);
}
@@ -305,6 +333,9 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'value' => [],
],
], JSON_THROW_ON_ERROR),
'channel_configuration' => [
'allowed_mentions' => ['not-valid'],
],
'message_template' => '',
])
->assertRedirect(route('projects.settings.notifications', $project))
@@ -314,6 +345,9 @@ class ProjectNotificationSettingsControllerTest extends TestCase
'enabled',
'delivery_url',
'filters.0.value',
'channel_configuration.allowed_mentions.0',
'channel_configuration.allowed_mentions.0',
'filters.0.value',
'message_template',
]);
}