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, ]); } }