Files
Twyco d9cb8d0903
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
Add MCP ticket creation with full fields and parent support
2026-09-09 07:40:25 +00:00

434 lines
21 KiB
PHP

<?php
namespace Tests\Feature\Mcp;
use App\Actions\Notifications\DeliverNotificationAction;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Tickets\SyncProjectTicketFieldValues;
use App\Enums\NotificationEventType;
use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\CreateTicket;
use App\Models\NotificationAction;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketPriority;
use App\Models\TicketStatus;
use App\Models\TicketType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Queue;
use Illuminate\Testing\Fluent\AssertableJson;
use PHPUnit\Framework\Attributes\DataProvider;
use RuntimeException;
use Tests\TestCase;
class CreateTicketTest extends TestCase
{
use RefreshDatabase;
private Project $project;
private User $user;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->project = Project::factory()->create(['key' => 'CORE']);
app(InitializeProjectTicketMetadata::class)->handle($this->project);
$this->syncProjectPermissions($this->project, $this->user, ['write']);
}
public function test_application_key_can_discover_creation_fields_and_required_inputs(): void
{
$response = $this->withApplicationKey($this->user)->postJson('/mcp/kanbangit', [
'id' => 1,
'jsonrpc' => '2.0',
'method' => 'tools/list',
'params' => [],
])->assertOk();
$tool = collect($response->json('result.tools'))->firstWhere('name', 'create_ticket');
$this->assertNotNull($tool);
$this->assertFalse($tool['annotations']['destructiveHint']);
$this->assertEqualsCanonicalizing([
'project_id', 'title', 'ticket_type_id', 'ticket_status_id', 'ticket_priority_id',
], $tool['inputSchema']['required']);
$this->assertEqualsCanonicalizing([
'project_id', 'title', 'description', 'ticket_type_id', 'ticket_status_id',
'ticket_priority_id', 'parent_ticket_id', 'assignee_user_id', 'author_user_id',
'story_points', 'is_blocked', 'blocked_comment', 'acceptance_criteria', 'field_values',
], array_keys($tool['inputSchema']['properties']));
$this->assertSame('integer', Arr::get($tool, 'inputSchema.properties.ticket_type_id.type'));
$this->assertSame(['integer', 'null'], Arr::get($tool, 'inputSchema.properties.parent_ticket_id.type'));
$this->assertSame('object', Arr::get($tool, 'inputSchema.properties.acceptance_criteria.items.type'));
$this->assertSame('object', Arr::get($tool, 'inputSchema.properties.field_values.items.type'));
}
public function test_application_key_can_create_a_child_with_all_editable_fields(): void
{
$assignee = User::factory()->create();
$author = User::factory()->create();
$type = TicketType::factory()->for($this->project)->create(['name' => 'Bug', 'is_parent' => false, 'position' => 3]);
$status = TicketStatus::factory()->for($this->project)->create(['name' => 'Ready', 'position' => 2]);
$priority = TicketPriority::factory()->for($this->project)->create(['name' => 'High', 'position' => 2]);
$parent = $this->parentTicket($this->project);
$numberField = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'number']);
$userField = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 2]);
$this->syncProjectPermissions($this->project, $this->user, ['admin']);
$this->withApplicationKey($this->user)->postJson('/mcp/kanbangit', [
'id' => 2,
'jsonrpc' => '2.0',
'method' => 'tools/call',
'params' => [
'name' => 'create_ticket',
'arguments' => $this->arguments([
'title' => 'Fix child ticket creation',
'description' => 'Created through MCP.',
'ticket_type_id' => $type->id,
'ticket_status_id' => $status->id,
'ticket_priority_id' => $priority->id,
'parent_ticket_id' => $parent->id,
'assignee_user_id' => $assignee->id,
'author_user_id' => $author->id,
'story_points' => 0,
'is_blocked' => true,
'blocked_comment' => 'Waiting for the dependency.',
'acceptance_criteria' => [
['text' => 'Parent criterion', 'checked' => true, 'indent' => 0],
['text' => 'Child criterion', 'checked' => false, 'indent' => 1],
],
'field_values' => [
['ticket_field_definition_id' => $numberField->id, 'value_number' => 2.5],
['ticket_field_definition_id' => $userField->id, 'value_user_id' => $assignee->id],
],
]),
],
])
->assertOk()
->assertJsonPath('result.isError', false)
->assertJsonPath('result.structuredContent.meta.message', 'CORE-2 was created.')
->assertJsonPath('result.structuredContent.data.ticket.ticket_type.id', $type->id)
->assertJsonPath('result.structuredContent.data.ticket.ticket_status.id', $status->id)
->assertJsonPath('result.structuredContent.data.ticket.ticket_priority.id', $priority->id)
->assertJsonPath('result.structuredContent.data.ticket.parent_ticket.id', $parent->id)
->assertJsonPath('result.structuredContent.data.ticket.author.id', $author->id)
->assertJsonPath('result.structuredContent.data.ticket.assignee.id', $assignee->id)
->assertJsonPath('result.structuredContent.data.ticket.is_blocked', true)
->assertJsonPath('result.structuredContent.data.ticket.acceptance_criteria.1.indent', 1)
->assertJsonCount(2, 'result.structuredContent.data.ticket.field_values');
$ticket = $this->project->tickets()->where('ticket_key', 'CORE-2')->firstOrFail();
$this->assertSame('Fix child ticket creation', $ticket->title);
$this->assertSame('Created through MCP.', $ticket->description);
$this->assertSame(0, $ticket->story_points);
$this->assertSame('Waiting for the dependency.', $ticket->blocked_comment);
$this->assertSame($parent->id, $ticket->parent_ticket_id);
$this->assertSame($type->id, $ticket->ticket_type_id);
$this->assertSame(2.5, $ticket->fieldValues()->where('ticket_field_definition_id', $numberField->id)->firstOrFail()->value_number);
$this->assertSame($assignee->id, $ticket->fieldValues()->where('ticket_field_definition_id', $userField->id)->firstOrFail()->value_user_id);
$criteria = $ticket->acceptanceCriteria()->get()->keyBy('text');
$this->assertTrue($criteria['Parent criterion']->is_checked);
$this->assertFalse($criteria['Child criterion']->is_checked);
$this->assertSame($criteria['Parent criterion']->id, $criteria['Child criterion']->parent_acceptance_criterion_id);
$this->assertDatabaseCount('ticket_worklog_entries', 1);
$this->assertDatabaseHas('ticket_worklog_entries', [
'ticket_id' => $ticket->id,
'actor_id' => $this->user->id,
'entry_type' => 'created',
]);
}
public function test_minimal_creation_defaults_optional_fields_and_generates_identity(): void
{
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([
'ticket_key' => 'FORGED-999',
'sequence_number' => 999,
'id' => 999,
]))
->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.ticket.ticket_key', 'CORE-1')
->where('data.ticket.sequence_number', 1)
->where('data.ticket.author_user_id', $this->user->id)
->where('data.ticket.assignee_user_id', null)
->where('data.ticket.parent_ticket_id', null)
->where('data.ticket.description', null)
->where('data.ticket.story_points', null)
->where('data.ticket.is_blocked', false)
->where('data.ticket.blocked_comment', null)
->where('data.ticket.acceptance_criteria', [])
->where('data.ticket.field_values', [])
->etc());
$this->assertDatabaseCount('tickets', 1);
$this->assertDatabaseMissing('tickets', ['id' => 999]);
}
public function test_admin_can_create_without_an_author_and_with_nullable_fields(): void
{
$this->syncProjectPermissions($this->project, $this->user, ['admin']);
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([
'author_user_id' => null,
'assignee_user_id' => null,
'parent_ticket_id' => null,
'description' => null,
'story_points' => null,
'acceptance_criteria' => null,
'field_values' => [],
'is_blocked' => false,
'blocked_comment' => 'Discard this when unblocked.',
]))
->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.ticket.author_user_id', null)
->where('data.ticket.blocked_comment', null)
->where('data.ticket.acceptance_criteria', [])
->etc());
}
public function test_parent_enabled_tickets_can_also_be_children_and_checklists_use_defaults(): void
{
$parent = $this->parentTicket($this->project);
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([
'ticket_type_id' => $parent->ticket_type_id,
'parent_ticket_id' => $parent->id,
'author_user_id' => $this->user->id,
'acceptance_criteria' => [['text' => ' A criterion ']],
]))
->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.ticket.ticket_type.is_parent', true)
->where('data.ticket.parent_ticket_id', $parent->id)
->where('data.ticket.acceptance_criteria.0.text', 'A criterion')
->where('data.ticket.acceptance_criteria.0.checked', false)
->where('data.ticket.acceptance_criteria.0.indent', 0)
->etc());
}
public function test_creation_requires_authentication_and_project_write_permission(): void
{
KanbanGitServer::tool(CreateTicket::class, $this->arguments())
->assertHasErrors(['Authentication required.']);
foreach ([[], ['view']] as $permissions) {
$this->syncProjectPermissions($this->project, $this->user, $permissions);
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments())
->assertHasErrors(['cannot update tickets']);
}
$this->assertDatabaseCount('tickets', 0);
}
public function test_non_admin_cannot_override_or_remove_the_author(): void
{
$author = User::factory()->create();
foreach ([$author->id, null] as $authorId) {
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([
'author_user_id' => $authorId,
]))->assertHasErrors(['Only project admins can change the author.']);
}
$this->assertDatabaseCount('tickets', 0);
}
public function test_creation_rejects_foreign_project_metadata_and_parent(): void
{
$otherProject = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
$parent = $this->parentTicket($otherProject);
$field = TicketFieldDefinition::factory()->for($otherProject)->create(['field_type' => 'number']);
$this->syncProjectPermissions($otherProject, $this->user, ['write']);
foreach ([
['ticket_type_id' => $otherProject->ticketTypes()->firstOrFail()->id],
['ticket_status_id' => $otherProject->ticketStatuses()->firstOrFail()->id],
['ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id],
['parent_ticket_id' => $parent->id],
['field_values' => [['ticket_field_definition_id' => $field->id, 'value_number' => 1]]],
] as $attributes) {
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments($attributes))
->assertHasErrors();
}
$this->assertSame(0, $this->project->tickets()->count());
$this->assertDatabaseCount('ticket_worklog_entries', 0);
}
public function test_creation_rejects_a_parent_without_a_parent_enabled_type(): void
{
$parent = $this->parentTicket($this->project);
$parent->update(['ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id]);
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([
'parent_ticket_id' => $parent->id,
]))->assertHasErrors(['Only tickets with a parent-enabled type']);
$this->assertDatabaseCount('tickets', 1);
}
#[DataProvider('invalidArguments')]
public function test_invalid_arguments_do_not_create_a_ticket(array $attributes): void
{
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments($attributes))
->assertHasErrors();
$this->assertDatabaseCount('tickets', 0);
$this->assertDatabaseCount('acceptance_criteria', 0);
$this->assertDatabaseCount('ticket_field_values', 0);
$this->assertDatabaseCount('ticket_worklog_entries', 0);
}
/**
* @return array<string, array{array<string, mixed>}>
*/
public static function invalidArguments(): array
{
return [
'missing project' => [['project_id' => null]],
'unknown project' => [['project_id' => 999999]],
'invalid project' => [['project_id' => 'invalid']],
'missing title' => [['title' => null]],
'blank title' => [['title' => ' ']],
'long title' => [['title' => str_repeat('a', 256)]],
'invalid description' => [['description' => []]],
'missing type' => [['ticket_type_id' => null]],
'unknown type' => [['ticket_type_id' => 999999]],
'missing status' => [['ticket_status_id' => null]],
'unknown status' => [['ticket_status_id' => 999999]],
'missing priority' => [['ticket_priority_id' => null]],
'unknown priority' => [['ticket_priority_id' => 999999]],
'unknown parent' => [['parent_ticket_id' => 999999]],
'unknown assignee' => [['assignee_user_id' => 999999]],
'unknown author' => [['author_user_id' => 999999]],
'negative estimate' => [['story_points' => -1]],
'fractional estimate' => [['story_points' => 1.5]],
'invalid blocked flag' => [['is_blocked' => 'yes']],
'missing blocker reason' => [['is_blocked' => true]],
'blank blocker reason' => [['is_blocked' => true, 'blocked_comment' => ' ']],
'invalid checklist' => [['acceptance_criteria' => 'text']],
'blank checklist text' => [['acceptance_criteria' => [['text' => ' ']]]],
'invalid checklist flag' => [['acceptance_criteria' => [['text' => 'Test', 'checked' => 'yes']]]],
'invalid checklist indent' => [['acceptance_criteria' => [['text' => 'Test', 'indent' => 9]]]],
'unknown field' => [['field_values' => [['ticket_field_definition_id' => 999999, 'value_number' => 1]]]],
'invalid fields' => [['field_values' => 'text']],
];
}
public function test_custom_fields_reject_duplicate_definitions_and_incompatible_or_invalid_values(): void
{
$numberField = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'number']);
$userField = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 2]);
foreach ([
[['ticket_field_definition_id' => $numberField->id, 'value_number' => 1], ['ticket_field_definition_id' => $numberField->id, 'value_number' => 2]],
[['ticket_field_definition_id' => $numberField->id, 'value_user_id' => $this->user->id]],
[['ticket_field_definition_id' => $userField->id, 'value_number' => 2]],
[['ticket_field_definition_id' => $numberField->id, 'value_number' => 'invalid']],
[['ticket_field_definition_id' => $userField->id, 'value_user_id' => 999999]],
] as $fieldValues) {
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([
'field_values' => $fieldValues,
]))->assertHasErrors();
}
$this->assertDatabaseCount('tickets', 0);
$this->assertDatabaseCount('ticket_field_values', 0);
}
public function test_zero_and_empty_custom_field_values_are_supported(): void
{
$numberField = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'number']);
$userField = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 2]);
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([
'field_values' => [
['ticket_field_definition_id' => $numberField->id, 'value_number' => 0],
['ticket_field_definition_id' => $userField->id, 'value_user_id' => null],
],
]))->assertOk();
$this->assertDatabaseCount('ticket_field_values', 1);
$this->assertDatabaseHas('ticket_field_values', [
'ticket_field_definition_id' => $numberField->id,
'value_number' => 0,
'value_user_id' => null,
]);
}
public function test_mcp_creation_queues_one_creation_notification_with_the_key_owner_as_actor(): void
{
$notificationAction = NotificationAction::factory()->webhook()->for($this->project)->create([
'event_type' => NotificationEventType::TicketCreate,
]);
Queue::fake();
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments())->assertOk();
Queue::assertPushed(DeliverNotificationAction::class, 1);
Queue::assertPushed(DeliverNotificationAction::class, fn (DeliverNotificationAction $job): bool => $job->notificationActionId === $notificationAction->id
&& $job->event->payload()->actorId === $this->user->id
&& $job->event->payload()->ticketKey === 'CORE-1'
&& $job->event->eventType() === NotificationEventType::TicketCreate);
$this->assertDatabaseCount('notification_delivery_logs', 1);
$this->assertDatabaseCount('ticket_worklog_entries', 1);
}
public function test_creation_rolls_back_if_custom_field_persistence_fails(): void
{
$field = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'number']);
$this->mock(SyncProjectTicketFieldValues::class)
->shouldReceive('handle')->once()->andThrow(new RuntimeException('Unable to save fields.'));
$this->expectException(RuntimeException::class);
try {
KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->arguments([
'acceptance_criteria' => [['text' => 'Criterion']],
'field_values' => [['ticket_field_definition_id' => $field->id, 'value_number' => 1]],
]));
} finally {
$this->assertDatabaseCount('tickets', 0);
$this->assertDatabaseCount('acceptance_criteria', 0);
$this->assertDatabaseCount('ticket_field_values', 0);
$this->assertDatabaseCount('ticket_worklog_entries', 0);
$this->assertDatabaseCount('notification_delivery_logs', 0);
}
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
private function arguments(array $overrides = []): array
{
return [
'project_id' => $this->project->id,
'title' => 'MCP ticket',
'ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
'ticket_status_id' => $this->project->ticketStatuses()->firstOrFail()->id,
'ticket_priority_id' => $this->project->ticketPriorities()->firstOrFail()->id,
...$overrides,
];
}
private function parentTicket(Project $project): Ticket
{
return Ticket::factory()->create([
'project_id' => $project->id,
'sequence_number' => 1,
'ticket_key' => "{$project->key}-1",
'ticket_type_id' => $project->ticketTypes()->where('is_parent', true)->firstOrFail()->id,
'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
]);
}
}