createUser(['ROLE_DOCTOR']); $doctor = new Doctor($owner, 'دکتر سرویس'); $this->em->persist($doctor); $date = date('Y-m-d', strtotime('tomorrow')); $dayKey = (string) (((int) date('w', strtotime($date)) + 1) % 7); $schedule = $this->newWeeklySchedule($doctor, [ $dayKey => ['sessions' => [[ 'active' => true, 'start_time' => '15:00', 'end_time' => '19:00', 'duration_per_patient' => 20, 'location_id' => 1, ]]], ]); $schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => 0]); $this->em->persist($schedule); $this->em->flush(); return [$owner, $doctor, $date]; } private function service(Doctor $doctor, string $name, int $minutes): ServiceItem { $section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name); $this->em->persist($section); $item = new ServiceItem($section, $name, 0); $item->setDurationMinutes($minutes)->setBookable(true); $this->em->persist($item); $this->em->flush(); return $item; } public function testBookingServicesReturnsSection(): void { [$owner, $doctor] = $this->serviceDoctor(); $svc = $this->service($doctor, 'بوتاکس', 30); $res = $this->authJson('GET', '/api/v1/appointment-booking-services/' . $doctor->getUuid(), $owner); self::assertSame(200, $this->responseCode()); $row = $res['data']['services'][0]; self::assertSame($svc->getUuid(), $row['uuid']); self::assertArrayHasKey('service_section', $row); self::assertSame($svc->getSection()->getUuid(), $row['service_section']['uuid']); self::assertSame($svc->getSection()->getName(), $row['service_section']['name']); } public function testServiceSlotsHonorsDurationOverride(): void { [$owner, $doctor, $date] = $this->serviceDoctor(); $svc = $this->service($doctor, 'فیلر', 30); // بدون override: مدت کل = ۳۰ $base = $this->authJson('GET', sprintf( '/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s', $doctor->getUuid(), $date, $svc->getUuid() ), $owner); self::assertSame(30, $base['data']['total_duration_minutes']); // با override = ۹۰ $over = $this->authJson('GET', sprintf( '/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s&durations[%s]=90', $doctor->getUuid(), $date, $svc->getUuid(), $svc->getUuid() ), $owner); self::assertSame(90, $over['data']['total_duration_minutes']); // پیش‌فرضِ سرویس در DB تغییر نکرده $this->em->clear(); $reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $svc->getUuid()]); self::assertSame(30, $reloaded->getDurationMinutes()); } public function testCreateAppliesDurationOverrideToSlotEnd(): void { [$owner, $doctor] = $this->serviceDoctor(); $svc = $this->service($doctor, 'لیزر', 30); $start = time() + 86_400 + random_int(0, 3_600) * 100; $nc = '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT); $res = $this->authJson('POST', '/api/v1/my/appointment', $owner, [ 'doctor_uuid' => $doctor->getUuid(), 'slot_start' => $start, 'slot_end' => $start + 60, // نادیده گرفته می‌شود (بازمحاسبه) 'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), 'patient_name' => 'بیمار تست', 'patient_national_code' => $nc, 'service_item_uuids' => [$svc->getUuid()], 'duration_from_services' => true, 'service_durations' => [$svc->getUuid() => 75], ]); self::assertSame(201, $this->responseCode()); $this->em->clear(); $appt = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $res['data']['uuid']]); // slot_end = start + 75 دقیقه (override)، نه ۳۰ پیش‌فرض self::assertSame($start + 75 * 60, $appt->getSlotEnd()); // پیش‌فرضِ سرویس دست‌نخورده $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()); } }