Files
clinicpro/tests/Doctor/DoctorListLocationTest.php
T
hamedandClaude Opus 4.8 3363dfbf22 feat(doctor): expose city/state in public doctor list
The public doctor list had no location field, so multi-domain consumers
could not tell which city domain owns a doctor. nobat724_front's sitemap
worked around this by fetching the list once per city (35 sweeps) and
subtracting, costing ~13s to build the root sitemap.

Location is resolved in bulk by DoctorRepository::findLocationsByDoctors
using the same rule the city_id/state_id filter applies: the doctor's own
address first, falling back to the address of a clinic they belong to.
Without the clinic fallback a doctor could match city_id=X yet report no
city, which would break the sitemap's per-domain partitioning.

city/state are arrays with at most one entry, matching the shape already
used by the doctor detail response and the clinic list. A doctor with no
address reports [] rather than null. Multi-location doctors get a single
primary city, mirroring the canonical rule on the public site.

Also surface the applied page size as meta.limit. Repositories silently
clamp limit to 50, which previously made clients believe pagination had
ended early — this is what truncated the sitemap to 50 doctors.

The clinic doctor-list endpoint gets the same location data so both
endpoints agree.

Location resolution costs at most 2 queries regardless of page size,
asserted directly against the repository rather than through the
endpoint, since the endpoint carries a pre-existing specialties N+1 in
findWithFilters that is unrelated to this change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 08:08:48 +03:30

191 lines
7.3 KiB
PHP

<?php
namespace App\Tests\Doctor;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Doctor\Repository\DoctorRepository;
use App\Location\Entity\City;
use App\Location\Entity\Province;
use App\Tests\ApiTestCase;
/**
* The public doctor list must expose each doctor's city/state so multi-domain
* consumers (nobat724_front sitemap) can tell which city domain owns a doctor.
*
* Location resolution mirrors the city_id/state_id filter in
* DoctorRepository::findWithFilters — own address first, clinic address as
* fallback — otherwise a doctor could match the filter but report no city.
*/
class DoctorListLocationTest extends ApiTestCase
{
private function makeCity(string $cityName, string $provinceName): City
{
$province = new Province($provinceName);
$this->em->persist($province);
$city = new City($cityName, $province);
$this->em->persist($city);
return $city;
}
private function makeDoctor(string $name): Doctor
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), $name);
$this->em->persist($doctor);
return $doctor;
}
private function giveOwnAddress(Doctor $doctor, City $city): void
{
$address = DoctorAddress::forDoctor($doctor);
$address->setCity($city)->setProvince($city->getProvince());
$this->em->persist($address);
}
/** @return array<int, array> map of doctor name => list payload */
private function fetchListByName(string $name): array
{
$this->client->request('GET', '/api/v1/doctors?limit=50&name=' . urlencode($name));
$this->assertSame(200, $this->responseCode());
$payload = json_decode($this->client->getResponse()->getContent(), true);
$byName = [];
foreach ($payload['data'] as $row) {
$byName[$row['name']] = $row;
}
return $byName;
}
public function testDoctorWithOwnAddressReportsItsCity(): void
{
$city = $this->makeCity('یاسوج', 'کهگیلویه و بویراحمد');
$marker = 'loc-own-' . bin2hex(random_bytes(4));
$doctor = $this->makeDoctor($marker);
$this->giveOwnAddress($doctor, $city);
$this->em->flush();
$row = $this->fetchListByName($marker)[$marker] ?? null;
$this->assertNotNull($row, 'doctor missing from list');
$this->assertCount(1, $row['city']);
$this->assertSame('یاسوج', $row['city'][0]['name']);
$this->assertSame((string) $city->getId(), $row['city'][0]['id']);
$this->assertSame((string) $city->getProvince()->getId(), $row['city'][0]['parent']);
$this->assertCount(1, $row['state']);
$this->assertSame('کهگیلویه و بویراحمد', $row['state'][0]['name']);
}
public function testDoctorWithoutOwnAddressFallsBackToClinicCity(): void
{
$city = $this->makeCity('تبریز', 'آذربایجان شرقی');
$marker = 'loc-clinic-' . bin2hex(random_bytes(4));
$doctor = $this->makeDoctor($marker);
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$this->em->persist($clinic);
$clinic->getDoctors()->add($doctor);
$this->em->flush();
// آدرس کلینیک ردیفی از DoctorAddress با doctor NULL و clinicId پرشده است.
$clinicAddress = DoctorAddress::forClinic($clinic->getId());
$clinicAddress->setCity($city)->setProvince($city->getProvince());
$this->em->persist($clinicAddress);
$this->em->flush();
$row = $this->fetchListByName($marker)[$marker] ?? null;
$this->assertNotNull($row, 'doctor missing from list');
$this->assertCount(1, $row['city'], 'clinic-located doctor must still report a city');
$this->assertSame('تبریز', $row['city'][0]['name']);
}
public function testDoctorWithNoLocationReportsEmptyArrays(): void
{
$marker = 'loc-none-' . bin2hex(random_bytes(4));
$this->makeDoctor($marker);
$this->em->flush();
$row = $this->fetchListByName($marker)[$marker] ?? null;
$this->assertNotNull($row, 'doctor missing from list');
$this->assertSame([], $row['city']);
$this->assertSame([], $row['state']);
}
public function testReportedCityMatchesCityIdFilter(): void
{
$city = $this->makeCity('یزد', 'یزد');
$marker = 'loc-filter-' . bin2hex(random_bytes(4));
$doctor = $this->makeDoctor($marker);
$this->giveOwnAddress($doctor, $city);
$this->em->flush();
$this->client->request('GET', '/api/v1/doctors?limit=50&city_id=' . $city->getId());
$this->assertSame(200, $this->responseCode());
$payload = json_decode($this->client->getResponse()->getContent(), true);
$this->assertNotEmpty($payload['data']);
foreach ($payload['data'] as $row) {
$this->assertNotEmpty(
$row['city'],
"doctor {$row['name']} matched city_id filter but reports no city"
);
$this->assertSame((string) $city->getId(), $row['city'][0]['id']);
}
}
/**
* سهمِ خودِ حل‌مکان اندازه‌گیری می‌شود، نه کل اندپوینت: پاسخ لیست lazy-loadهای
* قدیمی (specialties) هم دارد که با تعداد پزشک رشد می‌کنند و ربطی به این تغییر
* ندارند. پس مستقیم ریپازیتوری تست می‌شود — باید حداکثر ۲ کوئری باشد، ثابت.
*/
public function testLocationResolutionCostIsConstant(): void
{
$city = $this->makeCity('اصفهان', 'اصفهان');
$repo = static::getContainer()->get(DoctorRepository::class);
$makeDoctors = function (int $count) use ($city): array {
$doctors = [];
for ($i = 0; $i < $count; $i++) {
$doctor = $this->makeDoctor('loc-cost-' . bin2hex(random_bytes(4)));
$this->giveOwnAddress($doctor, $city);
$doctors[] = $doctor;
}
$this->em->flush();
return $doctors;
};
$few = $makeDoctors(2);
$many = $makeDoctors(12);
$qFew = $this->countQueries(fn () => $repo->findLocationsByDoctors($few));
$qMany = $this->countQueries(fn () => $repo->findLocationsByDoctors($many));
$this->assertSame($qFew, $qMany, "location resolution scales with doctor count: $qFew -> $qMany");
$this->assertLessThanOrEqual(2, $qMany, 'location resolution must cost at most 2 queries');
// و همچنان درست کار کند
$resolved = $repo->findLocationsByDoctors($many);
$this->assertCount(12, $resolved);
foreach ($many as $doctor) {
$this->assertSame('اصفهان', $resolved[$doctor->getId()]['city']['name']);
}
}
public function testPaginationMetaExposesAppliedLimit(): void
{
$this->client->request('GET', '/api/v1/doctors?limit=500');
$this->assertSame(200, $this->responseCode());
$payload = json_decode($this->client->getResponse()->getContent(), true);
// سقف ریپازیتوری ۵۰ است؛ meta باید مقدار واقعاً اعمال‌شده را بگوید نه ۵۰۰.
$this->assertSame(50, $payload['meta']['limit']);
}
}