Files
clinicpro/tests/Appointment/BookingLocationsScanTest.php
T
hamedandClaude Opus 4.8 8065ae3be1 test: enable Doctrine profiling in the test env and fix the N+1 it exposed
APP_DEBUG=0 in .env means doctrine.dbal.profiling, which defaults to
%kernel.debug%, was off in tests too, so doctrine.debug_data_holder was never
registered. Every test calling countQueries() errored out — all four N+1
regression tests had been dead for as long as they have existed. Turning
profiling on for when@test brings the harness back.

Three of the four passed immediately. The fourth was a real N+1: the
service-coverage endpoint batch-fetched its ServiceItem entities to avoid one
find() per row, but ServiceItem maps staffMembers as fetch: EAGER, so hydrating
N items fired N extra collection loads and the batch bought nothing. Six
coverage rows cost 11 queries where one row cost 6.

ServiceItemRepository::findUuidsByIds() returns the id => uuid map as a scalar
query, so no entity is hydrated and no eager collection is touched.

Also adds the query-count assertion for next_available_at that could not be
written while the harness was broken. Confirmed it fails against the previous
per-day implementation (40 queries for 2 locations, 113 for 6) and passes now.

Suite: 411 tests, 2 failures — both pre-existing and unrelated
(LowTierFixesTest, PatientWalletSessionSettleTest).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 14:06:38 +03:30

130 lines
4.6 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 must not grow
* with the number of days walked or slots inspected — only with the number of
* locations, by a small constant.
*/
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 testQueryCountGrowsOnlyPerLocation(): void
{
// Keep one kernel so the shared query logger stays consistent.
$this->client->disableReboot();
$few = $this->makeDoctorWithSchedules(1);
$many = $this->makeDoctorWithSchedules(5);
$qFew = $this->countQueries(fn () => $this->client->request(
'GET', '/api/v1/appointment-booking-locations/' . $few->getUuid()
));
self::assertSame(200, $this->responseCode());
$qMany = $this->countQueries(fn () => $this->client->request(
'GET', '/api/v1/appointment-booking-locations/' . $many->getUuid()
));
self::assertSame(200, $this->responseCode());
// 2 locations -> 6 locations. Each extra one costs a fixed handful of
// queries (its schedule, holidays, overrides, blocking intervals,
// address). The regression this guards against is a per-day or per-slot
// query, which on a schedule active every day would add hundreds.
$extraLocations = 4;
$budgetEach = 8;
self::assertLessThanOrEqual(
$qFew + $extraLocations * $budgetEach,
$qMany,
"next_available_at scales badly: $qFew queries for 2 locations, $qMany for 6"
);
}
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);
}
}