createUser(['ROLE_USER', 'ROLE_CLINIC']); $clinic = new Clinic($owner); $clinic->setName('کلینیک رزرو منبعی'); $this->em->persist($clinic); $this->em->flush(); $doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'مینا یوسفی'); $this->em->persist($doctor); // بدون عضویت، رزرو در محیط کلینیک «محل نوبت‌دهی یافت نشد» می‌گیرد. $clinic->getDoctors()->add($doctor); $address = DoctorAddress::forClinic($clinic->getId()); $address->setName('شعبهٔ مرکزی'); $this->em->persist($address); $section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر'); $this->em->persist($section); $this->em->flush(); return [$clinic, $doctor, $address, $section]; } private function resource(Clinic $clinic, DoctorAddress $address, string $name, ?Doctor $doctor = null): ClinicResource { $type = new ResourceType('clinic', (int) $clinic->getId(), 'r_' . bin2hex(random_bytes(3)), 'منبع'); $this->em->persist($type); $this->em->flush(); $resource = new ClinicResource($address, $type, $name); if ($doctor !== null) { $resource->linkTo($doctor); } $this->em->persist($resource); $this->em->flush(); return $resource; } private function service(ServiceSection $section, string $name, int $minutes = 30): ServiceItem { $item = new ServiceItem($section, $name, 8_000_000); $item->setDurationMinutes($minutes)->setBookable(true); $this->em->persist($item); $this->em->flush(); return $item; } /** @param array $extra */ private function book(array $extra): array { $start = time() + 86_400 + random_int(1, 5_000) * 60; return [$this->authJson('POST', '/api/v1/appointment', $this->createUser(['ROLE_USER']), $extra + [ 'slot_start' => $start, 'slot_end' => $start + 30 * 60, 'for_self' => true, 'patient_national_code' => str_pad((string) random_int(0, 9_999_999_999), 10, '0', STR_PAD_LEFT), 'patient_gender' => 'female', ]), $start]; } // ── ✅ موفق ────────────────────────────────────────────────────────────── public function testBookingForADoctorResourceInfersTheDoctor(): void { [$clinic, $doctor, $address] = $this->clinic(); $resource = $this->resource($clinic, $address, 'دکتر یوسفی', $doctor); // بدون `doctor_uuid` — پزشک از خودِ منبع درمی‌آید. [$res] = $this->book(['resource_uuid' => $resource->getUuid()]); self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); $row = $res['data']['data']; self::assertSame($resource->getUuid(), $row['resource']['uuid']); self::assertSame('دکتر یوسفی', $row['resource']['name']); } /** * دستگاه لیزر پزشک نیست؛ پزشکِ ناظرش می‌نشیند. * * مسیر پنل این را از قبل داشت و مسیر عمومی نداشت — یعنی همان رزرو از سایت * ۴۲۲ می‌گرفت با پیامی که می‌گفت `resource_uuid` بفرست، در حالی که فرستاده بود. */ public function testBookingForADeviceFallsBackToItsSupervisor(): void { [$clinic, $doctor, $address] = $this->clinic(); $device = $this->resource($clinic, $address, 'دستگاه لیزر ۲'); $device->setSupervisor($doctor); $this->em->flush(); [$res] = $this->book(['resource_uuid' => $device->getUuid()]); self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); self::assertSame($device->getUuid(), $res['data']['data']['resource']['uuid']); self::assertSame($doctor->getUuid(), $res['data']['data']['doctor']['uuid']); } /** پیام باید بگوید ناظر ندارد، نه اینکه uuid نفرستادی. */ public function testADeviceWithoutASupervisorSaysSo(): void { [$clinic, , $address] = $this->clinic(); $device = $this->resource($clinic, $address, 'دستگاه بی‌ناظر'); [$res] = $this->book(['resource_uuid' => $device->getUuid()]); self::assertSame(422, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); self::assertSame('resource_uuid', $res['errors'][0]['field']); self::assertStringContainsString('پزشک ناظر', $res['errors'][0]['message']); } public function testTheOldDoctorOnlyPathIsUntouched(): void { [, $doctor] = $this->clinic(); [$res] = $this->book(['doctor_uuid' => $doctor->getUuid()]); self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); // سایت عمومی امروز `resource_uuid` نمی‌فرستد؛ پاسخ باید null بدهد نه خطا. self::assertNull($res['data']['data']['resource']); } // ── ❌ خطا ─────────────────────────────────────────────────────────────── public function testAResourceThatDoesNotOfferTheServiceIsRejected(): void { [$clinic, $doctor, $address, $section] = $this->clinic(); $offering = $this->resource($clinic, $address, 'دستگاه مجاز'); $other = $this->resource($clinic, $address, 'دستگاه بی‌ارتباط'); $service = $this->service($section, 'لیزر پا'); // فقط یکی از دو دستگاه این سرویس را می‌دهد. $this->em->persist(new ResourceServiceOffering($offering, $service)); $this->em->flush(); [$res] = $this->book([ 'doctor_uuid' => $doctor->getUuid(), 'clinic_uuid' => $clinic->getUuid(), 'resource_uuid' => $other->getUuid(), 'service_item_uuids' => [$service->getUuid()], ]); self::assertSame(422, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); self::assertSame('این منبع این سرویس را ارائه نمی‌دهد', $res['errors'][0]['message']); } /** * مدتِ نوبت باید همان عددی باشد که `appointment-resource-slots` با آن زمان‌ها را * ساخته — وگرنه بیمار وقتی را می‌گیرد که سرور جای دیگری آزاد حساب کرده بود. */ public function testTheLengthComesFromTheResourceOfferingNotTheServiceDefault(): void { [$clinic, $doctor, $address, $section] = $this->clinic(); $resource = $this->resource($clinic, $address, 'دستگاه لیزر', $doctor); $service = $this->service($section, 'لیزر پا', 30); // پیش‌فرضِ سرویس $offering = new ResourceServiceOffering($resource, $service); $offering->setDurationMinutes(45); // مدتِ همین دستگاه $this->em->persist($offering); $this->em->flush(); [$res] = $this->book([ 'doctor_uuid' => $doctor->getUuid(), 'clinic_uuid' => $clinic->getUuid(), 'resource_uuid' => $resource->getUuid(), 'service_item_uuids' => [$service->getUuid()], ]); self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); $row = $res['data']['data']; self::assertSame(45 * 60, $row['slot_end'] - $row['slot_start']); } /** * سازگاری عقب‌رو: محیطی که هنوز هیچ ردیفِ منبع↔سرویس نساخته باید مثل قبل کار کند — * مدت از پیش‌فرضِ خودِ سرویس می‌آید و نوبت‌دهی‌اش قطع نمی‌شود. */ public function testWithoutAnyOfferingRowTheLengthStaysTheServiceDefault(): void { [$clinic, $doctor, $address, $section] = $this->clinic(); $resource = $this->resource($clinic, $address, 'دستگاه بدون رابطه', $doctor); $service = $this->service($section, 'سرویس بدون رابطه', 30); [$res] = $this->book([ 'doctor_uuid' => $doctor->getUuid(), 'clinic_uuid' => $clinic->getUuid(), 'resource_uuid' => $resource->getUuid(), 'service_item_uuids' => [$service->getUuid()], ]); self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); $row = $res['data']['data']; self::assertSame(30 * 60, $row['slot_end'] - $row['slot_start']); } /** * ردیفِ **غیرفعال** فرق دارد و همان ۴۲۲ قبلی را می‌گیرد: «فعلاً این را نمی‌دهم» * حرفِ صریحِ مالک است، نه سکوتِ محیطی که هنوز رابطه‌ها را پر نکرده. */ public function testAnInactiveOfferingStillRejectsTheBooking(): void { [$clinic, $doctor, $address, $section] = $this->clinic(); $resource = $this->resource($clinic, $address, 'دستگاه لیزر', $doctor); $service = $this->service($section, 'لیزر پا', 30); $offering = new ResourceServiceOffering($resource, $service); $offering->setDurationMinutes(45)->setActive(false); $this->em->persist($offering); $this->em->flush(); [$res] = $this->book([ 'doctor_uuid' => $doctor->getUuid(), 'clinic_uuid' => $clinic->getUuid(), 'resource_uuid' => $resource->getUuid(), 'service_item_uuids' => [$service->getUuid()], ]); self::assertSame(422, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); self::assertSame('این منبع این سرویس را ارائه نمی‌دهد', $res['errors'][0]['message']); } /** * گیتِ «نمایش در نوبت‌دهی آنلاین» با منبع هم دور زده نمی‌شود: شاخهٔ منبع فقط وقتی * فعال است که سرویس عمومی باشد، و در غیر این‌صورت همان ۴۲۲ مسیر پزشک‌محور می‌آید. */ public function testAServiceWithTheOnlineToggleOffIsStillRejectedOnAResource(): void { [$clinic, $doctor, $address, $section] = $this->clinic(); $resource = $this->resource($clinic, $address, 'دستگاه لیزر', $doctor); $service = $this->service($section, 'لیزر پا', 30); $service->setBookable(false); $offering = new ResourceServiceOffering($resource, $service); $offering->setDurationMinutes(45); $this->em->persist($offering); $this->em->flush(); [$res] = $this->book([ 'doctor_uuid' => $doctor->getUuid(), 'clinic_uuid' => $clinic->getUuid(), 'resource_uuid' => $resource->getUuid(), 'service_item_uuids' => [$service->getUuid()], ]); self::assertSame(422, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); self::assertSame('این سرویس برای نوبت‌دهی فعال نیست', $res['errors'][0]['message']); } public function testAnUnknownResourceIsRejected(): void { $this->book(['resource_uuid' => '00000000-0000-4000-8000-000000000000']); self::assertSame(422, $this->responseCode()); } public function testNeitherDoctorNorResourceIsRejected(): void { $this->book([]); self::assertSame(422, $this->responseCode()); } // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── public function testWithoutAnyOfferingRowsTheResourceIsNotSecondGuessed(): void { [$clinic, $doctor, $address, $section] = $this->clinic(); $resource = $this->resource($clinic, $address, 'دستگاه بدون رابطه'); $service = $this->service($section, 'سرویس بدون رابطه'); // هیچ ردیفی برای این سرویس نیست → همان سازگاری عقب‌روِ `findEligible`. [$res] = $this->book([ 'doctor_uuid' => $doctor->getUuid(), 'clinic_uuid' => $clinic->getUuid(), 'resource_uuid' => $resource->getUuid(), 'service_item_uuids' => [$service->getUuid()], ]); self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE)); } public function testAnInactiveResourceCannotBeBooked(): void { [$clinic, $doctor, $address] = $this->clinic(); $resource = $this->resource($clinic, $address, 'دستگاه خاموش', $doctor); $resource->setActive(false); $this->em->flush(); $this->book(['resource_uuid' => $resource->getUuid()]); self::assertSame(422, $this->responseCode()); } }