Files
clinicpro/tests/Doctor/DoctorSpecialtySearchTest.php
T
hamedandClaude Opus 5 2da5b5188c feat(doctors): search every specialty a doctor has, and expose the tree
`GET /api/v1/doctors` could not answer either question the public search box
asks. Typing a specialty name returned nothing, because `name` only matched
`d.name`. And `specialty_id` matched one id exactly, so a parent group only
found doctors who happened to carry the parent — which they usually do, but
only as a side effect of `expandWithAncestors` running on save. A doctor
imported through any other path has no denormalised parent, and a search
guarantee resting on a save-time side effect is not a guarantee.

`expandWithDescendants` mirrors the existing ancestor walk over the same cached
parentMap, so no extra query. It deliberately keeps unknown ids instead of
dropping them like its mirror does: the result feeds an `IN (...)`, and an empty
array turns the filter into a no-op that returns every doctor — an unknown id
must mean "nothing", never "everything".

Both specialty filters use their own EXISTS alias rather than the shared `s`
join. Two conditions on one alias force a single join row to satisfy both, so a
doctor filtered by specialty A while searching the name of specialty B was
silently dropped. Verified by reverting to the shared alias and watching
testFilterOnOneSpecialtyWhileSearchingTheNameOfAnother fail.

toListArray now carries specialties[].parent_id so a client can tell the main
specialty from a sub-specialty instead of printing all of them. It is a string,
matching toDetailArray and the sibling `id` key — one concept should not have
two types across two endpoints. Reading the id off the parent proxy costs no
query; measured 6→11 queries with four more doctors both with and without the
field. That growth is a pre-existing N+1 (findWithFilters does not fetch-join
specialties, unlike findByClinic) and is left untouched here.

Also drops the phantom `search` parameter from the OpenAPI annotation — it was
advertised but never read, so a client sending it got an unfiltered list — and
documents the six live parameters that were missing.

Note for deploy: DoctorRepository gained a constructor argument, so a stale
container fails with ArgumentCountError until cache:clear runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 16:44:32 +03:30

251 lines
11 KiB
PHP

<?php
namespace App\Tests\Doctor;
use App\Doctor\Entity\Doctor;
use App\Specialty\Entity\Specialty;
use App\Tests\ApiTestCase;
/**
* GET /api/v1/doctors — جستجو روی همهٔ تخصص‌های پزشک.
*
* پزشکان تست عمداً **مستقیم** ساخته می‌شوند، نه از راه اندپوینت: مسیر اندپوینت
* expandWithAncestors را صدا می‌زند و والد را خودکار روی پزشک می‌نشاند، و آن‌وقت
* تستِ «گسترش به فرزندان» چیزی را نمی‌سنجد که ادعا می‌کند.
*/
class DoctorSpecialtySearchTest extends ApiTestCase
{
/** @return array{root: Specialty, childA: Specialty, childB: Specialty} */
private function newTree(): array
{
$suffix = uniqid();
$root = new Specialty('ریشهٔ ' . $suffix, 'sroot-' . $suffix);
$this->em->persist($root);
$this->em->flush();
$childA = new Specialty('زیرشاخهٔ الف ' . $suffix, 'schild-a-' . $suffix, $root);
$childB = new Specialty('زیرشاخهٔ ب ' . $suffix, 'schild-b-' . $suffix, $root);
$this->em->persist($childA);
$this->em->persist($childB);
$this->em->flush();
return ['root' => $root, 'childA' => $childA, 'childB' => $childB];
}
/** پزشکِ فعال و قابل‌نمایش در لیست عمومی، با دقیقاً همین تخصص‌ها. */
private function newDoctor(string $name, array $specialties): Doctor
{
$doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), $name);
$doctor->setActiveDoctorAppointment(true);
foreach ($specialties as $s) {
$doctor->getSpecialties()->add($s);
}
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
/** @return list<string> uuidهای پاسخ لیست */
private function uuidsOf(array $body): array
{
return array_map(static fn(array $d) => $d['uuid'], $body['data']);
}
private function get(string $query): array
{
$this->client->request('GET', '/api/v1/doctors?limit=50&' . $query);
return json_decode($this->client->getResponse()->getContent(), true) ?? [];
}
// ── گسترش به نوادگان ──────────────────────────────────────────────────────
public function testParentIdFindsADoctorTaggedOnlyWithAChild(): void
{
$t = $this->newTree();
// هیچ والدی روی پزشک ثبت نشده — همان حالتی که import دسته‌ای می‌سازد.
$doctor = $this->newDoctor('دکتر زیرشاخه ' . uniqid(), [$t['childA']]);
$body = $this->get('specialty_id=' . $t['root']->getId());
self::assertSame(200, $this->client->getResponse()->getStatusCode());
self::assertContains($doctor->getUuid(), $this->uuidsOf($body));
}
public function testParentIdAlsoFindsADoctorTaggedWithTheParentItself(): void
{
$t = $this->newTree();
$doctor = $this->newDoctor('دکتر ریشه ' . uniqid(), [$t['root']]);
$body = $this->get('specialty_id=' . $t['root']->getId());
self::assertContains($doctor->getUuid(), $this->uuidsOf($body));
}
public function testLeafIdDoesNotLeakSiblingDoctors(): void
{
$t = $this->newTree();
$a = $this->newDoctor('دکتر الف ' . uniqid(), [$t['childA']]);
$b = $this->newDoctor('دکتر ب ' . uniqid(), [$t['childB']]);
$uuids = $this->uuidsOf($this->get('specialty_id=' . $t['childA']->getId()));
self::assertContains($a->getUuid(), $uuids);
self::assertNotContains($b->getUuid(), $uuids);
}
public function testLeafIdDoesNotLeakTheParentsOwnDoctors(): void
{
$t = $this->newTree();
$onRoot = $this->newDoctor('دکتر فقط ریشه ' . uniqid(), [$t['root']]);
$uuids = $this->uuidsOf($this->get('specialty_id=' . $t['childA']->getId()));
self::assertNotContains($onRoot->getUuid(), $uuids);
}
// ── جستجوی متنی ───────────────────────────────────────────────────────────
public function testNameMatchesASpecialtyName(): void
{
$t = $this->newTree();
$doctor = $this->newDoctor('دکتر بی‌نام‌ونشان ' . uniqid(), [$t['childA']]);
$uuids = $this->uuidsOf($this->get('name=' . urlencode($t['childA']->getName())));
self::assertContains($doctor->getUuid(), $uuids);
}
public function testNameStillMatchesTheDoctorName(): void
{
$t = $this->newTree();
$marker = 'یکتانام' . uniqid();
$doctor = $this->newDoctor($marker, [$t['childA']]);
$uuids = $this->uuidsOf($this->get('name=' . urlencode($marker)));
self::assertContains($doctor->getUuid(), $uuids);
}
public function testSpecialtyFilterAndNameSearchCombineWithoutNarrowingEachOther(): void
{
// حالتِ مرزیِ اصلی: پزشک با تخصص A فیلتر را پاس می‌کند و با نامِ خودش
// جستجو را. اگر هر دو شرط روی یک alias بنشینند، یک ردیفِ join باید هر دو را
// با هم ارضا کند و پزشک بی‌صدا حذف می‌شود.
$t = $this->newTree();
$marker = 'ترکیبی' . uniqid();
$doctor = $this->newDoctor($marker, [$t['childA'], $t['childB']]);
$uuids = $this->uuidsOf(
$this->get('specialty_id=' . $t['root']->getId() . '&name=' . urlencode($marker))
);
self::assertContains($doctor->getUuid(), $uuids);
}
public function testFilterOnOneSpecialtyWhileSearchingTheNameOfAnother(): void
{
// تمایزدهنده‌ترین حالت: نام پزشک به عبارت نمی‌خورد، فیلتر روی تخصص الف است و
// عبارت جستجو نامِ تخصص ب. با alias مشترک، یک ردیفِ join باید همزمان
// `id = childA` و `name LIKE childB` باشد — ناممکن، و پزشک بی‌صدا حذف می‌شود.
$t = $this->newTree();
$doctor = $this->newDoctor('دکتر بدون واژهٔ مشترک ' . uniqid(), [$t['childA'], $t['childB']]);
$uuids = $this->uuidsOf(
$this->get('specialty_id=' . $t['childA']->getId() . '&name=' . urlencode($t['childB']->getName()))
);
self::assertContains($doctor->getUuid(), $uuids);
}
public function testNameThatMatchesNothingReturnsAnEmptyList(): void
{
$body = $this->get('name=' . urlencode('عبارتیکهوجودنداردناباور' . uniqid()));
self::assertSame(200, $this->client->getResponse()->getStatusCode());
self::assertSame([], $body['data']);
self::assertSame(0, $body['meta']['totalRecords']);
}
// ── مرزی ──────────────────────────────────────────────────────────────────
public function testUnknownSpecialtyIdReturnsAnEmptyListNotEveryDoctor(): void
{
// اگر گسترش، شناسهٔ ناشناس را حذف کند خروجی `IN ()` می‌شود و فیلتر خنثی.
$this->newDoctor('دکتر بی‌ربط ' . uniqid(), []);
$body = $this->get('specialty_id=999999999');
self::assertSame(200, $this->client->getResponse()->getStatusCode());
self::assertSame([], $body['data']);
self::assertSame(0, $body['meta']['totalRecords']);
}
// ── parent_id در پاسخ ─────────────────────────────────────────────────────
public function testEverySpecialtyInTheResponseCarriesItsParentId(): void
{
$t = $this->newTree();
$this->newDoctor('دکتر شجره ' . uniqid(), [$t['root'], $t['childA']]);
$body = $this->get('specialty_id=' . $t['root']->getId());
$row = $body['data'][0];
$byId = [];
foreach ($row['specialties'] as $s) {
self::assertArrayHasKey('parent_id', $s, 'هر تخصص پاسخ باید parent_id داشته باشد');
$byId[(int) $s['id']] = $s['parent_id'];
}
self::assertNull($byId[$t['root']->getId()], 'ریشه parent_id ندارد');
// رشته، هم‌شکل با toDetailArray و با کلید id در همین آرایه.
self::assertSame((string) $t['root']->getId(), $byId[$t['childA']->getId()]);
}
public function testParentIdAddsNoQueryPerSpecialty(): void
{
// این لیست یک N+1 **از قبل موجود** دارد: findWithFilters مجموعهٔ تخصص‌ها را
// fetch-join نمی‌کند، پس هر پزشک یک کوئری برای بارگذاری collection می‌خورد.
// اندازه‌گیری شد: با ۴ پزشک بیشتر، ۶ → ۱۱ کوئری — چه با parent_id چه بدون آن.
//
// آنچه اینجا تثبیت می‌شود این است که parent_id چیزی به آن اضافه **نمی‌کند**:
// خواندن شناسه از proxy والد کوئری نمی‌زند، چون شناسه از قبل معلوم است.
// اگر می‌زد، رشد به‌ازای هر پزشک به تعداد تخصص‌هایش (اینجا ۳ برابر) می‌شد.
//
// بدون disableReboot، kernel در هر درخواست ری‌بوت می‌شود و شمارندهٔ مشترک
// صفر برمی‌گردد — همان الگوی ClinicDoctorListNPlusOneTest.
$this->client->disableReboot();
$t = $this->newTree();
for ($i = 0; $i < 6; $i++) {
$this->newDoctor('دکتر شمارش ' . uniqid(), [$t['root'], $t['childA'], $t['childB']]);
}
$qSmall = $this->countQueries(fn() => $this->get('specialty_id=' . $t['root']->getId() . '&limit=2'));
$qLarge = $this->countQueries(fn() => $this->get('specialty_id=' . $t['root']->getId() . '&limit=6'));
$extraDoctors = 4;
self::assertLessThanOrEqual(
$qSmall + $extraDoctors + 1,
$qLarge,
sprintf(
'رشد کوئری از حدِ «یکی به‌ازای هر پزشک» گذشت (%d → %d) — یعنی parent_id هم کوئری می‌زند',
$qSmall,
$qLarge,
),
);
}
public function testTotalRecordsDoesNotDoubleCountAMultiSpecialtyDoctor(): void
{
$t = $this->newTree();
$doctor = $this->newDoctor('دکتر چندتخصصی ' . uniqid(), [$t['root'], $t['childA'], $t['childB']]);
$body = $this->get('specialty_id=' . $t['root']->getId());
self::assertSame(1, $body['meta']['totalRecords'], 'join چندبه‌چند نباید پزشک را چند بار بشمارد');
self::assertSame([$doctor->getUuid()], $this->uuidsOf($body));
}
}