Files
clinicpro/tests/Appointment/BookForResourceTest.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

332 lines
14 KiB
PHP

<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Clinic\Entity\Clinic;
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\ResourceServiceOffering;
use App\Resource\Entity\ResourceType;
use App\Tests\ApiTestCase;
/**
* `POST /api/v1/appointment` با `resource_uuid`.
*
* مسیر قدیمی (`doctor_uuid`) باید دست‌نخورده بماند — سایت عمومی امروز همان را می‌فرستد
* و شکستنش در build هیچ کلاینتی خطا نمی‌دهد.
*/
class BookForResourceTest extends ApiTestCase
{
/** @return array{0: Clinic, 1: Doctor, 2: DoctorAddress, 3: ServiceSection} */
private function clinic(): array
{
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($owner);
$clinic->setName('کلینیک رزرو منبعی');
$this->em->persist($clinic);
$this->em->flush();
$doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'مینا یوسفی');
$this->em->persist($doctor);
// بدون عضویت، رزرو در محیط کلینیک «محل نوبت‌دهی یافت نشد» می‌گیرد.
$clinic->getDoctors()->add($doctor);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$section = new ServiceSection('clinic', (int) $clinic->getId(), 'لیزر');
$this->em->persist($section);
$this->em->flush();
return [$clinic, $doctor, $address, $section];
}
private function resource(Clinic $clinic, DoctorAddress $address, string $name, ?Doctor $doctor = null): ClinicResource
{
$type = new ResourceType('clinic', (int) $clinic->getId(), 'r_' . bin2hex(random_bytes(3)), 'منبع');
$this->em->persist($type);
$this->em->flush();
$resource = new ClinicResource($address, $type, $name);
if ($doctor !== null) {
$resource->linkTo($doctor);
}
$this->em->persist($resource);
$this->em->flush();
return $resource;
}
private function service(ServiceSection $section, string $name, int $minutes = 30): ServiceItem
{
$item = new ServiceItem($section, $name, 8_000_000);
$item->setDurationMinutes($minutes)->setBookable(true);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** @param array<string, mixed> $extra */
private function book(array $extra): array
{
$start = time() + 86_400 + random_int(1, 5_000) * 60;
return [$this->authJson('POST', '/api/v1/appointment', $this->createUser(['ROLE_USER']), $extra + [
'slot_start' => $start,
'slot_end' => $start + 30 * 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',
]), $start];
}
// ── ✅ موفق ──────────────────────────────────────────────────────────────
public function testBookingForADoctorResourceInfersTheDoctor(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$resource = $this->resource($clinic, $address, 'دکتر یوسفی', $doctor);
// بدون `doctor_uuid` — پزشک از خودِ منبع درمی‌آید.
[$res] = $this->book(['resource_uuid' => $resource->getUuid()]);
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
$row = $res['data']['data'];
self::assertSame($resource->getUuid(), $row['resource']['uuid']);
self::assertSame('دکتر یوسفی', $row['resource']['name']);
}
/**
* دستگاه لیزر پزشک نیست؛ پزشکِ ناظرش می‌نشیند.
*
* مسیر پنل این را از قبل داشت و مسیر عمومی نداشت — یعنی همان رزرو از سایت
* ۴۲۲ می‌گرفت با پیامی که می‌گفت `resource_uuid` بفرست، در حالی که فرستاده بود.
*/
public function testBookingForADeviceFallsBackToItsSupervisor(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$device = $this->resource($clinic, $address, 'دستگاه لیزر ۲');
$device->setSupervisor($doctor);
$this->em->flush();
[$res] = $this->book(['resource_uuid' => $device->getUuid()]);
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
self::assertSame($device->getUuid(), $res['data']['data']['resource']['uuid']);
self::assertSame($doctor->getUuid(), $res['data']['data']['doctor']['uuid']);
}
/** پیام باید بگوید ناظر ندارد، نه اینکه uuid نفرستادی. */
public function testADeviceWithoutASupervisorSaysSo(): void
{
[$clinic, , $address] = $this->clinic();
$device = $this->resource($clinic, $address, 'دستگاه بی‌ناظر');
[$res] = $this->book(['resource_uuid' => $device->getUuid()]);
self::assertSame(422, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
self::assertSame('resource_uuid', $res['errors'][0]['field']);
self::assertStringContainsString('پزشک ناظر', $res['errors'][0]['message']);
}
public function testTheOldDoctorOnlyPathIsUntouched(): void
{
[, $doctor] = $this->clinic();
[$res] = $this->book(['doctor_uuid' => $doctor->getUuid()]);
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
// سایت عمومی امروز `resource_uuid` نمی‌فرستد؛ پاسخ باید null بدهد نه خطا.
self::assertNull($res['data']['data']['resource']);
}
// ── ❌ خطا ───────────────────────────────────────────────────────────────
public function testAResourceThatDoesNotOfferTheServiceIsRejected(): void
{
[$clinic, $doctor, $address, $section] = $this->clinic();
$offering = $this->resource($clinic, $address, 'دستگاه مجاز');
$other = $this->resource($clinic, $address, 'دستگاه بی‌ارتباط');
$service = $this->service($section, 'لیزر پا');
// فقط یکی از دو دستگاه این سرویس را می‌دهد.
$this->em->persist(new ResourceServiceOffering($offering, $service));
$this->em->flush();
[$res] = $this->book([
'doctor_uuid' => $doctor->getUuid(),
'clinic_uuid' => $clinic->getUuid(),
'resource_uuid' => $other->getUuid(),
'service_item_uuids' => [$service->getUuid()],
]);
self::assertSame(422, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
self::assertSame('این منبع این سرویس را ارائه نمی‌دهد', $res['errors'][0]['message']);
}
/**
* مدتِ نوبت باید همان عددی باشد که `appointment-resource-slots` با آن زمان‌ها را
* ساخته — وگرنه بیمار وقتی را می‌گیرد که سرور جای دیگری آزاد حساب کرده بود.
*/
public function testTheLengthComesFromTheResourceOfferingNotTheServiceDefault(): void
{
[$clinic, $doctor, $address, $section] = $this->clinic();
$resource = $this->resource($clinic, $address, 'دستگاه لیزر', $doctor);
$service = $this->service($section, 'لیزر پا', 30); // پیش‌فرضِ سرویس
$offering = new ResourceServiceOffering($resource, $service);
$offering->setDurationMinutes(45); // مدتِ همین دستگاه
$this->em->persist($offering);
$this->em->flush();
[$res] = $this->book([
'doctor_uuid' => $doctor->getUuid(),
'clinic_uuid' => $clinic->getUuid(),
'resource_uuid' => $resource->getUuid(),
'service_item_uuids' => [$service->getUuid()],
]);
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
$row = $res['data']['data'];
self::assertSame(45 * 60, $row['slot_end'] - $row['slot_start']);
}
/**
* سازگاری عقب‌رو: محیطی که هنوز هیچ ردیفِ منبع↔سرویس نساخته باید مثل قبل کار کند —
* مدت از پیش‌فرضِ خودِ سرویس می‌آید و نوبت‌دهی‌اش قطع نمی‌شود.
*/
public function testWithoutAnyOfferingRowTheLengthStaysTheServiceDefault(): void
{
[$clinic, $doctor, $address, $section] = $this->clinic();
$resource = $this->resource($clinic, $address, 'دستگاه بدون رابطه', $doctor);
$service = $this->service($section, 'سرویس بدون رابطه', 30);
[$res] = $this->book([
'doctor_uuid' => $doctor->getUuid(),
'clinic_uuid' => $clinic->getUuid(),
'resource_uuid' => $resource->getUuid(),
'service_item_uuids' => [$service->getUuid()],
]);
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
$row = $res['data']['data'];
self::assertSame(30 * 60, $row['slot_end'] - $row['slot_start']);
}
/**
* ردیفِ **غیرفعال** فرق دارد و همان ۴۲۲ قبلی را می‌گیرد: «فعلاً این را نمی‌دهم»
* حرفِ صریحِ مالک است، نه سکوتِ محیطی که هنوز رابطه‌ها را پر نکرده.
*/
public function testAnInactiveOfferingStillRejectsTheBooking(): void
{
[$clinic, $doctor, $address, $section] = $this->clinic();
$resource = $this->resource($clinic, $address, 'دستگاه لیزر', $doctor);
$service = $this->service($section, 'لیزر پا', 30);
$offering = new ResourceServiceOffering($resource, $service);
$offering->setDurationMinutes(45)->setActive(false);
$this->em->persist($offering);
$this->em->flush();
[$res] = $this->book([
'doctor_uuid' => $doctor->getUuid(),
'clinic_uuid' => $clinic->getUuid(),
'resource_uuid' => $resource->getUuid(),
'service_item_uuids' => [$service->getUuid()],
]);
self::assertSame(422, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
self::assertSame('این منبع این سرویس را ارائه نمی‌دهد', $res['errors'][0]['message']);
}
/**
* گیتِ «نمایش در نوبت‌دهی آنلاین» با منبع هم دور زده نمی‌شود: شاخهٔ منبع فقط وقتی
* فعال است که سرویس عمومی باشد، و در غیر این‌صورت همان ۴۲۲ مسیر پزشک‌محور می‌آید.
*/
public function testAServiceWithTheOnlineToggleOffIsStillRejectedOnAResource(): void
{
[$clinic, $doctor, $address, $section] = $this->clinic();
$resource = $this->resource($clinic, $address, 'دستگاه لیزر', $doctor);
$service = $this->service($section, 'لیزر پا', 30);
$service->setBookable(false);
$offering = new ResourceServiceOffering($resource, $service);
$offering->setDurationMinutes(45);
$this->em->persist($offering);
$this->em->flush();
[$res] = $this->book([
'doctor_uuid' => $doctor->getUuid(),
'clinic_uuid' => $clinic->getUuid(),
'resource_uuid' => $resource->getUuid(),
'service_item_uuids' => [$service->getUuid()],
]);
self::assertSame(422, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
self::assertSame('این سرویس برای نوبت‌دهی فعال نیست', $res['errors'][0]['message']);
}
public function testAnUnknownResourceIsRejected(): void
{
$this->book(['resource_uuid' => '00000000-0000-4000-8000-000000000000']);
self::assertSame(422, $this->responseCode());
}
public function testNeitherDoctorNorResourceIsRejected(): void
{
$this->book([]);
self::assertSame(422, $this->responseCode());
}
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
public function testWithoutAnyOfferingRowsTheResourceIsNotSecondGuessed(): void
{
[$clinic, $doctor, $address, $section] = $this->clinic();
$resource = $this->resource($clinic, $address, 'دستگاه بدون رابطه');
$service = $this->service($section, 'سرویس بدون رابطه');
// هیچ ردیفی برای این سرویس نیست → همان سازگاری عقب‌روِ `findEligible`.
[$res] = $this->book([
'doctor_uuid' => $doctor->getUuid(),
'clinic_uuid' => $clinic->getUuid(),
'resource_uuid' => $resource->getUuid(),
'service_item_uuids' => [$service->getUuid()],
]);
self::assertSame(201, $this->responseCode(), json_encode($res, JSON_UNESCAPED_UNICODE));
}
public function testAnInactiveResourceCannotBeBooked(): void
{
[$clinic, $doctor, $address] = $this->clinic();
$resource = $this->resource($clinic, $address, 'دستگاه خاموش', $doctor);
$resource->setActive(false);
$this->em->flush();
$this->book(['resource_uuid' => $resource->getUuid()]);
self::assertSame(422, $this->responseCode());
}
}