createUser() drew a random mobile and recovered from a collision by catching the unique-constraint violation and calling resetManager(). That hands back a brand-new EntityManager, which detaches every entity the running test had built so far; its next flush died with "Multiple non-persisted new entities were found", always in a different test and never reproducible in isolation. The number is now checked before the insert, so the collision never reaches the database and the manager stays open. testParentIdAddsNoQueryPerSpecialty counted queries on the first request of each size, so one-shot per-process caches — site config, subscription plan, Doctrine metadata — landed inside the count or not depending on which tests had run before it. Both requests are now warmed first; the assertion measures steady-state growth, which is what it was always about. The 23 PHPUnit notices were all one complaint: doubles created with createMock() that never had an expectation. The ones that only stub return values became createStub(); in SmsServiceLookupOnlyTest the provider and the bus got the expectations they were missing, since "dispatch does not touch the provider" and "sendNow does not enqueue" are exactly what that suite is there to prove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
258 lines
12 KiB
PHP
258 lines
12 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']]);
|
|
}
|
|
|
|
// اجرای گرمکننده پیش از شمارش: کوئریهای یکبارهٔ هر پردازه — پیکربندی سایت،
|
|
// پلن اشتراک، متادیتای Doctrine — بسته به اینکه کدام تستها زودتر اجرا شدهاند
|
|
// پر یا خالیاند. بدون این گرمکردن، همان کوئریها در اجرای کامل suite داخل
|
|
// شمارش میافتادند و تست بهصورت تصادفی میشکست. اینجا رشدِ حالت پایدار مهم است.
|
|
$this->get('specialty_id=' . $t['root']->getId() . '&limit=2');
|
|
$this->get('specialty_id=' . $t['root']->getId() . '&limit=6');
|
|
|
|
$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));
|
|
}
|
|
}
|