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', 'position' => 1, ]); $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, ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->get(route('projects.settings.index', [ 'project' => $project, 'section' => 'metadata', ])) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Settings') ->where('activeSection', 'metadata') ->where('project.show_author', true) ->where('project.show_story_points', false) ->where('sectionData.ticketTypes.0.name', 'Task') ->where('sectionData.ticketTypes.1.name', 'Release') ->where('sectionData.ticketTypes.1.is_parent', true) ->where('sectionData.ticketTypes.2.name', 'Bug') ->where('sectionData.ticketLinkTypes.0.label', 'Relates to') ->where('sectionData.templateState.templateName', 'Issue Tracker Template') ->where('sectionData.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.index', ['project' => $project, 'section' => 'metadata'])) ->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.index', ['project' => $project, 'section' => 'metadata'])) ->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.index', ['project' => $project, 'section' => 'metadata'])) ->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.index', ['project' => $project, 'section' => 'metadata'])) ->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.index', ['project' => $project, 'section' => 'metadata'])) ->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.index', ['project' => $project, 'section' => 'metadata'])) ->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.index', ['project' => $project, 'section' => 'metadata'])); $this->actingAs($user) ->patch(route('projects.settings.metadata.priorities.update', [$project, $priority]), [ 'name' => 'Urgent', 'position' => 2, ]) ->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata'])) ->assertSessionHas('status', 'Urgent priority was updated.'); $this->actingAs($user) ->delete(route('projects.settings.metadata.priorities.destroy', [$project, $priority])) ->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata'])) ->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, ]); $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.index', ['project' => $project, 'section' => 'metadata'])) ->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.index', ['project' => $project, 'section' => 'metadata'])) ->assertSessionHas('status', 'Risk score field was updated.'); $this->assertDatabaseHas('ticket_field_definitions', [ 'id' => $fieldDefinition->id, 'label' => 'Risk score', 'field_type' => 'number', ]); } /** * 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, ]); $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.index', ['project' => $project, 'section' => 'metadata'])) ->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.index', ['project' => $project, 'section' => 'metadata'])) ->assertSessionHas('status', 'Depends on link type was updated.'); $this->assertDatabaseHas('ticket_link_types', [ 'id' => $ticketLinkType->id, 'label' => 'Depends on', 'counterpart_label' => 'Required by', ]); $this->actingAs($user) ->delete(route('projects.settings.metadata.link-types.destroy', [$project, $ticketLinkType])) ->assertRedirect(route('projects.settings.index', ['project' => $project, 'section' => 'metadata'])) ->assertSessionHas('status', 'Depends on link type was removed.'); $this->assertDatabaseMissing('ticket_link_types', [ 'id' => $ticketLinkType->id, ]); } }