create(); $project = Project::factory()->create(); app(InitializeProjectTicketMetadata::class)->handle($project); TicketType::factory()->for($project)->create([ 'name' => 'Bug', 'position' => 3, ]); TicketLinkType::factory()->for($project)->create([ 'label' => 'Relates to', 'counterpart_label' => 'Related from', 'position' => 1, ]); $fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([ 'field_type' => 'number', 'label' => 'Complexity', 'position' => 1, ]); $sourceTicket = Ticket::factory()->for($project)->create(); $targetTicket = Ticket::factory()->for($project)->create([ 'sequence_number' => 2, 'ticket_key' => 'TEST-2', ]); $fieldDefinition->ticketFieldValues()->create([ 'ticket_id' => $sourceTicket->id, 'value_number' => 8, ]); $project->ticketLinkTypes()->firstOrFail()->ticketLinks()->create([ 'source_ticket_id' => $sourceTicket->id, 'target_ticket_id' => $targetTicket->id, ]); $template = ProjectTemplate::factory()->create([ 'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_TICKET_TYPES, DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES, ]), 'copy_areas' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_TICKET_TYPES, DuplicateProjectConfiguration::COPY_TICKET_LINK_TYPES, ], 'name' => 'Issue Tracker Template', ]); $project->update([ 'project_template_id' => $template->id, 'show_story_points' => false, 'template_overrides' => [DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS], ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->get(route('projects.settings.metadata', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/settings/Metadata') ->where('project.show_author', true) ->where('project.show_story_points', false) ->where('ticketTypes.0.name', 'Task') ->where('ticketTypes.1.name', 'Release') ->where('ticketTypes.1.is_parent', true) ->where('ticketTypes.2.name', 'Bug') ->where('fieldTypeOptions.0.value', 'user') ->where('fieldTypeOptions.1.value', 'number') ->where('ticketFieldDefinitions.0.label', 'Complexity') ->where('ticketFieldDefinitions.0.field_type', 'number') ->where('ticketFieldDefinitions.0.ticket_field_values_count', 1) ->where('ticketLinkTypes.0.label', 'Relates to') ->where('ticketLinkTypes.0.counterpart_label', 'Related from') ->where('ticketLinkTypes.0.ticket_links_count', 1) ->where('templateState.templateName', 'Issue Tracker Template') ->where('templateState.overriddenAreas.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS), ); } /** * Ensure users can create custom ticket types. */ public function test_users_with_edit_permission_can_create_custom_ticket_types(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->post(route('projects.settings.metadata.types.store', $project), [ 'is_parent' => '1', 'name' => 'Bug', ]) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHas('status', 'Bug type was added.'); $this->assertDatabaseHas('ticket_types', [ 'project_id' => $project->id, 'name' => 'Bug', 'is_parent' => true, ]); } /** * Ensure type parent flags cannot be changed after creation. */ public function test_ticket_type_parent_flags_cannot_be_changed_after_creation(): void { $user = User::factory()->create(); $project = Project::factory()->create(); app(InitializeProjectTicketMetadata::class)->handle($project); $releaseType = $project->ticketTypes() ->where('is_parent', true) ->orderBy('position') ->firstOrFail(); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->patch(route('projects.settings.metadata.types.update', [$project, $releaseType]), [ 'is_parent' => '0', 'name' => 'Standard Release', 'position' => 2, ]) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHas('status', 'Standard Release type was updated.'); $this->assertDatabaseHas('ticket_types', [ 'id' => $releaseType->id, 'name' => 'Standard Release', 'is_parent' => true, ]); } /** * Ensure users with edit permission can update project ticket visibility settings. */ public function test_users_with_edit_permission_can_update_ticket_visibility_settings(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'show_author' => true, 'show_story_points' => true, ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->patch(route('projects.settings.metadata.update', $project), [ 'show_author' => false, 'show_story_points' => false, ]) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHas('status', 'Settings have been updated.'); $this->assertDatabaseHas('projects', [ 'id' => $project->id, 'show_author' => false, 'show_story_points' => false, ]); } /** * Ensure users without project admin permission cannot update ticket metadata settings. */ public function test_non_admin_users_cannot_update_ticket_metadata_settings(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $this->syncProjectPermissions($project, $user, ['view']); $this->actingAs($user) ->patch(route('projects.settings.metadata.update', $project), [ 'show_author' => false, 'show_story_points' => false, ]) ->assertForbidden(); } /** * Ensure the last remaining ticket type cannot be deleted. */ public function test_the_last_remaining_ticket_type_cannot_be_deleted(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $ticketType = TicketType::factory()->for($project)->create([ 'name' => 'Solo', 'position' => 1, ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->delete(route('projects.settings.metadata.types.destroy', [$project, $ticketType])) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHas('status', 'The last ticket type cannot be deleted.'); $this->assertDatabaseHas('ticket_types', [ 'id' => $ticketType->id, ]); } /** * Ensure deleting a used ticket type requires a replacement and reassigns affected tickets. */ public function test_used_ticket_types_require_replacement_before_deletion_and_reassign_tickets(): void { $user = User::factory()->create(); $author = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $priority = $project->ticketPriorities()->firstOrFail(); $taskType = $project->ticketTypes() ->where('is_parent', false) ->orderBy('position') ->firstOrFail(); $releaseType = $project->ticketTypes() ->where('is_parent', true) ->orderBy('position') ->firstOrFail(); $ticket = Ticket::factory()->create([ 'author_user_id' => $author->id, 'project_id' => $project->id, 'sequence_number' => 1, 'ticket_key' => 'CORE-1', 'ticket_priority_id' => $priority->id, 'ticket_type_id' => $taskType->id, 'title' => 'Replace my type', ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->from(route('projects.settings.metadata', $project)) ->delete(route('projects.settings.metadata.types.destroy', [$project, $taskType])) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHasErrors('replacement_ticket_type_id'); $this->actingAs($user) ->delete(route('projects.settings.metadata.types.destroy', [$project, $taskType]), [ 'replacement_ticket_type_id' => $releaseType->id, ]) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHas('status', 'Task type was removed.'); $this->assertDatabaseMissing('ticket_types', [ 'id' => $taskType->id, ]); $this->assertDatabaseHas('tickets', [ 'id' => $ticket->id, 'ticket_type_id' => $releaseType->id, ]); $this->assertDatabaseHas('ticket_worklog_entries', [ 'ticket_id' => $ticket->id, 'actor_id' => $user->id, 'entry_type' => 'updated', 'summary' => 'Updated type because Task type was deleted.', ]); } /** * Ensure parent tickets cannot be reassigned to a non-parent replacement type during delete. */ public function test_parent_tickets_require_a_parent_enabled_replacement_type_when_deleting_types(): void { $user = User::factory()->create(); $author = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $priority = $project->ticketPriorities()->firstOrFail(); $taskType = $project->ticketTypes() ->where('is_parent', false) ->orderBy('position') ->firstOrFail(); $releaseType = $project->ticketTypes() ->where('is_parent', true) ->orderBy('position') ->firstOrFail(); $epicType = TicketType::factory()->for($project)->create([ 'is_parent' => true, 'name' => 'Epic', 'position' => 3, ]); $parentTicket = Ticket::factory()->create([ 'author_user_id' => $author->id, 'project_id' => $project->id, 'sequence_number' => 1, 'ticket_key' => 'CORE-1', 'ticket_priority_id' => $priority->id, 'ticket_type_id' => $releaseType->id, 'title' => 'Parent ticket', ]); Ticket::factory()->create([ 'author_user_id' => $author->id, 'parent_ticket_id' => $parentTicket->id, 'project_id' => $project->id, 'sequence_number' => 2, 'ticket_key' => 'CORE-2', 'ticket_priority_id' => $priority->id, 'ticket_type_id' => $taskType->id, 'title' => 'Child ticket', ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->from(route('projects.settings.metadata', $project)) ->delete(route('projects.settings.metadata.types.destroy', [$project, $releaseType]), [ 'replacement_ticket_type_id' => $taskType->id, ]) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHasErrors('replacement_ticket_type_id'); $this->actingAs($user) ->delete(route('projects.settings.metadata.types.destroy', [$project, $releaseType]), [ 'replacement_ticket_type_id' => $epicType->id, ]) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHas('status', 'Release type was removed.'); $this->assertDatabaseHas('tickets', [ 'id' => $parentTicket->id, 'ticket_type_id' => $epicType->id, ]); } /** * Ensure priorities can be created, updated, and deleted. */ public function test_users_with_edit_permission_can_manage_ticket_priorities(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $priority = TicketPriority::factory()->for($project)->create([ 'name' => 'High', 'position' => 1, ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->post(route('projects.settings.metadata.priorities.store', $project), [ 'name' => 'Low', ]) ->assertRedirect(route('projects.settings.metadata', $project)); $this->actingAs($user) ->patch(route('projects.settings.metadata.priorities.update', [$project, $priority]), [ 'name' => 'Urgent', 'position' => 2, ]) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHas('status', 'Urgent priority was updated.'); $this->actingAs($user) ->delete(route('projects.settings.metadata.priorities.destroy', [$project, $priority])) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHas('status', 'Urgent priority was removed.'); $this->assertDatabaseMissing('ticket_priorities', [ 'id' => $priority->id, ]); } /** * Ensure custom field definitions can be created and updated with supported types. */ public function test_users_with_edit_permission_can_manage_custom_field_definitions(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $fieldDefinition = TicketFieldDefinition::factory()->for($project)->create([ 'label' => 'Complexity', 'field_type' => 'number', 'position' => 1, ]); $ticket = Ticket::factory()->for($project)->create(); $fieldValue = $fieldDefinition->ticketFieldValues()->create([ 'ticket_id' => $ticket->id, 'value_number' => 5, ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->post(route('projects.settings.metadata.fields.store', $project), [ 'label' => 'Reviewer', 'field_type' => 'user', ]) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHas('status', 'Reviewer field was added.'); $this->actingAs($user) ->patch(route('projects.settings.metadata.fields.update', [$project, $fieldDefinition]), [ 'label' => 'Risk score', 'field_type' => 'number', 'position' => 2, ]) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHas('status', 'Risk score field was updated.'); $this->assertDatabaseHas('ticket_field_definitions', [ 'id' => $fieldDefinition->id, 'label' => 'Risk score', 'field_type' => 'number', ]); $this->assertDatabaseHas('ticket_field_values', [ 'id' => $fieldValue->id, 'ticket_field_definition_id' => $fieldDefinition->id, 'value_number' => 5, ]); } /** * Ensure unsupported field types are rejected. */ public function test_unsupported_field_types_are_rejected(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->from(route('projects.settings.metadata', $project)) ->post(route('projects.settings.metadata.fields.store', $project), [ 'label' => 'Reviewer', 'field_type' => 'text', ]) ->assertSessionHasErrors('field_type') ->assertRedirect(route('projects.settings.metadata', $project)); } /** * Ensure users with edit permission can manage ticket link types. */ public function test_users_with_edit_permission_can_manage_ticket_link_types(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $ticketLinkType = TicketLinkType::factory()->for($project)->create([ 'label' => 'Relates to', 'counterpart_label' => 'Relates to', 'position' => 1, ]); $sourceTicket = Ticket::factory()->for($project)->create(); $targetTicket = Ticket::factory()->for($project)->create([ 'sequence_number' => 2, 'ticket_key' => 'TEST-2', ]); $ticketLink = $ticketLinkType->ticketLinks()->create([ 'source_ticket_id' => $sourceTicket->id, 'target_ticket_id' => $targetTicket->id, ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->post(route('projects.settings.metadata.link-types.store', $project), [ 'name' => 'Blocks', 'counterpart_name' => 'Blocked by', ]) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHas('status', 'Blocks link type was added.'); $this->assertDatabaseHas('ticket_link_types', [ 'project_id' => $project->id, 'label' => 'Blocks', 'counterpart_label' => 'Blocked by', ]); $this->actingAs($user) ->patch(route('projects.settings.metadata.link-types.update', [$project, $ticketLinkType]), [ 'name' => 'Depends on', 'counterpart_name' => 'Required by', 'position' => 2, ]) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHas('status', 'Depends on link type was updated.'); $this->assertDatabaseHas('ticket_link_types', [ 'id' => $ticketLinkType->id, 'label' => 'Depends on', 'counterpart_label' => 'Required by', ]); $this->assertDatabaseHas('ticket_links', [ 'id' => $ticketLink->id, 'ticket_link_type_id' => $ticketLinkType->id, ]); $this->actingAs($user) ->delete(route('projects.settings.metadata.link-types.destroy', [$project, $ticketLinkType])) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHas('status', 'Depends on link type was removed.'); $this->assertDatabaseMissing('ticket_link_types', [ 'id' => $ticketLinkType->id, ]); $this->assertDatabaseMissing('ticket_links', [ 'id' => $ticketLink->id, ]); } public function test_last_priority_is_protected_from_deletion(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $priority = TicketPriority::factory()->for($project)->create(); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->delete(route('projects.settings.metadata.priorities.destroy', [$project, $priority])) ->assertRedirect(route('projects.settings.metadata', $project)) ->assertSessionHas('error', true) ->assertSessionHas('status', 'The last ticket priority cannot be deleted.'); $this->assertModelExists($priority); } public function test_type_order_and_usage_counts_are_returned_after_reordering(): void { $user = User::factory()->create(); $project = Project::factory()->create(); app(InitializeProjectTicketMetadata::class)->handle($project); $types = $project->ticketTypes()->get(); $first = $types->first(); $last = $types->last(); Ticket::factory()->for($project)->create([ 'ticket_type_id' => $last->id, 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id, ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->patch(route('projects.settings.metadata.types.update', [$project, $last]), [ 'name' => 'Customer request', 'position' => 1, ]) ->assertSessionHasNoErrors(); $this->get(route('projects.settings.metadata', $project)) ->assertInertia(fn (Assert $page) => $page ->where('ticketTypes.0.id', $last->id) ->where('ticketTypes.0.name', 'Customer request') ->where('ticketTypes.0.position', 1) ->where('ticketTypes.0.tickets_count', 1) ->where('ticketTypes.1.id', $first->id) ->where('ticketTypes.1.position', 2) ->where('ticketTypes.1.tickets_count', 0)); } public function test_foreign_replacement_type_is_rejected_without_changing_tickets(): void { $user = User::factory()->create(); $project = Project::factory()->create(); app(InitializeProjectTicketMetadata::class)->handle($project); $type = $project->ticketTypes()->firstOrFail(); $foreignType = TicketType::factory()->create(); $ticket = Ticket::factory()->for($project)->create([ 'ticket_type_id' => $type->id, 'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id, ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->from(route('projects.settings.metadata', $project)) ->delete(route('projects.settings.metadata.types.destroy', [$project, $type]), [ 'replacement_ticket_type_id' => $foreignType->id, ]) ->assertSessionHasErrors('replacement_ticket_type_id'); $this->assertModelExists($type); $this->assertSame($type->id, $ticket->fresh()->ticket_type_id); } }