- 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.
332 lines
14 KiB
PHP
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());
|
|
}
|
|
}
|