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}> */ 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 $overrides * @return array */ 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, ]); } }