Files
KanbanGit/tests/Feature/Mcp/UpdateTicketTest.php
Twyco 9ed8d0c6cc
CI/CD / preconditions (pull_request) Successful in 2s
CI/CD / test_and_check_app (pull_request) Successful in 6m12s
CI/CD / Publish immutable image (pull_request) Skipped
feat(KAN-72): complete MCP ticket editing and discovery
2026-10-02 13:34:00 +02:00

236 lines
14 KiB
PHP

<?php
namespace Tests\Feature\Mcp;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Tickets\Support\AcceptanceCriteriaChecklistMapper;
use App\Actions\Tickets\SyncProjectTicketFieldValues;
use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\UpdateTicket;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketPriority;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;
use Laravel\Mcp\Server\Testing\TestResponse;
use PHPUnit\Framework\Attributes\DataProvider;
use RuntimeException;
use Tests\TestCase;
class UpdateTicketTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Project $project;
private Ticket $ticket;
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, ['admin']);
$this->ticket = $this->newTicket(['description' => 'Original', 'story_points' => 5, 'author_user_id' => $this->user->id]);
AcceptanceCriteriaChecklistMapper::syncTicket($this->ticket, [
['text' => 'First', 'checked' => false, 'indent' => 0],
['text' => 'Second', 'checked' => true, 'indent' => 0],
]);
}
public function test_partial_update_and_no_op_preserve_checklist_ids_and_unrelated_fields(): void
{
$before = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
$this->updateTicket(['title' => 'Refined'])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.ticket.description', 'Original')->where('data.ticket.story_points', 5)
->where('data.ticket.acceptance_criteria', $before)->where('meta.updated', true)->etc());
$this->updateTicket(['title' => 'Refined'])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.updated', false)->etc());
$this->assertSame(1, $this->ticket->worklogEntries()->count());
$this->assertSame('Refined', $this->ticket->fresh()->title);
}
public function test_checklist_replacement_adds_reorders_indents_checks_and_removes_by_identity(): void
{
$before = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
$replacement = [
['text' => 'New root', 'checked' => false, 'indent' => 0],
['id' => $before[1]['id'], 'text' => 'Second edited', 'checked' => false, 'indent' => 1],
['id' => $before[0]['id'], 'text' => 'First', 'checked' => true, 'indent' => 0],
];
$this->updateTicket(['acceptance_criteria' => $replacement])->assertOk();
$after = AcceptanceCriteriaChecklistMapper::fromModelsWithIds($this->ticket->acceptanceCriteria()->get());
$this->assertNotContains($after[0]['id'], array_column($before, 'id'));
$this->assertSame($replacement[1], ['id' => $after[1]['id'], 'text' => $after[1]['text'], 'checked' => $after[1]['checked'], 'indent' => $after[1]['indent']]);
$this->assertSame($before[0]['id'], $after[2]['id']);
$this->updateTicket(['acceptance_criteria' => $after])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.updated', false)->etc());
$this->updateTicket(['acceptance_criteria' => [$after[2]]])->assertOk();
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'CORE-1'])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data.ticket.acceptance_criteria', 1)->where('data.ticket.acceptance_criteria.0.id', $before[0]['id'])->etc());
$this->updateTicket(['acceptance_criteria' => []])->assertOk();
$this->assertSame(0, $this->ticket->acceptanceCriteria()->count());
}
public function test_scalar_null_and_custom_field_replacement_have_explicit_clear_semantics(): void
{
$number = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'number']);
$person = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 2]);
$this->updateTicket(['field_values' => [
['ticket_field_definition_id' => $number->id, 'value_number' => 0],
['ticket_field_definition_id' => $person->id, 'value_user_id' => $this->user->id],
]])->assertOk();
$this->updateTicket(['description' => null, 'story_points' => null, 'assignee_user_id' => null, 'author_user_id' => null])->assertOk();
$this->assertSame(2, $this->ticket->fieldValues()->count());
$this->assertNull($this->ticket->fresh()->description);
$this->assertNull($this->ticket->fresh()->story_points);
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $number->id, 'value_number' => 3]]])->assertOk();
$this->assertSame(1, $this->ticket->fieldValues()->count());
$this->updateTicket(['field_values' => []])->assertOk();
$this->assertSame(0, $this->ticket->fieldValues()->count());
}
#[DataProvider('invalidInputs')]
public function test_invalid_payload_does_not_partially_change_ticket(array $input): void
{
$before = $this->ticket->fresh()->getAttributes();
$this->updateTicket(['title' => 'Must not change', ...$input])->assertHasErrors();
$this->assertSame($before, $this->ticket->fresh()->getAttributes());
$this->assertSame(0, $this->ticket->worklogEntries()->count());
$this->assertSame(2, $this->ticket->acceptanceCriteria()->count());
}
public static function invalidInputs(): array
{
return [
'null title' => [['title' => null]],
'null type' => [['ticket_type_id' => null]],
'null priority' => [['ticket_priority_id' => null]],
'null blocked state' => [['is_blocked' => null]],
'invalid criterion text' => [['acceptance_criteria' => [['text' => []]]]],
'null criteria' => [['acceptance_criteria' => null]],
'null fields' => [['field_values' => null]],
'invalid criterion' => [['acceptance_criteria' => [['id' => 99999, 'text' => 'Foreign']]]],
'blank criterion' => [['acceptance_criteria' => [['text' => ' ']]]],
'skipped indent' => [['acceptance_criteria' => [['text' => 'Child', 'indent' => 1]]]],
'negative points' => [['story_points' => -1]],
'missing blocker' => [['is_blocked' => true]],
'status not editable' => [['ticket_status_id' => 999]],
];
}
public function test_foreign_and_duplicate_criterion_ids_are_rejected_atomically(): void
{
$other = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2]);
AcceptanceCriteriaChecklistMapper::syncTicket($other, [['text' => 'Other', 'checked' => false, 'indent' => 0]]);
$foreign = $other->acceptanceCriteria()->firstOrFail()->id;
$this->updateTicket(['title' => 'Rejected', 'acceptance_criteria' => [['id' => $foreign, 'text' => 'Steal']]])->assertHasErrors();
$id = $this->ticket->acceptanceCriteria()->firstOrFail()->id;
$this->updateTicket(['acceptance_criteria' => [['id' => $id, 'text' => 'One'], ['id' => $id, 'text' => 'Two']]])->assertHasErrors();
$this->assertSame('Original', $this->ticket->fresh()->description);
$this->assertSame('Other', $other->acceptanceCriteria()->firstOrFail()->text);
}
public function test_project_read_and_write_permissions_and_author_rules_are_enforced(): void
{
$this->syncProjectPermissions($this->project, $this->user, ['view']);
$this->updateTicket(['title' => 'Denied'])->assertHasErrors(['cannot update tickets']);
$this->syncProjectPermissions($this->project, $this->user, ['write']);
$this->updateTicket(['title' => 'Allowed'])->assertOk();
$this->updateTicket(['author_user_id' => null])->assertHasErrors(['Only project admins']);
$this->syncProjectPermissions($this->project, $this->user, []);
$this->updateTicket(['title' => 'Denied'])->assertHasErrors(['cannot update tickets']);
$this->assertSame('Allowed', $this->ticket->fresh()->title);
}
public function test_invalid_people_foreign_metadata_custom_types_and_parent_relations_are_rejected(): void
{
$hiddenUser = User::factory()->create();
$this->updateTicket(['assignee_user_id' => $hiddenUser->id])->assertHasErrors(['person']);
$this->updateTicket(['author_user_id' => $hiddenUser->id])->assertHasErrors(['person']);
$otherProject = Project::factory()->create();
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
$this->updateTicket(['ticket_type_id' => $otherProject->ticketTypes()->firstOrFail()->id])->assertHasErrors();
$this->updateTicket(['ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id])->assertHasErrors();
$foreignTicket = Ticket::factory()->for($otherProject)->create();
$this->updateTicket(['parent_ticket_id' => $foreignTicket->id])->assertHasErrors();
$field = TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user']);
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $field->id, 'value_user_id' => $hiddenUser->id]]])->assertHasErrors(['person']);
$this->updateTicket(['field_values' => [['ticket_field_definition_id' => $field->id, 'value_number' => 2]]])->assertHasErrors(['field type']);
$this->updateTicket(['parent_ticket_id' => $this->ticket->id])->assertHasErrors(['own parent']);
$task = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2]);
$this->updateTicket(['parent_ticket_id' => $task->id])->assertHasErrors(['parent-enabled']);
$parentType = $this->project->ticketTypes()->where('is_parent', true)->firstOrFail();
$this->updateTicket(['ticket_type_id' => $parentType->id])->assertOk();
$task->update(['parent_ticket_id' => $this->ticket->id, 'ticket_type_id' => $parentType->id]);
$this->updateTicket(['parent_ticket_id' => $task->id])->assertHasErrors(['descendants']);
$this->updateTicket(['ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id])->assertHasErrors(['child tickets']);
}
public function test_all_editable_scalar_fields_accept_valid_project_values(): void
{
$person = User::factory()->create();
$this->syncProjectPermissions($this->project, $person, ['view']);
$parentType = $this->project->ticketTypes()->where('is_parent', true)->firstOrFail();
$parent = $this->newTicket(['ticket_key' => 'CORE-2', 'sequence_number' => 2, 'ticket_type_id' => $parentType->id]);
$priority = TicketPriority::factory()->for($this->project)->create(['position' => 2]);
$input = [
'title' => 'All scalar fields', 'description' => 'Updated description',
'ticket_type_id' => $parentType->id, 'ticket_priority_id' => $priority->id,
'assignee_user_id' => $person->id, 'author_user_id' => $person->id,
'parent_ticket_id' => $parent->id, 'story_points' => 0,
'is_blocked' => true, 'blocked_comment' => 'Dependency',
];
$this->updateTicket($input)->assertOk();
foreach ($input as $field => $value) {
$this->assertSame($value, $this->ticket->fresh()->getAttribute($field));
}
$this->assertSame(1, $this->ticket->worklogEntries()->count());
$this->updateTicket(['parent_ticket_id' => null])->assertOk();
$this->assertNull($this->ticket->fresh()->parent_ticket_id);
}
public function test_blocker_validation_uses_preserved_state(): void
{
$this->updateTicket(['is_blocked' => true, 'blocked_comment' => 'Waiting'])->assertOk();
$this->updateTicket(['title' => 'Still blocked'])->assertOk();
$this->updateTicket(['blocked_comment' => null])->assertHasErrors(['blocker comment']);
$this->updateTicket(['is_blocked' => false])->assertOk();
$this->assertNull($this->ticket->fresh()->blocked_comment);
}
public function test_action_failure_rolls_back_scalars_checklist_and_worklog(): void
{
$this->mock(SyncProjectTicketFieldValues::class)->shouldReceive('handle')->once()->andThrow(new RuntimeException('Sync failed'));
try {
$this->updateTicket(['title' => 'Rollback', 'acceptance_criteria' => []]);
$this->fail('Expected action failure.');
} catch (RuntimeException $exception) {
$this->assertSame('Sync failed', $exception->getMessage());
}
$this->assertNotSame('Rollback', $this->ticket->fresh()->title);
$this->assertSame(2, $this->ticket->acceptanceCriteria()->count());
$this->assertSame(0, $this->ticket->worklogEntries()->count());
}
private function updateTicket(array $input): TestResponse
{
return KanbanGitServer::actingAs($this->user)->tool(UpdateTicket::class, ['ticket_id' => $this->ticket->id, ...$input]);
}
private function newTicket(array $attributes = []): Ticket
{
return Ticket::factory()->create([
'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'sequence_number' => 1,
'ticket_status_id' => $this->project->ticketStatuses()->firstOrFail()->id,
'ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
'ticket_priority_id' => $this->project->ticketPriorities()->firstOrFail()->id,
...$attributes,
]);
}
}