Files
KanbanGit/tests/Feature/Mcp/KanbanGitMcpTest.php
T
Koda 4832e7f984
tests / ci (8.4) (pull_request) Successful in 1m40s
linter / quality (pull_request) Successful in 1m18s
Fix queue job assertions for notification event payload
2026-04-25 22:10:27 +00:00

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->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_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,
]);
}
}