createUser(['ROLE_DOCTOR']); $doctor = new Doctor($owner, 'دکتر رزرو عمومی'); $this->em->persist($doctor); $this->em->flush(); $schedule = $this->newWeeklySchedule($doctor, [ '0' => ['sessions' => [[ 'active' => true, 'start_time' => '09:00', 'end_time' => '18:00', 'duration_per_patient' => 20, 'location_id' => 1, ]]], ]); $schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => $buffer]); $this->em->persist($schedule); $section = new ServiceSection('doctor', $doctor->getId(), 'زیبایی'); $this->em->persist($section); $this->em->flush(); return [$doctor, $section]; } private function service(ServiceSection $section, string $name, int $solo, ?int $additional = null): ServiceItem { $item = new ServiceItem($section, $name, 1_200_000); $item->setDurationMinutes($solo)->setBookable(true); if ($additional !== null) { $item->setAdditionalDurationMinutes($additional); } $this->em->persist($item); $this->em->flush(); return $item; } /** @param string[] $serviceUuids */ private function book(Doctor $doctor, array $serviceUuids, ?int $start = null, ?int $end = null): array { $patient = $this->createUser(['ROLE_USER']); $start ??= time() + 86_400 + random_int(1, 5_000) * 60; $res = $this->authJson('POST', '/api/v1/appointment', $patient, [ 'doctor_uuid' => $doctor->getUuid(), 'clinic_uuid' => null, 'slot_start' => $start, 'slot_end' => $end ?? $start + 20 * 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', 'service_item_uuids' => $serviceUuids, ]); return [$res, $start]; } private function reload(string $uuid): Appointment { $this->em->clear(); return $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]); } // ── ✅ موفق ────────────────────────────────────────────────────────────── public function testBookingThroughThePublicEndpointStoresTheServicesAndTheDuration(): void { [$doctor, $section] = $this->serviceDoctor(buffer: 10); $laser = $this->service($section, 'لیزر صورت', 20); [$res, $start] = $this->book($doctor, [$laser->getUuid()]); self::assertSame(201, $this->responseCode()); $appt = $this->reload($res['data']['data']['uuid']); self::assertCount(1, $appt->getServiceItems(), 'سرویس باید به نوبت بچسبد، نه فقط مدت را حساب کند'); self::assertSame('لیزر صورت', $appt->getServiceItem()?->getName()); self::assertSame(20, $appt->getServiceTotalMinutes()); self::assertSame(10, $appt->getServiceBufferMinutes(), 'بافر از تنظیمات محیط، نه صفر'); // بافر جزو مدت نوبت نیست: پایان = شروع + مدت. self::assertSame($start + 20 * 60, $appt->getSlotEnd()); } public function testTheResponseCarriesTheServiceFieldsTheSiteReadsBack(): void { [$doctor, $section] = $this->serviceDoctor(); $laser = $this->service($section, 'لیزر بیکینی', 15); [$res] = $this->book($doctor, [$laser->getUuid()]); self::assertSame(201, $this->responseCode()); $row = $res['data']['data']; self::assertSame(15, $row['service_total_minutes']); self::assertSame(['لیزر بیکینی'], array_column($row['service_items'], 'name')); } /** * مدت از `DurationCalculator` می‌آید، نه از جمعِ سادهٔ `duration_minutes`. * * سرویس دوم «کنار بقیه» ۵ دقیقه است نه ۲۰؛ جمع ساده ۴۰ می‌داد و نوبت با اسلات‌هایی * که `appointment-service-slots` برمی‌گرداند جور درنمی‌آمد. */ public function testTheDurationUsesTheAdditionalMinutesNotANaiveSum(): void { [$doctor, $section] = $this->serviceDoctor(buffer: 0); $first = $this->service($section, 'لیزر زیربغل', 20); $second = $this->service($section, 'لیزر خط بیکینی', 20, additional: 5); [$res, $start] = $this->book($doctor, [$first->getUuid(), $second->getUuid()]); self::assertSame(201, $this->responseCode()); $appt = $this->reload($res['data']['data']['uuid']); self::assertSame(25, $appt->getServiceTotalMinutes()); self::assertSame($start + 25 * 60, $appt->getSlotEnd()); self::assertCount(2, $appt->getServiceItems()); } // ── ❌ خطا ─────────────────────────────────────────────────────────────── public function testAnUnknownServiceIsRejected(): void { [$doctor] = $this->serviceDoctor(); $this->book($doctor, ['00000000-0000-4000-8000-000000000000']); self::assertSame(422, $this->responseCode()); } public function testAServiceFromAnotherContextIsRejected(): void { [$doctor] = $this->serviceDoctor(); [, $other] = $this->serviceDoctor(); $foreign = $this->service($other, 'سرویس محیط دیگر', 30); $this->book($doctor, [$foreign->getUuid()]); self::assertSame(422, $this->responseCode()); } public function testAServiceWithoutADurationIsRejected(): void { [$doctor, $section] = $this->serviceDoctor(); $item = new ServiceItem($section, 'بدون مدت', 500_000); $item->setBookable(true); $this->em->persist($item); $this->em->flush(); $this->book($doctor, [$item->getUuid()]); self::assertSame(422, $this->responseCode()); } // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── /** بدون سرویس، همان مسیر اسلاتیِ قبلی — ساعت پایانِ ارسالی حفظ می‌شود. */ public function testABookingWithoutServicesKeepsTheClientEndAndLeavesTheFieldsNull(): void { [$doctor] = $this->serviceDoctor(); $start = time() + 86_400 + random_int(1, 5_000) * 60; [$res] = $this->book($doctor, [], $start, $start + 45 * 60); self::assertSame(201, $this->responseCode()); $appt = $this->reload($res['data']['data']['uuid']); self::assertSame($start + 45 * 60, $appt->getSlotEnd()); self::assertNull($appt->getServiceTotalMinutes()); self::assertCount(0, $appt->getServiceItems()); } /** `slot_end` کلاینت در حالت سرویسی نادیده گرفته می‌شود — سرور خودش حساب می‌کند. */ public function testAWrongClientEndIsOverwrittenInsteadOfTrusted(): void { [$doctor, $section] = $this->serviceDoctor(buffer: 0); $laser = $this->service($section, 'لیزر کامل بدن', 60); $start = time() + 86_400 + random_int(1, 5_000) * 60; [$res] = $this->book($doctor, [$laser->getUuid()], $start, $start + 5 * 60); self::assertSame(201, $this->responseCode()); $appt = $this->reload($res['data']['data']['uuid']); self::assertSame($start + 60 * 60, $appt->getSlotEnd()); self::assertSame(60, $appt->getServiceTotalMinutes()); } }