Files
clinicpro/tests/Appointment/MyAppointmentsServiceFieldsTest.php
T
hamedandClaude Opus 5 4fbedecec1 fix(booking): reserve conversion produced a zero-length midnight appointment
TransferReserveModal built the live appointment from appointment_time/end_time,
which on a reserve entry are both 00:00 because slot_start == slot_end. Moving a
reserve back to the appointment list silently created a zero-length appointment
at midnight. With the new duration validation it would now fail loudly instead.

Converting back now asks for a real time: the service picker in service mode,
two required time inputs in slot mode. The appointment -> reserve direction is
untouched.

GET /my/appointments has its own array-hydration serializer rather than
Appointment::toArray(), so it exposed none of the service fields the panel needs.
Added service_items (separate query, no row multiplication and no N+1),
clinic_uuid and the duration pair. This was also a hidden prerequisite of the
public-site task, whose checklist listed it as "verify first".

The reserve table now lists every service instead of only the first.

Not done, deliberately: the DataTable migration the task asked for. Its stated
reason — inline tokens breaking dark mode — does not hold; this table's th/td
already use CSS variables and dark mode works. Rewriting a working table for no
real gain is unjustified risk.

Task: docs/new_feture/taskes/task-00-service-mode-completion/
Slot-mode contract: unchanged (--group=slot-mode-frozen green)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:04:09 +03:30

172 lines
7.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;
/**
* `GET /api/v1/my/appointments` سریالایزر خودش را دارد (array hydration)، نه
* `Appointment::toArray()`. پس فیلدهای سرویسی باید صریحاً همان‌جا اضافه شوند —
* وگرنه پنل ادمین و پنل کاربر سایت داده‌ای برای نمایش ندارند.
*/
class MyAppointmentsServiceFieldsTest extends ApiTestCase
{
/** @return array{0:\App\Auth\Entity\User,1:Doctor,2:ServiceSection} */
private function serviceDoctor(): 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' => 10]);
$this->em->persist($schedule);
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش فهرست');
$this->em->persist($section);
$this->em->flush();
return [$owner, $doctor, $section];
}
private function service(ServiceSection $section, string $name, int $minutes): ServiceItem
{
$item = new ServiceItem($section, $name, 120000);
$item->setDurationMinutes($minutes)->setBookable(true);
$this->em->persist($item);
$this->em->flush();
return $item;
}
private function appointment(Doctor $doctor, array $items, bool $reserve = false): Appointment
{
$patient = $this->createUser(['ROLE_USER']);
$start = (int) strtotime('+4 days 10:00');
$appt = $this->newAppointment($doctor, $patient, $reserve ? $start : $start, $reserve ? $start : $start + 1800);
if ($reserve) {
$appt->rescheduleTo($start, $start, true);
}
if ($items !== []) {
$appt->replaceServiceItems($items);
$appt->setServiceDuration(35, 10);
}
$this->em->persist($appt);
$this->em->flush();
return $appt;
}
private function rowFor(\App\Auth\Entity\User $actor, string $uuid, bool $reserve): ?array
{
$res = $this->authJson('GET', '/api/v1/my/appointments?limit=50' . ($reserve ? '&reserve=1' : ''), $actor);
foreach ($res['data'] ?? [] as $row) {
if ($row['uuid'] === $uuid) {
return $row;
}
}
return null;
}
// ── ✅ موفق ──────────────────────────────────────────────────────────────
public function testAllServiceItemsAreListedNotJustTheFirst(): void
{
[$owner, $doctor, $section] = $this->serviceDoctor();
$face = $this->service($section, 'لیزر صورت', 20);
$bikini = $this->service($section, 'لیزر بیکینی', 15);
$appt = $this->appointment($doctor, [$face, $bikini]);
$row = $this->rowFor($owner, $appt->getUuid(), false);
self::assertNotNull($row);
self::assertCount(2, $row['service_items'], 'فهرست کامل سرویس‌ها باید بیاید');
self::assertSame(
['لیزر صورت', 'لیزر بیکینی'],
array_column($row['service_items'], 'name'),
);
self::assertSame('لیزر صورت', $row['service_item']['name'], 'سرویس تکی همان اولی می‌ماند');
}
public function testDurationAndBufferAreExposed(): void
{
[$owner, $doctor, $section] = $this->serviceDoctor();
$item = $this->service($section, 'لیزر', 35);
$appt = $this->appointment($doctor, [$item]);
$row = $this->rowFor($owner, $appt->getUuid(), false);
self::assertSame(35, $row['service_total_minutes']);
self::assertSame(10, $row['service_buffer_minutes']);
}
public function testClinicUuidIsExposedForBookingModeDetection(): void
{
[$owner, $doctor, $section] = $this->serviceDoctor();
$item = $this->service($section, 'لیزر', 30);
$appt = $this->appointment($doctor, [$item]);
$row = $this->rowFor($owner, $appt->getUuid(), false);
self::assertArrayHasKey('clinic_uuid', $row);
self::assertNull($row['clinic_uuid'], 'مطب شخصی → null، و کلاینت باید بتواند تفکیک کند');
}
public function testReserveListCarriesServicesAndDurationToo(): void
{
[$owner, $doctor, $section] = $this->serviceDoctor();
$item = $this->service($section, 'لیزر', 45);
$appt = $this->appointment($doctor, [$item], reserve: true);
$row = $this->rowFor($owner, $appt->getUuid(), true);
self::assertNotNull($row, 'نوبت رزرو باید در فهرست reserve=1 باشد');
self::assertTrue($row['is_reserve']);
self::assertCount(1, $row['service_items']);
self::assertSame(35, $row['service_total_minutes'], 'مدت برای تبدیل بعدی لازم است');
}
// ── ⚠️ مرزی ──────────────────────────────────────────────────────────────
public function testAppointmentWithoutServicesReturnsAnEmptyListNotNull(): void
{
[$owner, $doctor] = $this->serviceDoctor();
$appt = $this->appointment($doctor, []);
$row = $this->rowFor($owner, $appt->getUuid(), false);
self::assertSame([], $row['service_items'], 'آرایهٔ خالی، نه null — کلاینت روی length می‌خواند');
self::assertNull($row['service_total_minutes']);
self::assertNull($row['service_buffer_minutes']);
}
public function testPaginationIsNotBrokenByTheServiceItemsJoin(): void
{
[$owner, $doctor, $section] = $this->serviceDoctor();
$a = $this->service($section, 'س۱', 10);
$b = $this->service($section, 'س۲', 10);
$c = $this->service($section, 'س۳', 10);
// سه سرویس روی یک نوبت: اگر collection را JOIN می‌کردیم، این یک نوبت سه ردیف
// می‌شد و صفحهٔ اول یک آیتم کمتر می‌داشت.
$appt = $this->appointment($doctor, [$a, $b, $c]);
$res = $this->authJson('GET', '/api/v1/my/appointments?limit=50', $owner);
$matching = array_values(array_filter($res['data'], fn($r) => $r['uuid'] === $appt->getUuid()));
self::assertCount(1, $matching, 'نوبت باید دقیقاً یک ردیف باشد');
self::assertCount(3, $matching[0]['service_items']);
}
}