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)); } }