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.', ]); } /** * 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'); } /** * 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.', ]); } }