Files
KanbanGit/tests/Feature/Projects/ProjectTicketMetadataSettingsControllerTest.php
T
Nico Schröder 9895fc36db
CI/CD / test_and_check_app (push) Failing after 3m8s
CI/CD / Publish immutable image (push) Skipped
CI/CD / test_and_check_app (pull_request) Failing after 3m8s
CI/CD / Publish immutable image (pull_request) Skipped
fix: make project template inheritance granular and safe
2026-09-08 10:25:52 +00:00

491 lines
18 KiB
PHP

<?php
namespace Tests\Feature\Projects;
use App\Actions\Projects\DuplicateProjectConfiguration;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\ProjectTemplate;
use App\Models\Ticket;
use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType;
use App\Models\TicketPriority;
use App\Models\TicketType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Tests\TestCase;
class ProjectTicketMetadataSettingsControllerTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure users with edit permission can view ticket metadata settings.
*/
public function test_users_with_edit_permission_can_view_ticket_metadata_settings(): void
{
$user = User::factory()->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,
'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('ticketLinkTypes.0.label', 'Relates to')
->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,
]);
$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',
]);
}
/**
* 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.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->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,
]);
}
}