create(['role' => UserRole::CourseDesigner]); $match = User::factory()->for($designer->organization)->create([ 'name' => 'Sara Manager', 'role' => UserRole::Manager, 'status' => AccountStatus::Active, ]); User::factory()->for($designer->organization)->create(['name' => 'Other Learner', 'role' => UserRole::Learner]); $foreign = User::factory()->create(['name' => 'Sara Manager', 'role' => UserRole::Manager]); Sanctum::actingAs($designer); $response = $this->getJson('/api/v1/users?search=Sara&role=manager&status=active&perPage=10') ->assertOk() ->assertJsonPath('meta.total', 1) ->assertJsonPath('data.0.id', $match->getKey()) ->assertJsonMissing(['id' => $foreign->getKey()]); $this->assertArrayNotHasKey('password', $response->json('data.0')); $this->assertArrayNotHasKey('remember_token', $response->json('data.0')); } public function test_designer_can_update_same_tenant_user_and_disabling_revokes_tokens(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $target = User::factory()->for($designer->organization)->create(['role' => UserRole::Learner]); $target->createToken('existing'); Sanctum::actingAs($designer); $this->patchJson('/api/v1/users/'.$target->getKey(), [ 'role' => UserRole::Manager->value, 'status' => AccountStatus::Disabled->value, ])->assertOk() ->assertJsonPath('data.role', UserRole::Manager->value) ->assertJsonPath('data.status', AccountStatus::Disabled->value); $this->assertDatabaseMissing('personal_access_tokens', ['tokenable_id' => $target->getKey()]); } public function test_designer_can_edit_workforce_profile_and_download_real_xlsx_template(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $manager = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager, 'job_level' => 'manager']); $target = User::factory()->for($designer->organization)->create(['role' => UserRole::Learner]); Sanctum::actingAs($designer); $this->patchJson('/api/v1/users/'.$target->getKey(), [ 'firstName' => 'مریم', 'lastName' => 'احمدی', 'email' => 'maryam.updated@example.test', 'department' => 'عملیات', 'jobLevel' => 'specialist', 'directManagerId' => $manager->getKey(), ])->assertOk() ->assertJsonPath('data.name', 'مریم احمدی') ->assertJsonPath('data.email', 'maryam.updated@example.test') ->assertJsonPath('data.directManager.id', $manager->getKey()); $this->assertDatabaseHas('users', ['id' => $target->getKey(), 'department' => 'عملیات', 'direct_manager_id' => $manager->getKey()]); $template = $this->get('/api/v1/users/import-template')->assertOk() ->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); $this->assertStringStartsWith('PK', $template->streamedContent()); } public function test_workforce_edit_rejects_self_manager_and_hierarchy_cycles(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $first = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager, 'job_level' => 'manager']); $second = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager, 'job_level' => 'manager', 'direct_manager_id' => $first->getKey()]); Sanctum::actingAs($designer); $this->patchJson('/api/v1/users/'.$first->getKey(), ['directManagerId' => $first->getKey()]) ->assertUnprocessable()->assertJsonValidationErrors('directManagerId'); $this->patchJson('/api/v1/users/'.$first->getKey(), ['directManagerId' => $second->getKey()]) ->assertUnprocessable()->assertJsonValidationErrors('directManagerId'); } public function test_last_active_designer_cannot_be_disabled_or_demoted(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); Sanctum::actingAs($designer); $this->patchJson('/api/v1/users/'.$designer->getKey(), ['status' => AccountStatus::Disabled->value]) ->assertUnprocessable() ->assertJsonValidationErrors('role'); $this->patchJson('/api/v1/users/'.$designer->getKey(), ['role' => UserRole::Manager->value]) ->assertUnprocessable() ->assertJsonValidationErrors('role'); } public function test_user_mutation_is_tenant_scoped_and_manager_is_read_only(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $foreign = User::factory()->create(); Sanctum::actingAs($designer); $this->patchJson('/api/v1/users/'.$foreign->getKey(), ['status' => AccountStatus::Disabled->value])->assertNotFound(); $manager = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager]); Sanctum::actingAs($manager); $this->patchJson('/api/v1/users/'.$designer->getKey(), ['status' => AccountStatus::Disabled->value])->assertForbidden(); } public function test_seat_limit_blocks_reactivation_of_a_disabled_user(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $disabled = User::factory()->for($designer->organization)->create(['status' => AccountStatus::Disabled]); Subscription::query()->create([ 'organization_id' => $designer->organization_id, 'plan_key' => 'limited', 'status' => 'active', 'starts_at' => now()->subDay(), 'seat_limit' => 1, ]); Sanctum::actingAs($designer); $this->patchJson('/api/v1/users/'.$disabled->getKey(), ['status' => AccountStatus::Active->value]) ->assertUnprocessable() ->assertJsonValidationErrors('status'); } public function test_designer_can_list_resend_and_revoke_invitation_without_token_leakage(): void { Notification::fake(); $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); Sanctum::actingAs($designer); $id = $this->postJson('/api/v1/user-invitations', [ 'email' => 'phase3@example.test', 'role' => UserRole::Learner->value, ])->assertCreated()->json('data.id'); $this->getJson('/api/v1/user-invitations') ->assertOk() ->assertJsonPath('data.0.status', 'pending') ->assertJsonMissing(['token_hash']); $this->postJson('/api/v1/user-invitations/'.$id.'/resend') ->assertOk() ->assertJsonPath('data.status', 'pending') ->assertJsonMissing(['token']); $this->deleteJson('/api/v1/user-invitations/'.$id)->assertOk()->assertJsonPath('data.revoked', true); $this->assertNotNull(UserInvitation::query()->findOrFail($id)->revoked_at); Notification::assertSentOnDemandTimes(UserInvited::class, 2); } }