436 lines
17 KiB
PHP
436 lines
17 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Api;
|
|
|
|
use App\Actions\Git\GitProviderKey;
|
|
use App\Actions\Notifications\DeliverNotificationAction;
|
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
|
use App\Enums\NotificationDeliveryStatus;
|
|
use App\Models\BoardColumn;
|
|
use App\Models\NotificationAction;
|
|
use App\Models\Project;
|
|
use App\Models\RepositoryConnection;
|
|
use App\Models\Ticket;
|
|
use App\Models\TicketLinkType;
|
|
use App\Models\TicketStatus;
|
|
use App\Models\User;
|
|
use App\Models\UserGitConnection;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Tests\TestCase;
|
|
|
|
class TicketApiTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_ticket_read_and_write_api_flows(): void
|
|
{
|
|
[$user, $project] = $this->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_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',
|
|
]);
|
|
}
|
|
}
|