feat: update user roles and passwords in QA driver, enhance documentation with error codes, and improve trial activation error handling

This commit is contained in:
hamed
2026-07-19 15:19:03 +03:30
parent 3e841b5e57
commit 7761c37a3e
10 changed files with 326 additions and 109 deletions
+2 -2
View File
@@ -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
+84 -92
View File
@@ -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 <mobile> --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` ساخته می‌شوند رمز نمی‌گیرند.
@@ -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([]);
});
});
+12 -13
View File
@@ -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<string, string> = {
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<string, string> = {
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<string, string> = Object.fromEntries(
Object.entries(STATUS_META).map(([k, v]) => [k, v.label]),
);
const APPT_COLOR: Record<string, string> = Object.fromEntries(
Object.entries(STATUS_META).map(([k, v]) => [k, v.color]),
);
const APPT_CLS: Record<string, string> = {
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)' },
];
+5
View File
@@ -80,6 +80,11 @@
همه‌ی سرویس‌های owner در همه‌ی بخش‌ها (برای انتخاب/جستجوی سراسری در فرم ثبت/ویرایش مراجعه). پاسخ مثل لیست هر بخش (آرایه‌ی `ServiceItem::toArray`)، مرتب بر نام.
**Errors:**
| Code | HTTP | توضیح |
|------|------|-------|
| ERR_FORBIDDEN_001 | 403 | کاربر نه پروفایل پزشک دارد نه کلینیک، پس محیط کاری قابل‌تعیین نیست (ادمین، منشی، نماینده، کاربر عادی) |
## GET /api/v1/service-items/{sectionUuid}
لیست سرویس‌های یک بخش.
+2 -1
View File
@@ -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` وجود ندارد یا غیرفعال است (نصب ناقص) |
---
@@ -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)
));
@@ -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());
@@ -0,0 +1,53 @@
<?php
namespace App\Tests\ClinicService;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* GET /api/v1/service-items resolves the caller's working context. A user with
* neither a doctor profile nor a clinic (admin, secretary, representation, plain
* patient) resolves to EntityContext::unknown(), whose id is null — which used
* to reach ServiceItemRepository::findByEntity(int $entityId) and fatal with a
* 500. It must be a 403 instead.
*/
class ServiceItemsUnresolvedContextTest extends ApiTestCase
{
public function testUserWithoutDoctorOrClinicGetsForbiddenNotServerError(): void
{
foreach ([['ROLE_ADMIN'], ['ROLE_SECRETARY'], ['ROLE_REPRESENTATION'], ['ROLE_USER']] as $roles) {
$user = $this->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'));
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Tests\Subscription;
use App\Config\Repository\SiteConfigRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Subscription\Entity\SubscriptionPlan;
use App\Subscription\Repository\ClinicSubscriptionRepository;
use App\Subscription\Repository\SubscriptionPeriodRepository;
use App\Subscription\Repository\SubscriptionPlanRepository;
use App\Subscription\Service\SubscriptionService;
use PHPUnit\Framework\TestCase;
/**
* A `basic` plan whose trial period has been deactivated is a normal admin
* configuration. It used to surface as ERR_NOT_FOUND_001/500; it must now be
* the same ERR_TRIAL_DISABLED/422 the trial_enabled flag produces.
*/
class ActivateTrialTest extends TestCase
{
private function service(
bool $usedTrial,
?string $trialEnabled,
?SubscriptionPlan $basicPlan,
mixed $trialPeriod,
): SubscriptionService {
$subscriptionRepo = $this->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());
}
}
}