diff --git a/.claude/skills/qa-clinicpro/driver.mjs b/.claude/skills/qa-clinicpro/driver.mjs index 866f1af1..f9734f70 100644 --- a/.claude/skills/qa-clinicpro/driver.mjs +++ b/.claude/skills/qa-clinicpro/driver.mjs @@ -37,8 +37,8 @@ const ROLES = { admin: ['09120671756', 'QaTest@1234'], clinic: ['09127000000', 'QaTest@1234'], secretary: ['09123456778', 'QaTest@1234'], - doctor: ['09390039833', '09390039833'], - representation: ['09124000001', '09124000001'], + doctor: ['09390039833', 'QaTest@1234'], + representation: ['09124000001', 'QaTest@1234'], // Provisioned by Phase 0. Reserved QA range 0912900000x, password QaTest@1234. doctor_solo: ['09129000001', 'QaTest@1234'], // own office, no clinic diff --git a/TEST_USERS.md b/TEST_USERS.md index 3270b881..eb17eedd 100644 --- a/TEST_USERS.md +++ b/TEST_USERS.md @@ -2,108 +2,100 @@ **پنل ادمین:** https://clinic-pro.ddev.site/admin -> رمز عبور همه کاربران seed‌شده: `Test@1234` +> رمز عبور همهٔ پرسوناها: `QaTest@1234` + +این فایل وضعیت واقعی دیتابیس لوکال پس از بازسازی کامل (drop → migrate → seed) را +توصیف می‌کند. صحتش با `node .claude/skills/qa-clinicpro/driver.mjs roles` قابل +تأیید است — اگر ردیفی `✗` گرفت، این فایل کهنه شده است. --- -## ادمین +## پرسوناها -| فیلد | مقدار | -| ------ | --------------- | -| موبایل | `09100000001` | -| پسورد | `Test@1234` | -| نقش | `ROLE_ADMIN` | -| نام | مدیر سیستم | +واحد کار «پرسونا» است نه `ROLE_*`؛ پزشک مستقل و پزشک عضو کلینیک هر دو `ROLE_DOCTOR` +دارند ولی دادهٔ متفاوتی می‌بینند. + +| پرسونا | موبایل | نقش‌ها | تمایز | +|---|---|---|---| +| `admin` | `09120671756` | `ROLE_ADMIN` | — | +| `clinic` | `09127000000` | `ROLE_CLINIC` | مالک «کلینیک تست QA» | +| `secretary` | `09123456778` | `ROLE_SECRETARY` | منشیِ `doctor_solo` | +| `doctor` | `09390039833` | `ROLE_DOCTOR` | پزشک ساده، بدون کلینیک | +| `representation` | `09124000001` | `ROLE_REPRESENTATION` | نمایندهٔ شهری | +| `doctor_solo` | `09129000001` | `ROLE_DOCTOR` | مطب شخصی، بدون کلینیک | +| `doctor_member` | `09129000002` | `ROLE_DOCTOR` | عضو «کلینیک تست QA» → موقع ورود «انتخاب محیط کاری» می‌بیند | +| `clinic_doctor` | `09129000003` | `ROLE_CLINIC` + `ROLE_DOCTOR` | چندنقشی، مالک «کلینیک تست چندنقشی» | +| `secretary_clinic` | `09129000004` | `ROLE_SECRETARY` | منشیِ `doctor_member` در کلینیک | +| `unclaimed_doctor` | `09129000005` | `ROLE_UNCLAIMED_DOCTOR` | — | +| `patient` | `09129000006` | `ROLE_USER` | کاربر عادی سایت | +| `importer` | `09129000007` | `ROLE_IMPORTER` | — | + +`patient`، `unclaimed_doctor` و `importer` به پنل مدیریت دسترسی ندارند و در صفحهٔ +ورود پیام «حساب شما دسترسی به پنل مدیریت را ندارد» می‌گیرند. این باگ نیست: +`PasswordAuthenticator` هر کاربری را که `User::isStaff()` نباشد رد می‌کند. + +## شناسه‌ها + +| موجودیت | UUID | +|---|---| +| پزشک `doctor_solo` | `01e2a9b4-72f4-4a48-924c-0f95bb77a994` | +| پزشک `doctor_member` | `439c9935-77bc-4f72-b73d-2432712bb6f5` | +| پزشک `clinic_doctor` | `e3e4c2bf-170a-479c-a385-4af7d57fcbbe` | +| پزشک `doctor` | `c3311b98-86b7-4d8e-8538-1390c36c2a90` | +| پروفایل تصاحب‌نشده | `ded7a65d-d0fa-47e0-bc16-e801c5c75147` | +| کلینیک تست QA | `bcb00726-2343-4d63-90c6-d0175cc74591` | +| کلینیک تست چندنقشی | `e62f69a2-381b-4a6c-9235-7f9c173f3c46` | + +هر سه پزشکِ `doctor_solo` / `doctor_member` / `clinic_doctor` آدرس مطب، تخصص و +برنامهٔ هفتگی (شنبه تا چهارشنبه، ۰۹:۰۰–۱۳:۰۰ و ۱۶:۰۰–۱۹:۰۰، اسلات ۲۰ دقیقه‌ای) +دارند، پس صفحات نوبت‌دهی‌شان خالی نیستند. + +## دادهٔ انبوه + +`app:seed-demo-data` حدود ۸٬۴۰۰ کاربر، ۱۸۰ پزشک، ۲۰۰ کلینیک، ۲۵ نماینده و ۱۵٬۰۰۰ +نوبت می‌سازد — برای تست «دادهٔ زیاد» نیازی به seed اضافه نیست. --- -## کلینیک نمونه — تبریز +## بازسازی از صفر -| فیلد | مقدار | -| ----------- | -------------------------------------- | -| موبایل | `09100100000` | -| پسورد | `Test@1234` | -| نقش | `ROLE_CLINIC` | -| نام | کلینیک تخصصی امید تبریز | -| UUID کلینیک | `9ac73318-d313-4772-bf1e-418d47f8f4bc` | -| شهر | تبریز (id: 101) | - ---- - -## دکتر نمونه کامل — تبریز - -| فیلد | مقدار | -| ----------- | -------------------------------------- | -| موبایل | `09100100001` | -| پسورد | `Test@1234` | -| نقش | `ROLE_DOCTOR` | -| نام | دکتر آرمان رضایی | -| تخصص | قلب و عروق + داخلی | -| UUID دکتر | `2a3a7ab9-8d34-4118-862f-b458bcd6d77f` | -| کلینیک | کلینیک تخصصی امید تبریز (عضو) | -| آدرس مطب | تبریز، خیابان آزادی | -| برنامه کاری | شنبه–چهارشنبه ۹–۱۳ و ۱۴–۱۸، پنجشنبه ۹–۱۳ | -| بیمه | تأمین اجتماعی، خدمات درمانی، نیروهای مسلح، ایران | - ---- - -## منشی دکتر نمونه - -| فیلد | مقدار | -| ------ | ------------------- | -| موبایل | `09100100002` | -| پسورد | `Test@1234` | -| نقش | `ROLE_SECRETARY` | -| نام | خانم نرگس صادقی | -| مرتبط | دکتر آرمان رضایی | - ---- - -## دکتران bulk (۱۵۰۰ دکتر در ۱۵ شهر) - -هر شهر ۱۰۰ دکتر با توزیع واقعی تخصص: - -| شهر | شروع موبایل | تعداد | -| ---------- | ------------- | ----- | -| تبریز | `09100100003` | ۱۰۰ | -| ارومیه | `09100100103` | ۱۰۰ | -| اردبیل | `09100100203` | ۱۰۰ | -| اصفهان | `09100100303` | ۱۰۰ | -| کرج | `09100100403` | ۱۰۰ | -| تهران | `09100100503` | ۱۰۰ | -| مشهد | `09100100603` | ۱۰۰ | -| اهواز | `09100100703` | ۱۰۰ | -| شیراز | `09100100803` | ۱۰۰ | -| کرمان | `09100100903` | ۱۰۰ | -| کرمانشاه | `09100101003` | ۱۰۰ | -| رشت | `09100101103` | ۱۰۰ | -| ساری | `09100101203` | ۱۰۰ | -| همدان | `09100101303` | ۱۰۰ | -| یزد | `09100101403` | ۱۰۰ | - -توزیع تخصص در هر شهر: -- ۱۵ پزشک عمومی -- ۱۰ داخلی + قلب -- ۸ جراحی عمومی -- ۸ زنان و زایمان -- ۸ اطفال -- ۷ ارتوپدی -- ۶ گوارش، نورولوژی، پوست، چشم‌پزشکی، دندانپزشکی (هر کدام ۶) -- ۵ ENT، روانپزشکی (هر کدام ۵) -- ۴ اورولوژی - ---- - -## ساخت مجدد - -اگر دیتابیس ریست شد، ادمین را مستقیم در دیتابیس بساز: +ترتیب اجباری است — وابستگی‌ها چرخه‌ای‌اند: ```bash -ddev mysql -e "INSERT INTO users (uuid, mobile_number, password_hash, real_name, roles, status, created_at, updated_at) VALUES (UUID(), '09100000001', '\$2y\$13\$8.5nFvKRxQGAXfJMXPB6sO.eR.c1RNxj0nJalcQfhzFTiEXnfaJbG', 'مدیر سیستم', '[\"ROLE_USER\",\"ROLE_ADMIN\"]', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP());" +ddev exec php bin/console doctrine:schema:drop --full-database --force +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +ddev exec php bin/console app:create-admin 09120671756 'QaTest@1234' +# نماینده‌ها باید قبل از شهرها باشند: data/seed/cities.json به representation_id +# های ۱ تا ۳ ارجاع می‌دهد و app:seed-categories اعتبارسنجی‌شان می‌کند. +# POST /api/v1/representation ×۳ (با توکن ادمین) +ddev exec php bin/console app:seed-categories --no-interaction +ddev exec php bin/console app:seed-sms-message-templates --no-interaction +ddev exec php bin/console app:seed-demo-data --purge --no-interaction ``` -برای seed دیتای واقعی دکتران و کلینیک: +`app:seed-demo-data` خودش بازهٔ `09124000%` را مالک است و نماینده‌های مرحلهٔ قبل را +purge و بازسازی می‌کند؛ بعد از آن `cities.representation_id` به شناسه‌های قدیمی اشاره +می‌کند و باید به شناسه‌های جدید نگاشت شود. -```bash -ddev exec php seed_realistic_data.php -``` +سپس پرسوناها از راه اندپوینت‌های خود اپ ساخته می‌شوند: +`POST /api/v1/admin/doctors` · `POST /api/v1/admin/clinic` · +`POST /api/v1/admin/clinic/{uuid}/invite-doctor` + `POST /api/v1/doctor/invitation/{uuid}/respond` · +`POST /api/v1/secretary` · `POST /api/v1/admin/doctors/import` · +`send-code → verify-code → register` برای `patient`. + +`ROLE_IMPORTER` و `ROLE_UNCLAIMED_DOCTOR` **هیچ مسیر اپلیکیشنی ندارند** — نگاشت نقش +در `AdminApiController::updateUserRole` فقط `admin/doctor/secretary/clinic/patient` +را می‌شناسد، پس این دو با SQL مستقیم ست می‌شوند. + +## نکته‌ها + +- **کپچا:** نصب تازه `ALTCHA_ENABLED=true` دارد و override پنل خالی است، پس لاگین و + ثبت‌نام اسکریپتی رد می‌شود. از پنل ادمین یا + `PATCH /api/v1/admin/settings {"altcha_enabled":"0"}` غیرفعالش کن. +- **کد OTP در dev همیشه `12345` است** (`OtpService::sendCode`). +- **`send-code` سقف ۵ درخواست در ساعت به‌ازای هر IP دارد.** برای تست‌های انبوه توکن را + مستقیم بساز: + `ddev exec 'php bin/console lexik:jwt:generate-token --user-class="App\\Auth\\Entity\\User"'` +- **رمز پس از ریست:** `ddev exec php bin/console security:hash-password 'QaTest@1234'` + و هش را در `users.password_hash` بگذار. کاربرانی که از راه `POST /api/v1/admin/doctors` + یا `/api/v1/admin/clinic` ساخته می‌شوند رمز نمی‌گیرند. diff --git a/assets/admin/components/ui/appointmentStatusMeta.test.ts b/assets/admin/components/ui/appointmentStatusMeta.test.ts new file mode 100644 index 00000000..08385223 --- /dev/null +++ b/assets/admin/components/ui/appointmentStatusMeta.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { STATUS_META } from './AppointmentStatusDropdown'; + +/** + * Mirrors Appointment::STATUS_* on the backend. The dashboard used to keep its + * own copy of this map, which drifted and rendered `confirmed` / `expired` as + * raw English; every consumer now derives from STATUS_META, so this list is the + * one place that has to stay in sync with the entity. + */ +const BACKEND_STATUSES = [ + 'pending', + 'confirmed', + 'completed', + 'cancelled_by_doctor', + 'cancelled_by_user', + 'expired', + 'no_show', + 'following_up', + 'salon', +] as const; + +describe('STATUS_META', () => { + it.each(BACKEND_STATUSES)('has a Persian label and colour for %s', (status) => { + expect(STATUS_META[status]).toBeDefined(); + expect(STATUS_META[status].label.trim()).not.toBe(''); + expect(STATUS_META[status].color).toMatch(/^#[0-9a-f]{6}$/i); + }); + + it('carries no label that is still English', () => { + const latin = Object.entries(STATUS_META) + .filter(([, v]) => /[A-Za-z]/.test(v.label)) + .map(([k]) => k); + expect(latin).toEqual([]); + }); + + it('defines no status the backend does not know about', () => { + const unknown = Object.keys(STATUS_META).filter( + (k) => !BACKEND_STATUSES.includes(k as (typeof BACKEND_STATUSES)[number]), + ); + expect(unknown).toEqual([]); + }); +}); diff --git a/assets/admin/pages/DashboardPage.tsx b/assets/admin/pages/DashboardPage.tsx index 655389a7..70d82aeb 100644 --- a/assets/admin/pages/DashboardPage.tsx +++ b/assets/admin/pages/DashboardPage.tsx @@ -20,6 +20,7 @@ const jalaali = require('jalaali-js') as { import { NewAppointmentsTable } from '../components/dashboard/NewAppointmentsTable'; import DoctorAppointmentsPanel from '../components/dashboard/DoctorAppointmentsPanel'; import { usePermissions } from '../hooks/usePermissions'; +import { STATUS_META } from '../components/ui/AppointmentStatusDropdown'; // ── Chart period (Jalali) ───────────────────────────────────────────────── @@ -47,18 +48,15 @@ function useJalaliChartPeriod() { // ── Shared Status Maps ──────────────────────────────────────────────────── -const APPT_LABEL: Record = { - waiting_for_payment: 'انتظار پرداخت', reserved: 'رزرو شده', checked_in: 'ورود به مطب', - waiting: 'صف انتظار', in_progress: 'در حال ویزیت', visited: 'ویزیت شده', - completed: 'تکمیل شده', cancelled_by_doctor: 'لغو پزشک', cancelled_by_user: 'لغو بیمار', - auto_cancel_unpaid: 'لغو خودکار', no_show: 'غیبت', -}; -const APPT_COLOR: Record = { - waiting_for_payment: '#f59e0b', reserved: '#3b82f6', checked_in: '#6366f1', - waiting: '#f97316', in_progress: '#8b5cf6', visited: '#10b981', - completed: '#22c55e', cancelled_by_doctor: '#ef4444', cancelled_by_user: '#f43f5e', - auto_cancel_unpaid: '#94a3b8', no_show: '#64748b', -}; +// Derived from the canonical STATUS_META rather than kept as a second copy — +// the local map had drifted off the backend's Appointment::STATUS_* set, so +// `confirmed` and `expired` rendered as raw English on the dashboard. +const APPT_LABEL: Record = Object.fromEntries( + Object.entries(STATUS_META).map(([k, v]) => [k, v.label]), +); +const APPT_COLOR: Record = Object.fromEntries( + Object.entries(STATUS_META).map(([k, v]) => [k, v.color]), +); const APPT_CLS: Record = { waiting_for_payment: 'amber', reserved: 'blue', checked_in: 'violet', waiting: 'amber', in_progress: 'violet', visited: 'green', completed: 'green', @@ -384,7 +382,8 @@ function AdminDashboard() { { label: 'پزشکان فعال', value: fn(stats?.active_doctors), hint: stats ? `از ${fn(stats.total_doctors)} پزشک` : '', icon: HeartIcon, color: 'var(--success)', bg: 'var(--success-bg)' }, { label: 'کلینیک‌ها', value: fn(stats?.total_clinics), hint: '', icon: BuildingOffice2Icon, color: 'var(--info)', bg: 'var(--info-bg)' }, { label: 'نوبت‌های امروز', value: fn(stats?.today_appointments), hint: stats ? `ماه جاری: ${fn(stats.this_month_appointments)}` : '', icon: CalendarDaysIcon, color: 'var(--warning)', bg: 'var(--warning-bg)' }, - { label: 'درآمد این ماه', value: fr(stats?.this_month_revenue), hint: 'تومان', icon: CreditCardIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' }, + // formatRial already suffixes « تومان» — a 'تومان' hint here renders it twice. + { label: 'درآمد این ماه', value: fr(stats?.this_month_revenue), hint: '', icon: CreditCardIcon, color: 'var(--primary)', bg: 'var(--primary-soft)' }, { label: 'در انتظار بررسی', value: fn(stats ? stats.pending_comments + stats.pending_settlements : undefined), hint: stats ? `${fn(stats.pending_comments)} نظر · ${fn(stats.pending_settlements)} تسویه` : '', icon: BellAlertIcon, color: 'var(--danger)', bg: 'var(--danger-bg)' }, ]; diff --git a/docs/api/clinic-services.md b/docs/api/clinic-services.md index 7540fb5a..44e8faf3 100644 --- a/docs/api/clinic-services.md +++ b/docs/api/clinic-services.md @@ -80,6 +80,11 @@ همه‌ی سرویس‌های owner در همه‌ی بخش‌ها (برای انتخاب/جستجوی سراسری در فرم ثبت/ویرایش مراجعه). پاسخ مثل لیست هر بخش (آرایه‌ی `ServiceItem::toArray`)، مرتب بر نام. +**Errors:** +| Code | HTTP | توضیح | +|------|------|-------| +| ERR_FORBIDDEN_001 | 403 | کاربر نه پروفایل پزشک دارد نه کلینیک، پس محیط کاری قابل‌تعیین نیست (ادمین، منشی، نماینده، کاربر عادی) | + ## GET /api/v1/service-items/{sectionUuid} لیست سرویس‌های یک بخش. diff --git a/docs/api/subscription.md b/docs/api/subscription.md index bfbdbbb9..04c4f398 100644 --- a/docs/api/subscription.md +++ b/docs/api/subscription.md @@ -95,8 +95,9 @@ | Code | HTTP | توضیح | |------|------|-------| | ERR_TRIAL_ALREADY_USED | 422 | قبلاً از تریال استفاده شده | -| ERR_TRIAL_DISABLED | 422 | تریال غیرفعال است (SiteConfig: trial_enabled=0) | +| ERR_TRIAL_DISABLED | 422 | تریال غیرفعال است — یا `SiteConfig: trial_enabled=0`، یا پلن `basic` هیچ دورهٔ تریالِ `active` ندارد | | ERR_FORBIDDEN_001 | 403 | پروفایل doctor/clinic یافت نشد | +| ERR_NOT_FOUND_001 | 500 | پلن `basic` وجود ندارد یا غیرفعال است (نصب ناقص) | --- diff --git a/src/ClinicService/Controller/ClinicServiceController.php b/src/ClinicService/Controller/ClinicServiceController.php index 4d38b799..3b69f574 100644 --- a/src/ClinicService/Controller/ClinicServiceController.php +++ b/src/ClinicService/Controller/ClinicServiceController.php @@ -219,6 +219,14 @@ class ClinicServiceController extends BaseController { [$entityType, $entityId] = $this->resolveEntity($user); + // A user with neither a doctor profile nor a clinic (admin, secretary, + // representation, plain patient) resolves to EntityContext::unknown(), + // whose id is null — findByEntity() declares int and fataled with a 500. + // Same condition, same answer as assertServicesGate(): forbidden. + if ($entityId === null) { + throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, null, 403); + } + return $this->success($this->serializeItems( $this->itemRepo->findByEntity($entityType, $entityId) )); diff --git a/src/Subscription/Service/SubscriptionService.php b/src/Subscription/Service/SubscriptionService.php index 51830658..4b300799 100644 --- a/src/Subscription/Service/SubscriptionService.php +++ b/src/Subscription/Service/SubscriptionService.php @@ -71,9 +71,12 @@ class SubscriptionService throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, null, 500); } + // Deactivating the trial period is a normal admin configuration, not a + // broken install — surface it as "trial disabled", the same 422 the + // trial_enabled flag produces, rather than a 500. $trialPeriod = $this->periodRepo->findTrialPeriodForPlan($basicPlan); if ($trialPeriod === null) { - throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, null, 500); + throw new AppException(ErrorCodes::ERR_TRIAL_DISABLED, null, 422); } $expiresAt = $this->calculateExpiresAt(null, $trialPeriod->getDurationMonths()); diff --git a/tests/ClinicService/ServiceItemsUnresolvedContextTest.php b/tests/ClinicService/ServiceItemsUnresolvedContextTest.php new file mode 100644 index 00000000..1e886042 --- /dev/null +++ b/tests/ClinicService/ServiceItemsUnresolvedContextTest.php @@ -0,0 +1,53 @@ +createUser($roles); + + $this->authJson('GET', '/api/v1/service-items', $user); + + $this->assertSame( + 403, + $this->responseCode(), + sprintf('roles %s should be forbidden, not a server error', implode(',', $roles)), + ); + } + } + + public function testDoctorStillGetsTheirOwnItems(): void + { + $owner = $this->createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر تست محیط'); + $this->em->persist($doctor); + $this->em->flush(); + + $section = new ServiceSection('doctor', $doctor->getId(), 'تزریقات'); + $item = new ServiceItem($section, 'سرم ۵۰۰cc'); + $this->em->persist($section); + $this->em->persist($item); + $this->em->flush(); + + $body = $this->authJson('GET', '/api/v1/service-items', $owner); + + $this->assertSame(200, $this->responseCode()); + $this->assertTrue($body['success']); + $this->assertContains('سرم ۵۰۰cc', array_column($body['data'], 'name')); + } +} diff --git a/tests/Subscription/ActivateTrialTest.php b/tests/Subscription/ActivateTrialTest.php new file mode 100644 index 00000000..17007497 --- /dev/null +++ b/tests/Subscription/ActivateTrialTest.php @@ -0,0 +1,114 @@ +createMock(ClinicSubscriptionRepository::class); + $subscriptionRepo->method('hasUsedTrial')->willReturn($usedTrial); + + $planRepo = $this->createMock(SubscriptionPlanRepository::class); + $planRepo->method('findByName')->willReturn($basicPlan); + + $periodRepo = $this->createMock(SubscriptionPeriodRepository::class); + $periodRepo->method('findTrialPeriodForPlan')->willReturn($trialPeriod); + + $configRepo = $this->createMock(SiteConfigRepository::class); + $configRepo->method('get')->willReturn($trialEnabled); + + return new SubscriptionService($subscriptionRepo, $planRepo, $periodRepo, $configRepo); + } + + public function testNoActiveTrialPeriodIsReportedAsTrialDisabled(): void + { + $service = $this->service( + usedTrial: false, + trialEnabled: '1', + basicPlan: $this->createMock(SubscriptionPlan::class), + trialPeriod: null, + ); + + try { + $service->activateTrial('doctor', 1); + $this->fail('expected AppException'); + } catch (AppException $e) { + $this->assertSame(ErrorCodes::ERR_TRIAL_DISABLED, $e->getErrorCode()); + $this->assertSame(422, $e->getHttpStatus()); + } + } + + public function testMissingBasicPlanStaysAServerError(): void + { + $service = $this->service( + usedTrial: false, + trialEnabled: '1', + basicPlan: null, + trialPeriod: null, + ); + + try { + $service->activateTrial('doctor', 1); + $this->fail('expected AppException'); + } catch (AppException $e) { + $this->assertSame(ErrorCodes::ERR_NOT_FOUND_001, $e->getErrorCode()); + $this->assertSame(500, $e->getHttpStatus()); + } + } + + public function testAlreadyUsedTrialTakesPrecedence(): void + { + $service = $this->service( + usedTrial: true, + trialEnabled: '1', + basicPlan: null, + trialPeriod: null, + ); + + try { + $service->activateTrial('doctor', 1); + $this->fail('expected AppException'); + } catch (AppException $e) { + $this->assertSame(ErrorCodes::ERR_TRIAL_ALREADY_USED, $e->getErrorCode()); + $this->assertSame(422, $e->getHttpStatus()); + } + } + + public function testDisabledFlagTakesPrecedenceOverMissingPlan(): void + { + $service = $this->service( + usedTrial: false, + trialEnabled: '0', + basicPlan: null, + trialPeriod: null, + ); + + try { + $service->activateTrial('doctor', 1); + $this->fail('expected AppException'); + } catch (AppException $e) { + $this->assertSame(ErrorCodes::ERR_TRIAL_DISABLED, $e->getErrorCode()); + $this->assertSame(422, $e->getHttpStatus()); + } + } +}