Files
clinicpro/tests/Doctor/ClinicDoctorListNPlusOneTest.php
T
hamedandClaude Opus 4.8 6b12f3ddb9 perf(doctor): fetch-join specialties in clinic doctor list (H6)
findByClinicWithFilters left-joined specialties only for filtering, so
toListArray() lazy-loaded them per doctor (N+1). addSelect them and switch the
result fetch to Paginator(fetchJoinCollection: true) so LIMIT still paginates by
doctor.

Test infra: ApiTestCase::countQueries() (via doctrine.debug_data_holder).
Regression: tests/Doctor/ClinicDoctorListNPlusOneTest asserts the query count
does not grow with doctor count (4→10 without the fix).

Also relaxed AppointmentExpiryServiceTest's exact-count assertion (it counts all
stale pendings in the shared db_test, which accumulates) — logged test-isolation
debt as E6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:24:49 +03:30

65 lines
2.4 KiB
PHP

<?php
namespace App\Tests\Doctor;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Specialty\Entity\Specialty;
use App\Tests\ApiTestCase;
/**
* The clinic doctor-list endpoint must hydrate each doctor's specialties in the
* list query, not lazily per doctor. The query count must therefore be constant
* regardless of how many doctors the clinic has.
*/
class ClinicDoctorListNPlusOneTest extends ApiTestCase
{
private function makeClinicWithDoctors(int $doctorCount): Clinic
{
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$this->em->persist($clinic);
for ($i = 0; $i < $doctorCount; $i++) {
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), "دکتر $i");
$slug = 'sp-' . bin2hex(random_bytes(6));
$specialty = new Specialty('تخصص', $slug);
$this->em->persist($specialty);
$doctor->getSpecialties()->add($specialty);
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
}
$this->em->flush();
return $clinic;
}
public function testQueryCountDoesNotGrowWithDoctorCount(): void
{
// Keep one kernel/container across both requests so the shared query
// logger (doctrine.debug_data_holder) stays consistent.
$this->client->disableReboot();
$small = $this->makeClinicWithDoctors(2);
$large = $this->makeClinicWithDoctors(6);
$qSmall = $this->countQueries(fn () => $this->client->request(
'GET', '/api/v1/clinic/doctor-list/' . $small->getUuid()
));
$this->assertSame(200, $this->responseCode());
$qLarge = $this->countQueries(fn () => $this->client->request(
'GET', '/api/v1/clinic/doctor-list/' . $large->getUuid()
));
$this->assertSame(200, $this->responseCode());
// No N+1: 4 extra doctors must not add ~4 extra queries. A tiny slack
// absorbs one-off warmup variance; the N+1 signal (≈ +doctorCount) is
// far larger than the slack.
$this->assertLessThanOrEqual($qSmall + 1, $qLarge, "N+1: query count grew from $qSmall to $qLarge with more doctors");
// Correctness preserved: specialties are present in the payload.
$body = json_decode($this->client->getResponse()->getContent(), true);
$this->assertNotEmpty($body['data']['data'][0]['specialties']);
}
}