From 131a78343b324e7f55e610f3e3a3c24d8ab40c6c Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 28 Jun 2026 20:01:12 +0330 Subject: [PATCH] test(audit): strengthen H5/H7/H8 into true fail-without-fix regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification pass found three tests only guarded correctness, not the fix's behavior: - H7: add repository white-box test asserting likes/replies come back as initialised PersistentCollections (lazy without the fetch-join). - H8: add a query-count test (constant vs coverage-row count) — without the batch fetch the count grows ~1 per row. - H5: add an end-to-end test hitting DELETE /api/v1/doctor and asserting the insurance config is purged (the service unit test didn't cover the wiring). All three now fail when their fix is reverted. Suite: 39 tests / 92 assertions. Co-Authored-By: Claude Opus 4.8 --- purgeForEntity | 2 + .../Insurance/ServiceCoverageNPlusOneTest.php | 42 +++++++++++++++++++ .../Insurance/TenantInsuranceCleanupTest.php | 28 +++++++++++++ tests/Rating/CommentListNPlusOneTest.php | 33 +++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 purgeForEntity diff --git a/purgeForEntity b/purgeForEntity new file mode 100644 index 00000000..5307fe73 --- /dev/null +++ b/purgeForEntity @@ -0,0 +1,2 @@ +src/Doctor/Controller/DoctorController.php:1 +src/Admin/Controller/AdminApiController.php:1 diff --git a/tests/Insurance/ServiceCoverageNPlusOneTest.php b/tests/Insurance/ServiceCoverageNPlusOneTest.php index 58a97d85..fcb1a38e 100644 --- a/tests/Insurance/ServiceCoverageNPlusOneTest.php +++ b/tests/Insurance/ServiceCoverageNPlusOneTest.php @@ -54,4 +54,46 @@ class ServiceCoverageNPlusOneTest extends ApiTestCase sort($returnedUuids); $this->assertSame($expectedUuids, $returnedUuids); } + + /** @return array{0: \App\Auth\Entity\User, 1: TenantInsurance} */ + private function makeContract(int $coverageRows): array + { + $owner = $this->createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر تست'); + $this->em->persist($doctor); + $this->em->flush(); + + $tenant = new TenantInsurance(TenantInsurance::TYPE_DOCTOR, $doctor->getId(), 1); + $section = new ServiceSection(TenantInsurance::TYPE_DOCTOR, $doctor->getId(), 'بخش'); + $this->em->persist($tenant); + $this->em->persist($section); + $this->em->flush(); + + for ($i = 0; $i < $coverageRows; $i++) { + $item = new ServiceItem($section, "خدمت $i"); + $this->em->persist($item); + $this->em->flush(); + $this->em->persist(new TenantServiceCoverage($tenant->getId(), $item->getId())); + } + $this->em->flush(); + + return [$owner, $tenant]; + } + + public function testQueryCountDoesNotGrowWithCoverageRows(): void + { + $this->client->disableReboot(); + + [$ownerS, $small] = $this->makeContract(1); + [$ownerL, $large] = $this->makeContract(6); + $this->em->clear(); + + $url = fn (TenantInsurance $t) => '/api/v1/billing/tenant-insurances/' . $t->getUuid() . '/service-coverage'; + + $qSmall = $this->countQueries(fn () => $this->authJson('GET', $url($small), $ownerS)); + $qLarge = $this->countQueries(fn () => $this->authJson('GET', $url($large), $ownerL)); + + // batch fetch → constant query count; per-row find() would add ~1/row. + $this->assertLessThanOrEqual($qSmall + 1, $qLarge, "N+1: query count grew from $qSmall to $qLarge"); + } } diff --git a/tests/Insurance/TenantInsuranceCleanupTest.php b/tests/Insurance/TenantInsuranceCleanupTest.php index e6b958c6..f2101be8 100644 --- a/tests/Insurance/TenantInsuranceCleanupTest.php +++ b/tests/Insurance/TenantInsuranceCleanupTest.php @@ -2,6 +2,7 @@ namespace App\Tests\Insurance; +use App\Doctor\Entity\Doctor; use App\Insurance\Entity\EntityInsurancePricing; use App\Insurance\Entity\TenantInsurance; use App\Insurance\Entity\TenantServiceCoverage; @@ -40,4 +41,31 @@ class TenantInsuranceCleanupTest extends ApiTestCase $this->assertNull($this->em->getRepository(TenantServiceCoverage::class) ->findOneBy(['tenantInsuranceId' => $tenant->getId()])); } + + /** + * End-to-end: the DELETE doctor endpoint must trigger the purge. Without the + * wiring the doctor is removed but its insurance rows are left orphaned. + */ + public function testDeleteDoctorEndpointPurgesInsuranceConfig(): void + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر حذف'); + $this->em->persist($doctor); + $this->em->flush(); + $doctorId = $doctor->getId(); + + $tenant = new TenantInsurance(TenantInsurance::TYPE_DOCTOR, $doctorId, 1); + $this->em->persist($tenant); + $this->em->persist(new EntityInsurancePricing(TenantInsurance::TYPE_DOCTOR, $doctorId, 1)); + $this->em->flush(); + + $admin = $this->createUser(['ROLE_ADMIN']); + $this->authJson('DELETE', '/api/v1/doctor/' . $doctor->getUuid(), $admin); + $this->assertSame(200, $this->responseCode()); + + $this->em->clear(); + $this->assertCount(0, $this->em->getRepository(TenantInsurance::class) + ->findBy(['entityType' => TenantInsurance::TYPE_DOCTOR, 'entityId' => $doctorId])); + $this->assertCount(0, $this->em->getRepository(EntityInsurancePricing::class) + ->findBy(['entityType' => TenantInsurance::TYPE_DOCTOR, 'entityId' => $doctorId])); + } } diff --git a/tests/Rating/CommentListNPlusOneTest.php b/tests/Rating/CommentListNPlusOneTest.php index c80aec34..7a4c0151 100644 --- a/tests/Rating/CommentListNPlusOneTest.php +++ b/tests/Rating/CommentListNPlusOneTest.php @@ -6,6 +6,7 @@ use App\Doctor\Entity\Doctor; use App\Rating\Entity\Comment; use App\Rating\Entity\Like; use App\Tests\ApiTestCase; +use Doctrine\ORM\PersistentCollection; /** * The public comment list fetch-joins likes/replies/authors (two passes) to @@ -54,4 +55,36 @@ class CommentListNPlusOneTest extends ApiTestCase return json_decode($this->client->getResponse()->getContent(), true) ?? []; } + + /** + * True N+1 regression: after the repository loads the roots, their likes and + * replies collections must already be initialised (fetch-joined). Without the + * fetch-join they are lazy and would each trigger a query during serialization. + */ + public function testRepositoryFetchJoinsCollections(): void + { + $doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست'); + $this->em->persist($doctor); + $root = (new Comment($this->createUser(), $doctor, 'نظر'))->approve(); + $this->em->persist($root); + $this->em->persist(new Like($this->createUser(), $root, 1)); + $this->em->persist((new Comment($this->createUser(), $doctor, 'پاسخ', $root))->approve()); + $this->em->flush(); + $doctorId = $doctor->getId(); + + // fresh load — collections must come back initialised, not lazy proxies + $this->em->clear(); + $doctor = $this->em->getRepository(Doctor::class)->find($doctorId); + $roots = $this->em->getRepository(Comment::class)->findApprovedRootsByDoctor($doctor); + + $this->assertNotEmpty($roots); + foreach ($roots as $r) { + $likes = $r->getLikes(); + $replies = $r->getReplies(); + $this->assertInstanceOf(PersistentCollection::class, $likes); + $this->assertInstanceOf(PersistentCollection::class, $replies); + $this->assertTrue($likes->isInitialized(), 'likes collection was not fetch-joined'); + $this->assertTrue($replies->isInitialized(), 'replies collection was not fetch-joined'); + } + } }