projectContext(); $priority = $project->ticketPriorities()->firstOrFail(); $status = $project->ticketStatuses()->firstOrFail(); $type = $project->ticketTypes()->where('is_parent', false)->firstOrFail(); $this->withApplicationKey($user) ->getJson(route('api.projects.ticket-options', [ 'project' => $project, 'source' => 'backlog', ])) ->assertOk() ->assertJsonPath('data.defaultTicketStatusId', $status->id) ->assertJsonPath('data.ticketTypes.0.id', $type->id); $createResponse = $this->withApplicationKey($user) ->postJson(route('api.projects.tickets.store', $project), [ 'acceptance_criteria' => [ [ 'checked' => false, 'indent' => 0, 'text' => 'Credentials are validated', ], [ 'checked' => true, 'indent' => 1, 'text' => 'Nested validation is supported', ], ], 'description' => 'Build the API-created ticket', 'story_points' => 3, 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $status->id, 'ticket_type_id' => $type->id, 'title' => 'API ticket', ]) ->assertCreated() ->assertJsonPath('data.ticket_key', 'CORE-1') ->assertJsonPath('data.acceptance_criteria.1.indent', 1) ->assertJsonPath('data.suggested_branch_name', 'core-1-api-ticket') ->assertJsonPath('meta.message', 'CORE-1 was created.'); $ticket = Ticket::query()->findOrFail($createResponse->json('data.id')); $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.index', $project)) ->assertOk() ->assertJsonPath('data.0.ticket_key', 'CORE-1'); $this->withApplicationKey($user) ->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [ 'acceptance_criteria' => [ [ 'checked' => true, 'indent' => 0, 'text' => 'API update preserves checked state', ], ], 'blocked_comment' => null, 'description' => 'Updated through the API', 'is_blocked' => false, 'parent_ticket_id' => null, 'story_points' => 5, 'ticket_priority_id' => $priority->id, 'ticket_type_id' => $type->id, 'title' => 'Updated API ticket', ]) ->assertOk() ->assertJsonPath('data.title', 'Updated API ticket') ->assertJsonPath('data.acceptance_criteria.0.checked', true) ->assertJsonPath('meta.message', 'CORE-1 was updated.'); $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.show', [$project, $ticket])) ->assertOk() ->assertJsonPath('data.ticket.ticket_key', 'CORE-1') ->assertJsonPath('data.ticket.story_points', 5) ->assertJsonPath('data.ticket.suggested_branch_name', 'core-1-updated-api-ticket') ->assertJsonPath('data.suggestedBranchName', 'core-1-updated-api-ticket'); } public function test_ticket_update_hides_author_for_non_admins_when_author_visibility_is_disabled(): void { $writer = User::factory()->create(); $author = User::factory()->create([ 'name' => 'Author User', ]); $project = Project::factory()->create([ 'key' => 'CORE', 'show_author' => false, ]); app(InitializeProjectTicketMetadata::class)->handle($project); $priority = $project->ticketPriorities()->firstOrFail(); $status = $project->ticketStatuses()->firstOrFail(); $type = $project->ticketTypes()->where('is_parent', false)->firstOrFail(); $ticket = Ticket::factory()->create([ 'author_user_id' => $author->id, 'project_id' => $project->id, 'sequence_number' => 1, 'ticket_key' => 'CORE-1', 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $status->id, 'ticket_type_id' => $type->id, 'title' => 'API ticket', ]); $this->syncProjectPermissions($project, $writer, ['write', 'view']); $this->withApplicationKey($writer) ->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [ 'acceptance_criteria' => [], 'blocked_comment' => $ticket->blocked_comment, 'description' => $ticket->description, 'is_blocked' => $ticket->is_blocked, 'parent_ticket_id' => $ticket->parent_ticket_id, 'story_points' => $ticket->story_points, 'ticket_priority_id' => $priority->id, 'ticket_type_id' => $type->id, 'title' => 'Updated API ticket', ]) ->assertOk() ->assertJsonPath('data.author', null) ->assertJsonPath('data.author_user_id', null); } public function test_ticket_options_only_return_project_visible_users(): void { [$user, $project] = $this->projectContext(); $projectViewer = User::factory()->create(); $outsider = User::factory()->create(); $this->syncProjectPermissions($project, $projectViewer, ['view']); $response = $this->withApplicationKey($user) ->getJson(route('api.projects.ticket-options', [ 'project' => $project, 'source' => 'backlog', ])) ->assertOk(); $userIds = collect($response->json('data.users')) ->pluck('id') ->all(); $this->assertContains($user->id, $userIds); $this->assertContains($projectViewer->id, $userIds); $this->assertNotContains($outsider->id, $userIds); } public function test_tickets_can_return_status_targets_and_move_statuses(): void { [$user, $project] = $this->projectContext(); $backlogStatus = $project->ticketStatuses()->firstOrFail(); $boardColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Doing', 'position' => 1, ]); $doingStatus = TicketStatus::factory() ->for($project) ->for($boardColumn) ->create([ 'name' => 'Doing', 'position' => 1, ]); $backlogStatus->allowedTargetStatuses()->sync([$doingStatus->id]); $ticket = $this->ticket($project, $backlogStatus); $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.status-targets', [$project, $ticket])) ->assertOk() ->assertJsonPath('data.current_status.id', $backlogStatus->id) ->assertJsonPath('data.target_statuses.0.id', $doingStatus->id); $this->withApplicationKey($user) ->patchJson(route('api.projects.tickets.status.update', [$project, $ticket]), [ 'ticket_status_id' => $doingStatus->id, ]) ->assertOk() ->assertJsonPath('data.ticket_status_id', $doingStatus->id) ->assertJsonPath('meta.message', 'CORE-1 moved to Doing.'); $this->assertDatabaseHas('ticket_worklog_entries', [ 'ticket_id' => $ticket->id, 'actor_id' => $user->id, 'entry_type' => 'status_changed', ]); } public function test_status_moves_still_queue_notification_deliveries_over_the_api(): void { Queue::fake(); [$user, $project] = $this->projectContext(); $backlogStatus = $project->ticketStatuses()->firstOrFail(); $boardColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Doing', 'position' => 1, ]); $doingStatus = TicketStatus::factory() ->for($project) ->for($boardColumn) ->create([ 'name' => 'Doing', 'position' => 1, ]); $backlogStatus->allowedTargetStatuses()->sync([$doingStatus->id]); $ticket = $this->ticket($project, $backlogStatus); $notificationAction = NotificationAction::factory() ->webhook() ->for($project) ->create(); $this->withApplicationKey($user) ->patchJson(route('api.projects.tickets.status.update', [$project, $ticket]), [ 'ticket_status_id' => $doingStatus->id, ]) ->assertOk() ->assertJsonPath('data.ticket_status_id', $doingStatus->id) ->assertJsonPath('meta.message', 'CORE-1 moved to Doing.'); Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($notificationAction): bool { return $job->notificationActionId === $notificationAction->id && $job->event->payload()->ticketKey === 'CORE-1' && $job->event->payload()->toStatusLabel === 'Doing' && $job->notificationDeliveryLogId !== null; }); $this->assertDatabaseHas('notification_delivery_logs', [ 'notification_action_id' => $notificationAction->id, 'delivery_status' => NotificationDeliveryStatus::Pending->value, 'attempt_count' => 0, ]); } public function test_no_op_status_moves_keep_the_existing_api_response_and_do_not_queue_notifications(): void { Queue::fake(); [$user, $project] = $this->projectContext(); $backlogStatus = $project->ticketStatuses()->firstOrFail(); $ticket = $this->ticket($project, $backlogStatus); NotificationAction::factory() ->webhook() ->for($project) ->create(); $this->withApplicationKey($user) ->patchJson(route('api.projects.tickets.status.update', [$project, $ticket]), [ 'ticket_status_id' => $backlogStatus->id, ]) ->assertOk() ->assertJsonPath('data.ticket_status_id', $backlogStatus->id) ->assertJsonPath('meta.message', 'CORE-1 already belongs to Backlog.'); Queue::assertNothingPushed(); $this->assertDatabaseCount('ticket_worklog_entries', 0); } public function test_counterpart_link_direction_is_reflected_on_both_tickets_in_the_api(): void { [$user, $project] = $this->projectContext(); $status = $project->ticketStatuses()->firstOrFail(); $blockingTicket = $this->ticket($project, $status); $blockedTicket = $this->ticket($project, $status, 2); $linkType = TicketLinkType::factory()->for($project)->create([ 'label' => 'Blocks', 'counterpart_label' => 'Blocked by', ]); $this->withApplicationKey($user) ->postJson(route('api.projects.tickets.links.store', [$project, $blockedTicket]), [ 'linked_ticket_id' => $blockingTicket->id, 'ticket_link_type_id' => $linkType->id, 'link_direction' => 'counterpart', ]) ->assertCreated() ->assertJsonPath('data.source_ticket_id', $blockingTicket->id) ->assertJsonPath('data.target_ticket_id', $blockedTicket->id) ->assertJsonPath('data.ticket_link_type.label', 'Blocked by') ->assertJsonPath('meta.message', 'Blocked by link was saved.'); $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.show', [$project, $blockingTicket])) ->assertOk() ->assertJsonPath('data.linkedTickets.0.ticket_link_type.label', 'Blocks'); $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.show', [$project, $blockedTicket])) ->assertOk() ->assertJsonPath('data.linkedTickets.0.ticket_link_type.label', 'Blocked by'); $this->withApplicationKey($user) ->postJson(route('api.projects.tickets.links.store', [$project, $blockedTicket]), [ 'linked_ticket_id' => $blockingTicket->id, 'ticket_link_type_id' => $linkType->id, 'link_direction' => 'invalid', ]) ->assertUnprocessable() ->assertJsonValidationErrors('link_direction'); } public function test_ticket_links_and_branch_endpoints_are_available(): void { Http::preventStrayRequests(); Http::fake([ 'https://git.example.test/api/v1/repos/core-team/kanbangit/branches*' => Http::sequence() ->push([ ['name' => 'main'], ], 200) ->push([ ['name' => 'main'], ], 200) ->push([ 'name' => 'core-1-api-ticket', ], 201), 'https://git.example.test/api/v1/repos/core-team/kanbangit' => Http::response([ 'id' => 501, 'name' => 'kanbangit', 'full_name' => 'core-team/kanbangit', 'html_url' => 'https://git.example.test/core-team/kanbangit', 'default_branch' => 'main', 'owner' => [ 'login' => 'core-team', ], 'private' => false, ]), ]); [$user, $project] = $this->projectContext(); $ticket = $this->ticket($project, $project->ticketStatuses()->firstOrFail()); $linkedTicket = $this->ticket($project, $project->ticketStatuses()->firstOrFail(), 2); $linkType = TicketLinkType::factory()->for($project)->create([ 'label' => 'Relates to', 'position' => 1, ]); $this->withApplicationKey($user) ->postJson(route('api.projects.tickets.links.store', [$project, $ticket]), [ 'linked_ticket_id' => $linkedTicket->id, 'ticket_link_type_id' => $linkType->id, ]) ->assertCreated() ->assertJsonPath('data.ticket_link_type.label', 'Relates to') ->assertJsonPath('meta.message', 'Relates to link was saved.'); $repositoryConnection = RepositoryConnection::factory()->for($project)->create([ 'base_url' => 'https://git.example.test', 'owner_name' => 'core-team', 'provider' => GitProviderKey::Gitea, 'provider_repository_id' => '501', 'repository_full_name' => 'core-team/kanbangit', 'repository_name' => 'kanbangit', 'repository_url' => 'https://git.example.test/core-team/kanbangit', ]); UserGitConnection::factory()->for($user)->create([ 'api_token' => 'branch-token', 'base_url' => 'https://git.example.test', 'provider' => GitProviderKey::Gitea, ]); $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.branches.validate', [ 'project' => $project, 'ticket' => $ticket, 'branch_name' => 'core-1-api-ticket', 'repository_connection_id' => $repositoryConnection->id, ])) ->assertOk() ->assertJsonPath('data.is_valid', true); $this->withApplicationKey($user) ->postJson(route('api.projects.tickets.branches.store', [$project, $ticket]), [ 'branch_name' => 'core-1-api-ticket', 'repository_connection_id' => $repositoryConnection->id, ]) ->assertCreated() ->assertJsonPath('data.branch_name', 'core-1-api-ticket') ->assertJsonPath('meta.message', 'core-1-api-ticket was created in core-team/kanbangit.'); } public function test_board_and_backlog_payloads_are_returned(): void { [$user, $project] = $this->projectContext(); $backlogStatus = $project->ticketStatuses()->firstOrFail(); $ticket = $this->ticket($project, $backlogStatus); $boardColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Todo', 'position' => 1, ]); TicketStatus::factory() ->for($project) ->for($boardColumn) ->create([ 'name' => 'Todo', 'position' => 1, ]); $this->withApplicationKey($user) ->getJson(route('api.projects.backlog', $project)) ->assertOk() ->assertJsonPath('data.tickets.0.id', $ticket->id); $this->withApplicationKey($user) ->getJson(route('api.projects.board', $project)) ->assertOk() ->assertJsonPath('data.boardColumns.0.name', 'Todo'); } /** * @return array{0: User, 1: Project} */ private function projectContext(): array { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $this->syncProjectPermissions($project, $user, ['edit', 'view']); return [$user, $project]; } private function ticket(Project $project, TicketStatus $ticketStatus, int $sequenceNumber = 1): Ticket { $priority = $project->ticketPriorities()->firstOrFail(); $type = $project->ticketTypes()->where('is_parent', false)->firstOrFail(); return Ticket::factory()->create([ 'project_id' => $project->id, 'sequence_number' => $sequenceNumber, 'ticket_key' => "CORE-{$sequenceNumber}", 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $ticketStatus->id, 'ticket_type_id' => $type->id, 'title' => 'API ticket', ]); } }