160 خطوط
7.7 KiB
PHP
160 خطوط
7.7 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Identity;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Identity\Domain\Enums\AccountStatus;
|
|
use App\Modules\Identity\Domain\Enums\UserRole;
|
|
use App\Modules\Identity\Domain\UserInvitation;
|
|
use App\Modules\Identity\Notifications\UserInvited;
|
|
use App\Modules\Subscriptions\Domain\Subscription;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use Laravel\Sanctum\Sanctum;
|
|
use Tests\TestCase;
|
|
|
|
class UserManagementTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_designer_can_search_and_filter_safe_tenant_user_directory(): void
|
|
{
|
|
$designer = User::factory()->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);
|
|
}
|
|
}
|