get(route('projects.index')) ->assertRedirect(route('login')); $this->get(route('projects.create')) ->assertRedirect(route('login')); } /** * Ensure authenticated users can view the projects index. */ public function test_authenticated_users_can_view_the_projects_index(): void { $user = User::factory()->admin()->create(); $firstProject = Project::factory()->create([ 'name' => 'Core Platform', 'key' => 'CORE', ]); $secondProject = Project::factory()->create([ 'name' => 'Authentication', 'key' => 'AUTH', ]); $this->actingAs($user) ->get(route('projects.index')) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Index') ->has('projects', 2) ->where('projects.0.id', $secondProject->id) ->where('projects.1.id', $firstProject->id), ); } /** * Ensure non-admin users only see projects they can access directly or through teams. */ public function test_users_only_see_accessible_projects_on_the_index(): void { $user = User::factory()->create(); $team = Team::factory()->create([ 'name' => 'Platform Team', ]); $team->users()->attach($user); $directProject = Project::factory()->create([ 'name' => 'Direct Access', 'key' => 'DIR', ]); $teamProject = Project::factory()->create([ 'name' => 'Team Access', 'key' => 'TEAM', ]); Project::factory()->create([ 'name' => 'Hidden Project', 'key' => 'HIDE', ]); $this->syncProjectPermissions($directProject, $user, ['view']); $this->syncProjectPermissions($teamProject, $team, ['view']); $this->actingAs($user) ->get(route('projects.index')) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Index') ->has('projects', 2) ->where('projects.0.id', $teamProject->id) ->where('projects.1.id', $directProject->id), ); } /** * Ensure users with project create permission can view the project creation screen. */ public function test_authenticated_users_can_view_the_project_creation_screen(): void { $user = User::factory()->create(); $this->syncGlobalPermissions($user, ['project:create']); $visibleProject = Project::factory()->create([ 'name' => 'Visible Source', 'key' => 'SRC1', ]); Project::factory()->create([ 'name' => 'Hidden Source', 'key' => 'SRC2', ]); $template = ProjectTemplate::factory()->create([ 'name' => 'Reusable Setup', 'slug' => 'reusable-setup', 'source_project_id' => $visibleProject->id, ]); ProjectTemplate::factory()->create([ 'name' => 'Hidden Base', 'slug' => 'hidden-base', 'project_id' => $visibleProject->id, 'source_project_id' => $visibleProject->id, 'is_published' => false, ]); $this->syncProjectPermissions($visibleProject, $user, ['view']); $this->actingAs($user) ->get(route('projects.create', ['template' => $template->id])) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Create') ->has('copyAreaOptions', count(DuplicateProjectConfiguration::copyableAreas())) ->has('sourceProjects', 1) ->has('sourceTemplates', 1) ->where('sourceProjects.0.id', $visibleProject->id) ->where('sourceTemplates.0.id', $template->id) ->where('selectedSourceTemplateId', $template->id) ->where('copyAreaOptions.0.value', DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS), ); } /** * Ensure users without project create permission cannot view the project creation screen. */ public function test_users_without_project_create_permission_cannot_view_the_project_creation_screen(): void { $user = User::factory()->create(); $this->actingAs($user) ->get(route('projects.create')) ->assertForbidden(); } /** * Ensure users with project create permission can create projects with uppercase keys. */ public function test_authenticated_users_can_create_projects_with_uppercase_keys(): void { $user = User::factory()->create(); $this->syncGlobalPermissions($user, ['project:create']); $response = $this->actingAs($user) ->post(route('projects.store'), [ 'creation_mode' => 'scratch', 'name' => 'Kanban Foundation', 'key' => 'core', ]); $project = Project::query()->firstOrFail(); $response->assertRedirect(route('projects.show', $project)); $this->assertDatabaseHas('projects', [ 'name' => 'Kanban Foundation', 'key' => 'CORE', ]); $project->refresh(); $this->assertNotNull($project->project_template_id); $this->assertDatabaseHas('project_templates', [ 'id' => $project->project_template_id, 'project_id' => $project->id, 'source_project_id' => $project->id, 'is_published' => false, 'slug' => 'core-base-template', ]); $this->assertDatabaseHas('permission_assignments', [ 'assignee_id' => $user->id, 'assignee_type' => $user->getMorphClass(), 'permission' => 'project:admin', 'scope_id' => $project->id, 'scope_type' => $project->getMorphClass(), ]); $this->assertDatabaseHas('ticket_types', [ 'project_id' => $project->id, 'name' => 'Task', 'is_parent' => false, ]); $this->assertDatabaseHas('ticket_types', [ 'project_id' => $project->id, 'name' => 'Release', 'is_parent' => true, ]); } /** * Ensure project keys remain unique after normalization. */ public function test_project_keys_must_be_unique_case_insensitively(): void { $user = User::factory()->create(); $this->syncGlobalPermissions($user, ['project:create']); Project::factory()->create([ 'key' => 'AUTH', ]); $this->actingAs($user) ->from(route('projects.create')) ->post(route('projects.store'), [ 'name' => 'Another Auth Project', 'key' => 'auth', ]) ->assertSessionHasErrors('key') ->assertRedirect(route('projects.create')); } public function test_authenticated_users_can_create_projects_from_saved_templates(): void { $user = User::factory()->create(); $this->syncGlobalPermissions($user, ['project:create']); $sourceProject = Project::factory()->create([ 'key' => 'SRC1', 'name' => 'Source Project', 'show_author' => false, 'show_story_points' => false, ]); $template = ProjectTemplate::factory()->create([ 'source_project_id' => $sourceProject->id, 'name' => 'Kanban Template', 'slug' => 'kanban-template', 'copy_areas' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_TICKET_TYPES, ], 'configuration_snapshot' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ 'show_author' => false, 'show_story_points' => false, ], DuplicateProjectConfiguration::COPY_TICKET_TYPES => [ [ 'name' => 'Bug', 'position' => 1, 'is_parent' => false, ], ], ], ]); $response = $this->actingAs($user) ->post(route('projects.store'), [ 'creation_mode' => 'template', 'name' => 'Template Based Project', 'key' => 'TMP1', 'source_template_id' => $template->id, ]); $project = Project::query()->where('key', 'TMP1')->firstOrFail(); $response->assertRedirect(route('projects.show', $project)); $project->refresh(); $this->assertSame($template->id, $project->project_template_id); $this->assertDatabaseMissing('project_templates', [ 'project_id' => $project->id, 'is_published' => false, ]); $this->assertSame(false, $project->show_author); $this->assertSame(false, $project->show_story_points); $this->assertDatabaseHas('ticket_types', [ 'project_id' => $project->id, 'project_template_id' => $template->id, 'name' => 'Bug', 'position' => 1, 'is_parent' => false, ]); $this->assertDatabaseMissing('tickets', [ 'project_id' => $project->id, ]); } public function test_authenticated_users_can_duplicate_projects_with_selected_settings_only(): void { $user = User::factory()->create(); $this->syncGlobalPermissions($user, ['project:create']); $sourceProject = Project::factory()->create([ 'key' => 'SRC1', 'name' => 'Source Project', 'show_author' => false, 'show_story_points' => false, ]); $this->syncProjectPermissions($sourceProject, $user, ['view']); $sourceProject->ticketTypes()->delete(); $sourceProject->ticketPriorities()->delete(); $sourceProject->ticketStatuses()->delete(); $sourceProject->boardColumns()->delete(); $todoColumn = BoardColumn::factory()->create([ 'project_id' => $sourceProject->id, 'name' => 'Todo', 'position' => 1, ]); $doingColumn = BoardColumn::factory()->create([ 'project_id' => $sourceProject->id, 'name' => 'Doing', 'position' => 2, ]); $backlogStatus = TicketStatus::factory()->create([ 'project_id' => $sourceProject->id, 'board_column_id' => null, 'name' => 'Planned', 'position' => 1, ]); $todoStatus = TicketStatus::factory()->create([ 'project_id' => $sourceProject->id, 'board_column_id' => $todoColumn->id, 'name' => 'Todo', 'position' => 1, ]); $doingStatus = TicketStatus::factory()->create([ 'project_id' => $sourceProject->id, 'board_column_id' => $doingColumn->id, 'name' => 'Doing', 'position' => 1, ]); $todoStatus->allowedTargetStatuses()->sync([$doingStatus->id]); $backlogStatus->allowedTargetStatuses()->sync([$todoStatus->id]); TicketType::factory()->create([ 'project_id' => $sourceProject->id, 'name' => 'Bug', 'position' => 1, ]); TicketType::factory()->create([ 'project_id' => $sourceProject->id, 'name' => 'Epic', 'position' => 2, 'is_parent' => true, ]); TicketPriority::factory()->create([ 'project_id' => $sourceProject->id, 'name' => 'Urgent', 'position' => 1, ]); TicketFieldDefinition::factory()->create([ 'project_id' => $sourceProject->id, 'field_type' => 'number', 'label' => 'Story estimate', 'position' => 1, ]); TicketLinkType::factory()->create([ 'project_id' => $sourceProject->id, 'label' => 'Blocks', 'counterpart_label' => 'Blocked by', 'position' => 1, ]); RepositoryConnection::factory()->create([ 'project_id' => $sourceProject->id, 'repository_full_name' => 'OpenSource/source-project', ]); NotificationAction::factory()->create([ 'project_id' => $sourceProject->id, 'name' => 'Notify board changes', ]); Ticket::factory()->create([ 'project_id' => $sourceProject->id, 'sequence_number' => 42, 'title' => 'Existing ticket', ]); $response = $this->actingAs($user) ->post(route('projects.store'), [ 'name' => 'Duplicated Project', 'key' => 'dupe', 'creation_mode' => 'duplicate', 'source_project_id' => $sourceProject->id, 'copy_areas' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_BOARD_WORKFLOW, DuplicateProjectConfiguration::COPY_TICKET_TYPES, DuplicateProjectConfiguration::COPY_TICKET_FIELDS, ], ]); $project = Project::query()->where('key', 'DUPE')->firstOrFail(); $response->assertRedirect(route('projects.show', $project)); $this->assertSame('Duplicated Project', $project->name); $this->assertFalse($project->show_author); $this->assertFalse($project->show_story_points); $this->assertSame(0, $project->tickets()->count()); $this->assertSame(['Todo', 'Doing'], $project->boardColumns()->pluck('name')->all()); $this->assertSame(['Planned', 'Todo', 'Doing'], $project->ticketStatuses()->pluck('name')->all()); $this->assertSame(['Bug', 'Epic'], $project->ticketTypes()->pluck('name')->all()); $this->assertSame(['Story estimate'], $project->ticketFieldDefinitions()->pluck('label')->all()); $this->assertSame(['Medium'], $project->ticketPriorities()->pluck('name')->all()); $this->assertCount(0, $project->ticketLinkTypes); $this->assertCount(0, $project->repositoryConnections); $this->assertCount(0, $project->notificationActions); $duplicatedTodoStatus = $project->ticketStatuses()->where('name', 'Todo')->firstOrFail(); $this->assertSame(['Doing'], $duplicatedTodoStatus->allowedTargetStatuses()->pluck('name')->all()); } public function test_project_duplication_requires_accessible_source_projects(): void { $user = User::factory()->create(); $this->syncGlobalPermissions($user, ['project:create']); $sourceProject = Project::factory()->create(); $this->actingAs($user) ->from(route('projects.create')) ->post(route('projects.store'), [ 'name' => 'Denied Duplicate', 'key' => 'deny', 'creation_mode' => 'duplicate', 'source_project_id' => $sourceProject->id, ]) ->assertSessionHasErrors('source_project_id') ->assertRedirect(route('projects.create')); } /** * Ensure project keys cannot exceed five characters. */ public function test_project_keys_may_not_exceed_five_characters(): void { $user = User::factory()->create(); $this->syncGlobalPermissions($user, ['project:create']); $this->actingAs($user) ->from(route('projects.create')) ->post(route('projects.store'), [ 'name' => 'Too Long', 'key' => 'KANBAN', ]) ->assertSessionHasErrors('key') ->assertRedirect(route('projects.create')); } /** * Ensure authenticated users can view the project hub page. */ public function test_authenticated_users_can_view_a_project_hub_page(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'name' => 'Core Platform', 'key' => 'CORE', ]); $this->syncProjectPermissions($project, $user, ['view']); $this->actingAs($user) ->get(route('projects.show', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Show') ->where('canManageAccess', false) ->where('project.id', $project->id) ->where('project.key', 'CORE'), ); } /** * Ensure team-inherited permissions allow users to open a project. */ public function test_team_permissions_allow_users_to_view_a_project(): void { $user = User::factory()->create(); $team = Team::factory()->create(); $project = Project::factory()->create([ 'key' => 'TEAM', ]); $team->users()->attach($user); $this->syncProjectPermissions($project, $team, ['view']); $this->actingAs($user) ->get(route('projects.show', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Show') ->where('project.id', $project->id), ); } /** * Ensure users without direct or inherited access are forbidden. */ public function test_users_without_permission_cannot_view_a_project(): void { $user = User::factory()->create(); $project = Project::factory()->create(); $this->actingAs($user) ->get(route('projects.show', $project)) ->assertForbidden(); } /** * Ensure project admin access allows opening the project access management page. */ public function test_users_with_project_admin_permission_can_view_project_access_management(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); $team = Team::factory()->create([ 'name' => 'Platform Team', ]); $this->syncProjectPermissions($project, $user, ['admin']); $this->actingAs($user) ->get(route('projects.access', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/Access') ->where('project.id', $project->id) ->has('users', 1) ->has('teams', 1) ->where('permissionOptions.0.value', 'project:read'), ); } /** * Ensure admins can open the general project settings page. */ public function test_admins_can_view_general_project_settings(): void { $admin = User::factory()->admin()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); $this->actingAs($admin) ->get(route('projects.settings.general', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/settings/General') ->where('project.id', $project->id) ->where('project.key', 'CORE'), ); } /** * Ensure project admins can open general project settings. */ public function test_project_admins_can_view_general_project_settings(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); $this->syncProjectPermissions($project, $user, ['admin']); $this->actingAs($user) ->get(route('projects.settings.general', $project)) ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('projects/settings/General') ->where('project.id', $project->id), ); } public function test_project_admins_can_switch_projects_to_another_template(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', 'name' => 'Core Platform', 'show_author' => false, 'show_story_points' => false, ]); $currentTemplate = ProjectTemplate::factory()->create([ 'name' => 'Current Template', 'slug' => 'current-template', 'copy_areas' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_TICKET_TYPES, ], 'configuration_snapshot' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ 'show_author' => false, 'show_story_points' => false, ], DuplicateProjectConfiguration::COPY_TICKET_TYPES => [ ['name' => 'Bug', 'position' => 1, 'is_parent' => false], ], ], ]); $newTemplate = ProjectTemplate::factory()->create([ 'name' => 'Operations Template', 'slug' => 'operations-template', 'copy_areas' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_TICKET_TYPES, ], 'configuration_snapshot' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ 'show_author' => true, 'show_story_points' => true, ], DuplicateProjectConfiguration::COPY_TICKET_TYPES => [ ['name' => 'Incident', 'position' => 1, 'is_parent' => false], ['name' => 'Maintenance', 'position' => 2, 'is_parent' => false], ], ], ]); $project->update(['project_template_id' => $currentTemplate->id]); $project->ticketTypes()->delete(); TicketType::factory()->create(['project_id' => $project->id, 'name' => 'Bug', 'position' => 1, 'is_parent' => false]); $this->syncProjectPermissions($project, $user, ['admin']); $this->actingAs($user) ->from(route('projects.settings.general', $project)) ->post(route('projects.settings.general.switch-template', $project), [ 'confirmation_key' => 'CORE', 'source_template_id' => $newTemplate->id, ]) ->assertRedirect(route('projects.settings.general', $project)) ->assertSessionHas('status', 'Core Platform now uses the Operations Template template.'); $project->refresh(); $this->assertSame($newTemplate->id, $project->project_template_id); $this->assertTrue($project->show_author); $this->assertTrue($project->show_story_points); $this->assertSame(['Incident', 'Maintenance'], $project->ticketTypes()->pluck('name')->all()); $this->assertDatabaseHas('ticket_types', [ 'project_id' => $project->id, 'project_template_id' => $newTemplate->id, 'name' => 'Incident', ]); } public function test_project_admins_can_reset_projects_to_their_current_template_baseline(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', 'name' => 'Core Platform', 'show_author' => false, 'show_story_points' => false, ]); $template = ProjectTemplate::factory()->create([ 'name' => 'Workflow Template', 'slug' => 'workflow-template', 'copy_areas' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS, DuplicateProjectConfiguration::COPY_TICKET_TYPES, ], 'configuration_snapshot' => [ DuplicateProjectConfiguration::COPY_PROJECT_SETTINGS => [ 'show_author' => true, 'show_story_points' => true, ], DuplicateProjectConfiguration::COPY_TICKET_TYPES => [ ['name' => 'Bug', 'position' => 1, 'is_parent' => false], ['name' => 'Feature', 'position' => 2, 'is_parent' => false], ], ], ]); $project->update(['project_template_id' => $template->id]); $project->ticketTypes()->delete(); TicketType::factory()->create(['project_id' => $project->id, 'name' => 'Custom', 'position' => 1, 'is_parent' => false]); $this->syncProjectPermissions($project, $user, ['admin']); $this->actingAs($user) ->from(route('projects.settings.general', $project)) ->post(route('projects.settings.general.reset-template', $project), [ 'confirmation_key' => 'CORE', ]) ->assertRedirect(route('projects.settings.general', $project)) ->assertSessionHas('status', 'Core Platform was reset to the Workflow Template template baseline.'); $project->refresh(); $this->assertSame($template->id, $project->project_template_id); $this->assertTrue($project->show_author); $this->assertTrue($project->show_story_points); $this->assertSame(['Bug', 'Feature'], $project->ticketTypes()->pluck('name')->all()); $this->assertDatabaseHas('ticket_types', [ 'project_id' => $project->id, 'project_template_id' => $template->id, 'name' => 'Bug', ]); } public function test_project_template_switch_requires_exact_project_key_confirmation(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); $project->update([ 'project_template_id' => ProjectTemplate::factory()->create()->id, ]); $newTemplate = ProjectTemplate::factory()->create(); $this->syncProjectPermissions($project, $user, ['admin']); $this->actingAs($user) ->from(route('projects.settings.general', $project)) ->post(route('projects.settings.general.switch-template', $project), [ 'confirmation_key' => 'core', 'source_template_id' => $newTemplate->id, ]) ->assertRedirect(route('projects.settings.general', $project)) ->assertSessionHasErrors('confirmation_key'); $this->assertNotSame($newTemplate->id, $project->fresh()->project_template_id); } /** * Ensure admins can delete projects and their dependent project-scoped data. */ public function test_admins_can_delete_projects_from_general_settings(): void { $admin = User::factory()->admin()->create(); $member = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', 'name' => 'Core Platform', ]); app(InitializeProjectTicketMetadata::class)->handle($project); $priority = $project->ticketPriorities()->firstOrFail(); $boardColumn = BoardColumn::factory()->for($project)->create([ 'name' => 'In Progress', 'position' => 1, ]); $ticketStatus = TicketStatus::factory() ->for($project) ->for($boardColumn) ->create([ 'name' => 'In Progress', 'position' => 1, ]); $ticket = Ticket::factory()->create([ 'project_id' => $project->id, 'sequence_number' => 1, 'ticket_key' => 'CORE-1', 'ticket_priority_id' => $priority->id, 'ticket_status_id' => $ticketStatus->id, 'ticket_type_id' => $project->ticketTypes() ->where('is_parent', false) ->orderBy('position') ->firstOrFail() ->id, 'title' => 'Delete me', ]); $this->syncProjectPermissions($project, $member, ['view']); $this->actingAs($admin) ->delete(route('projects.destroy', $project), [ 'confirmation_key' => 'CORE', ]) ->assertRedirect(route('projects.index')) ->assertSessionHas('status', 'Project Core Platform was deleted.'); $this->assertDatabaseMissing('projects', [ 'id' => $project->id, ]); $this->assertDatabaseMissing('board_columns', [ 'id' => $boardColumn->id, ]); $this->assertDatabaseMissing('tickets', [ 'id' => $ticket->id, ]); $this->assertDatabaseMissing('permission_assignments', [ 'assignee_id' => $member->id, 'assignee_type' => $member->getMorphClass(), 'permission' => 'project:read', 'scope_id' => $project->id, 'scope_type' => $project->getMorphClass(), ]); } /** * Ensure project deletion requires the exact project key confirmation. */ public function test_admins_must_confirm_the_exact_project_key_to_delete_a_project(): void { $admin = User::factory()->admin()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); $this->actingAs($admin) ->from(route('projects.settings.general', $project)) ->delete(route('projects.destroy', $project), [ 'confirmation_key' => 'core', ]) ->assertRedirect(route('projects.settings.general', $project)) ->assertSessionHasErrors('confirmation_key'); $this->assertDatabaseHas('projects', [ 'id' => $project->id, ]); } /** * Ensure non-admin users cannot delete projects. */ public function test_non_admin_users_cannot_delete_projects(): void { $user = User::factory()->create(); $project = Project::factory()->create([ 'key' => 'CORE', ]); $this->syncProjectPermissions($project, $user, ['admin']); $this->actingAs($user) ->delete(route('projects.destroy', $project), [ 'confirmation_key' => 'CORE', ]) ->assertForbidden(); $this->assertDatabaseHas('projects', [ 'id' => $project->id, ]); } /** * Ensure admins can update direct project permissions for a user. */ public function test_admins_can_update_direct_project_permissions(): void { $admin = User::factory()->admin()->create(); $member = User::factory()->create([ 'name' => 'Regular User', ]); $project = Project::factory()->create([ 'key' => 'AUTH', ]); $this->actingAs($admin) ->patch(route('projects.permissions.users.update', [$project, $member]), [ 'permissions' => ['project:read', 'project:write'], ]) ->assertRedirect(route('projects.access', $project)) ->assertSessionHas('status', 'Regular User permissions were updated.'); $this->assertDatabaseHas('permission_assignments', [ 'assignee_id' => $member->id, 'assignee_type' => $member->getMorphClass(), 'permission' => 'project:read', 'scope_id' => $project->id, 'scope_type' => $project->getMorphClass(), ]); $this->assertDatabaseHas('permission_assignments', [ 'assignee_id' => $member->id, 'assignee_type' => $member->getMorphClass(), 'permission' => 'project:write', 'scope_id' => $project->id, 'scope_type' => $project->getMorphClass(), ]); } /** * Ensure users with project admin access can update team permissions. */ public function test_users_with_project_admin_permission_can_update_team_permissions(): void { $manager = User::factory()->create(); $team = Team::factory()->create([ 'name' => 'Delivery Team', ]); $project = Project::factory()->create([ 'key' => 'OPS', ]); $this->syncProjectPermissions($project, $manager, ['admin']); $this->actingAs($manager) ->patch(route('projects.permissions.teams.update', [$project, $team]), [ 'permissions' => ['project:read', 'project:write'], ]) ->assertRedirect(route('projects.access', $project)) ->assertSessionHas('status', 'Delivery Team permissions were updated.'); $this->assertDatabaseHas('permission_assignments', [ 'assignee_id' => $team->id, 'assignee_type' => $team->getMorphClass(), 'permission' => 'project:read', 'scope_id' => $project->id, 'scope_type' => $project->getMorphClass(), ]); $this->assertDatabaseHas('permission_assignments', [ 'assignee_id' => $team->id, 'assignee_type' => $team->getMorphClass(), 'permission' => 'project:write', 'scope_id' => $project->id, 'scope_type' => $project->getMorphClass(), ]); } }