diff --git a/docs/architecture/tenancy.md b/docs/architecture/tenancy.md index b3c89688..2de200e3 100644 --- a/docs/architecture/tenancy.md +++ b/docs/architecture/tenancy.md @@ -100,7 +100,31 @@ clinic_uuid صریحِ درخواست > UserActiveContext ذخیره‌شده ### ⚠️ فرزندان aggregate تور ایمنی ندارند -فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی `patient_attachments` بدون JOIN به `patient_records`، cross-tenant است. تست فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند. +فیلتر روی آن‌ها اعمال نمی‌شود. کوئری مستقیم روی `patient_attachments` بدون JOIN به `patient_records`، cross-tenant است. `TenantSchemaCoverageTest` فقط تضمین می‌کند زنجیرهٔ اعلام‌شده به ریشه‌ای با جفت tenant می‌رسد — نه اینکه کوئری‌ها واقعاً از ریشه شروع می‌شوند. + +فرزندی که لازم است مالکیتش سنجیده شود، جفت ارثی‌اش را expose می‌کند؛ `ServiceItem` این کار را با delegate به `ServiceSection` انجام می‌دهد. + +### uuid از درخواست — خطرناک‌ترین الگو + +سه نشتی واقعی در آدیت این نقطه پیدا شد و **هیچ‌کدام در repository نبودند**؛ همه در کنترلر و سرویس بودند، جایی که یک uuid از بدنه یا کوئری می‌آید و کسی محیطش را نمی‌سنجد: + +| مسیر | چه بود | +|---|---| +| `GET /api/v1/appointment-service-slots` | با uuid سرویسِ محیط دیگر، وجود/فعال‌بودن/مدتش لو می‌رفت و اسلات‌ها با آن محاسبه می‌شد | +| `POST /api/v1/my/appointment` | بخش/سرویس/پرسنلِ محیط دیگر به نوبت **چسبانده و ذخیره** می‌شد | +| `POST/PATCH` مراجعه | قیمتِ سرویسِ محیط دیگر وارد **فاکتور** می‌شد و `SessionService` با آن ذخیره می‌ماند | + +`App\Shared\Tenant\TenantOwnershipChecker` نقطهٔ واحد این بررسی است: + +```php +$this->tenantOwnership->belongsTo($context, $entity); // با EntityContext +$this->tenantOwnership->belongsToPair($type, $id, $entity); // وقتی جفت اسکالر است +$this->tenantOwnership->allBelongTo($context, $entities); // یک بیگانه = رد کل فهرست +``` + +موجودیتی که جفتش را expose نکند، **استثنا می‌دهد** — سکوت اینجا گاردِ همیشه-بسته می‌سازد که خودش باگ است. + +`TenantLookupInventoryTest` تعداد این جست‌وجوها را per-file نگه می‌دارد. افزودن یک `findByUuid` تازه روی موجودیت محیط‌دار تست را قرمز می‌کند تا کسی ثابت کند محیطش بررسی می‌شود و بعد عدد را به‌روز کند. ### بدهی باقی‌مانده @@ -161,3 +185,7 @@ php bin/console app:tenant:dump --tenant=clinic:12 --output=/tmp/clinic12.sql | `tests/Shared/TenantSchemaCoverageTest.php` | هیچ entity طبقه‌بندی‌نشده نمی‌ماند | | `tests/Appointment/BookingTenantTest.php` | نوبت در محیط درست ثبت می‌شود | | `tests/Secretary/SecretaryMultiClinicScopeTest.php` | یک منشی، یک پزشک، چند کلینیک | +| `tests/Shared/TenantOwnershipCheckerTest.php` | خودِ checker: null، محیط حل‌نشده، نوعِ متفاوت با شناسهٔ یکسان | +| `tests/Shared/TenantLookupInventoryTest.php` | جست‌وجوی uuid تازه‌ای بدون بازبینی اضافه نشده | +| `tests/Appointment/ServiceModeSectionDurationTest.php` | سرویسِ محیط دیگر نه اسلات می‌دهد نه به نوبت می‌چسبد | +| `tests/Patient/SessionServiceTenantTest.php` | سرویس/پرسنلِ محیط دیگر نه قیمت می‌خورد نه ذخیره می‌شود | diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index f5018e15..ce155dcd 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -207,6 +207,13 @@ class AppointmentController extends BaseController return $this->error(ErrorCodes::ERR_VALIDATION_001, 'انتخاب حداقل یک سرویس الزامی است', 422, 'service_item_uuids'); } + // پیش از هر بررسی دیگری: سرویس باید مالِ همین محیط باشد. مسیر ثبت نوبت همین + // گارد را دارد و این مسیر نداشت، پس با uuid سرویسِ محیط دیگر می‌شد وجود، + // فعال‌بودن و مدتش را از پیام‌های خطا و اسلات‌های برگشتی استنتاج کرد. + if (($err = $this->assertServicesMatchContext($uuids, $doctor, $clinic)) !== null) { + return $err; + } + // مدتِ override منشی (فقط برای همین محاسبه؛ پیش‌فرض سرویس تغییر نمی‌کند). durations[uuid]=minutes $overrides = (array) $request->query->all('durations'); diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 65aff488..df3a444a 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -45,6 +45,7 @@ class MyAppointmentsController extends BaseController private readonly \App\Auth\Repository\UserRepository $userRepo, private readonly \App\UserProfile\Repository\UserProfileRepository $profileRepo, private readonly VisitPriceRequirementResolver $visitPriceResolver, + private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership, ) {} /** @@ -187,7 +188,12 @@ class MyAppointmentsController extends BaseController $locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true); if ($locationId !== null) $appointment->setAddressId($locationId); - // Optional clinic-workflow fields (بخش/سرویس/پرسنل/بیعانه) — unknown uuid → 422. + // بخش/سرویس/پرسنل با uuid از خودِ درخواست می‌آیند و TenantFilter پوششان + // نمی‌دهد (سرویس فرزند aggregate است، و کاربرِ بدون محیطِ انتخاب‌شده اصلاً + // فیلتر نمی‌خورد). بدون این بررسی، نوبت با بخش/سرویس/پرسنلِ محیط دیگری + // ذخیره می‌شد — نه فقط خوانده، بلکه نوشته. + $bookingContext = EntityContext::forBooking($doctor, $bookingClinic); + foreach ([ 'service_section_uuid' => [$this->sectionRepo, 'setServiceSection', 'بخش'], 'service_item_uuid' => [$this->itemRepo, 'setServiceItem', 'سرویس'], @@ -198,11 +204,16 @@ class MyAppointmentsController extends BaseController continue; } $entity = $repo->findByUuid($value); - if ($entity === null) { + if (!$this->tenantOwnership->belongsTo($bookingContext, $entity)) { return $this->error(ErrorCodes::VALIDATION, $label . ' یافت نشد', 422); } $appointment->$setter($entity); } + + if (!$this->tenantOwnership->allBelongTo($bookingContext, $serviceItems)) { + return $this->error(ErrorCodes::VALIDATION, 'سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد', 422, 'service_item_uuids'); + } + // پیوستِ همهٔ سرویس‌های انتخاب‌شده؛ سرویسِ اصلی = اولین سرویس (addServiceItem). foreach ($serviceItems as $si) { $appointment->addServiceItem($si); diff --git a/src/ClinicService/Entity/ServiceItem.php b/src/ClinicService/Entity/ServiceItem.php index 9b005e65..f06b22dd 100644 --- a/src/ClinicService/Entity/ServiceItem.php +++ b/src/ClinicService/Entity/ServiceItem.php @@ -152,6 +152,15 @@ class ServiceItem public function getId(): ?int { return $this->id; } public function getUuid(): string { return $this->uuid; } public function getSection(): ServiceSection { return $this->section; } + + /** + * محیط را از بخشِ خودش به ارث می‌برد — ستون tenant ندارد و TenantFilter پوششش + * نمی‌دهد. این دو getter همان جفت را در دسترس می‌گذارند تا مالکیتش با بقیهٔ + * موجودیت‌های محیط‌دار یکسان بررسی شود ({@see \App\Shared\Tenant\TenantOwnershipChecker}). + */ + public function getEntityType(): string { return $this->section->getEntityType(); } + + public function getEntityId(): int { return $this->section->getEntityId(); } public function getStaff(): ?ClinicStaff { return $this->staff; } public function getName(): string { return $this->name; } public function getPriceRials(): int { return $this->priceRials; } diff --git a/src/Patient/Service/PatientService.php b/src/Patient/Service/PatientService.php index ed70a3a9..6746398a 100644 --- a/src/Patient/Service/PatientService.php +++ b/src/Patient/Service/PatientService.php @@ -56,6 +56,7 @@ class PatientService private readonly EntityInsurancePricingRepository $pricingRepo, private readonly \App\Discount\Service\DiscountEngine $discountEngine, private readonly \App\Patient\Repository\SessionAuditLogRepository $auditRepo, + private readonly \App\Shared\Tenant\TenantOwnershipChecker $tenantOwnership, private readonly LoggerInterface $logger, ) {} @@ -311,11 +312,12 @@ class PatientService } } - // جمع‌آوری service items (با احتساب تعداد) + // جمع‌آوری service items (با احتساب تعداد). سرویسِ محیط دیگر نادیده گرفته + // می‌شود، وگرنه قیمتش وارد محاسبهٔ فاکتور این محیط می‌شد. $serviceItemsData = []; foreach (($data['services'] ?? []) as $svc) { $item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? ''); - if ($item !== null) { + if ($this->tenantOwnership->belongsToPair($entityType, $entityId, $item)) { $qty = max(1, (int) ($svc['quantity'] ?? 1)); $serviceItemsData[] = ['item_id' => $item->getId(), 'price_rials' => $item->getPriceRials() * $qty]; } @@ -364,13 +366,18 @@ class PatientService $session->addConsumable($sc); } - // ثبت session services + // ثبت session services — سرویس و پرسنلِ محیط دیگر نادیده گرفته می‌شوند، + // همان قاعده‌ای که کالای مصرفی بالاتر رعایت می‌کند. foreach (($data['services'] ?? []) as $svc) { $item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? ''); - if ($item === null) { + if (!$this->tenantOwnership->belongsToPair($entityType, $entityId, $item)) { continue; } + $staff = !empty($svc['staff_uuid']) ? $this->staffRepo->findByUuid($svc['staff_uuid']) : null; + if ($staff !== null && !$this->tenantOwnership->belongsToPair($entityType, $entityId, $staff)) { + $staff = null; + } $qty = max(1, (int) ($svc['quantity'] ?? 1)); $ss = new SessionService($session, $item, $staff, $qty); $this->sessionServiceRepo->save($ss); @@ -411,9 +418,15 @@ class PatientService $session->getServices()->removeElement($old); } foreach (($data['services'] ?? []) as $svc) { + // سرویس و پرسنل با uuid از بدنهٔ درخواست می‌آیند و روی SessionService + // ذخیره می‌شوند؛ بدون این بررسی، دادهٔ محیط دیگری در مراجعه می‌نشست. $item = $this->serviceItemRepo->findByUuid($svc['service_item_uuid'] ?? ''); - if ($item === null) { continue; } + if (!$this->tenantOwnership->belongsToPair($entityType, $entityId, $item)) { continue; } + $staff = !empty($svc['staff_uuid']) ? $this->staffRepo->findByUuid($svc['staff_uuid']) : null; + if ($staff !== null && !$this->tenantOwnership->belongsToPair($entityType, $entityId, $staff)) { + $staff = null; + } $qty = max(1, (int) ($svc['quantity'] ?? 1)); $ss = new SessionService($session, $item, $staff, $qty); $this->sessionServiceRepo->save($ss); diff --git a/src/Shared/Tenant/TenantOwnershipChecker.php b/src/Shared/Tenant/TenantOwnershipChecker.php new file mode 100644 index 00000000..93d32f13 --- /dev/null +++ b/src/Shared/Tenant/TenantOwnershipChecker.php @@ -0,0 +1,68 @@ +isResolved()) { + return false; + } + + [$type, $id] = $context->toEntityPair(); + + return $this->belongsToPair($type, $id, $entity); + } + + /** + * برای فراخوانی‌هایی که جفت را از قبل به‌صورت اسکالر دارند و موجودیت محیط را در + * دست ندارند. ساختن EntityContext از اسکالر عمداً ممکن نیست: چنین contextی + * isClinic() درست می‌دهد ولی ->clinic تهی دارد و مصرف‌کننده را بی‌صدا می‌شکند. + */ + public function belongsToPair(string $entityType, int $entityId, ?object $entity): bool + { + if ($entity === null) { + return false; + } + + if (!method_exists($entity, 'getEntityType') || !method_exists($entity, 'getEntityId')) { + throw new \InvalidArgumentException(sprintf( + '%s does not expose a tenant pair; add getEntityType()/getEntityId() or delegate them to its aggregate root.', + $entity::class, + )); + } + + return $entity->getEntityType() === $entityType && $entity->getEntityId() === $entityId; + } + + /** + * یک بیگانه در فهرست، کل فهرست را رد می‌کند. + * + * @param iterable $entities + */ + public function allBelongTo(EntityContext $context, iterable $entities): bool + { + foreach ($entities as $entity) { + if (!$this->belongsTo($context, $entity)) { + return false; + } + } + + return true; + } +} diff --git a/tests/Appointment/ServiceModeSectionDurationTest.php b/tests/Appointment/ServiceModeSectionDurationTest.php index 204a6a8c..c4306ef6 100644 --- a/tests/Appointment/ServiceModeSectionDurationTest.php +++ b/tests/Appointment/ServiceModeSectionDurationTest.php @@ -119,4 +119,163 @@ class ServiceModeSectionDurationTest extends ApiTestCase $reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $svc->getUuid()]); self::assertSame(30, $reloaded->getDurationMinutes()); } + + // ── جداسازی محیط روی سرویس‌های انتخابی ────────────────────────────────── + + /** + * سرویس‌ها فرزند aggregateاند (service_items → service_sections) و ستون محیط + * ندارند، پس TenantFilter پوششان نمی‌دهد. مسیر ثبت نوبت با + * assertServicesMatchContext محافظت می‌شد ولی مسیر محاسبهٔ اسلات نه — با uuid + * سرویسِ محیط دیگر می‌شد وجود، فعال‌بودن و مدتش را استنتاج کرد. + */ + public function testServiceSlotsRejectsAServiceFromAnotherEnvironment(): void + { + [$owner, $doctor, $date] = $this->serviceDoctor(); + [, $stranger] = $this->serviceDoctor(); + $foreign = $this->service($stranger, 'سرویس بیگانه', 45); + + $this->authJson('GET', sprintf( + '/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s', + $doctor->getUuid(), $date, $foreign->getUuid(), + ), $owner); + + self::assertSame(422, $this->responseCode()); + } + + /** حتی وقتی یک سرویسِ خودی هم در فهرست است، سرویسِ بیگانه کل درخواست را رد می‌کند. */ + public function testOneForeignServiceInvalidatesTheWholeRequest(): void + { + [$owner, $doctor, $date] = $this->serviceDoctor(); + $mine = $this->service($doctor, 'سرویس خودی', 30); + [, $stranger] = $this->serviceDoctor(); + $foreign = $this->service($stranger, 'سرویس بیگانه', 45); + + $this->authJson('GET', sprintf( + '/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s&service_item_uuids[]=%s', + $doctor->getUuid(), $date, $mine->getUuid(), $foreign->getUuid(), + ), $owner); + + self::assertSame(422, $this->responseCode()); + } + + /** ⚠️ مسیر سالم نباید بشکند: سرویس همان پزشک همچنان اسلات می‌دهد. */ + public function testServiceSlotsStillWorkForTheDoctorsOwnService(): void + { + [$owner, $doctor, $date] = $this->serviceDoctor(); + $mine = $this->service($doctor, 'سرویس خودی', 30); + + $res = $this->authJson('GET', sprintf( + '/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s', + $doctor->getUuid(), $date, $mine->getUuid(), + ), $owner); + + self::assertSame(200, $this->responseCode()); + self::assertSame(30, $res['data']['total_duration_minutes']); + self::assertNotEmpty($res['data']['start_times']); + } + + /** + * نشتیِ نوشتنی: مسیر ثبت نوبت از پنل، بخش/سرویس/پرسنل را با uuid از بدنهٔ + * درخواست می‌گرفت و بدون بررسی محیط به نوبت می‌چسباند — یعنی دادهٔ محیط دیگری + * ذخیره می‌شد، نه فقط خوانده. + */ + public function testPanelBookingRejectsAServiceFromAnotherEnvironment(): void + { + [$owner, $doctor] = $this->serviceDoctor(); + [, $stranger] = $this->serviceDoctor(); + $foreign = $this->service($stranger, 'سرویس بیگانه', 30); + + $start = time() + 86_400 + random_int(0, 3_600) * 100; + + $this->authJson('POST', '/api/v1/my/appointment', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'slot_start' => $start, + 'slot_end' => $start + 1_800, + 'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'patient_name' => 'بیمار تست', + 'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT), + 'service_item_uuids' => [$foreign->getUuid()], + ]); + + self::assertSame(422, $this->responseCode()); + } + + /** همان نشتی از مسیر service_item_uuid تکی (فیلد workflow کلینیک). */ + public function testPanelBookingRejectsAForeignServiceItemField(): void + { + [$owner, $doctor] = $this->serviceDoctor(); + [, $stranger] = $this->serviceDoctor(); + $foreign = $this->service($stranger, 'سرویس بیگانه', 30); + + $start = time() + 86_400 + random_int(0, 3_600) * 100; + + $this->authJson('POST', '/api/v1/my/appointment', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'slot_start' => $start, + 'slot_end' => $start + 1_800, + 'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'patient_name' => 'بیمار تست', + 'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT), + 'service_item_uuid' => $foreign->getUuid(), + ]); + + self::assertSame(422, $this->responseCode()); + } + + /** و بخشِ محیط دیگر هم رد می‌شود. */ + public function testPanelBookingRejectsAForeignServiceSection(): void + { + [$owner, $doctor] = $this->serviceDoctor(); + [, $stranger] = $this->serviceDoctor(); + $foreignSection = $this->service($stranger, 'سرویس بیگانه', 30)->getSection(); + + $start = time() + 86_400 + random_int(0, 3_600) * 100; + + $this->authJson('POST', '/api/v1/my/appointment', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'slot_start' => $start, + 'slot_end' => $start + 1_800, + 'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'patient_name' => 'بیمار تست', + 'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT), + 'service_section_uuid' => $foreignSection->getUuid(), + ]); + + self::assertSame(422, $this->responseCode()); + } + + /** ⚠️ مسیر سالم نباید بشکند: سرویس خودی همچنان ثبت می‌شود. */ + public function testPanelBookingStillAcceptsTheDoctorsOwnService(): void + { + [$owner, $doctor] = $this->serviceDoctor(); + $mine = $this->service($doctor, 'سرویس خودی', 30); + + $start = time() + 86_400 + random_int(0, 3_600) * 100; + + $this->authJson('POST', '/api/v1/my/appointment', $owner, [ + 'doctor_uuid' => $doctor->getUuid(), + 'slot_start' => $start, + 'slot_end' => $start + 1_800, + 'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + 'patient_name' => 'بیمار تست', + 'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT), + 'service_item_uuids' => [$mine->getUuid()], + 'service_item_uuid' => $mine->getUuid(), + ]); + + self::assertSame(201, $this->responseCode()); + } + + /** uuid کاملاً ناموجود هم همان ۴۲۲ را می‌گیرد — پیام «وجود ندارد» جدا لو نمی‌رود. */ + public function testUnknownServiceUuidIsIndistinguishableFromAForeignOne(): void + { + [$owner, $doctor, $date] = $this->serviceDoctor(); + + $this->authJson('GET', sprintf( + '/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s', + $doctor->getUuid(), $date, '00000000-0000-4000-8000-000000000000', + ), $owner); + + self::assertSame(422, $this->responseCode()); + } } diff --git a/tests/Patient/SessionServiceTenantTest.php b/tests/Patient/SessionServiceTenantTest.php new file mode 100644 index 00000000..32e0e61a --- /dev/null +++ b/tests/Patient/SessionServiceTenantTest.php @@ -0,0 +1,167 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر مراجعه'); + $this->em->persist($doctor); + $this->em->flush(); + + $patient = $this->createUser(['ROLE_USER']); + $record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId()); + $this->em->persist($record); + $this->em->flush(); + + return [$owner, $doctor, $record]; + } + + private function serviceFor(Doctor $doctor, string $name, int $price): ServiceItem + { + $section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name); + $this->em->persist($section); + + $item = new ServiceItem($section, $name, $price); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + private function createSession(\App\Auth\Entity\User $owner, PatientRecord $record, array $body = []): array + { + $res = $this->authJson( + 'POST', + '/api/v1/patient/' . $record->getUuid() . '/session', + $owner, + $body + ['visit_price_rials' => 500_000], + ); + self::assertSame(201, $this->responseCode(), 'ساخت مراجعه باید موفق باشد'); + + return $res['data']; + } + + /** ✅ سرویس خودی وارد فاکتور می‌شود. */ + public function testOwnServiceIsPriced(): void + { + [$owner, $doctor, $record] = $this->recordFor(); + $mine = $this->serviceFor($doctor, 'سرویس خودی', 900_000); + + $session = $this->createSession($owner, $record, [ + 'services' => [['service_item_uuid' => $mine->getUuid(), 'quantity' => 1]], + ]); + + self::assertSame(900_000, $session['services_total_rials'], 'سرویس خودی باید قیمت بخورد'); + } + + /** ❌ سرویسِ محیط دیگر نه در قیمت می‌آید نه ذخیره می‌شود. */ + public function testAnotherEnvironmentsServiceIsIgnoredOnCreate(): void + { + [$owner, , $record] = $this->recordFor(); + [, $stranger] = $this->recordFor(); + $foreign = $this->serviceFor($stranger, 'سرویس بیگانه', 900_000); + + $session = $this->createSession($owner, $record, [ + 'services' => [['service_item_uuid' => $foreign->getUuid(), 'quantity' => 1]], + ]); + + self::assertSame(0, $session['services_total_rials'], 'قیمت سرویس بیگانه نباید اضافه شود'); + + // قیمت‌گذاری و ذخیره‌سازی دو حلقهٔ جدا در PatientService هستند؛ این یکی + // ذخیره را می‌سنجد و مستقیم از دیتابیس می‌خواند تا به شکل پاسخ وابسته نباشد. + self::assertSame(0, $this->storedServiceCount($session['uuid']), 'نباید روی مراجعه ذخیره شود'); + } + + private function storedServiceCount(string $sessionUuid): int + { + $this->em->clear(); + $session = $this->em->getRepository(PatientSession::class)->findOneBy(['uuid' => $sessionUuid]); + + return $session === null ? -1 : $session->getServices()->count(); + } + + /** ❌ همان قاعده در ویرایش مراجعه. */ + public function testAnotherEnvironmentsServiceIsIgnoredOnUpdate(): void + { + [$owner, , $record] = $this->recordFor(); + [, $stranger] = $this->recordFor(); + $foreign = $this->serviceFor($stranger, 'سرویس بیگانه', 900_000); + + $session = $this->createSession($owner, $record); + + $res = $this->authJson('PATCH', '/api/v1/session/' . $session['uuid'], $owner, [ + 'services' => [['service_item_uuid' => $foreign->getUuid(), 'quantity' => 1]], + ]); + self::assertSame(200, $this->responseCode()); + + self::assertSame(0, $res['data']['services_total_rials'], 'سرویس بیگانه نباید در فاکتور بنشیند'); + self::assertSame([], $res['data']['services'] ?? [], 'و نباید روی مراجعه ذخیره شود'); + } + + /** ⚠️ مرزی: سرویس خودی می‌ماند حتی وقتی یک بیگانه هم در فهرست است. */ + public function testOwnServiceSurvivesAlongsideAForeignOne(): void + { + [$owner, $doctor, $record] = $this->recordFor(); + [, $stranger] = $this->recordFor(); + $mine = $this->serviceFor($doctor, 'سرویس خودی', 300_000); + $foreign = $this->serviceFor($stranger, 'سرویس بیگانه', 900_000); + + $session = $this->createSession($owner, $record); + + $res = $this->authJson('PATCH', '/api/v1/session/' . $session['uuid'], $owner, [ + 'services' => [ + ['service_item_uuid' => $mine->getUuid(), 'quantity' => 1], + ['service_item_uuid' => $foreign->getUuid(), 'quantity' => 1], + ], + ]); + self::assertSame(200, $this->responseCode()); + + self::assertSame(300_000, $res['data']['services_total_rials'], 'فقط سرویس خودی باید بماند'); + self::assertCount(1, $res['data']['services'] ?? []); + } + + /** ❌ پرسنلِ محیط دیگر هم روی سرویس نمی‌نشیند. */ + public function testAnotherEnvironmentsStaffIsNotAttached(): void + { + [$owner, $doctor, $record] = $this->recordFor(); + [, $stranger] = $this->recordFor(); + $mine = $this->serviceFor($doctor, 'سرویس خودی', 300_000); + + $foreignStaff = new ClinicStaff('doctor', $stranger->getId(), 'پرسنل بیگانه'); + $this->em->persist($foreignStaff); + $this->em->flush(); + + $session = $this->createSession($owner, $record); + + $res = $this->authJson('PATCH', '/api/v1/session/' . $session['uuid'], $owner, [ + 'services' => [[ + 'service_item_uuid' => $mine->getUuid(), + 'staff_uuid' => $foreignStaff->getUuid(), + 'quantity' => 1, + ]], + ]); + self::assertSame(200, $this->responseCode()); + + $service = ($res['data']['services'] ?? [])[0] ?? null; + self::assertNotNull($service, 'سرویس خودی باید ثبت شود'); + self::assertNull($service['staff_uuid'] ?? null, 'پرسنل بیگانه نباید چسبیده باشد'); + } +} diff --git a/tests/Shared/TenantLookupInventoryTest.php b/tests/Shared/TenantLookupInventoryTest.php new file mode 100644 index 00000000..721238aa --- /dev/null +++ b/tests/Shared/TenantLookupInventoryTest.php @@ -0,0 +1,106 @@ + تعداد جست‌وجوی بازبینی‌شده. + * + * @var array + */ + private const REVIEWED = [ + // assertServicesMatchContext در هر سه مسیر (اسلات سرویسی، ثبت، و خودِ گارد) + 'src/Appointment/Controller/AppointmentController.php' => 3, + // TenantOwnershipChecker روی بخش/سرویس/پرسنل و فهرست سرویس‌ها + 'src/Appointment/Controller/MyAppointmentsController.php' => 1, + // ownsSession / getEntityType روی صورتحساب، پرونده و مطالبه + 'src/Billing/Controller/BillingController.php' => 3, + // ownsSection ×۹ و مقایسهٔ مستقیم جفت ×۳ (پکیج، کالا، پرسنل) + 'src/ClinicService/Controller/ClinicServiceController.php' => 10, + 'src/Discount/Controller/DiscountController.php' => 1, + // قرارداد بیمه با جفت، و سرویس با getSection()->getEntityType() + 'src/Insurance/Controller/InsuranceController.php' => 5, + // ownedItem() / ownedPackage() + 'src/Inventory/Controller/InventoryController.php' => 2, + 'src/Inventory/Service/InventoryService.php' => 1, + // ownsRecord ×۳۳ و دو پرداختِ لنگرخورده به مراجعهٔ بررسی‌شده + 'src/Patient/Controller/PatientController.php' => 35, + // TenantOwnershipChecker در هر سه حلقه (قیمت‌گذاری، ساخت، ویرایش) + کالا + 'src/Patient/Service/PatientService.php' => 5, + // ownsStaff() + 'src/Staff/Controller/StaffController.php' => 2, + ]; + + private function projectDir(): string + { + return dirname(__DIR__, 2); + } + + /** @return array */ + private function countLookups(): array + { + $pattern = '/->(' . implode('|', self::TENANT_REPOSITORY_VARIABLES) . ')->findByUuid\(/'; + $counts = []; + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->projectDir() . '/src', \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $found = preg_match_all($pattern, (string) file_get_contents($file->getPathname())); + if ($found > 0) { + $relative = str_replace($this->projectDir() . '/', '', $file->getPathname()); + $counts[$relative] = $found; + } + } + + ksort($counts); + + return $counts; + } + + public function testNoUnreviewedTenantLookupExists(): void + { + $actual = $this->countLookups(); + $expected = self::REVIEWED; + ksort($expected); + + self::assertSame($expected, $actual, <<<'MSG' + جست‌وجوی «uuid از درخواست → موجودیت محیط‌دار» تغییر کرده. + + برای هر مورد تازه ثابت کن محیطش بررسی می‌شود (ownsRecord/ownsSection/ + ownedItem، TenantOwnershipChecker، یا لنگر به ریشهٔ بررسی‌شده) و بعد عدد + همان فایل را در REVIEWED به‌روز کن. اگر عدد کم شده، فقط عدد را کم کن. + MSG); + } +} diff --git a/tests/Shared/TenantOwnershipCheckerTest.php b/tests/Shared/TenantOwnershipCheckerTest.php new file mode 100644 index 00000000..adb065e2 --- /dev/null +++ b/tests/Shared/TenantOwnershipCheckerTest.php @@ -0,0 +1,142 @@ +get(TenantOwnershipChecker::class); + } + + private function makeDoctor(): Doctor + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر مالکیت'); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + private function serviceFor(Doctor $doctor): ServiceItem + { + $section = new ServiceSection('doctor', $doctor->getId(), 'بخش'); + $this->em->persist($section); + $item = new ServiceItem($section, 'سرویس', 0); + $this->em->persist($item); + $this->em->flush(); + + return $item; + } + + public function testEntityOfTheSameEnvironmentBelongs(): void + { + $doctor = $this->makeDoctor(); + + self::assertTrue( + $this->checker()->belongsTo(EntityContext::forDoctor($doctor), $this->serviceFor($doctor)), + ); + } + + public function testEntityOfAnotherEnvironmentDoesNot(): void + { + $doctor = $this->makeDoctor(); + $foreign = $this->serviceFor($this->makeDoctor()); + + self::assertFalse($this->checker()->belongsTo(EntityContext::forDoctor($doctor), $foreign)); + } + + /** فرزند aggregate جفتش را از ریشه به ارث می‌برد — ServiceItem از ServiceSection. */ + public function testAggregateChildIsComparedThroughItsRoot(): void + { + $doctor = $this->makeDoctor(); + $item = $this->serviceFor($doctor); + + self::assertSame('doctor', $item->getEntityType()); + self::assertSame($doctor->getId(), $item->getEntityId()); + } + + /** ❌ null یعنی «پیدا نشد» و همان‌قدر رد می‌شود که یک موجودیت بیگانه. */ + public function testNullIsNeverOwned(): void + { + $doctor = $this->makeDoctor(); + + self::assertFalse($this->checker()->belongsTo(EntityContext::forDoctor($doctor), null)); + self::assertFalse($this->checker()->belongsToPair('doctor', $doctor->getId(), null)); + } + + /** ❌ محیط حل‌نشده هیچ‌چیز را مالک نیست. */ + public function testUnresolvedContextOwnsNothing(): void + { + $item = $this->serviceFor($this->makeDoctor()); + + self::assertFalse($this->checker()->belongsTo(EntityContext::unknown(), $item)); + } + + /** + * ❌ موجودیتی که جفت محیطش را expose نمی‌کند باید بلند خطا بدهد، نه اینکه + * بی‌صدا false برگرداند — سکوت اینجا یعنی گاردِ همیشه-بسته که باگ می‌سازد. + */ + public function testEntityWithoutATenantPairThrows(): void + { + $doctor = $this->makeDoctor(); + + $this->expectException(\InvalidArgumentException::class); + $this->checker()->belongsTo(EntityContext::forDoctor($doctor), new \stdClass()); + } + + /** ⚠️ یک بیگانه در فهرست، کل فهرست را رد می‌کند. */ + public function testOneForeignEntityRejectsTheWholeList(): void + { + $doctor = $this->makeDoctor(); + $mine = $this->serviceFor($doctor); + $foreign = $this->serviceFor($this->makeDoctor()); + $context = EntityContext::forDoctor($doctor); + + self::assertTrue($this->checker()->allBelongTo($context, [$mine])); + self::assertFalse($this->checker()->allBelongTo($context, [$mine, $foreign])); + self::assertTrue($this->checker()->allBelongTo($context, []), 'فهرست خالی مانعی ندارد'); + } + + /** نوعِ محیط هم بخشی از هویت است: کلینیک ۵ با پزشک ۵ یکی نیست. */ + public function testTypeIsPartOfTheIdentityNotJustTheId(): void + { + $doctor = $this->makeDoctor(); + $item = $this->serviceFor($doctor); + + $clinic = new Clinic($this->createUser(['ROLE_CLINIC'])); + $clinic->setName('کلینیک'); + $this->em->persist($clinic); + $this->em->flush(); + + self::assertFalse( + $this->checker()->belongsToPair('clinic', $doctor->getId(), $item), + 'همان شناسه ولی نوع دیگر نباید مالک شمرده شود', + ); + } + + /** موجودیت‌هایی که خودشان جفت دارند (نه از ریشه) هم با همین checker سنجیده می‌شوند. */ + public function testEntitiesCarryingTheirOwnPairWorkToo(): void + { + $doctor = $this->makeDoctor(); + $staff = new ClinicStaff('doctor', $doctor->getId(), 'پرسنل'); + $this->em->persist($staff); + $this->em->flush(); + + self::assertTrue($this->checker()->belongsTo(EntityContext::forDoctor($doctor), $staff)); + self::assertFalse($this->checker()->belongsToPair('doctor', $doctor->getId() + 1, $staff)); + } +}