feat: add staff role functionality with dashboard access and service management
- Implemented SidebarStaff component tests to ensure staff users see only their dashboard and services. - Created StaffMyServicesPage to display assigned services for staff users. - Added migration to link clinic staff rows to user accounts for ROLE_STAFF access. - Defined StaffPermissions class for static permissions related to staff role. - Introduced StaffRouteGuardSubscriber to restrict API access for staff users. - Developed StaffAccountService for managing staff user accounts and linking them to clinic staff. - Added comprehensive tests for StaffAccountService to validate user creation, mobile number handling, and account attachment. - Implemented tests for staff dashboard access to ensure proper permissions and access control. - Created tests for staff login context to verify correct environment visibility based on user roles.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Staff;
|
||||
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use App\Staff\Service\StaffAccountService;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* ساخت/اتصال/قطع حساب کاربری پرسنل: یک ردیف ClinicStaff به یک User با
|
||||
* ROLE_STAFF وصل میشود بدون اینکه کاربر موجود آسیب ببیند.
|
||||
*/
|
||||
class StaffAccountServiceTest extends ApiTestCase
|
||||
{
|
||||
/** سرویس تکمصرفه است و کانتینر inlineاش میکند، پس با وابستگیهای واقعی ساخته میشود. */
|
||||
private function service(): StaffAccountService
|
||||
{
|
||||
return new StaffAccountService(
|
||||
static::getContainer()->get(UserRepository::class),
|
||||
static::getContainer()->get(ClinicStaffRepository::class),
|
||||
static::getContainer()->get(UserPasswordHasherInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
private function newStaff(int $entityId, string $name = 'زهرا احمدی'): ClinicStaff
|
||||
{
|
||||
$staff = new ClinicStaff('doctor', $entityId, $name);
|
||||
$this->em->persist($staff);
|
||||
$this->em->flush();
|
||||
|
||||
return $staff;
|
||||
}
|
||||
|
||||
private function newDoctorOwner(): Doctor
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
public function testCreatesNewUserWithStaffRole(): void
|
||||
{
|
||||
$doctor = $this->newDoctorOwner();
|
||||
$staff = $this->newStaff($doctor->getId());
|
||||
$mobile = '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT);
|
||||
|
||||
$user = $this->service()->attachAccount($staff, $mobile, 'Staff@1234', $doctor->getUser());
|
||||
|
||||
self::assertContains('ROLE_STAFF', $user->getRoles());
|
||||
self::assertSame($mobile, $user->getMobileNumber());
|
||||
self::assertSame('زهرا احمدی', $user->getRealName());
|
||||
self::assertTrue($staff->hasAccount());
|
||||
self::assertSame($user->getId(), $staff->getUser()?->getId());
|
||||
self::assertSame($mobile, $staff->getPhone());
|
||||
|
||||
$hasher = static::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
self::assertTrue($hasher->isPasswordValid($user, 'Staff@1234'));
|
||||
}
|
||||
|
||||
/** کاربر موجود فقط نقش میگیرد؛ رمز و نامش پاک نمیشود. */
|
||||
public function testReusesExistingUserAndKeepsPassword(): void
|
||||
{
|
||||
$doctor = $this->newDoctorOwner();
|
||||
$existing = $this->createUser(['ROLE_USER']);
|
||||
$existing->setRealName('نام قبلی');
|
||||
$hasher = static::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
$existing->setPasswordHash($hasher->hashPassword($existing, 'Old@12345'));
|
||||
$this->em->flush();
|
||||
|
||||
$staff = $this->newStaff($doctor->getId());
|
||||
$user = $this->service()->attachAccount($staff, $existing->getMobileNumber(), null, $doctor->getUser());
|
||||
|
||||
self::assertSame($existing->getId(), $user->getId());
|
||||
self::assertContains('ROLE_STAFF', $user->getRoles());
|
||||
self::assertContains('ROLE_USER', $user->getRoles());
|
||||
self::assertSame('نام قبلی', $user->getRealName());
|
||||
self::assertTrue($hasher->isPasswordValid($user, 'Old@12345'));
|
||||
}
|
||||
|
||||
public function testRejectsInvalidMobile(): void
|
||||
{
|
||||
$doctor = $this->newDoctorOwner();
|
||||
$staff = $this->newStaff($doctor->getId());
|
||||
|
||||
$this->expectException(AppException::class);
|
||||
$this->expectExceptionMessage(ErrorCodes::message(ErrorCodes::ERR_STAFF_MOBILE_INVALID));
|
||||
|
||||
$this->service()->attachAccount($staff, '12345', 'Staff@1234', $doctor->getUser());
|
||||
}
|
||||
|
||||
public function testRejectsOwnerMobile(): void
|
||||
{
|
||||
$doctor = $this->newDoctorOwner();
|
||||
$staff = $this->newStaff($doctor->getId());
|
||||
|
||||
try {
|
||||
$this->service()->attachAccount($staff, $doctor->getUser()->getMobileNumber(), null, $doctor->getUser());
|
||||
self::fail('owner mobile must be rejected');
|
||||
} catch (AppException $e) {
|
||||
self::assertSame(ErrorCodes::ERR_STAFF_MOBILE_INVALID, $e->getErrorCode());
|
||||
self::assertSame(422, $e->getHttpStatus());
|
||||
}
|
||||
}
|
||||
|
||||
/** مرزی: همان شماره، همان محیط، ردیف دوم → 409. */
|
||||
public function testRejectsDuplicateMobileInSameEntity(): void
|
||||
{
|
||||
$doctor = $this->newDoctorOwner();
|
||||
$first = $this->newStaff($doctor->getId(), 'پرسنل اول');
|
||||
$second = $this->newStaff($doctor->getId(), 'پرسنل دوم');
|
||||
$mobile = '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT);
|
||||
|
||||
$this->service()->attachAccount($first, $mobile, null, $doctor->getUser());
|
||||
|
||||
try {
|
||||
$this->service()->attachAccount($second, $mobile, null, $doctor->getUser());
|
||||
self::fail('duplicate mobile in the same entity must be rejected');
|
||||
} catch (AppException $e) {
|
||||
self::assertSame(ErrorCodes::ERR_STAFF_MOBILE_TAKEN, $e->getErrorCode());
|
||||
self::assertSame(409, $e->getHttpStatus());
|
||||
}
|
||||
}
|
||||
|
||||
/** ارقام فارسی از هر کلاینتی بیاید، شمارهٔ ذخیرهشده لاتین است. */
|
||||
public function testNormalizesPersianDigits(): void
|
||||
{
|
||||
$doctor = $this->newDoctorOwner();
|
||||
$staff = $this->newStaff($doctor->getId());
|
||||
|
||||
$user = $this->service()->attachAccount($staff, '۰۹۱۲۳۴۵۶۷۸۹', null, $doctor->getUser());
|
||||
|
||||
self::assertSame('09123456789', $user->getMobileNumber());
|
||||
}
|
||||
|
||||
/** تغییر شماره یعنی تغییر نامکاربری ورود؛ ردیف پرسنل همان میماند. */
|
||||
public function testReattachWithNewMobileMovesTheAccount(): void
|
||||
{
|
||||
$doctor = $this->newDoctorOwner();
|
||||
$staff = $this->newStaff($doctor->getId());
|
||||
$first = '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT);
|
||||
$second = '0913' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT);
|
||||
|
||||
$this->service()->attachAccount($staff, $first, null, $doctor->getUser());
|
||||
$user = $this->service()->attachAccount($staff, $second, null, $doctor->getUser());
|
||||
|
||||
self::assertSame($second, $user->getMobileNumber());
|
||||
self::assertSame($second, $staff->getPhone());
|
||||
self::assertSame($user->getId(), $staff->getUser()?->getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Staff;
|
||||
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use App\Staff\Service\StaffAccountService;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* داشبورد پرسنل + گاردِ default-deny: نقش staff فقط داشبورد خودش را میبیند و
|
||||
* بقیهٔ API برایش بسته است.
|
||||
*/
|
||||
class StaffDashboardAccessTest extends ApiTestCase
|
||||
{
|
||||
private function accounts(): StaffAccountService
|
||||
{
|
||||
return new StaffAccountService(
|
||||
static::getContainer()->get(UserRepository::class),
|
||||
static::getContainer()->get(ClinicStaffRepository::class),
|
||||
static::getContainer()->get(UserPasswordHasherInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{0: Doctor, 1: ClinicStaff, 2: string} پزشکِ مالک، ردیف پرسنل، توکن پرسنل */
|
||||
private function staffFixture(bool $active = true): array
|
||||
{
|
||||
$ownerUser = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($ownerUser, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$staff = new ClinicStaff('doctor', $doctor->getId(), 'زهرا احمدی');
|
||||
$staff->setJobTitle('پرستار');
|
||||
$staff->setActive($active);
|
||||
$this->em->persist($staff);
|
||||
$this->em->flush();
|
||||
|
||||
$mobile = '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT);
|
||||
$staffUser = $this->accounts()->attachAccount($staff, $mobile, 'Staff@1234', $ownerUser);
|
||||
$token = static::getContainer()->get(JWTTokenManagerInterface::class)->create($staffUser);
|
||||
|
||||
// محیط فعال؛ در حالت واقعی /oauth/userinfo آن را برای تکمحیطیها ست میکند.
|
||||
$this->client->request('GET', '/oauth/userinfo', [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]);
|
||||
|
||||
return [$doctor, $staff, $token];
|
||||
}
|
||||
|
||||
private function get(string $path, string $token): int
|
||||
{
|
||||
$this->client->request('GET', $path, [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]);
|
||||
|
||||
return $this->client->getResponse()->getStatusCode();
|
||||
}
|
||||
|
||||
private function json(): array
|
||||
{
|
||||
return json_decode($this->client->getResponse()->getContent(), true);
|
||||
}
|
||||
|
||||
public function testDashboardListsAssignedServices(): void
|
||||
{
|
||||
[$doctor, $staff, $token] = $this->staffFixture();
|
||||
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'تزریقات');
|
||||
$item = new ServiceItem($section, 'سرم', 1_000_000);
|
||||
$item->setStaffMembers([$staff]);
|
||||
$other = new ServiceItem($section, 'سرویس بدون پرسنل', 500_000);
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->persist($other);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertSame(200, $this->get('/api/v1/dashboard/staff', $token));
|
||||
$data = $this->json()['data'];
|
||||
|
||||
self::assertSame('doctor', $data['scope']);
|
||||
self::assertSame($staff->getUuid(), $data['staff']['uuid']);
|
||||
self::assertSame(1, $data['stats']['services']);
|
||||
self::assertCount(1, $data['services']);
|
||||
self::assertSame('سرم', $data['services'][0]['name']);
|
||||
self::assertSame('تزریقات', $data['services'][0]['section_name']);
|
||||
}
|
||||
|
||||
/** مرزی: پرسنلِ بدون سرویس → لیست خالی، نه خطا. */
|
||||
public function testDashboardWithoutServicesReturnsEmptyList(): void
|
||||
{
|
||||
[, , $token] = $this->staffFixture();
|
||||
|
||||
self::assertSame(200, $this->get('/api/v1/dashboard/staff', $token));
|
||||
$data = $this->json()['data'];
|
||||
|
||||
self::assertSame([], $data['services']);
|
||||
self::assertSame(0, $data['stats']['services']);
|
||||
self::assertSame([], $data['today_appointments']);
|
||||
}
|
||||
|
||||
public function testInactiveStaffIsDenied(): void
|
||||
{
|
||||
[, , $token] = $this->staffFixture(active: false);
|
||||
|
||||
self::assertSame(403, $this->get('/api/v1/dashboard/staff', $token));
|
||||
self::assertSame('ERR_FORBIDDEN_001', $this->json()['errors'][0]['code']);
|
||||
}
|
||||
|
||||
/** گارد: هر مسیر API خارج از allowlist برای نقش staff بسته است. */
|
||||
public function testOtherApiRoutesAreForbidden(): void
|
||||
{
|
||||
[, , $token] = $this->staffFixture();
|
||||
|
||||
foreach (['/api/v1/service-items', '/api/v1/staff', '/api/v1/patients', '/api/v1/dashboard/clinic'] as $path) {
|
||||
self::assertSame(403, $this->get($path, $token), $path . ' must be forbidden for staff');
|
||||
self::assertSame('ERR_FORBIDDEN_001', $this->json()['errors'][0]['code'], $path);
|
||||
}
|
||||
|
||||
// مسیرهای مجاز دستنخوردهاند
|
||||
self::assertSame(200, $this->get('/oauth/userinfo', $token));
|
||||
}
|
||||
|
||||
/** گارد نباید نقشهای دیگر را بگیرد، حتی اگر کاربر همزمان پرسنل باشد. */
|
||||
public function testGuardSkipsUsersWithStrongerRole(): void
|
||||
{
|
||||
[, , $token] = $this->staffFixture();
|
||||
|
||||
$data = json_decode($this->client->getResponse()->getContent(), true);
|
||||
$staffUser = static::getContainer()->get(UserRepository::class)->findByUuid($data['data']['uuid'] ?? '');
|
||||
self::assertNotNull($staffUser);
|
||||
|
||||
$staffUser->addRole('ROLE_DOCTOR');
|
||||
$this->em->flush();
|
||||
$token = static::getContainer()->get(JWTTokenManagerInterface::class)->create($staffUser);
|
||||
|
||||
self::assertNotSame(403, $this->get('/api/v1/service-items', $token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Staff;
|
||||
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Staff\Repository\ClinicStaffRepository;
|
||||
use App\Staff\Service\StaffAccountService;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* پرسنلِ دارای حساب باید مثل بقیهٔ نقشهای پنل لاگین کند و محیط کاریاش را در
|
||||
* userinfo ببیند؛ پرسنل غیرفعال هیچ محیطی نمیگیرد.
|
||||
*/
|
||||
class StaffLoginContextTest extends ApiTestCase
|
||||
{
|
||||
private function service(): StaffAccountService
|
||||
{
|
||||
return new StaffAccountService(
|
||||
static::getContainer()->get(UserRepository::class),
|
||||
static::getContainer()->get(ClinicStaffRepository::class),
|
||||
static::getContainer()->get(UserPasswordHasherInterface::class),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{0: Doctor, 1: ClinicStaff, 2: string} */
|
||||
private function staffWithAccount(bool $active = true): array
|
||||
{
|
||||
$ownerUser = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($ownerUser, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$staff = new ClinicStaff('doctor', $doctor->getId(), 'زهرا احمدی');
|
||||
$staff->setActive($active);
|
||||
$this->em->persist($staff);
|
||||
$this->em->flush();
|
||||
|
||||
$mobile = '0912' . str_pad((string) random_int(0, 9_999_999), 7, '0', STR_PAD_LEFT);
|
||||
$this->service()->attachAccount($staff, $mobile, 'Staff@1234', $ownerUser);
|
||||
|
||||
return [$doctor, $staff, $mobile];
|
||||
}
|
||||
|
||||
private function json(): array
|
||||
{
|
||||
return json_decode($this->client->getResponse()->getContent(), true);
|
||||
}
|
||||
|
||||
public function testStaffCanLogInAndSeesOwnEnvironment(): void
|
||||
{
|
||||
[$doctor, , $mobile] = $this->staffWithAccount();
|
||||
|
||||
$this->client->request('POST', '/api/v1/user/login', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
|
||||
'mobile_number' => $mobile,
|
||||
'password' => 'Staff@1234',
|
||||
]));
|
||||
|
||||
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
||||
$token = $this->json()['access_token'] ?? null;
|
||||
self::assertNotNull($token);
|
||||
|
||||
$this->client->request('GET', '/oauth/userinfo', [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]);
|
||||
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
||||
|
||||
$data = $this->json()['data'];
|
||||
self::assertSame('staff', $data['primary_role']);
|
||||
|
||||
$staffContexts = array_values(array_filter($data['available_contexts'], fn(array $c) => $c['role'] === 'staff'));
|
||||
self::assertCount(1, $staffContexts);
|
||||
self::assertSame($doctor->getUuid(), $staffContexts[0]['db_uuid']);
|
||||
self::assertTrue($staffContexts[0]['permissions']['resources']['services']['view']);
|
||||
self::assertArrayNotHasKey('patients', $staffContexts[0]['permissions']['resources']);
|
||||
}
|
||||
|
||||
/** پرسنل غیرفعال: لاگین باز است ولی هیچ محیطی ندارد. */
|
||||
public function testInactiveStaffGetsNoContext(): void
|
||||
{
|
||||
[, , $mobile] = $this->staffWithAccount(active: false);
|
||||
|
||||
$this->client->request('POST', '/api/v1/user/login', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
|
||||
'mobile_number' => $mobile,
|
||||
'password' => 'Staff@1234',
|
||||
]));
|
||||
$token = $this->json()['access_token'] ?? null;
|
||||
self::assertNotNull($token);
|
||||
|
||||
$this->client->request('GET', '/oauth/userinfo', [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]);
|
||||
$data = $this->json()['data'];
|
||||
|
||||
self::assertSame('staff', $data['primary_role']);
|
||||
self::assertSame([], array_values(array_filter($data['available_contexts'], fn(array $c) => $c['role'] === 'staff')));
|
||||
}
|
||||
|
||||
/** مرزی: منشیای که پرسنل هم هست، نقش قویترش را نگه میدارد. */
|
||||
public function testSecretaryRoleWinsOverStaffRole(): void
|
||||
{
|
||||
[, $staff, $mobile] = $this->staffWithAccount();
|
||||
|
||||
$user = static::getContainer()->get(UserRepository::class)->findByMobile($mobile);
|
||||
$user->addRole('ROLE_SECRETARY');
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->request('POST', '/api/v1/user/login', [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([
|
||||
'mobile_number' => $mobile,
|
||||
'password' => 'Staff@1234',
|
||||
]));
|
||||
$token = $this->json()['access_token'];
|
||||
|
||||
$this->client->request('GET', '/oauth/userinfo', [], [], ['HTTP_AUTHORIZATION' => 'Bearer ' . $token]);
|
||||
$data = $this->json()['data'];
|
||||
|
||||
self::assertSame('secretary', $data['primary_role']);
|
||||
// محیطِ پرسنلیاش همچنان در فهرست هست
|
||||
self::assertNotEmpty(array_filter($data['available_contexts'], fn(array $c) => $c['role'] === 'staff'));
|
||||
self::assertTrue($staff->hasAccount());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user