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>
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Specialty;
|
||||
|
||||
use App\Specialty\Entity\Specialty;
|
||||
use App\Specialty\Repository\SpecialtyRepository;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* SpecialtyRepository::expandWithDescendants — قرینهٔ expandWithAncestors.
|
||||
*
|
||||
* تفاوت عمدی با قرینهاش: شناسهٔ ناشناس حذف نمیشود، چون خروجی مستقیم در یک
|
||||
* `IN (...)` مینشیند و آرایهٔ خالی فیلتر را خنثی میکند.
|
||||
*/
|
||||
class SpecialtyDescendantsTest extends ApiTestCase
|
||||
{
|
||||
private function repo(): SpecialtyRepository
|
||||
{
|
||||
return $this->em->getRepository(Specialty::class);
|
||||
}
|
||||
|
||||
/** @return array{0: Specialty, 1: Specialty, 2: Specialty} ریشه و دو فرزندش */
|
||||
private function newTree(): array
|
||||
{
|
||||
$suffix = uniqid();
|
||||
$root = new Specialty('ریشهٔ ' . $suffix, 'root-' . $suffix);
|
||||
$this->em->persist($root);
|
||||
$this->em->flush();
|
||||
|
||||
$childA = new Specialty('فرزند الف ' . $suffix, 'child-a-' . $suffix, $root);
|
||||
$childB = new Specialty('فرزند ب ' . $suffix, 'child-b-' . $suffix, $root);
|
||||
$this->em->persist($childA);
|
||||
$this->em->persist($childB);
|
||||
$this->em->flush();
|
||||
|
||||
return [$root, $childA, $childB];
|
||||
}
|
||||
|
||||
public function testRootExpandsToItselfAndEveryChild(): void
|
||||
{
|
||||
[$root, $childA, $childB] = $this->newTree();
|
||||
|
||||
$out = $this->repo()->expandWithDescendants([$root->getId()]);
|
||||
|
||||
self::assertContains($root->getId(), $out);
|
||||
self::assertContains($childA->getId(), $out);
|
||||
self::assertContains($childB->getId(), $out);
|
||||
}
|
||||
|
||||
public function testLeafExpandsToItselfOnly(): void
|
||||
{
|
||||
[$root, $childA, $childB] = $this->newTree();
|
||||
|
||||
$out = $this->repo()->expandWithDescendants([$childA->getId()]);
|
||||
|
||||
self::assertSame([$childA->getId()], $out);
|
||||
self::assertNotContains($root->getId(), $out);
|
||||
self::assertNotContains($childB->getId(), $out);
|
||||
}
|
||||
|
||||
public function testUnknownIdIsKeptSoTheFilterMatchesNothing(): void
|
||||
{
|
||||
// حذفش میکرد، خروجی خالی میشد و `IN ()` فیلتر را خنثی میکرد.
|
||||
self::assertSame([999_999_999], $this->repo()->expandWithDescendants([999_999_999]));
|
||||
}
|
||||
|
||||
public function testEmptyInputGivesEmptyOutput(): void
|
||||
{
|
||||
self::assertSame([], $this->repo()->expandWithDescendants([]));
|
||||
}
|
||||
|
||||
public function testDuplicateAndStringIdsAreNormalised(): void
|
||||
{
|
||||
[$root] = $this->newTree();
|
||||
$id = $root->getId();
|
||||
|
||||
$out = $this->repo()->expandWithDescendants([$id, (string) $id, $id]);
|
||||
|
||||
self::assertSame(count($out), count(array_unique($out)));
|
||||
self::assertContains($id, $out);
|
||||
}
|
||||
|
||||
public function testOutputIsSortedAscending(): void
|
||||
{
|
||||
[$root] = $this->newTree();
|
||||
|
||||
$out = $this->repo()->expandWithDescendants([$root->getId()]);
|
||||
$sorted = $out;
|
||||
sort($sorted);
|
||||
|
||||
self::assertSame($sorted, $out);
|
||||
}
|
||||
|
||||
public function testAncestorExpansionIsUnaffected(): void
|
||||
{
|
||||
// دو تابع دو جهتاند؛ این تست تثبیت میکند که قرینهٔ قدیمی دستنخورده مانده.
|
||||
[$root, $childA] = $this->newTree();
|
||||
|
||||
$out = $this->repo()->expandWithAncestors([$childA->getId()]);
|
||||
|
||||
self::assertContains($childA->getId(), $out);
|
||||
self::assertContains($root->getId(), $out);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user