Add application API and OpenAPI docs
linter / quality (pull_request) Successful in 1m18s
tests / ci (8.4) (pull_request) Successful in 1m28s

This commit is contained in:
2026-04-20 13:22:56 +02:00
parent 70d760e3a6
commit 8b25421e7c
80 changed files with 6908 additions and 1277 deletions
@@ -0,0 +1,81 @@
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
class OpenApiDocumentationTest extends TestCase
{
public function test_openapi_document_is_valid_json_and_covers_application_api_routes(): void
{
$documentPath = base_path('docs/openapi/kanbangit-api.openapi.json');
$this->assertFileExists($documentPath);
$document = json_decode((string) file_get_contents($documentPath), true);
$this->assertIsArray($document);
$this->assertSame(JSON_ERROR_NONE, json_last_error());
$this->assertSame('3.1.0', $document['openapi']);
$this->assertSame([], $document['security']);
$expectedOperations = [
['get', '/projects'],
['post', '/projects'],
['get', '/projects/{project}'],
['delete', '/projects/{project}'],
['get', '/projects/{project}/board'],
['get', '/projects/{project}/backlog'],
['get', '/projects/{project}/ticket-options'],
['get', '/projects/{project}/tickets'],
['post', '/projects/{project}/tickets'],
['get', '/projects/{project}/tickets/{ticket}'],
['patch', '/projects/{project}/tickets/{ticket}'],
['get', '/projects/{project}/tickets/{ticket}/status-targets'],
['patch', '/projects/{project}/tickets/{ticket}/status'],
['get', '/projects/{project}/tickets/{ticket}/branches/validate'],
['post', '/projects/{project}/tickets/{ticket}/branches'],
['post', '/projects/{project}/tickets/{ticket}/links'],
['delete', '/projects/{project}/tickets/{ticket}/links/{ticketLink}'],
['get', '/projects/{project}/settings/board'],
['post', '/projects/{project}/settings/board/columns'],
['patch', '/projects/{project}/settings/board/columns/{boardColumn}'],
['delete', '/projects/{project}/settings/board/columns/{boardColumn}'],
['post', '/projects/{project}/settings/board/statuses'],
['patch', '/projects/{project}/settings/board/statuses/{ticketStatus}'],
['delete', '/projects/{project}/settings/board/statuses/{ticketStatus}'],
['patch', '/projects/{project}/settings/board/statuses/{ticketStatus}/transitions'],
['get', '/projects/{project}/settings/metadata'],
['patch', '/projects/{project}/settings/metadata'],
['post', '/projects/{project}/settings/metadata/types'],
['patch', '/projects/{project}/settings/metadata/types/{ticketType}'],
['delete', '/projects/{project}/settings/metadata/types/{ticketType}'],
['post', '/projects/{project}/settings/metadata/priorities'],
['patch', '/projects/{project}/settings/metadata/priorities/{ticketPriority}'],
['delete', '/projects/{project}/settings/metadata/priorities/{ticketPriority}'],
['post', '/projects/{project}/settings/metadata/fields'],
['patch', '/projects/{project}/settings/metadata/fields/{ticketFieldDefinition}'],
['delete', '/projects/{project}/settings/metadata/fields/{ticketFieldDefinition}'],
['post', '/projects/{project}/settings/metadata/link-types'],
['patch', '/projects/{project}/settings/metadata/link-types/{ticketLinkType}'],
['delete', '/projects/{project}/settings/metadata/link-types/{ticketLinkType}'],
['get', '/projects/{project}/settings/repositories'],
['post', '/projects/{project}/settings/repositories'],
['patch', '/projects/{project}/settings/repositories/{repositoryConnection}'],
['delete', '/projects/{project}/settings/repositories/{repositoryConnection}'],
['get', '/git-connections'],
['post', '/git-connections'],
['patch', '/git-connections/{gitConnection}'],
['delete', '/git-connections/{gitConnection}'],
];
foreach ($expectedOperations as [$method, $path]) {
$this->assertArrayHasKey($path, $document['paths']);
$this->assertArrayHasKey($method, $document['paths'][$path]);
}
$this->assertArrayHasKey('ValidationError', $document['components']['schemas']);
$this->assertArrayHasKey('TicketUpdateRequest', $document['components']['schemas']);
$this->assertArrayHasKey('UserGitConnectionRequest', $document['components']['schemas']);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Tests\Feature\Api;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProjectApiTest extends TestCase
{
use RefreshDatabase;
public function test_users_can_list_only_visible_projects(): void
{
$user = User::factory()->create();
$visibleProject = Project::factory()->create([
'name' => 'Visible project',
]);
Project::factory()->create([
'name' => 'Hidden project',
]);
$this->syncProjectPermissions($visibleProject, $user, ['view']);
$this->actingAs($user)
->getJson(route('api.projects.index'))
->assertOk()
->assertJsonPath('data.0.name', 'Visible project')
->assertJsonCount(1, 'data');
}
public function test_users_with_project_create_permission_can_create_projects(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['create']);
$this->actingAs($user)
->postJson(route('api.projects.store'), [
'key' => 'api',
'name' => 'API Project',
])
->assertCreated()
->assertJsonPath('data.key', 'API')
->assertJsonPath('data.name', 'API Project')
->assertJsonPath('meta.message', 'API Project was created.');
$project = Project::query()->where('key', 'API')->firstOrFail();
$this->assertSame(2, $project->ticketTypes()->count());
$this->assertSame(1, $project->ticketStatuses()->count());
}
public function test_project_show_and_delete_enforce_permissions(): void
{
$user = User::factory()->create();
$admin = User::factory()->create();
$project = Project::factory()->create([
'key' => 'CORE',
]);
app(InitializeProjectTicketMetadata::class)->handle($project);
$this->actingAs($user)
->getJson(route('api.projects.show', $project))
->assertForbidden();
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->getJson(route('api.projects.show', $project))
->assertOk()
->assertJsonPath('data.key', 'CORE');
$this->syncGlobalPermissions($admin, ['admin']);
$this->actingAs($admin)
->deleteJson(route('api.projects.destroy', $project), [
'confirmation_key' => 'CORE',
])
->assertNoContent();
$this->assertDatabaseMissing('projects', [
'id' => $project->id,
]);
}
}
+231
View File
@@ -0,0 +1,231 @@
<?php
namespace Tests\Feature\Api;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class SettingsApiTest extends TestCase
{
use RefreshDatabase;
public function test_board_settings_can_be_read_and_mutated(): void
{
[$user, $project] = $this->projectContext();
$this->actingAs($user)
->getJson(route('api.projects.settings.board', $project))
->assertOk()
->assertJsonPath('data.backlogTicketStatuses.0.name', 'Backlog');
$columnId = $this->actingAs($user)
->postJson(route('api.projects.settings.board.columns.store', $project), [
'name' => 'In Review',
])
->assertCreated()
->assertJsonPath('data.name', 'In Review')
->json('data.id');
$this->actingAs($user)
->patchJson(route('api.projects.settings.board.columns.update', [$project, $columnId]), [
'name' => 'Review',
'position' => 1,
])
->assertOk()
->assertJsonPath('data.name', 'Review');
$statusId = $this->actingAs($user)
->postJson(route('api.projects.settings.board.statuses.store', $project), [
'board_column_id' => $columnId,
'name' => 'Ready',
])
->assertCreated()
->assertJsonPath('data.name', 'Ready')
->json('data.id');
$replacementStatusId = $this->actingAs($user)
->postJson(route('api.projects.settings.board.statuses.store', $project), [
'board_column_id' => $columnId,
'name' => 'QA',
])
->assertCreated()
->json('data.id');
$backlogStatus = $project->ticketStatuses()->whereNull('board_column_id')->firstOrFail();
$this->actingAs($user)
->patchJson(route('api.projects.settings.board.statuses.transitions.update', [$project, $backlogStatus]), [
'allowed_ticket_status_ids' => [$statusId],
])
->assertOk()
->assertJsonPath('data.allowed_target_statuses.0.id', $statusId);
$this->actingAs($user)
->deleteJson(route('api.projects.settings.board.statuses.destroy', [$project, $statusId]))
->assertNoContent();
$this->assertDatabaseHas('ticket_statuses', [
'id' => $replacementStatusId,
]);
$this->actingAs($user)
->deleteJson(route('api.projects.settings.board.columns.destroy', [$project, $columnId]))
->assertNoContent();
}
public function test_metadata_settings_can_be_read_and_mutated(): void
{
[$user, $project] = $this->projectContext();
$this->actingAs($user)
->getJson(route('api.projects.settings.metadata', $project))
->assertOk()
->assertJsonPath('data.ticketTypes.0.name', 'Task');
$this->actingAs($user)
->patchJson(route('api.projects.settings.metadata.update', $project), [
'show_author' => false,
'show_story_points' => false,
])
->assertOk()
->assertJsonPath('data.show_author', false);
$typeId = $this->actingAs($user)
->postJson(route('api.projects.settings.metadata.types.store', $project), [
'is_parent' => true,
'name' => 'Epic',
])
->assertCreated()
->assertJsonPath('data.name', 'Epic')
->json('data.id');
$this->actingAs($user)
->patchJson(route('api.projects.settings.metadata.types.update', [$project, $typeId]), [
'name' => 'Initiative',
'position' => 1,
])
->assertOk()
->assertJsonPath('data.name', 'Initiative');
$priorityId = $this->actingAs($user)
->postJson(route('api.projects.settings.metadata.priorities.store', $project), [
'name' => 'Urgent',
])
->assertCreated()
->json('data.id');
$fieldId = $this->actingAs($user)
->postJson(route('api.projects.settings.metadata.fields.store', $project), [
'field_type' => 'number',
'label' => 'Risk',
])
->assertCreated()
->json('data.id');
$linkTypeId = $this->actingAs($user)
->postJson(route('api.projects.settings.metadata.link-types.store', $project), [
'name' => 'Relates to',
])
->assertCreated()
->json('data.id');
$this->actingAs($user)
->deleteJson(route('api.projects.settings.metadata.fields.destroy', [$project, $fieldId]))
->assertNoContent();
$this->actingAs($user)
->deleteJson(route('api.projects.settings.metadata.link-types.destroy', [$project, $linkTypeId]))
->assertNoContent();
$this->actingAs($user)
->deleteJson(route('api.projects.settings.metadata.priorities.destroy', [$project, $priorityId]))
->assertNoContent();
$this->actingAs($user)
->deleteJson(route('api.projects.settings.metadata.types.destroy', [$project, $typeId]))
->assertNoContent();
}
public function test_project_repositories_and_user_git_connections_are_available(): void
{
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/user' => Http::response([
'id' => 101,
'login' => 'api-user',
'full_name' => 'API User',
]),
'https://git.example.test/api/v1/user/repos*' => Http::response([
[
'id' => 501,
'name' => 'kanbangit',
'full_name' => 'core-team/kanbangit',
'html_url' => 'https://git.example.test/core-team/kanbangit',
'owner' => [
'login' => 'core-team',
],
'private' => true,
],
]),
]);
[$user, $project] = $this->projectContext();
$gitConnectionId = $this->actingAs($user)
->postJson(route('api.git-connections.store'), [
'api_token' => 'secret-token',
'base_url' => 'https://git.example.test/api/v1',
'provider' => 'gitea',
])
->assertCreated()
->assertJsonPath('data.account_login', 'api-user')
->json('data.id');
$this->actingAs($user)
->getJson(route('api.git-connections.index'))
->assertOk()
->assertJsonPath('data.0.id', $gitConnectionId);
$repositoryConnectionId = $this->actingAs($user)
->postJson(route('api.projects.settings.repositories.store', $project), [
'provider_repository_id' => '501',
'user_git_connection_id' => $gitConnectionId,
])
->assertCreated()
->assertJsonPath('data.repository_full_name', 'core-team/kanbangit')
->json('data.id');
$this->actingAs($user)
->getJson(route('api.projects.settings.repositories', $project))
->assertOk()
->assertJsonPath('data.repositoryConnections.0.id', $repositoryConnectionId)
->assertJsonPath('data.availableRepositorySources.0.repositories.0.full_name', 'core-team/kanbangit');
$this->actingAs($user)
->deleteJson(route('api.projects.settings.repositories.destroy', [$project, $repositoryConnectionId]))
->assertNoContent();
$this->actingAs($user)
->deleteJson(route('api.git-connections.destroy', $gitConnectionId))
->assertNoContent();
}
/**
* @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];
}
}
+264
View File
@@ -0,0 +1,264 @@
<?php
namespace Tests\Feature\Api;
use App\Actions\Git\GitProviderKey;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
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 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->actingAs($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->actingAs($user)
->postJson(route('api.projects.tickets.store', $project), [
'acceptance_criteria' => 'Can sign in',
'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('meta.message', 'CORE-1 was created.');
$ticket = Ticket::query()->findOrFail($createResponse->json('data.id'));
$this->actingAs($user)
->getJson(route('api.projects.tickets.index', $project))
->assertOk()
->assertJsonPath('data.0.ticket_key', 'CORE-1');
$this->actingAs($user)
->patchJson(route('api.projects.tickets.update', [$project, $ticket]), [
'acceptance_criteria' => 'Can sign in from the API',
'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('meta.message', 'CORE-1 was updated.');
$this->actingAs($user)
->getJson(route('api.projects.tickets.show', [$project, $ticket]))
->assertOk()
->assertJsonPath('data.ticket.ticket_key', 'CORE-1')
->assertJsonPath('data.ticket.story_points', 5);
}
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->actingAs($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->actingAs($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_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->actingAs($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->actingAs($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->actingAs($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->actingAs($user)
->getJson(route('api.projects.backlog', $project))
->assertOk()
->assertJsonPath('data.tickets.0.id', $ticket->id);
$this->actingAs($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',
]);
}
}