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 saved outgoing targets reach both consumers without changing the reverse direction. */ public function test_saved_transition_subsets_and_empty_targets_are_reflected_in_ticket_and_board_responses(): void { $user = User::factory()->create(); $project = Project::factory()->create(['key' => 'CORE']); $this->syncProjectPermissions($project, $user, ['edit']); $column = BoardColumn::factory()->for($project)->create(['position' => 1]); $sourceStatus = TicketStatus::factory()->for($project)->for($column)->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, ]); $ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $sourceStatus->id]); $sourceStatus->allowedTargetStatuses()->sync([$reviewStatus->id, $doneStatus->id]); $reviewStatus->allowedTargetStatuses()->sync([$sourceStatus->id]); $this->actingAs($user) ->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [ 'allowed_ticket_status_ids' => [$reviewStatus->id], ]) ->assertRedirect(route('projects.settings.board', $project)) ->assertSessionHasNoErrors(); $this->assertSame([$reviewStatus->id], $sourceStatus->allowedTargetStatuses()->get()->modelKeys()); $this->assertSame([$sourceStatus->id], $reviewStatus->allowedTargetStatuses()->get()->modelKeys()); $this->withApplicationKey($user) ->getJson(route('api.projects.tickets.status-targets', [$project, $ticket])) ->assertOk() ->assertJsonPath('data.current_status.id', $sourceStatus->id) ->assertJsonCount(1, 'data.target_statuses') ->assertJsonPath('data.target_statuses.0.id', $reviewStatus->id); $this->get(route('projects.board.index', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Board') ->where('boardColumns.0.tickets.0.id', $ticket->id) ->has('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses', 1) ->where('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses.0.id', $reviewStatus->id), ); $this->patch(route('projects.settings.board.statuses.transitions.update', [$project, $sourceStatus]), [ 'allowed_ticket_status_ids' => [], ]) ->assertRedirect(route('projects.settings.board', $project)) ->assertSessionHasNoErrors(); $this->assertCount(0, $sourceStatus->allowedTargetStatuses()->get()); $this->assertSame([$sourceStatus->id], $reviewStatus->allowedTargetStatuses()->get()->modelKeys()); $this->getJson(route('api.projects.tickets.status-targets', [$project, $ticket])) ->assertOk() ->assertJsonPath('data.target_statuses', []); $this->get(route('projects.board.index', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Board') ->where('boardColumns.0.tickets.0.id', $ticket->id) ->has('boardColumns.0.tickets.0.ticket_status.allowed_target_statuses', 0), ); } /** * 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, ]); } public function test_deleting_a_used_column_preserves_statuses_tickets_and_backlog_order(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $this->syncProjectPermissions($project, $user, ['edit']); $backlog = TicketStatus::factory()->for($project)->create(['position' => 1]); $column = BoardColumn::factory()->for($project)->create(['position' => 1]); $statuses = TicketStatus::factory()->for($project)->for($column)->count(2) ->sequence(['position' => 1], ['position' => 2])->create(); $ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $statuses[1]->id]); $statuses[0]->allowedTargetStatuses()->sync([$statuses[1]->id]); $this->actingAs($user) ->delete(route('projects.settings.board.columns.destroy', [$project, $column])) ->assertRedirect(route('projects.settings.board', $project)) ->assertSessionHasNoErrors(); $this->assertModelMissing($column); $this->assertSame($statuses[1]->id, $ticket->fresh()->ticket_status_id); $this->assertSame([$backlog->id, ...$statuses->modelKeys()], $project->ticketStatuses()->pluck('id')->all()); $this->assertSame([1, 2, 3], $project->ticketStatuses()->pluck('position')->all()); $this->assertTrue($project->ticketStatuses()->get()->every(fn (TicketStatus $status): bool => $status->board_column_id === null)); $this->assertSame([$statuses[1]->id], $statuses[0]->allowedTargetStatuses()->pluck('ticket_statuses.id')->all()); } public function test_last_column_status_is_protected_and_invalid_position_preserves_order(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $this->syncProjectPermissions($project, $user, ['edit']); $column = BoardColumn::factory()->for($project)->create(['position' => 1]); $status = TicketStatus::factory()->for($project)->for($column)->create(); $ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $status->id]); $this->actingAs($user)->from(route('projects.settings.board', $project)) ->delete(route('projects.settings.board.statuses.destroy', [$project, $status])) ->assertSessionHasErrors('ticket_status_id'); $this->assertModelExists($status); $this->assertSame($status->id, $ticket->fresh()->ticket_status_id); $this->actingAs($user)->from(route('projects.settings.board', $project)) ->patch(route('projects.settings.board.columns.update', [$project, $column]), ['name' => 'Invalid move', 'position' => 0]) ->assertSessionHasErrors('position'); $this->assertSame(1, $column->fresh()->position); $this->assertSame($column->name, $column->fresh()->name); } }