create([ 'name' => 'Erica Example', ]); $fromAssignee = User::factory()->create([ 'name' => 'Alex Analyst', ]); $toAssignee = User::factory()->create([ 'name' => 'Priya Planner', ]); $project = Project::factory()->create([ 'key' => 'CORE', 'name' => 'Core Platform', ]); $otherProject = Project::factory()->create(); app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($otherProject); $backlogStatus = $this->backlogStatus($project); $ticket = $this->createTicket( $project, $backlogStatus, 'Fix webhook dispatch', $fromAssignee, ); $webhookAction = NotificationAction::factory() ->webhook() ->for($project) ->create([ 'name' => 'Primary webhook', 'event_type' => NotificationEventType::TicketAssigneeChanged, ]); $discordAction = NotificationAction::factory() ->discord() ->for($project) ->create([ 'name' => 'Discord alerts', 'event_type' => NotificationEventType::TicketAssigneeChanged, ]); $disabledAction = NotificationAction::factory() ->disabled() ->for($project) ->create([ 'name' => 'Disabled webhook', 'event_type' => NotificationEventType::TicketAssigneeChanged, ]); $statusChangedAction = NotificationAction::factory() ->for($project) ->create([ 'name' => 'Status changed webhook', 'event_type' => NotificationEventType::TicketStatusChanged, ]); $filterEqualsAction = NotificationAction::factory() ->webhook() ->for($project) ->create([ 'name' => 'Filtered to assignee webhook', 'event_type' => NotificationEventType::TicketAssigneeChanged, 'filters' => [ [ 'attribute' => 'assignee.to.id', 'operator' => 'equals', 'value' => (string) $toAssignee->id, ], ], ]); $filterExistsAction = NotificationAction::factory() ->webhook() ->for($project) ->create([ 'name' => 'Filtered from assignee exists webhook', 'event_type' => NotificationEventType::TicketAssigneeChanged, 'filters' => [ [ 'attribute' => 'assignee.from.id', 'operator' => 'exists', 'value' => null, ], ], ]); $filterNotExistsAction = NotificationAction::factory() ->webhook() ->for($project) ->create([ 'name' => 'Filtered from assignee not exists webhook', 'event_type' => NotificationEventType::TicketAssigneeChanged, 'filters' => [ [ 'attribute' => 'assignee.from.id', 'operator' => 'not_exists', 'value' => null, ], ], ]); $filterInAction = NotificationAction::factory() ->webhook() ->for($project) ->create([ 'name' => 'Filtered in assignee webhook', 'event_type' => NotificationEventType::TicketAssigneeChanged, 'filters' => [ [ 'attribute' => 'assignee.to.name', 'operator' => 'in', 'value' => ['Priya Planner', 'Someone Else'], ], ], ]); $foreignProjectAction = NotificationAction::factory() ->for($otherProject) ->create([ 'name' => 'Foreign webhook', 'event_type' => NotificationEventType::TicketAssigneeChanged, ]); $this->syncProjectPermissions($project, $actor, ['edit', 'view']); $this->actingAs($actor) ->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [ 'assignee_user_id' => $toAssignee->id, ])) ->assertRedirect(route('projects.tickets.show', [$project, $ticket])) ->assertSessionHas('status', 'CORE-1 was updated.'); Queue::assertPushed(DeliverNotificationAction::class, 5); Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($actor, $backlogStatus, $fromAssignee, $project, $ticket, $toAssignee, $webhookAction): bool { $payload = $job->event->payload(); return $job->notificationActionId === $webhookAction->id && $payload instanceof TicketAssigneeChangedPayload && $payload->projectId === $project->id && $payload->projectKey === 'CORE' && $payload->projectName === 'Core Platform' && $payload->ticketId === $ticket->id && $payload->ticketKey === 'CORE-1' && $payload->ticketTitle === 'Fix webhook dispatch' && $payload->ticketUrl === route('projects.tickets.show', [$project, $ticket]) && $payload->actorId === $actor->id && $payload->actorName === 'Erica Example' && $payload->fromStatusId === null && $payload->fromStatusLabel === null && $payload->toStatusId === $backlogStatus->id && $payload->toStatusLabel === 'Backlog' && $payload->fromAssigneeId === $fromAssignee->id && $payload->fromAssigneeName === 'Alex Analyst' && $payload->toAssigneeId === $toAssignee->id && $payload->toAssigneeName === 'Priya Planner' && $payload->toArray()['event_type'] === NotificationEventType::TicketAssigneeChanged->value && $job->notificationDeliveryLogId !== null; }); Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id && $job->event->eventType() === NotificationEventType::TicketAssigneeChanged && $job->notificationDeliveryLogId !== null); Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterEqualsAction->id && $job->event->eventType() === NotificationEventType::TicketAssigneeChanged && $job->notificationDeliveryLogId !== null); Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterExistsAction->id && $job->event->eventType() === NotificationEventType::TicketAssigneeChanged && $job->notificationDeliveryLogId !== null); Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterInAction->id && $job->event->eventType() === NotificationEventType::TicketAssigneeChanged && $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); Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $filterNotExistsAction->id); Queue::assertNotPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $foreignProjectAction->id); $this->assertDatabaseCount('notification_delivery_logs', 5); $this->assertDatabaseHas('notification_delivery_logs', [ 'notification_action_id' => $webhookAction->id, 'delivery_status' => NotificationDeliveryStatus::Pending->value, 'event_type' => NotificationEventType::TicketAssigneeChanged->value, 'attempt_count' => 0, 'http_status' => null, ]); $this->assertDatabaseHas('notification_delivery_logs', [ 'notification_action_id' => $discordAction->id, 'delivery_status' => NotificationDeliveryStatus::Pending->value, 'event_type' => NotificationEventType::TicketAssigneeChanged->value, 'attempt_count' => 0, 'http_status' => null, ]); $this->assertDatabaseHas('notification_delivery_logs', [ 'notification_action_id' => $filterEqualsAction->id, 'delivery_status' => NotificationDeliveryStatus::Pending->value, 'event_type' => NotificationEventType::TicketAssigneeChanged->value, 'attempt_count' => 0, 'http_status' => null, ]); $this->assertDatabaseHas('notification_delivery_logs', [ 'notification_action_id' => $filterExistsAction->id, 'delivery_status' => NotificationDeliveryStatus::Pending->value, 'event_type' => NotificationEventType::TicketAssigneeChanged->value, 'attempt_count' => 0, 'http_status' => null, ]); $this->assertDatabaseHas('notification_delivery_logs', [ 'notification_action_id' => $filterInAction->id, 'delivery_status' => NotificationDeliveryStatus::Pending->value, 'event_type' => NotificationEventType::TicketAssigneeChanged->value, 'attempt_count' => 0, 'http_status' => null, ]); $this->assertDatabaseHas('ticket_worklog_entries', [ 'ticket_id' => $ticket->id, 'actor_id' => $actor->id, 'entry_type' => 'updated', 'summary' => 'Updated assignee.', ]); } /** * Ensure no-op assignee updates keep their response behavior and do not queue deliveries. */ public function test_no_op_assignee_updates_do_not_queue_notification_deliveries(): void { Queue::fake(); $actor = User::factory()->create(); $assignee = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $backlogStatus = $this->backlogStatus($project); $ticket = $this->createTicket( $project, $backlogStatus, 'Keep the same assignee', $assignee, ); NotificationAction::factory() ->for($project) ->create([ 'name' => 'Assignee webhook', 'event_type' => NotificationEventType::TicketAssigneeChanged, ]); $this->syncProjectPermissions($project, $actor, ['edit', 'view']); $this->actingAs($actor) ->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [ 'assignee_user_id' => $assignee->id, ])) ->assertRedirect(route('projects.tickets.show', [$project, $ticket])) ->assertSessionHas('status', 'No ticket changes were detected.'); Queue::assertNothingPushed(); $this->assertDatabaseCount('notification_delivery_logs', 0); $this->assertDatabaseCount('ticket_worklog_entries', 0); } private function backlogStatus(Project $project): TicketStatus { return $project->ticketStatuses() ->whereNull('board_column_id') ->firstOrFail(); } private function createTicket( Project $project, TicketStatus $ticketStatus, string $title, ?User $assignee = null, ): Ticket { $priority = TicketPriority::factory()->for($project)->create([ 'name' => 'High', 'position' => 2, ]); return Ticket::factory()->create([ 'assignee_user_id' => $assignee?->id, 'project_id' => $project->id, 'sequence_number' => 1, 'ticket_key' => 'CORE-1', 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $ticketStatus->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'title' => $title, ]); } /** * @param array $overrides * @return array */ private function updatePayload(Ticket $ticket, array $overrides = []): array { return [ 'acceptance_criteria' => AcceptanceCriteriaChecklistMapper::fromModels( $ticket->acceptanceCriteria()->get(), ), 'assignee_user_id' => $ticket->assignee_user_id, 'author_user_id' => $ticket->author_user_id, 'blocked_comment' => $ticket->blocked_comment, 'description' => $ticket->description, 'field_values' => [], 'is_blocked' => $ticket->is_blocked, 'parent_ticket_id' => $ticket->parent_ticket_id, 'story_points' => $ticket->story_points, 'ticket_priority_id' => $ticket->ticket_priority_id, 'ticket_type_id' => $ticket->ticket_type_id, 'title' => $ticket->title, ...$overrides, ]; } }