487 lines
18 KiB
PHP
487 lines
18 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Mcp;
|
|
|
|
use App\Actions\Notifications\DeliverNotificationAction;
|
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
|
use App\Enums\NotificationDeliveryStatus;
|
|
use App\Mcp\Servers\KanbanGitServer;
|
|
use App\Mcp\Tools\GetTicket;
|
|
use App\Mcp\Tools\ListMyAssignedTickets;
|
|
use App\Mcp\Tools\ListTicketsByStatus;
|
|
use App\Mcp\Tools\MoveTicketStatus;
|
|
use App\Mcp\Tools\UpdateTicketAssignee;
|
|
use App\Mcp\Tools\UpdateTicketDescription;
|
|
use App\Models\BoardColumn;
|
|
use App\Models\NotificationAction;
|
|
use App\Models\Project;
|
|
use App\Models\Ticket;
|
|
use App\Models\TicketStatus;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Illuminate\Testing\Fluent\AssertableJson;
|
|
use Tests\TestCase;
|
|
|
|
class KanbanGitMcpTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_unauthenticated_http_mcp_endpoint_returns_unauthorized(): void
|
|
{
|
|
$this->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_mcp_status_moves_still_queue_notification_deliveries_when_actions_are_enabled(): void
|
|
{
|
|
Queue::fake();
|
|
|
|
$user = User::factory()->create();
|
|
$project = $this->project('CORE', 'Core project');
|
|
$backlogStatus = $project->ticketStatuses()->firstOrFail();
|
|
$doingStatus = $this->ticketStatus($project, 'Doing');
|
|
$ticket = $this->ticket($project, $backlogStatus, [
|
|
'ticket_key' => 'CORE-1',
|
|
'title' => 'Move ticket',
|
|
]);
|
|
|
|
$backlogStatus->allowedTargetStatuses()->sync([$doingStatus->id]);
|
|
$this->syncProjectPermissions($project, $user, ['write']);
|
|
|
|
$notificationAction = NotificationAction::factory()
|
|
->webhook()
|
|
->for($project)
|
|
->create();
|
|
|
|
KanbanGitServer::actingAs($user)
|
|
->tool(MoveTicketStatus::class, [
|
|
'ticket_id' => $ticket->id,
|
|
'ticket_status_id' => $doingStatus->id,
|
|
])
|
|
->assertOk()
|
|
->assertStructuredContent(fn (AssertableJson $json) => $json
|
|
->where('data.ticket.ticket_status_id', $doingStatus->id)
|
|
->where('meta.destination_label', 'Doing')
|
|
->where('meta.message', 'CORE-1 moved to Doing.')
|
|
->where('meta.moved', true)
|
|
->etc());
|
|
|
|
Queue::assertPushed(DeliverNotificationAction::class, function (DeliverNotificationAction $job) use ($notificationAction): bool {
|
|
return $job->notificationActionId === $notificationAction->id
|
|
&& $job->payload->ticketKey === 'CORE-1'
|
|
&& $job->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_mcp_no_op_status_moves_do_not_queue_notification_deliveries(): void
|
|
{
|
|
Queue::fake();
|
|
|
|
$user = User::factory()->create();
|
|
$project = $this->project('CORE', 'Core project');
|
|
$backlogStatus = $project->ticketStatuses()->firstOrFail();
|
|
$ticket = $this->ticket($project, $backlogStatus, [
|
|
'ticket_key' => 'CORE-1',
|
|
'title' => 'Move ticket',
|
|
]);
|
|
|
|
$this->syncProjectPermissions($project, $user, ['write']);
|
|
|
|
NotificationAction::factory()
|
|
->webhook()
|
|
->for($project)
|
|
->create();
|
|
|
|
KanbanGitServer::actingAs($user)
|
|
->tool(MoveTicketStatus::class, [
|
|
'ticket_id' => $ticket->id,
|
|
'ticket_status_id' => $backlogStatus->id,
|
|
])
|
|
->assertOk()
|
|
->assertStructuredContent(fn (AssertableJson $json) => $json
|
|
->where('data.ticket.ticket_status_id', $backlogStatus->id)
|
|
->where('meta.destination_label', 'Backlog')
|
|
->where('meta.message', 'CORE-1 already belongs to Backlog.')
|
|
->where('meta.moved', false)
|
|
->etc());
|
|
|
|
Queue::assertNothingPushed();
|
|
$this->assertDatabaseCount('ticket_worklog_entries', 0);
|
|
}
|
|
|
|
public function test_list_tickets_by_status_requires_a_status_selector(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
KanbanGitServer::actingAs($user)
|
|
->tool(ListTicketsByStatus::class)
|
|
->assertHasErrors(['Provide ticket_status_id, status_slug, or status_name.']);
|
|
}
|
|
|
|
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<string, mixed> $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,
|
|
]);
|
|
}
|
|
}
|