diff --git a/app/Actions/Tickets/StoreProjectTicketLink.php b/app/Actions/Tickets/StoreProjectTicketLink.php index c2df3dc..71d3719 100644 --- a/app/Actions/Tickets/StoreProjectTicketLink.php +++ b/app/Actions/Tickets/StoreProjectTicketLink.php @@ -29,6 +29,12 @@ class StoreProjectTicketLink ->findOrFail($linkedTicketId); return DB::transaction(function () use ($linkedTicket, $user, $ticketLinkTypeId, $ticket): TicketLink { + Ticket::query() + ->whereKey([$ticket->id, $linkedTicket->id]) + ->orderBy('id') + ->lockForUpdate() + ->get(['id']); + $ticketLink = TicketLink::query() ->where(function ($query) use ($ticket, $linkedTicket): void { $query diff --git a/tests/Feature/Projects/ProjectTicketLinkControllerTest.php b/tests/Feature/Projects/ProjectTicketLinkControllerTest.php index 02e1045..80da56c 100644 --- a/tests/Feature/Projects/ProjectTicketLinkControllerTest.php +++ b/tests/Feature/Projects/ProjectTicketLinkControllerTest.php @@ -146,6 +146,66 @@ class ProjectTicketLinkControllerTest extends TestCase ->assertSessionHasErrors('linked_ticket_id'); } + /** + * 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. */