`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>
105 lines
3.4 KiB
PHP
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);
|
|
}
|
|
}
|