postJson('/mcp/kanbangit', [ 'id' => 1, 'jsonrpc' => '2.0', 'method' => 'tools/list', 'params' => [], ])->assertUnauthorized(); } public function test_valid_application_key_can_list_tools_over_http(): void { $user = User::factory()->create(); $this->withApplicationKey($user) ->postJson('/mcp/kanbangit', [ 'id' => 1, 'jsonrpc' => '2.0', 'method' => 'tools/list', 'params' => [], ]) ->assertOk() ->assertJsonPath('result.tools.0.name', 'list_my_assigned_tickets') ->assertJsonPath('result.tools.5.name', 'move_ticket_status'); } public function test_list_my_assigned_tickets_returns_only_visible_current_user_tickets(): void { $user = User::factory()->create([ 'name' => 'MCP User', ]); $otherUser = User::factory()->create(); $visibleProject = $this->project('CORE', 'Visible project'); $hiddenProject = $this->project('HIDE', 'Hidden project'); $visibleStatus = $visibleProject->ticketStatuses()->firstOrFail(); $hiddenStatus = $hiddenProject->ticketStatuses()->firstOrFail(); $this->syncProjectPermissions($visibleProject, $user, ['view']); $this->ticket($visibleProject, $visibleStatus, [ 'assignee_user_id' => $user->id, 'sequence_number' => 2, 'ticket_key' => 'CORE-2', 'title' => 'Visible assigned ticket', ]); $this->ticket($visibleProject, $visibleStatus, [ 'assignee_user_id' => $otherUser->id, 'sequence_number' => 3, 'ticket_key' => 'CORE-3', 'title' => 'Other user ticket', ]); $this->ticket($hiddenProject, $hiddenStatus, [ 'assignee_user_id' => $user->id, 'sequence_number' => 1, 'ticket_key' => 'HIDE-1', 'title' => 'Hidden assigned ticket', ]); KanbanGitServer::actingAs($user) ->tool(ListMyAssignedTickets::class) ->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json ->where('meta.assignee.name', 'MCP User') ->where('data.0.project.name', 'Visible project') ->where('data.0.tickets.0.ticket_key', 'CORE-2') ->count('data.0.tickets', 1) ->count('data', 1) ->etc()); } public function test_list_tickets_by_status_works_globally_and_with_project_filter(): void { $user = User::factory()->create(); $visibleProject = $this->project('CORE', 'Core project'); $otherVisibleProject = $this->project('OPS', 'Ops project'); $visibleStatus = $this->ticketStatus($visibleProject, 'Ready'); $otherStatus = $this->ticketStatus($otherVisibleProject, 'Ready'); $this->syncProjectPermissions($visibleProject, $user, ['view']); $this->syncProjectPermissions($otherVisibleProject, $user, ['view']); $this->ticket($visibleProject, $visibleStatus, [ 'sequence_number' => 1, 'ticket_key' => 'CORE-1', 'title' => 'Core ready ticket', ]); $this->ticket($otherVisibleProject, $otherStatus, [ 'sequence_number' => 1, 'ticket_key' => 'OPS-1', 'title' => 'Ops ready ticket', ]); KanbanGitServer::actingAs($user) ->tool(ListTicketsByStatus::class, [ 'ticket_status_id' => $visibleStatus->id, ]) ->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json ->where('meta.statuses.0.id', $visibleStatus->id) ->where('data.0.project.name', 'Core project') ->where('data.0.tickets.0.ticket_key', 'CORE-1') ->count('data', 1) ->etc()); KanbanGitServer::actingAs($user) ->tool(ListTicketsByStatus::class, [ 'project_id' => $otherVisibleProject->id, 'status_name' => 'Ready', ]) ->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json ->where('meta.statuses.0.id', $otherStatus->id) ->where('data.0.project.name', 'Ops project') ->where('data.0.tickets.0.ticket_key', 'OPS-1') ->count('data', 1) ->etc()); } public function test_status_ticket_lists_exclude_invisible_projects(): void { $user = User::factory()->create(); $visibleProject = $this->project('CORE', 'Visible project'); $hiddenProject = $this->project('HIDE', 'Hidden project'); $visibleStatus = $this->ticketStatus($visibleProject, 'Visible Review'); $hiddenStatus = $this->ticketStatus($hiddenProject, 'Hidden Review'); $this->syncProjectPermissions($visibleProject, $user, ['view']); $this->ticket($visibleProject, $visibleStatus, [ 'ticket_key' => 'CORE-1', 'title' => 'Visible status ticket', ]); $this->ticket($hiddenProject, $hiddenStatus, [ 'ticket_key' => 'HIDE-1', 'title' => 'Hidden status ticket', ]); KanbanGitServer::actingAs($user) ->tool(ListTicketsByStatus::class, [ 'ticket_status_id' => $visibleStatus->id, ]) ->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json ->where('data.0.tickets.0.ticket_key', 'CORE-1') ->count('data', 1) ->etc()); KanbanGitServer::actingAs($user) ->tool(ListTicketsByStatus::class, [ 'ticket_status_id' => $hiddenStatus->id, ]) ->assertHasErrors(['Ticket status was not found in a visible project.']); } public function test_get_ticket_returns_visible_ticket_detail(): void { $user = User::factory()->create(); $project = $this->project('CORE', 'Core project'); $status = $project->ticketStatuses()->firstOrFail(); $ticket = $this->ticket($project, $status, [ 'description' => 'Detailed MCP description', 'ticket_key' => 'CORE-1', 'title' => 'Detailed ticket', ]); $this->syncProjectPermissions($project, $user, ['view']); KanbanGitServer::actingAs($user) ->tool(GetTicket::class, [ 'ticket_id' => $ticket->id, ]) ->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json ->where('data.project.name', 'Core project') ->where('data.ticket.ticket_key', 'CORE-1') ->where('data.ticket.description', 'Detailed MCP description') ->etc()); } public function test_description_update_requires_write_permission_and_preserves_unrelated_fields(): void { $user = User::factory()->create(); $assignee = User::factory()->create(); $project = $this->project('CORE', 'Core project'); $status = $project->ticketStatuses()->firstOrFail(); $ticket = $this->ticket($project, $status, [ 'assignee_user_id' => $assignee->id, 'description' => 'Old description', 'story_points' => 8, 'ticket_key' => 'CORE-1', 'title' => 'Description ticket', ]); $this->syncProjectPermissions($project, $user, ['view']); KanbanGitServer::actingAs($user) ->tool(UpdateTicketDescription::class, [ 'description' => 'New description', 'ticket_id' => $ticket->id, ]) ->assertHasErrors(['cannot update tickets']); $this->syncProjectPermissions($project, $user, ['write']); KanbanGitServer::actingAs($user) ->tool(UpdateTicketDescription::class, [ 'description' => 'New description', 'ticket_id' => $ticket->id, ]) ->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json ->where('data.ticket.description', 'New description') ->where('data.ticket.assignee_user_id', $assignee->id) ->where('data.ticket.story_points', 8) ->etc()); } public function test_assignee_update_supports_assigning_and_unassigning(): void { $user = User::factory()->create(); $assignee = User::factory()->create(); $project = $this->project('CORE', 'Core project'); $status = $project->ticketStatuses()->firstOrFail(); $ticket = $this->ticket($project, $status, [ 'assignee_user_id' => null, 'ticket_key' => 'CORE-1', 'title' => 'Assignee ticket', ]); $this->syncProjectPermissions($project, $user, ['write']); KanbanGitServer::actingAs($user) ->tool(UpdateTicketAssignee::class, [ 'assignee_user_id' => $assignee->id, 'ticket_id' => $ticket->id, ]) ->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json ->where('data.ticket.assignee_user_id', $assignee->id) ->etc()); KanbanGitServer::actingAs($user) ->tool(UpdateTicketAssignee::class, [ 'assignee_user_id' => null, 'ticket_id' => $ticket->id, ]) ->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json ->where('data.ticket.assignee_user_id', null) ->etc()); } public function test_status_move_requires_write_permission_and_allowed_transition(): void { $user = User::factory()->create(); $project = $this->project('CORE', 'Core project'); $backlogStatus = $project->ticketStatuses()->firstOrFail(); $doingStatus = $this->ticketStatus($project, 'Doing'); $blockedStatus = $this->ticketStatus($project, 'Blocked'); $ticket = $this->ticket($project, $backlogStatus, [ 'ticket_key' => 'CORE-1', 'title' => 'Move ticket', ]); $backlogStatus->allowedTargetStatuses()->sync([$doingStatus->id]); $this->syncProjectPermissions($project, $user, ['view']); KanbanGitServer::actingAs($user) ->tool(MoveTicketStatus::class, [ 'ticket_id' => $ticket->id, 'ticket_status_id' => $doingStatus->id, ]) ->assertHasErrors(['cannot update tickets']); $this->syncProjectPermissions($project, $user, ['write']); KanbanGitServer::actingAs($user) ->tool(MoveTicketStatus::class, [ 'ticket_id' => $ticket->id, 'ticket_status_id' => $blockedStatus->id, ]) ->assertHasErrors(['allowed to move']); KanbanGitServer::actingAs($user) ->tool(MoveTicketStatus::class, [ 'status_slug' => 'doing', 'ticket_id' => $ticket->id, ]) ->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json ->where('data.ticket.ticket_status_id', $doingStatus->id) ->where('meta.moved', true) ->where('meta.destination_label', 'Doing') ->etc()); } public function test_ambiguous_status_name_returns_mcp_error(): void { $user = User::factory()->create(); $firstProject = $this->project('ONE', 'First project'); $secondProject = $this->project('TWO', 'Second project'); $this->ticketStatus($firstProject, 'Review'); $this->ticketStatus($secondProject, 'Review'); $this->syncProjectPermissions($firstProject, $user, ['view']); $this->syncProjectPermissions($secondProject, $user, ['view']); KanbanGitServer::actingAs($user) ->tool(ListTicketsByStatus::class, [ 'status_name' => 'Review', ]) ->assertHasErrors(['Status lookup is ambiguous']) ->assertSee(['First project', 'Second project']); } private function project(string $key, string $name): Project { $project = Project::factory()->create([ 'key' => $key, 'name' => $name, ]); app(InitializeProjectTicketMetadata::class)->handle($project); return $project; } private function ticketStatus(Project $project, string $name): TicketStatus { $position = $project->boardColumns()->count() + 1; $boardColumn = BoardColumn::factory()->for($project)->create([ 'name' => $name, 'position' => $position, ]); return TicketStatus::factory() ->for($project) ->for($boardColumn) ->create([ 'name' => $name, 'position' => $position, ]); } /** * @param array $attributes */ private function ticket(Project $project, TicketStatus $ticketStatus, array $attributes = []): Ticket { $priority = $project->ticketPriorities()->firstOrFail(); $type = $project->ticketTypes()->where('is_parent', false)->firstOrFail(); return Ticket::factory()->create([ 'project_id' => $project->id, 'sequence_number' => 1, 'ticket_key' => "{$project->key}-1", 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $ticketStatus->id, 'ticket_type_id' => $type->id, 'title' => 'MCP ticket', ...$attributes, ]); } }