doctorWithAddress(); $resource = new ClinicResource($address, $this->resourceType($address), 'لیزر CO2'); $resource->setSupervisor($doctor); $this->em->persist($resource); $this->em->flush(); return [$doctor, $address, $resource]; } private function service(DoctorAddress $address, string $name, bool $bookable = true): ServiceItem { $section = new ServiceSection($address->tenantEntityType(), $address->tenantEntityId(), 'بخش ' . $name); $this->em->persist($section); $item = new ServiceItem($section, $name, 5_000_000); $item->setDurationMinutes(30)->setBookable($bookable); $this->em->persist($item); $this->em->flush(); return $item; } private function offer(ClinicResource $resource, ServiceItem $item, ?int $minutes = null, bool $active = true): ResourceServiceOffering { $offering = new ResourceServiceOffering($resource, $item); $offering->setDurationMinutes($minutes)->setActive($active); $this->em->persist($offering); $this->em->flush(); return $offering; } /** @return array درخواستِ ناشناس، دقیقاً مثل بازدیدکنندهٔ سایت */ private function publicJson(string $uri): array { $this->client->request('GET', $uri); return json_decode($this->client->getResponse()->getContent(), true) ?? []; } private function resourcesUri(Doctor $doctor): string { return '/api/v1/appointment-booking-resources/' . $doctor->getUuid(); } /** شیفت ۰۹:۰۰ تا ۱۷:۰۰ روی همان روزِ هفته‌ای که تست رویش نوبت می‌گیرد. */ private function shift(ClinicResource $resource): void { $this->em->persist(new ResourceCalendar($resource, $this->dayOfWeek($this->midnight()), 540, 1020)); $this->em->flush(); } /** فردا انتخاب می‌شود تا «زمانِ گذشته» نتیجه را کوتاه نکند. */ private function midnight(): int { return (int) strtotime('tomorrow midnight'); } private function date(): string { return date('Y-m-d', $this->midnight()); } /** ۰ = شنبه، همان قرارداد تقویم منبع. */ private function dayOfWeek(int $timestamp): int { return ((int) date('w', $timestamp) + 1) % 7; } /** @param list $serviceUuids */ private function slotsUri(string $resourceUuid, string $date, array $serviceUuids): string { return '/api/v1/appointment-resource-slots?' . http_build_query([ 'resource_uuid' => $resourceUuid, 'date' => $date, 'service_item_uuids' => $serviceUuids, ]); } // ── ✅ موفق ────────────────────────────────────────────────────────────── public function testAResourceWithAnOnlineEnabledServiceIsListedWithResolvedNumbers(): void { [$doctor, $address, $resource] = $this->doctorWithResource(); $item = $this->service($address, 'لیزر پا'); $this->offer($resource, $item, 45); // مدتِ اختصاصیِ همین دستگاه $body = $this->publicJson($this->resourcesUri($doctor)); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); self::assertCount(1, $body['data']['resources']); $row = $body['data']['resources'][0]; self::assertSame($resource->getUuid(), $row['uuid']); self::assertSame('لیزر CO2', $row['name']); self::assertSame($doctor->getUuid(), $row['supervisor']['uuid']); self::assertCount(1, $row['services']); // مدت از ردیف ارائه می‌آید، نه از پیش‌فرضِ ۳۰ دقیقه‌ایِ خودِ سرویس. self::assertSame(45, $row['services'][0]['duration_minutes']); self::assertSame(5_000_000, $row['services'][0]['price_rials']); // ظرفیت عددِ عملیاتیِ داخل کلینیک است و نباید به سایت نشت کند. self::assertArrayNotHasKey('capacity', $row); } // ── ❌ خطا ─────────────────────────────────────────────────────────────── public function testAServiceWithTheOnlineToggleOffHidesTheResource(): void { [$doctor, $address, $resource] = $this->doctorWithResource(); $this->offer($resource, $this->service($address, 'سرویس خاموش', bookable: false)); $body = $this->publicJson($this->resourcesUri($doctor)); self::assertSame(200, $this->responseCode()); self::assertSame([], $body['data']['resources']); } public function testAnInactiveOfferingHidesTheResource(): void { [$doctor, $address, $resource] = $this->doctorWithResource(); $this->offer($resource, $this->service($address, 'لیزر پا'), active: false); $body = $this->publicJson($this->resourcesUri($doctor)); self::assertSame(200, $this->responseCode()); self::assertSame([], $body['data']['resources']); } public function testAnUnknownDoctorIsRejected(): void { $this->publicJson('/api/v1/appointment-booking-resources/00000000-0000-4000-8000-000000000000'); self::assertSame(404, $this->responseCode()); } // ── ⚠️ مرزی ────────────────────────────────────────────────────────────── public function testADoctorWithoutAnyResourceGetsAnEmptyListNotAnError(): void { [, $doctor] = $this->doctorWithAddress(); $body = $this->publicJson($this->resourcesUri($doctor)); self::assertSame(200, $this->responseCode()); self::assertSame([], $body['data']['resources']); } /** * پزشکی که هیچ برنامهٔ هفتگی ندارد، هیچ «محل نوبت‌دهی» هم ندارد که سایت بتواند * `clinic_uuid` را از آن بردارد. منبع اما تقویم و شعبهٔ خودش را دارد و باید بدون * آن پارامتر هم پیدا شود، وگرنه دستگاهِ قابلِ رزرو هرگز در سایت دیده نمی‌شود. */ public function testWithoutAClinicParamTheResourcesOfEveryEnvironmentComeBack(): void { [$user, $clinic, $address] = $this->clinicWithAddress(); $doctor = $this->supervisorFor($address); $resource = new ClinicResource($address, $this->resourceType($address), 'کندلا'); $resource->setSupervisor($doctor); $this->em->persist($resource); $this->em->flush(); $this->offer($resource, $this->service($address, 'لیزر دست'), 20); // بدون clinic_uuid — دقیقاً همان چیزی که سایت برای پزشکِ بی‌برنامه می‌فرستد. $body = $this->publicJson($this->resourcesUri($doctor)); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); self::assertCount(1, $body['data']['resources']); // محیطِ منبع همراهش می‌آید تا سایت بتواند نوبت را در همان کلینیک ثبت کند. self::assertSame($clinic->getUuid(), $body['data']['resources'][0]['clinic_uuid']); } public function testAResourceSupervisedByAnotherDoctorIsNotListed(): void { [, $address, $resource] = $this->doctorWithResource(); $this->offer($resource, $this->service($address, 'لیزر پا')); $other = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'پزشک دیگر'); $this->em->persist($other); $this->em->flush(); $body = $this->publicJson($this->resourcesUri($other)); self::assertSame(200, $this->responseCode()); self::assertSame([], $body['data']['resources']); } public function testAnInactiveResourceIsNotListed(): void { [$doctor, $address, $resource] = $this->doctorWithResource(); $this->offer($resource, $this->service($address, 'لیزر پا')); $resource->setActive(false); $this->em->flush(); $body = $this->publicJson($this->resourcesUri($doctor)); self::assertSame(200, $this->responseCode()); self::assertSame([], $body['data']['resources']); } // ── اسلات‌های عمومیِ منبع ──────────────────────────────────────────────── public function testTheFreeStartTimesOfAResourceComeBackWithoutAToken(): void { [, $address, $resource] = $this->doctorWithResource(); $item = $this->service($address, 'لیزر پا'); $this->offer($resource, $item, 45); $this->shift($resource); $body = $this->publicJson($this->slotsUri($resource->getUuid(), $this->date(), [$item->getUuid()])); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); // مدت از ردیف ارائه می‌آید، نه از پیش‌فرضِ ۳۰ دقیقه‌ایِ سرویس. self::assertSame(45, $body['data']['total_duration_minutes']); self::assertNotEmpty($body['data']['start_times']); self::assertSame('09:00', $body['data']['start_times'][0]['start_time']); self::assertSame('09:45', $body['data']['start_times'][0]['end_time']); } public function testAServiceWithTheOnlineToggleOffIsRejectedEvenWhenTheResourceOffersIt(): void { [, $address, $resource] = $this->doctorWithResource(); // یکی روشن تا خودِ منبع عمومی بماند، یکی خاموش تا گیتِ سرویس سنجیده شود. $this->offer($resource, $this->service($address, 'لیزر پا'), 45); $off = $this->service($address, 'سرویس خاموش', bookable: false); $this->offer($resource, $off, 20); $this->shift($resource); $body = $this->publicJson($this->slotsUri($resource->getUuid(), $this->date(), [$off->getUuid()])); self::assertSame(422, $this->responseCode()); self::assertSame('این سرویس برای نوبت‌دهی آنلاین فعال نیست', $body['errors'][0]['message']); self::assertSame('service_item_uuids', $body['errors'][0]['field']); } public function testAResourceWithoutAnyOnlineServiceIsNotReachable(): void { [, $address, $resource] = $this->doctorWithResource(); $item = $this->service($address, 'سرویس خاموش', bookable: false); $this->offer($resource, $item, 20); $this->shift($resource); $body = $this->publicJson($this->slotsUri($resource->getUuid(), $this->date(), [$item->getUuid()])); self::assertSame(422, $this->responseCode()); self::assertSame('resource_uuid', $body['errors'][0]['field']); } public function testAMalformedDateIsRejected(): void { [, $address, $resource] = $this->doctorWithResource(); $item = $this->service($address, 'لیزر پا'); $this->offer($resource, $item, 45); $body = $this->publicJson($this->slotsUri($resource->getUuid(), '2026-13-99', [$item->getUuid()])); self::assertSame(422, $this->responseCode()); self::assertSame('date', $body['errors'][0]['field']); } public function testAnEmptyServiceSelectionIsRejected(): void { [, $address, $resource] = $this->doctorWithResource(); $this->offer($resource, $this->service($address, 'لیزر پا'), 45); $body = $this->publicJson($this->slotsUri($resource->getUuid(), $this->date(), [])); self::assertSame(422, $this->responseCode()); self::assertSame('service_item_uuids', $body['errors'][0]['field']); } // ── تقویم ماهِ منبع ────────────────────────────────────────────────────── /** ماهِ آینده انتخاب می‌شود تا «زمانِ گذشته» همهٔ روزها را خالی نکند. */ private function nextMonth(): array { $first = (int) strtotime('first day of next month midnight'); return [(int) date('Y', $first), (int) date('n', $first)]; } /** @param list $serviceUuids */ private function monthUri(string $resourceUuid, int $year, int $month, array $serviceUuids): string { return '/api/v1/appointment-resource-month-availability/' . $resourceUuid . '?' . http_build_query([ 'year' => $year, 'month' => $month, 'service_item_uuids' => $serviceUuids, ]); } public function testOnlyTheWeekdaysWithAShiftAreEnabled(): void { [, $address, $resource] = $this->doctorWithResource(); $item = $this->service($address, 'لیزر پا'); $this->offer($resource, $item, 45); // تنها شیفتِ منبع: شنبه‌ها (۰ در قرارداد تقویم منبع). $this->em->persist(new ResourceCalendar($resource, 0, 540, 1020)); $this->em->flush(); [$year, $month] = $this->nextMonth(); $body = $this->publicJson($this->monthUri($resource->getUuid(), $year, $month, [$item->getUuid()])); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); self::assertNotEmpty($body['data']['enabled_dates']); foreach ($body['data']['enabled_dates'] as $date) { self::assertSame('Saturday', date('l', (int) strtotime($date)), $date); } // هیچ روزی از قلم نمی‌افتد — تقویم سایت روی همین دو فهرست تصمیم می‌گیرد. $daysInMonth = (int) date('t', (int) strtotime(sprintf('%04d-%02d-01', $year, $month))); self::assertCount( $daysInMonth, array_merge($body['data']['enabled_dates'], $body['data']['disabled_dates']), ); } public function testAnInvalidMonthIsRejected(): void { [, $address, $resource] = $this->doctorWithResource(); $item = $this->service($address, 'لیزر پا'); $this->offer($resource, $item, 45); $body = $this->publicJson($this->monthUri($resource->getUuid(), 2026, 13, [$item->getUuid()])); self::assertSame(422, $this->responseCode()); self::assertSame('month', $body['errors'][0]['field']); } public function testAPastMonthHasNoEnabledDay(): void { [, $address, $resource] = $this->doctorWithResource(); $item = $this->service($address, 'لیزر پا'); $this->offer($resource, $item, 45); $this->em->persist(new ResourceCalendar($resource, 0, 540, 1020)); $this->em->flush(); $past = (int) strtotime('first day of last month midnight'); $body = $this->publicJson( $this->monthUri($resource->getUuid(), (int) date('Y', $past), (int) date('n', $past), [$item->getUuid()]), ); self::assertSame(200, $this->responseCode()); self::assertSame([], $body['data']['enabled_dates']); } public function testADayWithoutAShiftReturnsAnEmptyListNotAnError(): void { [, $address, $resource] = $this->doctorWithResource(); $item = $this->service($address, 'لیزر پا'); $this->offer($resource, $item, 45); // بدون هیچ ResourceCalendar — منبع آن روز باز نیست. $body = $this->publicJson($this->slotsUri($resource->getUuid(), $this->date(), [$item->getUuid()])); self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); self::assertSame([], $body['data']['start_times']); } }