create(['role' => UserRole::Manager]); $otherManager = User::factory()->for($manager->organization)->create(['role' => UserRole::Manager]); $ownLearner = User::factory()->for($manager->organization)->create(['role' => UserRole::Learner]); $privateLearner = User::factory()->for($manager->organization)->create(['role' => UserRole::Learner]); $ownTeam = $this->team($manager, 'Own team'); $privateTeam = $this->team($manager, 'Private team'); $ownTeam->managers()->attach($manager); $ownTeam->members()->attach($ownLearner); $privateTeam->managers()->attach($otherManager); $privateTeam->members()->attach($privateLearner); Sanctum::actingAs($manager); $response = $this->getJson('/api/v1/users')->assertOk(); $response->assertJsonFragment(['id' => $ownLearner->getKey()]); $response->assertJsonMissing(['id' => $privateLearner->getKey()]); $teams = $this->getJson('/api/v1/teams')->assertOk(); $teams->assertJsonFragment(['id' => $ownTeam->getKey()]); $teams->assertJsonMissing(['id' => $privateTeam->getKey()]); } public function test_designer_can_create_team_and_attach_same_tenant_member_and_manager(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $manager = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager]); $learner = User::factory()->for($designer->organization)->create(['role' => UserRole::Learner]); Sanctum::actingAs($designer); $teamId = $this->postJson('/api/v1/teams', ['name' => 'Sales'])->assertCreated()->json('data.id'); $this->putJson('/api/v1/teams/'.$teamId.'/members', ['userId' => $learner->getKey()])->assertOk(); $this->putJson('/api/v1/teams/'.$teamId.'/managers', ['userId' => $manager->getKey()])->assertOk(); $this->assertDatabaseHas('team_memberships', ['team_id' => $teamId, 'user_id' => $learner->getKey()]); $this->assertDatabaseHas('team_managers', ['team_id' => $teamId, 'user_id' => $manager->getKey()]); } public function test_selecting_manager_automatically_attaches_active_direct_reports(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $manager = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager]); $report = User::factory()->for($designer->organization)->create(['role' => UserRole::Learner, 'direct_manager_id' => $manager->getKey()]); $other = User::factory()->for($designer->organization)->create(['role' => UserRole::Learner]); Sanctum::actingAs($designer); $teamId = $this->postJson('/api/v1/teams', ['name' => 'Automatic', 'managerId' => $manager->getKey()]) ->assertCreated() ->assertJsonPath('data.managerCount', 1) ->assertJsonPath('data.memberCount', 1) ->json('data.id'); $this->assertDatabaseHas('team_managers', ['team_id' => $teamId, 'user_id' => $manager->getKey()]); $this->assertDatabaseHas('team_memberships', ['team_id' => $teamId, 'user_id' => $report->getKey()]); $this->assertDatabaseMissing('team_memberships', ['team_id' => $teamId, 'user_id' => $other->getKey()]); } public function test_manager_cannot_mutate_team_membership(): void { $manager = User::factory()->create(['role' => UserRole::Manager]); $learner = User::factory()->for($manager->organization)->create(); $team = $this->team($manager, 'Managed team'); $team->managers()->attach($manager); Sanctum::actingAs($manager); $this->putJson('/api/v1/teams/'.$team->getKey().'/members', ['userId' => $learner->getKey()])->assertForbidden(); } public function test_cross_tenant_user_cannot_be_attached_to_team(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $otherUser = User::factory()->create(); $team = $this->team($designer, 'Local team'); Sanctum::actingAs($designer); $this->putJson('/api/v1/teams/'.$team->getKey().'/members', ['userId' => $otherUser->getKey()])->assertNotFound(); } public function test_designer_can_read_update_and_detach_team_assignments(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $manager = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager]); $learner = User::factory()->for($designer->organization)->create(['role' => UserRole::Learner]); $team = $this->team($designer, 'Support'); $team->managers()->attach($manager); $team->members()->attach($learner); Sanctum::actingAs($designer); $this->getJson('/api/v1/teams/'.$team->getKey()) ->assertOk() ->assertJsonPath('data.memberCount', 1) ->assertJsonPath('data.managerCount', 1) ->assertJsonPath('data.members.0.id', $learner->getKey()) ->assertJsonMissing(['pivot']); $this->patchJson('/api/v1/teams/'.$team->getKey(), ['name' => 'Support', 'description' => 'Updated']) ->assertOk() ->assertJsonPath('data.description', 'Updated'); $this->deleteJson('/api/v1/teams/'.$team->getKey().'/members/'.$learner->getKey())->assertOk(); $this->deleteJson('/api/v1/teams/'.$team->getKey().'/managers/'.$manager->getKey())->assertOk(); $this->assertDatabaseMissing('team_memberships', ['team_id' => $team->getKey(), 'user_id' => $learner->getKey()]); $this->assertDatabaseMissing('team_managers', ['team_id' => $team->getKey(), 'user_id' => $manager->getKey()]); } public function test_manager_cannot_read_an_unmanaged_team_detail(): void { $manager = User::factory()->create(['role' => UserRole::Manager]); $unmanaged = $this->team($manager, 'Unmanaged'); Sanctum::actingAs($manager); $this->getJson('/api/v1/teams/'.$unmanaged->getKey())->assertNotFound(); } public function test_cross_tenant_team_and_detach_targets_are_not_disclosed(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $foreignDesigner = User::factory()->create(['role' => UserRole::CourseDesigner]); $foreignTeam = $this->team($foreignDesigner, 'Foreign'); $localTeam = $this->team($designer, 'Local'); Sanctum::actingAs($designer); $this->getJson('/api/v1/teams/'.$foreignTeam->getKey())->assertNotFound(); $this->deleteJson('/api/v1/teams/'.$localTeam->getKey().'/members/'.$foreignDesigner->getKey())->assertNotFound(); } private function team(User $creator, string $name): Team { return Team::query()->create([ 'organization_id' => $creator->organization_id, 'name' => $name, 'status' => 'active', 'created_by' => $creator->getKey(), ]); } }