354 lines
14 KiB
PHP
354 lines
14 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Projects;
|
|
|
|
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
|
|
use App\Models\Project;
|
|
use App\Models\Ticket;
|
|
use App\Models\TicketLink;
|
|
use App\Models\TicketLinkType;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
class ProjectTicketLinkControllerTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
/**
|
|
* Ensure users with edit permission can link tickets and create a worklog entry.
|
|
*/
|
|
public function test_users_with_edit_permission_can_link_tickets(): void
|
|
{
|
|
$user = 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();
|
|
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
|
'label' => 'Blocks',
|
|
'counterpart_label' => 'Blocked by',
|
|
'position' => 1,
|
|
]);
|
|
$ticket = Ticket::factory()->create([
|
|
'project_id' => $project->id,
|
|
'sequence_number' => 1,
|
|
'ticket_key' => 'CORE-1',
|
|
'ticket_priority_id' => $priority->id,
|
|
'ticket_type_id' => $taskType->id,
|
|
]);
|
|
$linkedTicket = Ticket::factory()->create([
|
|
'project_id' => $project->id,
|
|
'sequence_number' => 2,
|
|
'ticket_key' => 'CORE-2',
|
|
'ticket_priority_id' => $priority->id,
|
|
'ticket_type_id' => $taskType->id,
|
|
]);
|
|
|
|
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
|
|
|
$this->actingAs($user)
|
|
->post(route('projects.tickets.links.store', [$project, $ticket]), [
|
|
'linked_ticket_id' => $linkedTicket->id,
|
|
'ticket_link_type_id' => $ticketLinkType->id,
|
|
])
|
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
|
->assertSessionHas('status', 'Blocks link was saved.');
|
|
|
|
$this->assertDatabaseHas('ticket_links', [
|
|
'source_ticket_id' => $ticket->id,
|
|
'target_ticket_id' => $linkedTicket->id,
|
|
'ticket_link_type_id' => $ticketLinkType->id,
|
|
]);
|
|
|
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
|
'ticket_id' => $ticket->id,
|
|
'actor_id' => $user->id,
|
|
'entry_type' => 'link_updated',
|
|
'summary' => 'Linked CORE-2 as Blocks.',
|
|
]);
|
|
|
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
|
'ticket_id' => $linkedTicket->id,
|
|
'actor_id' => $user->id,
|
|
'entry_type' => 'link_updated',
|
|
'summary' => 'Linked CORE-1 as Blocked by.',
|
|
]);
|
|
}
|
|
|
|
public function test_counterpart_label_can_be_selected_from_the_current_ticket(): void
|
|
{
|
|
$user = 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)->firstOrFail();
|
|
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
|
'label' => 'Blocks',
|
|
'counterpart_label' => 'Blocked by',
|
|
]);
|
|
$blockingTicket = Ticket::factory()->create([
|
|
'project_id' => $project->id,
|
|
'sequence_number' => 1,
|
|
'ticket_key' => 'CORE-1',
|
|
'ticket_priority_id' => $priority->id,
|
|
'ticket_type_id' => $taskType->id,
|
|
]);
|
|
$blockedTicket = Ticket::factory()->create([
|
|
'project_id' => $project->id,
|
|
'sequence_number' => 2,
|
|
'ticket_key' => 'CORE-2',
|
|
'ticket_priority_id' => $priority->id,
|
|
'ticket_type_id' => $taskType->id,
|
|
]);
|
|
|
|
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
|
|
|
$this->actingAs($user)
|
|
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
|
|
'linked_ticket_id' => $blockingTicket->id,
|
|
'ticket_link_type_id' => $ticketLinkType->id,
|
|
'link_direction' => 'counterpart',
|
|
])
|
|
->assertRedirect(route('projects.tickets.show', [$project, $blockedTicket]))
|
|
->assertSessionHas('status', 'Blocked by link was saved.');
|
|
|
|
$this->assertDatabaseHas('ticket_links', [
|
|
'source_ticket_id' => $blockingTicket->id,
|
|
'target_ticket_id' => $blockedTicket->id,
|
|
'ticket_link_type_id' => $ticketLinkType->id,
|
|
]);
|
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
|
'ticket_id' => $blockedTicket->id,
|
|
'summary' => 'Linked CORE-1 as Blocked by.',
|
|
]);
|
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
|
'ticket_id' => $blockingTicket->id,
|
|
'summary' => 'Linked CORE-2 as Blocks.',
|
|
]);
|
|
|
|
$this->actingAs($user)
|
|
->post(route('projects.tickets.links.store', [$project, $blockedTicket]), [
|
|
'linked_ticket_id' => $blockingTicket->id,
|
|
'ticket_link_type_id' => $ticketLinkType->id,
|
|
'link_direction' => 'source',
|
|
])
|
|
->assertSessionHas('status', 'Blocks link was saved.');
|
|
|
|
$this->assertSame(1, TicketLink::query()->count());
|
|
$this->assertDatabaseHas('ticket_links', [
|
|
'source_ticket_id' => $blockedTicket->id,
|
|
'target_ticket_id' => $blockingTicket->id,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Ensure ticket links cannot target the current ticket or a ticket from another project.
|
|
*/
|
|
public function test_ticket_links_must_target_other_tickets_within_the_same_project(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$project = Project::factory()->create();
|
|
$otherProject = Project::factory()->create();
|
|
|
|
app(InitializeProjectTicketMetadata::class)->handle($project);
|
|
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
|
|
|
|
$priority = $project->ticketPriorities()->firstOrFail();
|
|
$otherPriority = $otherProject->ticketPriorities()->firstOrFail();
|
|
$taskType = $project->ticketTypes()
|
|
->where('is_parent', false)
|
|
->orderBy('position')
|
|
->firstOrFail();
|
|
$otherTaskType = $otherProject->ticketTypes()
|
|
->where('is_parent', false)
|
|
->orderBy('position')
|
|
->firstOrFail();
|
|
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
|
'label' => 'Relates to',
|
|
'counterpart_label' => 'Relates to',
|
|
'position' => 1,
|
|
]);
|
|
$ticket = Ticket::factory()->create([
|
|
'project_id' => $project->id,
|
|
'sequence_number' => 1,
|
|
'ticket_key' => 'APP-1',
|
|
'ticket_priority_id' => $priority->id,
|
|
'ticket_type_id' => $taskType->id,
|
|
]);
|
|
$foreignTicket = Ticket::factory()->create([
|
|
'project_id' => $otherProject->id,
|
|
'sequence_number' => 1,
|
|
'ticket_key' => 'OPS-1',
|
|
'ticket_priority_id' => $otherPriority->id,
|
|
'ticket_type_id' => $otherTaskType->id,
|
|
]);
|
|
|
|
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
|
|
|
$this->actingAs($user)
|
|
->from(route('projects.tickets.show', [$project, $ticket]))
|
|
->post(route('projects.tickets.links.store', [$project, $ticket]), [
|
|
'linked_ticket_id' => $ticket->id,
|
|
'ticket_link_type_id' => $ticketLinkType->id,
|
|
])
|
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
|
->assertSessionHasErrors('linked_ticket_id');
|
|
|
|
$this->actingAs($user)
|
|
->from(route('projects.tickets.show', [$project, $ticket]))
|
|
->post(route('projects.tickets.links.store', [$project, $ticket]), [
|
|
'linked_ticket_id' => $foreignTicket->id,
|
|
'ticket_link_type_id' => $ticketLinkType->id,
|
|
])
|
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
|
->assertSessionHasErrors('linked_ticket_id');
|
|
|
|
$this->actingAs($user)
|
|
->from(route('projects.tickets.show', [$project, $ticket]))
|
|
->post(route('projects.tickets.links.store', [$project, $ticket]), [
|
|
'linked_ticket_id' => $foreignTicket->id,
|
|
'ticket_link_type_id' => $ticketLinkType->id,
|
|
'link_direction' => 'sideways',
|
|
])
|
|
->assertSessionHasErrors('link_direction');
|
|
}
|
|
|
|
/**
|
|
* Ensure relinking an existing reverse-direction pair updates the existing row instead of creating a duplicate.
|
|
*/
|
|
public function test_linking_an_existing_reverse_direction_pair_reuses_the_same_row(): void
|
|
{
|
|
$user = 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();
|
|
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
|
'label' => 'Blocks',
|
|
'counterpart_label' => 'Blocked by',
|
|
'position' => 1,
|
|
]);
|
|
$ticket = Ticket::factory()->create([
|
|
'project_id' => $project->id,
|
|
'sequence_number' => 1,
|
|
'ticket_key' => 'CORE-1',
|
|
'ticket_priority_id' => $priority->id,
|
|
'ticket_type_id' => $taskType->id,
|
|
]);
|
|
$linkedTicket = Ticket::factory()->create([
|
|
'project_id' => $project->id,
|
|
'sequence_number' => 2,
|
|
'ticket_key' => 'CORE-2',
|
|
'ticket_priority_id' => $priority->id,
|
|
'ticket_type_id' => $taskType->id,
|
|
]);
|
|
$existingLink = TicketLink::factory()->create([
|
|
'source_ticket_id' => $linkedTicket->id,
|
|
'target_ticket_id' => $ticket->id,
|
|
'ticket_link_type_id' => $ticketLinkType->id,
|
|
]);
|
|
|
|
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
|
|
|
$this->actingAs($user)
|
|
->post(route('projects.tickets.links.store', [$project, $ticket]), [
|
|
'linked_ticket_id' => $linkedTicket->id,
|
|
'ticket_link_type_id' => $ticketLinkType->id,
|
|
])
|
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
|
->assertSessionHas('status', 'Blocks link was saved.');
|
|
|
|
$this->assertSame(1, TicketLink::query()->count());
|
|
|
|
$existingLink->refresh();
|
|
|
|
$this->assertSame($ticket->id, $existingLink->source_ticket_id);
|
|
$this->assertSame($linkedTicket->id, $existingLink->target_ticket_id);
|
|
}
|
|
|
|
/**
|
|
* Ensure users with edit permission can remove ticket links.
|
|
*/
|
|
public function test_users_with_edit_permission_can_remove_ticket_links(): void
|
|
{
|
|
$user = 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();
|
|
$ticketLinkType = TicketLinkType::factory()->for($project)->create([
|
|
'label' => 'Blocks',
|
|
'counterpart_label' => 'Blocked by',
|
|
'position' => 1,
|
|
]);
|
|
$ticket = Ticket::factory()->create([
|
|
'project_id' => $project->id,
|
|
'sequence_number' => 1,
|
|
'ticket_key' => 'CORE-1',
|
|
'ticket_priority_id' => $priority->id,
|
|
'ticket_type_id' => $taskType->id,
|
|
]);
|
|
$linkedTicket = Ticket::factory()->create([
|
|
'project_id' => $project->id,
|
|
'sequence_number' => 2,
|
|
'ticket_key' => 'CORE-2',
|
|
'ticket_priority_id' => $priority->id,
|
|
'ticket_type_id' => $taskType->id,
|
|
]);
|
|
$ticketLink = TicketLink::factory()->create([
|
|
'source_ticket_id' => $ticket->id,
|
|
'target_ticket_id' => $linkedTicket->id,
|
|
'ticket_link_type_id' => $ticketLinkType->id,
|
|
]);
|
|
|
|
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
|
|
|
|
$this->actingAs($user)
|
|
->delete(route('projects.tickets.links.destroy', [$project, $ticket, $ticketLink]))
|
|
->assertRedirect(route('projects.tickets.show', [$project, $ticket]))
|
|
->assertSessionHas('status', 'Blocks link was removed.');
|
|
|
|
$this->assertDatabaseMissing('ticket_links', [
|
|
'id' => $ticketLink->id,
|
|
]);
|
|
|
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
|
'ticket_id' => $ticket->id,
|
|
'actor_id' => $user->id,
|
|
'entry_type' => 'link_removed',
|
|
'summary' => 'Removed Blocks link to CORE-2.',
|
|
]);
|
|
|
|
$this->assertDatabaseHas('ticket_worklog_entries', [
|
|
'ticket_id' => $linkedTicket->id,
|
|
'actor_id' => $user->id,
|
|
'entry_type' => 'link_removed',
|
|
'summary' => 'Removed Blocked by link to CORE-1.',
|
|
]);
|
|
}
|
|
}
|