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:
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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)' },
|
||||
];
|
||||
|
||||
|
||||
@@ -80,6 +80,11 @@
|
||||
|
||||
همهی سرویسهای owner در همهی بخشها (برای انتخاب/جستجوی سراسری در فرم ثبت/ویرایش مراجعه). پاسخ مثل لیست هر بخش (آرایهی `ServiceItem::toArray`)، مرتب بر نام.
|
||||
|
||||
**Errors:**
|
||||
| Code | HTTP | توضیح |
|
||||
|------|------|-------|
|
||||
| ERR_FORBIDDEN_001 | 403 | کاربر نه پروفایل پزشک دارد نه کلینیک، پس محیط کاری قابلتعیین نیست (ادمین، منشی، نماینده، کاربر عادی) |
|
||||
|
||||
## GET /api/v1/service-items/{sectionUuid}
|
||||
|
||||
لیست سرویسهای یک بخش.
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user