create([ 'name' => 'Erica Example', ]); $project = Project::factory()->create([ 'key' => 'CORE', 'name' => 'Core Platform', ]); $otherProject = Project::factory()->create(); app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($otherProject); $doingColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Doing', 'position' => 1, ]); $doingStatus = $this->createColumnStatus($doingColumn); $backlogStatus = $this->backlogStatus($project); $this->allowMove($backlogStatus, $doingStatus); $ticket = $this->createTicket($project, $backlogStatus, 'Fix webhook dispatch'); $webhookAction = NotificationAction::factory() ->webhook() ->for($project) ->create([ 'name' => 'Primary webhook', ]); $discordAction = NotificationAction::factory() ->discord() ->for($project) ->create([ 'name' => 'Discord alerts', ]); $disabledAction = NotificationAction::factory() ->disabled() ->for($project) ->create([ 'name' => 'Disabled webhook', ]); $foreignProjectAction = NotificationAction::factory() ->for($otherProject) ->create([ 'name' => 'Foreign webhook', ]); DB::table('notification_actions')->insert([ 'project_id' => $project->id, 'name' => 'Wrong event', 'enabled' => true, 'event_type' => 'ticket_created', 'channel' => NotificationChannel::Webhook->value, 'channel_configuration' => json_encode([]), 'message_template' => null, 'delivery_url' => 'https://hooks.example.test/kanbangit/wrong-event', 'created_at' => CarbonImmutable::now(), 'updated_at' => CarbonImmutable::now(), ]); $this->syncProjectPermissions($project, $user, ['edit', 'view']); $this->actingAs($user) ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ 'redirect_to' => 'backlog', 'ticket_status_id' => $doingStatus->id, ]) ->assertRedirect(route('projects.backlog.index', $project)) ->assertSessionHas('status', 'CORE-1 moved to Doing.'); Queue::assertPushed(DeliverNotificationAction::class, 2); Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, $project, $ticket, $user, $backlogStatus, $doingStatus): bool { return $job->notificationActionId === $webhookAction->id && $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->payload()->eventType() === NotificationEventType::TicketStatusChanged && $job->notificationDeliveryLogId !== null; }); Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $discordAction->id && $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 === $foreignProjectAction->id); $this->assertDatabaseCount('notification_delivery_logs', 2); $this->assertDatabaseHas('notification_delivery_logs', [ 'notification_action_id' => $webhookAction->id, 'delivery_status' => NotificationDeliveryStatus::Pending->value, 'attempt_count' => 0, 'http_status' => null, ]); $this->assertDatabaseHas('notification_delivery_logs', [ 'notification_action_id' => $discordAction->id, 'delivery_status' => NotificationDeliveryStatus::Pending->value, 'attempt_count' => 0, 'http_status' => null, ]); $this->assertDatabaseHas('ticket_worklog_entries', [ 'ticket_id' => $ticket->id, 'actor_id' => $user->id, 'entry_type' => 'status_changed', 'summary' => 'Moved from Backlog to Doing.', ]); } /** * Ensure no-op moves keep their response behavior and do not queue deliveries. */ public function test_no_op_status_moves_do_not_queue_notification_deliveries(): void { Queue::fake(); $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $backlogStatus = $this->backlogStatus($project); $ticket = $this->createTicket($project, $backlogStatus, 'Stay in backlog'); NotificationAction::factory() ->for($project) ->create([ 'name' => 'Backlog webhook', ]); $this->syncProjectPermissions($project, $user, ['edit', 'view']); $this->actingAs($user) ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ 'redirect_to' => 'backlog', 'ticket_status_id' => $backlogStatus->id, ]) ->assertRedirect(route('projects.backlog.index', $project)) ->assertSessionHas('status', 'CORE-1 already belongs to Backlog.'); Queue::assertNothingPushed(); $this->assertDatabaseCount('ticket_worklog_entries', 0); } /** * Ensure ticket moves stay successful even when webhook delivery fails later on a queued attempt. */ public function test_ticket_moves_still_succeed_when_webhook_delivery_fails_later(): void { Queue::fake(); Http::preventStrayRequests(); $user = User::factory()->create([ 'name' => 'Erica Example', ]); $project = Project::factory()->create([ 'key' => 'CORE', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $doingColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Doing', 'position' => 1, ]); $doingStatus = $this->createColumnStatus($doingColumn); $backlogStatus = $this->backlogStatus($project); $this->allowMove($backlogStatus, $doingStatus); $ticket = $this->createTicket($project, $backlogStatus, 'Retry failing webhook'); $webhookAction = NotificationAction::factory() ->webhook() ->for($project) ->create([ 'delivery_url' => 'https://hooks.example.test/kanbangit/retry-failing-webhook', ]); $this->syncProjectPermissions($project, $user, ['edit', 'view']); $this->actingAs($user) ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ 'redirect_to' => 'backlog', 'ticket_status_id' => $doingStatus->id, ]) ->assertRedirect(route('projects.backlog.index', $project)) ->assertSessionHas('status', 'CORE-1 moved to Doing.'); $queuedJob = null; Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($webhookAction, &$queuedJob): bool { if ($job->notificationActionId !== $webhookAction->id) { return false; } $queuedJob = $job; return true; }); $this->assertInstanceOf(DeliverNotificationAction::class, $queuedJob); Http::fake([ 'https://hooks.example.test/kanbangit/retry-failing-webhook' => Http::failedConnection( 'Connection timed out while contacting the remote endpoint.', ), ]); $queuedJob->withFakeQueueInteractions()->handle(); $queuedJob->assertReleased($queuedJob->backoff()[0]); $this->assertDatabaseHas('tickets', [ 'id' => $ticket->id, 'ticket_status_id' => $doingStatus->id, ]); $this->assertDatabaseHas('notification_delivery_logs', [ 'notification_action_id' => $webhookAction->id, 'delivery_status' => NotificationDeliveryStatus::Retryable->value, 'attempt_count' => 1, 'http_status' => null, ]); } private function backlogStatus(Project $project): TicketStatus { return $project->ticketStatuses() ->whereNull('board_column_id') ->firstOrFail(); } private function createColumnStatus(BoardColumn $boardColumn): TicketStatus { return TicketStatus::factory() ->for($boardColumn->project) ->for($boardColumn) ->create([ 'name' => $boardColumn->name, 'position' => 1, ]); } private function createTicket(Project $project, TicketStatus $ticketStatus, string $title): Ticket { $priority = TicketPriority::factory()->for($project)->create([ 'name' => 'High', 'position' => 2, ]); return Ticket::factory()->create([ '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, ]); } private function allowMove(TicketStatus $sourceStatus, TicketStatus $targetStatus): void { $sourceStatus->allowedTargetStatuses()->syncWithoutDetaching([$targetStatus->id]); } }