feat(KAN-72): complete MCP ticket editing and discovery
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user