user = User::factory()->create(); $this->project = Project::factory()->create(['key' => 'CORE', 'name' => 'Visible']); $this->hidden = Project::factory()->create(['key' => 'HIDE']); app(InitializeProjectTicketMetadata::class)->handle($this->project); app(InitializeProjectTicketMetadata::class)->handle($this->hidden); $this->syncProjectPermissions($this->project, $this->user, ['view']); $this->ticket = $this->newTicket($this->project, 1, ['title' => 'Special needle', 'description' => 'First description', 'assignee_user_id' => $this->user->id]); $this->newTicket($this->project, 2, ['title' => 'Other title', 'description' => 'Needle in description', 'parent_ticket_id' => $this->ticket->id]); $this->newTicket($this->hidden, 1, ['title' => 'Hidden needle']); } public function test_exact_key_and_existing_id_lookup_work_and_hidden_tickets_are_denied(): void { foreach ([['ticket_id' => $this->ticket->id], ['ticket_key' => 'CORE-1']] as $input) { KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, $input)->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.id', $this->ticket->id)->etc()); } KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'HIDE-1'])->assertHasErrors(['cannot view']); KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'CORE-99'])->assertHasErrors(['not found']); } #[DataProvider('invalidSelectors')] public function test_key_lookup_requires_exactly_one_valid_selector(array $input): void { KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, $input)->assertHasErrors(); } public static function invalidSelectors(): array { return [ [[]], [['ticket_key' => 'CORE']], [['ticket_key' => 'core-1']], [['ticket_key' => 'CORE-0']], [['ticket_key' => 'CORE-1', 'ticket_id' => 1]], [['ticket_key' => null]], ]; } public function test_search_combines_filters_and_paginates_only_visible_tickets_with_compact_payloads(): void { KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => 'needle', 'per_page' => 1]) ->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json ->where('data.0.id', $this->ticket->id)->count('data', 1) ->where('data.0.status.id', $this->ticket->ticket_status_id) ->missing('data.0.description')->missing('data.0.project') ->where('meta.total', 2)->where('meta.last_page', 2)->where('meta.has_more_pages', true)->etc()); KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => 'needle', 'page' => 2, 'per_page' => 1]) ->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json ->where('data.0.ticket_key', 'CORE-2')->where('meta.current_page', 2)->where('meta.has_more_pages', false)->etc()); KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, [ 'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'ticket_status_id' => $this->ticket->ticket_status_id, 'ticket_type_id' => $this->ticket->ticket_type_id, 'assignee_user_id' => $this->user->id, 'parent_ticket_id' => null, ])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.id', $this->ticket->id)->etc()); KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['parent_ticket_id' => $this->ticket->id, 'assignee_user_id' => null]) ->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.ticket_key', 'CORE-2')->etc()); KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_type_id' => $this->hidden->ticketTypes()->firstOrFail()->id]) ->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->etc()); KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['project_id' => $this->hidden->id])->assertHasErrors(['cannot view']); KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['page' => 10])->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->where('meta.total', 2)->etc()); } public function test_search_returns_tickets_without_a_status(): void { $this->ticket->update(['ticket_status_id' => null]); KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_key' => 'CORE-1'])->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->ticket->id) ->where('data.0.ticket_status_id', null)->where('data.0.status', null)->etc()); } public function test_literal_search_does_not_expand_sql_wildcards(): void { $this->ticket->update(['title' => 'A 50%_! literal']); KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => '%_!']) ->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.id', $this->ticket->id)->etc()); } public function test_project_list_is_compact_paginated_and_independent_of_assignments_with_team_and_global_access(): void { $this->ticket->update(['assignee_user_id' => null]); KanbanGitServer::actingAs($this->user)->tool(ListProjects::class)->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.key', 'CORE')->missing('data.0.template_state')->where('meta.total', 1)->etc()); $team = Team::factory()->create(); $team->users()->attach($this->user); $this->syncProjectPermissions($this->hidden, $team, ['view']); KanbanGitServer::actingAs($this->user)->tool(ListProjects::class, ['per_page' => 1, 'page' => 2])->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->hidden->id)->where('meta.total', 2)->etc()); $global = User::factory()->create(); $this->syncGlobalPermissions($global, ['admin']); KanbanGitServer::actingAs($global)->tool(SearchTickets::class)->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.total', 3)->etc()); KanbanGitServer::actingAs(User::factory()->create())->tool(ListProjects::class)->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->where('meta.total', 0)->etc()); } public function test_configuration_returns_current_workflow_and_allowed_people_without_full_resources(): void { $first = $this->project->ticketStatuses()->firstOrFail(); $column = BoardColumn::factory()->for($this->project)->create(['position' => 1]); $second = TicketStatus::factory()->for($this->project)->for($column)->create(['position' => 1]); TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 1]); $first->allowedTargetStatuses()->sync([$second->id]); $first->update(['name' => 'Fresh workflow']); $otherViewer = User::factory()->create(); $this->syncProjectPermissions($this->project, $otherViewer, ['view']); User::factory()->create(); KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_per_page' => 1]) ->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json ->where('data.project.id', $this->project->id)->missing('data.project.template_state') ->where('data.permissions.can_write', false)->where('data.permissions.can_change_author', false) ->where('data.ticket_statuses.0.id', $first->id)->where('data.ticket_statuses.0.slug', 'fresh-workflow') ->where('data.ticket_statuses.0.allowed_target_status_ids', [$second->id]) ->where('data.ticket_statuses.0.board_column_id', $first->board_column_id) ->has('data.ticket_field_definitions.0.field_type')->has('data.ticket_types.0.is_parent')->has('data.ticket_priorities.0.id')->has('data.board_columns.0.position') ->count('data.people', 1)->missing('data.people.0.email')->missing('data.people.0.avatar') ->where('meta.people.total', 2)->etc()); KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_page' => 2, 'people_per_page' => 1]) ->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.people.0.id', $otherViewer->id)->etc()); KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->hidden->id])->assertHasErrors(['cannot view']); } public function test_numeric_project_keys_and_configured_column_order_are_supported(): void { $this->project->update(['key' => '42']); $this->ticket->update(['ticket_key' => '42-1']); KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => '42-1'])->assertOk(); KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_key' => '42-1'])->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->ticket->id)->etc()); $later = BoardColumn::factory()->for($this->project)->create(['position' => 2]); $earlier = BoardColumn::factory()->for($this->project)->create(['position' => 1]); $laterStatus = TicketStatus::factory()->for($this->project)->for($later)->create(['position' => 1]); $earlierStatus = TicketStatus::factory()->for($this->project)->for($earlier)->create(['position' => 1]); KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id])->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json ->where('data.board_columns.0.id', $earlier->id) ->where('data.ticket_statuses.1.id', $earlierStatus->id) ->where('data.ticket_statuses.2.id', $laterStatus->id)->etc()); } public function test_pagination_limits_and_missing_projects_are_validated(): void { foreach ([ListProjects::class, SearchTickets::class] as $tool) { KanbanGitServer::actingAs($this->user)->tool($tool, ['per_page' => 101])->assertHasErrors(); KanbanGitServer::actingAs($this->user)->tool($tool, ['page' => 0])->assertHasErrors(); } KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => 999])->assertHasErrors(['not found']); KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_per_page' => 101])->assertHasErrors(); } public function test_application_key_discovers_new_schemas_and_completes_refinement_without_browser_login(): void { $this->syncProjectPermissions($this->project, $this->user, ['write']); $this->withApplicationKey($this->user); $response = $this->postJson('/mcp/kanbangit', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list', 'params' => []])->assertOk(); $tools = collect($response->json('result.tools'))->keyBy('name'); foreach (['list_projects', 'search_tickets', 'get_project_configuration'] as $name) { $this->assertTrue($tools[$name]['annotations']['readOnlyHint']); } $this->assertSame(['ticket_id'], $tools['update_ticket']['inputSchema']['required']); $this->assertArrayHasKey('ticket_key', $tools['get_ticket']['inputSchema']['properties']); $this->assertFalse($tools['update_ticket']['annotations']['destructiveHint']); $this->rpc('update_ticket', ['ticket_id' => $this->ticket->id, 'title' => 'Refined via MCP', 'description' => 'Refinement', 'acceptance_criteria' => [['text' => 'Real criterion']]]); $ticket = $this->rpc('get_ticket', ['ticket_key' => 'CORE-1'])->json('result.structuredContent.data.ticket'); $this->assertSame('Refined via MCP', $ticket['title']); $this->assertSame('Real criterion', $ticket['acceptance_criteria'][0]['text']); $this->rpc('update_ticket', ['ticket_id' => $this->ticket->id, 'acceptance_criteria' => [['id' => $ticket['acceptance_criteria'][0]['id'], 'text' => 'Criterion checked', 'checked' => true]]]); $target = TicketStatus::factory()->for($this->project)->create(['position' => 2]); $this->ticket->ticketStatus->allowedTargetStatuses()->sync([$target->id]); $this->rpc('move_ticket_status', ['ticket_id' => $this->ticket->id, 'ticket_status_id' => $target->id]); $this->assertSame($target->id, $this->ticket->fresh()->ticket_status_id); } private function rpc(string $name, array $arguments): TestResponse { return $this->postJson('/mcp/kanbangit', [ 'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => $name, 'arguments' => $arguments], ])->assertOk()->assertJsonPath('result.isError', false); } private function newTicket(Project $project, int $sequence, array $attributes = []): Ticket { return Ticket::factory()->create([ 'project_id' => $project->id, 'ticket_key' => "{$project->key}-{$sequence}", 'sequence_number' => $sequence, 'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id, 'ticket_type_id' => $project->ticketTypes()->where('is_parent', $sequence === 1)->firstOrFail()->id, 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id, ...$attributes, ]); } }