Files
clinicpro/tests/Specialty/SpecialtyDescendantsTest.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

105 lines
3.4 KiB
PHP

<?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);
}
}