create(); $assignee = User::factory()->create([ 'name' => 'Taylor Reed', ]); $project = Project::factory()->create([ 'key' => 'CORE', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $todoColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Todo', 'position' => 1, ]); $doingColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Doing', 'position' => 2, ]); $this->createColumnStatus($todoColumn); $doingStatus = $this->createColumnStatus($doingColumn); $backlogStatus = $this->backlogStatus($project); $priority = TicketPriority::factory()->for($project)->create([ 'name' => 'High', 'position' => 2, ]); Ticket::factory()->create([ 'project_id' => $project->id, 'sequence_number' => 1, 'ticket_key' => 'CORE-1', 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $backlogStatus->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'title' => 'Backlog ticket', ]); $boardTicket = Ticket::factory()->create([ 'assignee_user_id' => $assignee->id, 'project_id' => $project->id, 'sequence_number' => 2, 'ticket_key' => 'CORE-2', 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $doingStatus->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'title' => 'Board ticket', ]); $this->syncProjectPermissions($project, $user, ['view']); $this->actingAs($user) ->get(route('projects.board.index', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Board') ->where('project.id', $project->id) ->where('boardColumns.0.name', $todoColumn->name) ->has('boardColumns.0.tickets', 0) ->where('boardColumns.1.name', $doingColumn->name) ->has('boardColumns.1.tickets', 1) ->where('boardColumns.1.tickets.0.ticket_key', $boardTicket->ticket_key) ->where('boardColumns.1.tickets.0.title', $boardTicket->title) ->where('boardColumns.1.tickets.0.assignee.id', $assignee->id) ->where('boardColumns.1.tickets.0.assignee.name', $assignee->name) ->where('boardColumns.1.tickets.0.ticket_priority.name', $priority->name) ->where('boardColumns.1.tickets.0.ticket_status.name', $doingStatus->name) ->where('canCreateTickets', false) ->where('canEditTickets', false), ); } /** * Ensure moving backlog tickets into a board column requires edit permission and logs the change. */ public function test_users_with_edit_permission_can_move_backlog_tickets_to_a_board_column(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $todoColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Todo', 'position' => 1, ]); $todoStatus = $this->createColumnStatus($todoColumn); $backlogStatus = $this->backlogStatus($project); $priority = TicketPriority::factory()->for($project)->create([ 'name' => 'High', 'position' => 2, ]); $ticket = Ticket::factory()->create([ 'project_id' => $project->id, 'sequence_number' => 1, 'ticket_key' => 'CORE-1', 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $backlogStatus->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'title' => 'Backlog ticket', ]); $this->syncProjectPermissions($project, $user, ['edit', 'view']); $this->allowMove($backlogStatus, $todoStatus); $this->actingAs($user) ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ 'redirect_to' => 'backlog', 'ticket_status_id' => $todoStatus->id, ]) ->assertRedirect(route('projects.backlog.index', $project)) ->assertSessionHas('status', 'CORE-1 moved to Todo.'); $this->assertDatabaseHas('tickets', [ 'id' => $ticket->id, 'ticket_status_id' => $todoStatus->id, ]); $this->assertDatabaseHas('ticket_worklog_entries', [ 'ticket_id' => $ticket->id, 'actor_id' => $user->id, 'entry_type' => 'status_changed', 'summary' => 'Moved from Backlog to Todo.', ]); } /** * Ensure board tickets can move between columns. */ public function test_users_with_edit_permission_can_move_board_tickets_between_columns(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $todoColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Todo', 'position' => 1, ]); $doneColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Done', 'position' => 2, ]); $todoStatus = $this->createColumnStatus($todoColumn); $doneStatus = $this->createColumnStatus($doneColumn); $priority = TicketPriority::factory()->for($project)->create([ 'name' => 'High', 'position' => 2, ]); $ticket = Ticket::factory()->create([ 'project_id' => $project->id, 'sequence_number' => 1, 'ticket_key' => 'CORE-1', 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $todoStatus->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'title' => 'Board ticket', ]); $this->syncProjectPermissions($project, $user, ['edit', 'view']); $this->allowMove($todoStatus, $doneStatus); $this->actingAs($user) ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ 'redirect_to' => 'board', 'ticket_status_id' => $doneStatus->id, ]) ->assertRedirect(route('projects.board.index', $project)) ->assertSessionHas('status', 'CORE-1 moved to Done.'); $this->assertDatabaseHas('tickets', [ 'id' => $ticket->id, 'ticket_status_id' => $doneStatus->id, ]); $this->assertDatabaseHas('ticket_worklog_entries', [ 'ticket_id' => $ticket->id, 'actor_id' => $user->id, 'entry_type' => 'status_changed', 'summary' => 'Moved from Todo to Done.', ]); } /** * Ensure moving a board ticket back to backlog removes it from the board columns. */ public function test_users_with_edit_permission_can_move_board_tickets_back_to_backlog(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $doingColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Doing', 'position' => 1, ]); $doingStatus = $this->createColumnStatus($doingColumn); $backlogStatus = $this->backlogStatus($project); $priority = TicketPriority::factory()->for($project)->create([ 'name' => 'High', 'position' => 2, ]); $ticket = Ticket::factory()->create([ 'project_id' => $project->id, 'sequence_number' => 1, 'ticket_key' => 'CORE-1', 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $doingStatus->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'title' => 'Board ticket', ]); $this->syncProjectPermissions($project, $user, ['edit', 'view']); $this->allowMove($doingStatus, $backlogStatus); $this->actingAs($user) ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ 'redirect_to' => 'board', 'ticket_status_id' => $backlogStatus->id, ]) ->assertRedirect(route('projects.board.index', $project)) ->assertSessionHas('status', 'CORE-1 moved to Backlog.'); $this->assertDatabaseHas('tickets', [ 'id' => $ticket->id, 'ticket_status_id' => $backlogStatus->id, ]); $this->actingAs($user) ->get(route('projects.board.index', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->where('boardColumns.0.name', $doingColumn->name) ->has('boardColumns.0.tickets', 0), ); } /** * Ensure cross-project board column moves are rejected. */ public function test_status_moves_to_columns_from_other_projects_are_rejected(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $otherProject = Project::factory()->create(); $sharedTemplate = ProjectTemplate::factory()->create(); $project->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly(); $otherProject->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly(); app(InitializeProjectTicketMetadata::class)->handle($project); app(InitializeProjectTicketMetadata::class)->handle($otherProject); $otherProjectColumn = BoardColumn::factory()->for($otherProject)->create([ 'name' => 'Foreign', 'position' => 1, ]); $otherProjectStatus = $this->createColumnStatus($otherProjectColumn); $backlogStatus = $this->backlogStatus($project); $priority = TicketPriority::factory()->for($project)->create([ 'name' => 'High', 'position' => 2, ]); $ticket = Ticket::factory()->create([ 'project_id' => $project->id, 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $backlogStatus->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, ]); $this->syncProjectPermissions($project, $user, ['edit', 'view']); $this->actingAs($user) ->from(route('projects.backlog.index', $project)) ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ 'redirect_to' => 'backlog', 'ticket_status_id' => $otherProjectStatus->id, ]) ->assertRedirect(route('projects.backlog.index', $project)) ->assertSessionHasErrors('ticket_status_id'); $this->assertDatabaseHas('tickets', [ 'id' => $ticket->id, 'ticket_status_id' => $backlogStatus->id, ]); $this->assertDatabaseCount('ticket_worklog_entries', 0); } /** * Ensure the status transition relation controls valid ticket moves. */ public function test_status_moves_without_an_allowed_transition_are_rejected(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $todoColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Todo', 'position' => 1, ]); $todoStatus = $this->createColumnStatus($todoColumn); $backlogStatus = $this->backlogStatus($project); $priority = TicketPriority::factory()->for($project)->create([ 'name' => 'High', 'position' => 2, ]); $ticket = Ticket::factory()->create([ 'project_id' => $project->id, 'sequence_number' => 1, 'ticket_key' => 'CORE-1', 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $backlogStatus->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'title' => 'Backlog ticket', ]); $this->syncProjectPermissions($project, $user, ['edit', 'view']); $this->actingAs($user) ->from(route('projects.backlog.index', $project)) ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ 'redirect_to' => 'backlog', 'ticket_status_id' => $todoStatus->id, ]) ->assertRedirect(route('projects.backlog.index', $project)) ->assertSessionHasErrors('ticket_status_id'); $this->assertDatabaseHas('tickets', [ 'id' => $ticket->id, 'ticket_status_id' => $backlogStatus->id, ]); $this->assertDatabaseCount('ticket_worklog_entries', 0); } /** * Ensure configured status moves can model a restricted workflow. */ public function test_users_with_edit_permission_can_follow_configured_status_transitions(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $progressColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Progress', 'position' => 1, ]); $reviewColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Review', 'position' => 2, ]); $doneColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Done', 'position' => 3, ]); $progressStatus = $this->createColumnStatus($progressColumn); $reviewStatus = $this->createColumnStatus($reviewColumn); $doneStatus = $this->createColumnStatus($doneColumn); $this->allowMove($progressStatus, $reviewStatus); $this->allowMove($reviewStatus, $doneStatus); $this->allowMove($reviewStatus, $progressStatus); $priority = TicketPriority::factory()->for($project)->create([ 'name' => 'High', 'position' => 2, ]); $ticket = Ticket::factory()->create([ 'project_id' => $project->id, 'sequence_number' => 1, 'ticket_key' => 'CORE-1', 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $progressStatus->id, 'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id, 'title' => 'Board ticket', ]); $this->syncProjectPermissions($project, $user, ['edit', 'view']); $this->actingAs($user) ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ 'redirect_to' => 'board', 'ticket_status_id' => $reviewStatus->id, ]) ->assertRedirect(route('projects.board.index', $project)) ->assertSessionHas('status', 'CORE-1 moved to Review.'); $ticket->refresh(); $this->actingAs($user) ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ 'redirect_to' => 'board', 'ticket_status_id' => $progressStatus->id, ]) ->assertRedirect(route('projects.board.index', $project)) ->assertSessionHas('status', 'CORE-1 moved to Progress.'); $ticket->refresh(); $this->actingAs($user) ->from(route('projects.board.index', $project)) ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ 'redirect_to' => 'board', 'ticket_status_id' => $doneStatus->id, ]) ->assertRedirect(route('projects.board.index', $project)) ->assertSessionHasErrors('ticket_status_id'); $this->assertDatabaseHas('tickets', [ 'id' => $ticket->id, 'ticket_status_id' => $progressStatus->id, ]); $this->assertDatabaseCount('ticket_worklog_entries', 2); $this->actingAs($user) ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ 'redirect_to' => 'board', 'ticket_status_id' => $reviewStatus->id, ]) ->assertRedirect(route('projects.board.index', $project)); $ticket->refresh(); $this->actingAs($user) ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ 'redirect_to' => 'board', 'ticket_status_id' => $doneStatus->id, ]) ->assertRedirect(route('projects.board.index', $project)) ->assertSessionHas('status', 'CORE-1 moved to Done.'); $ticket->refresh(); $this->actingAs($user) ->from(route('projects.board.index', $project)) ->patch(route('projects.tickets.status.update', [$project, $ticket]), [ 'redirect_to' => 'board', 'ticket_status_id' => $reviewStatus->id, ]) ->assertRedirect(route('projects.board.index', $project)) ->assertSessionHasErrors('ticket_status_id'); $this->assertDatabaseHas('tickets', [ 'id' => $ticket->id, 'ticket_status_id' => $doneStatus->id, ]); $this->assertDatabaseCount('ticket_worklog_entries', 4); } private function backlogStatus(Project $project): TicketStatus { return $project->ticketStatuses() ->whereNull('board_column_id') ->firstOrFail(); } private function createColumnStatus(BoardColumn $boardColumn): TicketStatus { return TicketStatus::factory() ->for($boardColumn->project) ->for($boardColumn) ->create([ 'name' => $boardColumn->name, 'position' => 1, ]); } private function allowMove(TicketStatus $sourceStatus, TicketStatus $targetStatus): void { $sourceStatus->allowedTargetStatuses()->syncWithoutDetaching([$targetStatus->id]); } }