create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); app(InitializeProjectTicketMetadata::class)->handle($project); BoardColumn::factory()->for($project)->create([ 'name' => 'Todo', 'position' => 1, ]); $template = ProjectTemplate::factory()->create([ 'configuration_snapshot' => app(DuplicateProjectConfiguration::class)->export($project, [ DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW, ]), 'copy_areas' => [DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW], 'name' => 'Starter Workflow', ]); $project->update(['project_template_id' => $template->id]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->get(route('projects.settings.board', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/settings/Board') ->where('project.id', $project->id) ->where('backlogTicketStatuses.0.name', 'Backlog') ->where('boardColumns.0.name', 'Todo') ->where('ticketStatuses.0.name', 'Backlog') ->where('templateState.templateName', 'Starter Workflow') ->where('templateState.inheritedAreas.0.value', DuplicateProjectConfiguration::COPY_BOARD_COLUMNS) ->where('templateState.inheritedAreas.1.value', DuplicateProjectConfiguration::COPY_TICKET_STATUSES) ->where('templateState.inheritedAreas.2.value', DuplicateProjectConfiguration::COPY_STATUS_TRANSITIONS), ); } /** * Ensure users without edit permission cannot view board settings. */ public function test_users_without_edit_permission_cannot_view_board_settings(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $this->actingAs($user) ->get(route('projects.settings.board', $project)) ->assertForbidden(); } /** * Ensure authorized users can create board columns. */ public function test_users_with_edit_permission_can_create_board_columns(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->post(route('projects.settings.board.columns.store', $project), [ 'name' => 'In Review', ]) ->assertRedirect(route('projects.settings.board', $project)) ->assertSessionHas('status', 'In Review was added to the board workflow.'); $this->assertDatabaseHas('board_columns', [ 'project_id' => $project->id, 'name' => 'In Review', 'position' => 1, ]); $this->assertDatabaseHas('ticket_statuses', [ 'project_id' => $project->id, 'name' => 'In Review', 'slug' => 'in-review', 'position' => 1, ]); } /** * Ensure authorized users can rename and reorder board columns. */ public function test_users_with_edit_permission_can_update_and_reorder_board_columns(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $firstColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Todo', 'position' => 1, ]); $secondColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'In Progress', 'position' => 2, ]); $thirdColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Done', 'position' => 3, ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->patch(route('projects.settings.board.columns.update', [$project, $thirdColumn]), [ 'name' => 'Ready for QA', 'position' => 1, ]) ->assertRedirect(route('projects.settings.board', $project)) ->assertSessionHas('status', 'Ready for QA was updated.'); $firstColumn->refresh(); $secondColumn->refresh(); $thirdColumn->refresh(); $this->assertSame(2, $firstColumn->position); $this->assertSame(3, $secondColumn->position); $this->assertSame(1, $thirdColumn->position); $this->assertSame('Ready for QA', $thirdColumn->name); } /** * Ensure deleting a column normalizes the remaining order. */ public function test_deleting_a_board_column_normalizes_remaining_positions(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $firstColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Todo', 'position' => 1, ]); $secondColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'In Progress', 'position' => 2, ]); $thirdColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Done', 'position' => 3, ]); $this->syncProjectPermissions($project, $user, ['edit']); $this->actingAs($user) ->delete(route('projects.settings.board.columns.destroy', [$project, $secondColumn])) ->assertRedirect(route('projects.settings.board', $project)) ->assertSessionHas('status', 'In Progress was removed from the board workflow.'); $firstColumn->refresh(); $thirdColumn->refresh(); $this->assertSame(1, $firstColumn->position); $this->assertSame(2, $thirdColumn->position); $this->assertDatabaseMissing('board_columns', [ 'id' => $secondColumn->id, ]); } /** * Ensure authorized users can manage multiple statuses for backlog and columns. */ public function test_users_with_edit_permission_can_manage_ticket_statuses(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); $this->syncProjectPermissions($project, $user, ['edit']); $boardColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'Todo', 'position' => 1, ]); $defaultStatus = TicketStatus::factory() ->for($project) ->for($boardColumn) ->create([ 'name' => 'Todo', 'position' => 1, ]); $this->actingAs($user) ->post(route('projects.settings.board.statuses.store', $project), [ 'board_column_id' => $boardColumn->id, 'name' => 'Ready for QA', ]) ->assertRedirect(route('projects.settings.board', $project)) ->assertSessionHas('status', 'Ready for QA was added to Todo.'); $newStatus = TicketStatus::query() ->where('project_id', $project->id) ->where('name', 'Ready for QA') ->firstOrFail(); $this->assertSame($boardColumn->id, $newStatus->board_column_id); $this->assertSame('ready-for-qa', $newStatus->slug); $this->assertFalse($defaultStatus->allowedTargetStatuses()->whereKey($newStatus->id)->exists()); $this->actingAs($user) ->patch(route('projects.settings.board.statuses.update', [$project, $newStatus]), [ 'name' => 'QA Ready', ]) ->assertRedirect(route('projects.settings.board', $project)) ->assertSessionHas('status', 'QA Ready was updated.'); $newStatus->refresh(); $this->assertSame('QA Ready', $newStatus->name); $this->assertSame('qa-ready', $newStatus->slug); $ticket = Ticket::factory()->create([ 'project_id' => $project->id, 'ticket_status_id' => $newStatus->id, ]); $this->actingAs($user) ->delete(route('projects.settings.board.statuses.destroy', [$project, $newStatus])) ->assertRedirect(route('projects.settings.board', $project)) ->assertSessionHas('status', 'QA Ready was removed from Todo.'); $this->assertDatabaseMissing('ticket_statuses', [ 'id' => $newStatus->id, ]); $this->assertDatabaseHas('tickets', [ 'id' => $ticket->id, 'ticket_status_id' => $defaultStatus->id, ]); } /** * Ensure authorized users can configure the allowed move targets for a status. */ public function test_users_with_edit_permission_can_update_ticket_status_transitions(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); $this->syncProjectPermissions($project, $user, ['edit']); $sourceStatus = TicketStatus::factory()->for($project)->create([ 'name' => 'Progress', 'position' => 1, ]); $reviewStatus = TicketStatus::factory()->for($project)->create([ 'name' => 'Review', 'position' => 2, ]); $doneStatus = TicketStatus::factory()->for($project)->create([ 'name' => 'Done', 'position' => 3, ]); $this->actingAs($user) ->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [ 'allowed_ticket_status_ids' => [$reviewStatus->id, $doneStatus->id], ]) ->assertRedirect(route('projects.settings.board', $project)) ->assertSessionHas('status', 'Progress move targets were updated.'); $this->assertEqualsCanonicalizing( [$reviewStatus->id, $doneStatus->id], $sourceStatus->allowedTargetStatuses()->get()->pluck('id')->all(), ); $this->actingAs($user) ->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), []) ->assertRedirect(route('projects.settings.board', $project)) ->assertSessionHas('status', 'Progress move targets were updated.'); $this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get()); } /** * Ensure transition updates only accept targets from the same project and never the source status itself. */ public function test_ticket_status_transition_updates_reject_foreign_and_self_targets(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); $otherProject = Project::factory()->create([ 'key' => 'EXT', ]); $sharedTemplate = ProjectTemplate::factory()->create(); $project->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly(); $otherProject->forceFill(['project_template_id' => $sharedTemplate->id])->saveQuietly(); $this->syncProjectPermissions($project, $user, ['edit']); $sourceStatus = TicketStatus::factory()->for($project)->create([ 'name' => 'Progress', 'position' => 1, ]); $foreignStatus = TicketStatus::factory()->for($otherProject)->create([ 'name' => 'External review', 'position' => 1, ]); $this->actingAs($user) ->from(route('projects.settings.board', $project)) ->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [ 'allowed_ticket_status_ids' => [$foreignStatus->id], ]) ->assertRedirect(route('projects.settings.board', $project)) ->assertSessionHasErrors('allowed_ticket_status_ids.0'); $this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get()); $this->actingAs($user) ->from(route('projects.settings.board', $project)) ->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [ 'allowed_ticket_status_ids' => [$sourceStatus->id], ]) ->assertRedirect(route('projects.settings.board', $project)) ->assertSessionHasErrors('allowed_ticket_status_ids'); $this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get()); } /** * Ensure the last status in a column or backlog cannot be deleted. */ public function test_last_ticket_status_for_a_context_cannot_be_deleted(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $this->syncProjectPermissions($project, $user, ['edit']); $backlogStatus = TicketStatus::factory() ->for($project) ->create([ 'name' => 'Backlog', 'position' => 1, ]); $this->actingAs($user) ->from(route('projects.settings.board', $project)) ->delete(route('projects.settings.board.statuses.destroy', [$project, $backlogStatus])) ->assertRedirect(route('projects.settings.board', $project)) ->assertSessionHasErrors('ticket_status_id'); $this->assertDatabaseHas('ticket_statuses', [ 'id' => $backlogStatus->id, ]); } }