Files
clinicpro/tests/Resource/PublicResourceBookingTest.php
T
hamed cfeb447645 feat: add PublicResourceBookingController and PublicResourceBookingService for public booking functionality
- Implemented PublicResourceBookingController to handle public resource booking requests.
- Added methods for retrieving bookable resources, available slots, and month availability.
- Created PublicResourceBookingService to manage public resource offerings and service visibility.
- Developed tests for public resource booking to ensure correct functionality and error handling.
2026-08-09 10:45:51 +03:30

389 lines
17 KiB
PHP

<?php
namespace App\Tests\Resource;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Resource\Entity\ClinicResource;
use App\Resource\Entity\ResourceCalendar;
use App\Resource\Entity\ResourceServiceOffering;
/**
* نوبت‌دهی منبع‌محور از **سایت عمومی** — بدون هیچ توکنی.
*
* تا پیش از این، منابع و سرویس‌هایشان فقط از داخل پنل دیده می‌شدند، پس تیکِ «نمایش در
* نوبت‌دهی آنلاین» روی سرویسِ یک دستگاه هیچ اثری در سایت نداشت.
*/
class PublicResourceBookingTest extends ResourceTestCase
{
/** @return array{0: Doctor, 1: DoctorAddress, 2: ClinicResource} */
private function doctorWithResource(): array
{
[, $doctor, $address] = $this->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<string, mixed> درخواستِ ناشناس، دقیقاً مثل بازدیدکنندهٔ سایت */
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<string> $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<string> $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']);
}
}