POST /api/v1/appointment resolved the selected services, summed their minutes, used that to compute slot_end — and then dropped the result. It never called replaceServiceItems() or setServiceDuration(), so an appointment booked from the public site kept no record of what it was booked for: - the patient panel showed neither the service nor the duration - reports counted the appointment as having no services - a later reschedule had no duration to preserve The management path did all of this correctly; only the public path did not. Found by booking through the real endpoint and looking at the panel, which is the one thing no test did. The duration was also computed as a naive sum of duration_minutes, ignoring the solo/additional split. That made a multi-service booking's length disagree with the slots appointment-service-slots had just offered the patient — the booking would occupy a different span than the one shown. Both paths now go through ServiceBookingCalculator, which is what builds those slots. For data that only sets duration_minutes, the calculator returns the same total as the old sum, so existing services are unaffected. assertServicesMatchContext() is gone: the calculator performs the identical ownership check with the same error code and message, and the tenant-lookup inventory is updated to match. Tests: PublicBookingServicePersistenceTest starts at the endpoint rather than building an appointment in memory — the gap that let this ship. Verified it fails (4 of 8) with the fix disabled. Full suite 1433 green, slot-mode-frozen green, phpstan at its 14-error baseline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
9.1 KiB
PHP
206 lines
9.1 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Appointment;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Appointment\Entity\WeeklySchedule;
|
|
use App\ClinicService\Entity\ServiceItem;
|
|
use App\ClinicService\Entity\ServiceSection;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* `POST /api/v1/appointment` — همان endpointی که **سایت عمومی** با آن نوبت ثبت میکند
|
|
* (`nobat724_front/components/appointment/detail/SubmitData.js`).
|
|
*
|
|
* چرا این تست هست: {@see PublicSiteAppointmentContractTest} قرارداد *خواندن* را میسنجد
|
|
* ولی نوبتش را در حافظه میساخت و خودش `replaceServiceItems()` و `setServiceDuration()`
|
|
* را صدا میزد. برای همین وقتی مسیر *نوشتن* هیچکدام را صدا نمیزد، همهٔ تستها سبز
|
|
* بودند و نوبتِ واقعیِ ثبتشده از سایت هیچ سرویسی نداشت. اینجا از خود endpoint شروع
|
|
* میکنیم تا آن شکاف دوباره باز نشود.
|
|
*/
|
|
class PublicBookingServicePersistenceTest extends ApiTestCase
|
|
{
|
|
/** @return array{0:Doctor,1:ServiceSection} */
|
|
private function serviceDoctor(int $buffer = 10): array
|
|
{
|
|
$owner = $this->createUser(['ROLE_DOCTOR']);
|
|
$doctor = new Doctor($owner, 'دکتر رزرو عمومی');
|
|
$this->em->persist($doctor);
|
|
$this->em->flush();
|
|
|
|
$schedule = $this->newWeeklySchedule($doctor, [
|
|
'0' => ['sessions' => [[
|
|
'active' => true, 'start_time' => '09:00', 'end_time' => '18:00',
|
|
'duration_per_patient' => 20, 'location_id' => 1,
|
|
]]],
|
|
]);
|
|
$schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => $buffer]);
|
|
$this->em->persist($schedule);
|
|
|
|
$section = new ServiceSection('doctor', $doctor->getId(), 'زیبایی');
|
|
$this->em->persist($section);
|
|
$this->em->flush();
|
|
|
|
return [$doctor, $section];
|
|
}
|
|
|
|
private function service(ServiceSection $section, string $name, int $solo, ?int $additional = null): ServiceItem
|
|
{
|
|
$item = new ServiceItem($section, $name, 1_200_000);
|
|
$item->setDurationMinutes($solo)->setBookable(true);
|
|
if ($additional !== null) {
|
|
$item->setAdditionalDurationMinutes($additional);
|
|
}
|
|
$this->em->persist($item);
|
|
$this->em->flush();
|
|
|
|
return $item;
|
|
}
|
|
|
|
/** @param string[] $serviceUuids */
|
|
private function book(Doctor $doctor, array $serviceUuids, ?int $start = null, ?int $end = null): array
|
|
{
|
|
$patient = $this->createUser(['ROLE_USER']);
|
|
$start ??= time() + 86_400 + random_int(1, 5_000) * 60;
|
|
|
|
$res = $this->authJson('POST', '/api/v1/appointment', $patient, [
|
|
'doctor_uuid' => $doctor->getUuid(),
|
|
'clinic_uuid' => null,
|
|
'slot_start' => $start,
|
|
'slot_end' => $end ?? $start + 20 * 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',
|
|
'service_item_uuids' => $serviceUuids,
|
|
]);
|
|
|
|
return [$res, $start];
|
|
}
|
|
|
|
private function reload(string $uuid): Appointment
|
|
{
|
|
$this->em->clear();
|
|
|
|
return $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]);
|
|
}
|
|
|
|
// ── ✅ موفق ──────────────────────────────────────────────────────────────
|
|
|
|
public function testBookingThroughThePublicEndpointStoresTheServicesAndTheDuration(): void
|
|
{
|
|
[$doctor, $section] = $this->serviceDoctor(buffer: 10);
|
|
$laser = $this->service($section, 'لیزر صورت', 20);
|
|
|
|
[$res, $start] = $this->book($doctor, [$laser->getUuid()]);
|
|
self::assertSame(201, $this->responseCode());
|
|
|
|
$appt = $this->reload($res['data']['data']['uuid']);
|
|
|
|
self::assertCount(1, $appt->getServiceItems(), 'سرویس باید به نوبت بچسبد، نه فقط مدت را حساب کند');
|
|
self::assertSame('لیزر صورت', $appt->getServiceItem()?->getName());
|
|
self::assertSame(20, $appt->getServiceTotalMinutes());
|
|
self::assertSame(10, $appt->getServiceBufferMinutes(), 'بافر از تنظیمات محیط، نه صفر');
|
|
// بافر جزو مدت نوبت نیست: پایان = شروع + مدت.
|
|
self::assertSame($start + 20 * 60, $appt->getSlotEnd());
|
|
}
|
|
|
|
public function testTheResponseCarriesTheServiceFieldsTheSiteReadsBack(): void
|
|
{
|
|
[$doctor, $section] = $this->serviceDoctor();
|
|
$laser = $this->service($section, 'لیزر بیکینی', 15);
|
|
|
|
[$res] = $this->book($doctor, [$laser->getUuid()]);
|
|
self::assertSame(201, $this->responseCode());
|
|
|
|
$row = $res['data']['data'];
|
|
self::assertSame(15, $row['service_total_minutes']);
|
|
self::assertSame(['لیزر بیکینی'], array_column($row['service_items'], 'name'));
|
|
}
|
|
|
|
/**
|
|
* مدت از `DurationCalculator` میآید، نه از جمعِ سادهٔ `duration_minutes`.
|
|
*
|
|
* سرویس دوم «کنار بقیه» ۵ دقیقه است نه ۲۰؛ جمع ساده ۴۰ میداد و نوبت با اسلاتهایی
|
|
* که `appointment-service-slots` برمیگرداند جور درنمیآمد.
|
|
*/
|
|
public function testTheDurationUsesTheAdditionalMinutesNotANaiveSum(): void
|
|
{
|
|
[$doctor, $section] = $this->serviceDoctor(buffer: 0);
|
|
$first = $this->service($section, 'لیزر زیربغل', 20);
|
|
$second = $this->service($section, 'لیزر خط بیکینی', 20, additional: 5);
|
|
|
|
[$res, $start] = $this->book($doctor, [$first->getUuid(), $second->getUuid()]);
|
|
self::assertSame(201, $this->responseCode());
|
|
|
|
$appt = $this->reload($res['data']['data']['uuid']);
|
|
self::assertSame(25, $appt->getServiceTotalMinutes());
|
|
self::assertSame($start + 25 * 60, $appt->getSlotEnd());
|
|
self::assertCount(2, $appt->getServiceItems());
|
|
}
|
|
|
|
// ── ❌ خطا ───────────────────────────────────────────────────────────────
|
|
|
|
public function testAnUnknownServiceIsRejected(): void
|
|
{
|
|
[$doctor] = $this->serviceDoctor();
|
|
|
|
$this->book($doctor, ['00000000-0000-4000-8000-000000000000']);
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
|
|
public function testAServiceFromAnotherContextIsRejected(): void
|
|
{
|
|
[$doctor] = $this->serviceDoctor();
|
|
[, $other] = $this->serviceDoctor();
|
|
$foreign = $this->service($other, 'سرویس محیط دیگر', 30);
|
|
|
|
$this->book($doctor, [$foreign->getUuid()]);
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
|
|
public function testAServiceWithoutADurationIsRejected(): void
|
|
{
|
|
[$doctor, $section] = $this->serviceDoctor();
|
|
$item = new ServiceItem($section, 'بدون مدت', 500_000);
|
|
$item->setBookable(true);
|
|
$this->em->persist($item);
|
|
$this->em->flush();
|
|
|
|
$this->book($doctor, [$item->getUuid()]);
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
|
|
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
|
|
|
|
/** بدون سرویس، همان مسیر اسلاتیِ قبلی — ساعت پایانِ ارسالی حفظ میشود. */
|
|
public function testABookingWithoutServicesKeepsTheClientEndAndLeavesTheFieldsNull(): void
|
|
{
|
|
[$doctor] = $this->serviceDoctor();
|
|
$start = time() + 86_400 + random_int(1, 5_000) * 60;
|
|
|
|
[$res] = $this->book($doctor, [], $start, $start + 45 * 60);
|
|
self::assertSame(201, $this->responseCode());
|
|
|
|
$appt = $this->reload($res['data']['data']['uuid']);
|
|
self::assertSame($start + 45 * 60, $appt->getSlotEnd());
|
|
self::assertNull($appt->getServiceTotalMinutes());
|
|
self::assertCount(0, $appt->getServiceItems());
|
|
}
|
|
|
|
/** `slot_end` کلاینت در حالت سرویسی نادیده گرفته میشود — سرور خودش حساب میکند. */
|
|
public function testAWrongClientEndIsOverwrittenInsteadOfTrusted(): void
|
|
{
|
|
[$doctor, $section] = $this->serviceDoctor(buffer: 0);
|
|
$laser = $this->service($section, 'لیزر کامل بدن', 60);
|
|
$start = time() + 86_400 + random_int(1, 5_000) * 60;
|
|
|
|
[$res] = $this->book($doctor, [$laser->getUuid()], $start, $start + 5 * 60);
|
|
self::assertSame(201, $this->responseCode());
|
|
|
|
$appt = $this->reload($res['data']['data']['uuid']);
|
|
self::assertSame($start + 60 * 60, $appt->getSlotEnd());
|
|
self::assertSame(60, $appt->getServiceTotalMinutes());
|
|
}
|
|
}
|