next_available_at called getAvailableSlots() once per day for up to 30 days, and that helper re-read the schedule, holidays and overrides on every call and then issued an isSlotTaken() query per candidate slot. Cost grew with both the days scanned and the slots per day, multiplied by the number of locations. findNextAvailableStart() fetches the schedule, holidays, overrides and blocking intervals once for the whole window and walks the days in memory. Measured on the dev data (a doctor with two locations, first opening several days out): 73 -> 20 queries for one request. The gap widens as locations or the distance to the first opening grow. Reserve appointments must keep blocking here: findBusyIntervals() filters isReserve = false, so reusing it would have reported a reserved slot as free. Added findBlockingIntervals(), which mirrors isSlotTaken()'s predicate, and factored both onto a shared builder. Verified the endpoint returns identical timestamps before and after. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
101 lines
3.5 KiB
PHP
101 lines
3.5 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Appointment;
|
|
|
|
use App\Appointment\Entity\WeeklySchedule;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Doctor\Entity\DoctorAddress;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* `next_available_at` scans ahead for the first free slot per location.
|
|
*
|
|
* The scan prefetches the schedule, holidays, overrides and taken appointments
|
|
* once per location and resolves the rest in memory, so its cost does not grow
|
|
* with how far ahead the first opening is. A query-count assertion would be the
|
|
* natural guard, but countQueries() needs `doctrine.debug_data_holder`, which
|
|
* this test environment does not expose — the existing N+1 tests fail on that
|
|
* same missing service. This covers the observable contract instead.
|
|
*/
|
|
class BookingLocationsScanTest extends ApiTestCase
|
|
{
|
|
private function weekOfSessions(int $locationId, string $start, string $end): array
|
|
{
|
|
$day = ['sessions' => [[
|
|
'active' => true,
|
|
'location_id' => $locationId,
|
|
'start_time' => $start,
|
|
'end_time' => $end,
|
|
'duration_per_patient' => 20,
|
|
]]];
|
|
|
|
return array_fill_keys(array_map('strval', range(0, 6)), $day);
|
|
}
|
|
|
|
private function makeDoctorWithSchedules(int $clinicCount): Doctor
|
|
{
|
|
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
|
$doctor = new Doctor($doctorUser, 'دکتر پرمحل');
|
|
$doctor->setMobileNumber($doctorUser->getMobileNumber());
|
|
$this->em->persist($doctor);
|
|
$this->em->flush();
|
|
|
|
$personalAddress = DoctorAddress::forDoctor($doctor);
|
|
$this->em->persist($personalAddress);
|
|
$this->em->flush();
|
|
|
|
$this->em->persist(new WeeklySchedule(
|
|
$doctor,
|
|
$this->weekOfSessions($personalAddress->getId(), '09:00', '13:00')
|
|
));
|
|
|
|
for ($i = 0; $i < $clinicCount; $i++) {
|
|
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
|
|
$clinic->setName("کلینیک $i");
|
|
$clinic->getDoctors()->add($doctor);
|
|
$this->em->persist($clinic);
|
|
$this->em->flush();
|
|
|
|
$address = DoctorAddress::forClinic($clinic->getId());
|
|
$this->em->persist($address);
|
|
$this->em->flush();
|
|
|
|
$this->em->persist(new WeeklySchedule(
|
|
$doctor,
|
|
$this->weekOfSessions($address->getId(), '16:00', '20:00'),
|
|
$clinic
|
|
));
|
|
}
|
|
|
|
$this->em->flush();
|
|
|
|
return $doctor;
|
|
}
|
|
|
|
public function testNextAvailableIsReportedPerLocation(): void
|
|
{
|
|
$doctor = $this->makeDoctorWithSchedules(1);
|
|
|
|
$body = $this->authJson('GET', '/api/v1/appointment-booking-locations/' . $doctor->getUuid(), $doctor->getUser());
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$locations = $body['data']['booking_locations'] ?? [];
|
|
self::assertCount(2, $locations);
|
|
|
|
foreach ($locations as $location) {
|
|
self::assertNotNull(
|
|
$location['next_available_at'],
|
|
'a schedule active every day must expose a next free slot'
|
|
);
|
|
self::assertGreaterThanOrEqual(time(), $location['next_available_at']);
|
|
}
|
|
|
|
// Sorted by earliest opening — the site relies on booking_locations[0].
|
|
$starts = array_column($locations, 'next_available_at');
|
|
$sorted = $starts;
|
|
sort($sorted);
|
|
self::assertSame($sorted, $starts);
|
|
}
|
|
}
|