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