- 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.
389 lines
17 KiB
PHP
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']);
|
|
}
|
|
}
|