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' => 10]); $this->em->persist($schedule); $section = new ServiceSection('doctor', $doctor->getId(), 'بخش فهرست'); $this->em->persist($section); $this->em->flush(); return [$owner, $doctor, $section]; } private function service(ServiceSection $section, string $name, int $minutes): ServiceItem { $item = new ServiceItem($section, $name, 120000); $item->setDurationMinutes($minutes)->setBookable(true); $this->em->persist($item); $this->em->flush(); return $item; } private function appointment(Doctor $doctor, array $items, bool $reserve = false): Appointment { $patient = $this->createUser(['ROLE_USER']); $start = (int) strtotime('+4 days 10:00'); $appt = $this->newAppointment($doctor, $patient, $reserve ? $start : $start, $reserve ? $start : $start + 1800); if ($reserve) { $appt->rescheduleTo($start, $start, true); } if ($items !== []) { $appt->replaceServiceItems($items); $appt->setServiceDuration(35, 10); } $this->em->persist($appt); $this->em->flush(); return $appt; } private function rowFor(\App\Auth\Entity\User $actor, string $uuid, bool $reserve): ?array { $res = $this->authJson('GET', '/api/v1/my/appointments?limit=50' . ($reserve ? '&reserve=1' : ''), $actor); foreach ($res['data'] ?? [] as $row) { if ($row['uuid'] === $uuid) { return $row; } } return null; } // ── ✅ موفق ────────────────────────────────────────────────────────────── public function testAllServiceItemsAreListedNotJustTheFirst(): void { [$owner, $doctor, $section] = $this->serviceDoctor(); $face = $this->service($section, 'لیزر صورت', 20); $bikini = $this->service($section, 'لیزر بیکینی', 15); $appt = $this->appointment($doctor, [$face, $bikini]); $row = $this->rowFor($owner, $appt->getUuid(), false); self::assertNotNull($row); self::assertCount(2, $row['service_items'], 'فهرست کامل سرویس‌ها باید بیاید'); self::assertSame( ['لیزر صورت', 'لیزر بیکینی'], array_column($row['service_items'], 'name'), ); self::assertSame('لیزر صورت', $row['service_item']['name'], 'سرویس تکی همان اولی می‌ماند'); } public function testDurationAndBufferAreExposed(): void { [$owner, $doctor, $section] = $this->serviceDoctor(); $item = $this->service($section, 'لیزر', 35); $appt = $this->appointment($doctor, [$item]); $row = $this->rowFor($owner, $appt->getUuid(), false); self::assertSame(35, $row['service_total_minutes']); self::assertSame(10, $row['service_buffer_minutes']); } public function testClinicUuidIsExposedForBookingModeDetection(): void { [$owner, $doctor, $section] = $this->serviceDoctor(); $item = $this->service($section, 'لیزر', 30); $appt = $this->appointment($doctor, [$item]); $row = $this->rowFor($owner, $appt->getUuid(), false); self::assertArrayHasKey('clinic_uuid', $row); self::assertNull($row['clinic_uuid'], 'مطب شخصی → null، و کلاینت باید بتواند تفکیک کند'); } public function testReserveListCarriesServicesAndDurationToo(): void { [$owner, $doctor, $section] = $this->serviceDoctor(); $item = $this->service($section, 'لیزر', 45); $appt = $this->appointment($doctor, [$item], reserve: true); $row = $this->rowFor($owner, $appt->getUuid(), true); self::assertNotNull($row, 'نوبت رزرو باید در فهرست reserve=1 باشد'); self::assertTrue($row['is_reserve']); self::assertCount(1, $row['service_items']); self::assertSame(35, $row['service_total_minutes'], 'مدت برای تبدیل بعدی لازم است'); } // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── public function testAppointmentWithoutServicesReturnsAnEmptyListNotNull(): void { [$owner, $doctor] = $this->serviceDoctor(); $appt = $this->appointment($doctor, []); $row = $this->rowFor($owner, $appt->getUuid(), false); self::assertSame([], $row['service_items'], 'آرایهٔ خالی، نه null — کلاینت روی length می‌خواند'); self::assertNull($row['service_total_minutes']); self::assertNull($row['service_buffer_minutes']); } public function testPaginationIsNotBrokenByTheServiceItemsJoin(): void { [$owner, $doctor, $section] = $this->serviceDoctor(); $a = $this->service($section, 'س۱', 10); $b = $this->service($section, 'س۲', 10); $c = $this->service($section, 'س۳', 10); // سه سرویس روی یک نوبت: اگر collection را JOIN می‌کردیم، این یک نوبت سه ردیف // می‌شد و صفحهٔ اول یک آیتم کمتر می‌داشت. $appt = $this->appointment($doctor, [$a, $b, $c]); $res = $this->authJson('GET', '/api/v1/my/appointments?limit=50', $owner); $matching = array_values(array_filter($res['data'], fn($r) => $r['uuid'] === $appt->getUuid())); self::assertCount(1, $matching, 'نوبت باید دقیقاً یک ردیف باشد'); self::assertCount(3, $matching[0]['service_items']); } }