236 lines
14 KiB
PHP
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,
|
|
]);
|
|
}
|
|
}
|