feat(KAN-72): complete MCP ticket editing and discovery
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

This commit is contained in:
2026-10-02 13:34:00 +02:00
parent ba12e65e81
commit 9ed8d0c6cc
12 changed files with 894 additions and 12 deletions
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace Tests\Feature\Mcp;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\GetProjectConfiguration;
use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\ListProjects;
use App\Mcp\Tools\SearchTickets;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketStatus;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class TicketDiscoveryTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Project $project;
private Project $hidden;
private Ticket $ticket;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->project = Project::factory()->create(['key' => 'CORE', 'name' => 'Visible']);
$this->hidden = Project::factory()->create(['key' => 'HIDE']);
app(InitializeProjectTicketMetadata::class)->handle($this->project);
app(InitializeProjectTicketMetadata::class)->handle($this->hidden);
$this->syncProjectPermissions($this->project, $this->user, ['view']);
$this->ticket = $this->newTicket($this->project, 1, ['title' => 'Special needle', 'description' => 'First description', 'assignee_user_id' => $this->user->id]);
$this->newTicket($this->project, 2, ['title' => 'Other title', 'description' => 'Needle in description', 'parent_ticket_id' => $this->ticket->id]);
$this->newTicket($this->hidden, 1, ['title' => 'Hidden needle']);
}
public function test_exact_key_and_existing_id_lookup_work_and_hidden_tickets_are_denied(): void
{
foreach ([['ticket_id' => $this->ticket->id], ['ticket_key' => 'CORE-1']] as $input) {
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, $input)->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.id', $this->ticket->id)->etc());
}
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'HIDE-1'])->assertHasErrors(['cannot view']);
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => 'CORE-99'])->assertHasErrors(['not found']);
}
#[DataProvider('invalidSelectors')]
public function test_key_lookup_requires_exactly_one_valid_selector(array $input): void
{
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, $input)->assertHasErrors();
}
public static function invalidSelectors(): array
{
return [
[[]], [['ticket_key' => 'CORE']], [['ticket_key' => 'core-1']], [['ticket_key' => 'CORE-0']],
[['ticket_key' => 'CORE-1', 'ticket_id' => 1]], [['ticket_key' => null]],
];
}
public function test_search_combines_filters_and_paginates_only_visible_tickets_with_compact_payloads(): void
{
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => 'needle', 'per_page' => 1])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.0.id', $this->ticket->id)->count('data', 1)
->where('data.0.status.id', $this->ticket->ticket_status_id)
->missing('data.0.description')->missing('data.0.project')
->where('meta.total', 2)->where('meta.last_page', 2)->where('meta.has_more_pages', true)->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => 'needle', 'page' => 2, 'per_page' => 1])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.0.ticket_key', 'CORE-2')->where('meta.current_page', 2)->where('meta.has_more_pages', false)->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, [
'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'ticket_status_id' => $this->ticket->ticket_status_id,
'ticket_type_id' => $this->ticket->ticket_type_id, 'assignee_user_id' => $this->user->id, 'parent_ticket_id' => null,
])->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.id', $this->ticket->id)->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['parent_ticket_id' => $this->ticket->id, 'assignee_user_id' => null])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.ticket_key', 'CORE-2')->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_type_id' => $this->hidden->ticketTypes()->firstOrFail()->id])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->etc());
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['project_id' => $this->hidden->id])->assertHasErrors(['cannot view']);
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['page' => 10])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->where('meta.total', 2)->etc());
}
public function test_search_returns_tickets_without_a_status(): void
{
$this->ticket->update(['ticket_status_id' => null]);
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_key' => 'CORE-1'])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->ticket->id)
->where('data.0.ticket_status_id', null)->where('data.0.status', null)->etc());
}
public function test_literal_search_does_not_expand_sql_wildcards(): void
{
$this->ticket->update(['title' => 'A 50%_! literal']);
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['query' => '%_!'])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.id', $this->ticket->id)->etc());
}
public function test_project_list_is_compact_paginated_and_independent_of_assignments_with_team_and_global_access(): void
{
$this->ticket->update(['assignee_user_id' => null]);
KanbanGitServer::actingAs($this->user)->tool(ListProjects::class)->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 1)->where('data.0.key', 'CORE')->missing('data.0.template_state')->where('meta.total', 1)->etc());
$team = Team::factory()->create();
$team->users()->attach($this->user);
$this->syncProjectPermissions($this->hidden, $team, ['view']);
KanbanGitServer::actingAs($this->user)->tool(ListProjects::class, ['per_page' => 1, 'page' => 2])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->hidden->id)->where('meta.total', 2)->etc());
$global = User::factory()->create();
$this->syncGlobalPermissions($global, ['admin']);
KanbanGitServer::actingAs($global)->tool(SearchTickets::class)->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('meta.total', 3)->etc());
KanbanGitServer::actingAs(User::factory()->create())->tool(ListProjects::class)->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->count('data', 0)->where('meta.total', 0)->etc());
}
public function test_configuration_returns_current_workflow_and_allowed_people_without_full_resources(): void
{
$first = $this->project->ticketStatuses()->firstOrFail();
$column = BoardColumn::factory()->for($this->project)->create(['position' => 1]);
$second = TicketStatus::factory()->for($this->project)->for($column)->create(['position' => 1]);
TicketFieldDefinition::factory()->for($this->project)->create(['field_type' => 'user', 'position' => 1]);
$first->allowedTargetStatuses()->sync([$second->id]);
$first->update(['name' => 'Fresh workflow']);
$otherViewer = User::factory()->create();
$this->syncProjectPermissions($this->project, $otherViewer, ['view']);
User::factory()->create();
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_per_page' => 1])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.project.id', $this->project->id)->missing('data.project.template_state')
->where('data.permissions.can_write', false)->where('data.permissions.can_change_author', false)
->where('data.ticket_statuses.0.id', $first->id)->where('data.ticket_statuses.0.slug', 'fresh-workflow')
->where('data.ticket_statuses.0.allowed_target_status_ids', [$second->id])
->where('data.ticket_statuses.0.board_column_id', $first->board_column_id)
->has('data.ticket_field_definitions.0.field_type')->has('data.ticket_types.0.is_parent')->has('data.ticket_priorities.0.id')->has('data.board_columns.0.position')
->count('data.people', 1)->missing('data.people.0.email')->missing('data.people.0.avatar')
->where('meta.people.total', 2)->etc());
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_page' => 2, 'people_per_page' => 1])
->assertOk()->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.people.0.id', $otherViewer->id)->etc());
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->hidden->id])->assertHasErrors(['cannot view']);
}
public function test_numeric_project_keys_and_configured_column_order_are_supported(): void
{
$this->project->update(['key' => '42']);
$this->ticket->update(['ticket_key' => '42-1']);
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_key' => '42-1'])->assertOk();
KanbanGitServer::actingAs($this->user)->tool(SearchTickets::class, ['ticket_key' => '42-1'])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.0.id', $this->ticket->id)->etc());
$later = BoardColumn::factory()->for($this->project)->create(['position' => 2]);
$earlier = BoardColumn::factory()->for($this->project)->create(['position' => 1]);
$laterStatus = TicketStatus::factory()->for($this->project)->for($later)->create(['position' => 1]);
$earlierStatus = TicketStatus::factory()->for($this->project)->for($earlier)->create(['position' => 1]);
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json
->where('data.board_columns.0.id', $earlier->id)
->where('data.ticket_statuses.1.id', $earlierStatus->id)
->where('data.ticket_statuses.2.id', $laterStatus->id)->etc());
}
public function test_pagination_limits_and_missing_projects_are_validated(): void
{
foreach ([ListProjects::class, SearchTickets::class] as $tool) {
KanbanGitServer::actingAs($this->user)->tool($tool, ['per_page' => 101])->assertHasErrors();
KanbanGitServer::actingAs($this->user)->tool($tool, ['page' => 0])->assertHasErrors();
}
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => 999])->assertHasErrors(['not found']);
KanbanGitServer::actingAs($this->user)->tool(GetProjectConfiguration::class, ['project_id' => $this->project->id, 'people_per_page' => 101])->assertHasErrors();
}
public function test_application_key_discovers_new_schemas_and_completes_refinement_without_browser_login(): void
{
$this->syncProjectPermissions($this->project, $this->user, ['write']);
$this->withApplicationKey($this->user);
$response = $this->postJson('/mcp/kanbangit', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list', 'params' => []])->assertOk();
$tools = collect($response->json('result.tools'))->keyBy('name');
foreach (['list_projects', 'search_tickets', 'get_project_configuration'] as $name) {
$this->assertTrue($tools[$name]['annotations']['readOnlyHint']);
}
$this->assertSame(['ticket_id'], $tools['update_ticket']['inputSchema']['required']);
$this->assertArrayHasKey('ticket_key', $tools['get_ticket']['inputSchema']['properties']);
$this->assertFalse($tools['update_ticket']['annotations']['destructiveHint']);
$this->rpc('update_ticket', ['ticket_id' => $this->ticket->id, 'title' => 'Refined via MCP', 'description' => 'Refinement', 'acceptance_criteria' => [['text' => 'Real criterion']]]);
$ticket = $this->rpc('get_ticket', ['ticket_key' => 'CORE-1'])->json('result.structuredContent.data.ticket');
$this->assertSame('Refined via MCP', $ticket['title']);
$this->assertSame('Real criterion', $ticket['acceptance_criteria'][0]['text']);
$this->rpc('update_ticket', ['ticket_id' => $this->ticket->id, 'acceptance_criteria' => [['id' => $ticket['acceptance_criteria'][0]['id'], 'text' => 'Criterion checked', 'checked' => true]]]);
$target = TicketStatus::factory()->for($this->project)->create(['position' => 2]);
$this->ticket->ticketStatus->allowedTargetStatuses()->sync([$target->id]);
$this->rpc('move_ticket_status', ['ticket_id' => $this->ticket->id, 'ticket_status_id' => $target->id]);
$this->assertSame($target->id, $this->ticket->fresh()->ticket_status_id);
}
private function rpc(string $name, array $arguments): TestResponse
{
return $this->postJson('/mcp/kanbangit', [
'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => $name, 'arguments' => $arguments],
])->assertOk()->assertJsonPath('result.isError', false);
}
private function newTicket(Project $project, int $sequence, array $attributes = []): Ticket
{
return Ticket::factory()->create([
'project_id' => $project->id, 'ticket_key' => "{$project->key}-{$sequence}", 'sequence_number' => $sequence,
'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id,
'ticket_type_id' => $project->ticketTypes()->where('is_parent', $sequence === 1)->firstOrFail()->id,
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
...$attributes,
]);
}
}
+235
View File
@@ -0,0 +1,235 @@
<?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,
]);
}
}