get(EntityManagerInterface::class); } private function makeDoctor(?User $user = null): Doctor { $doctor = new Doctor($user ?? $this->createUser(['ROLE_DOCTOR']), 'دکتر نشتی'); $this->em->persist($doctor); $this->em->flush(); return $doctor; } private function makeClinic(?User $owner = null): Clinic { $clinic = new Clinic($owner ?? $this->createUser(['ROLE_CLINIC'])); $clinic->setName('کلینیک نشتی'); $this->em->persist($clinic); $this->em->flush(); return $clinic; } private function rule(string $type, int $id, string $name): DiscountRule { $rule = new DiscountRule($type, $id, $name, DiscountRule::TYPE_INVOICE_AMOUNT); $this->em->persist($rule); $this->em->flush(); return $rule; } private function enableFilterFor(string $type, int $id): void { $this->em()->getFilters() ->enable(TenantFilter::NAME) ->setParameter(TenantFilter::PARAM_TYPE, $type, 'string') ->setParameter(TenantFilter::PARAM_ID, $id, 'integer'); } protected function tearDown(): void { $filters = $this->em()->getFilters(); if ($filters->isEnabled(TenantFilter::NAME)) { $filters->disable(TenantFilter::NAME); } parent::tearDown(); } /** ✅ کوئری بدون هیچ WHERE دستی، فقط ردیف‌های محیط جاری را می‌دهد. */ public function testFilterAloneBlocksCrossTenantRowsWithoutAnyManualCondition(): void { $mine = $this->makeDoctor(); $other = $this->makeDoctor(); $this->rule('doctor', $mine->getId(), 'قانون من'); $this->rule('doctor', $other->getId(), 'قانون دیگری'); $this->em->clear(); $this->enableFilterFor('doctor', $mine->getId()); $names = array_map( static fn(DiscountRule $r) => $r->getName(), $this->em()->createQuery('SELECT r FROM ' . DiscountRule::class . ' r')->getResult(), ); self::assertContains('قانون من', $names); self::assertNotContains('قانون دیگری', $names); } /** findOneBy هم فیلتر می‌خورد — رکورد محیط دیگر «وجود ندارد». */ public function testFindOneByCannotReachAnotherTenantsRow(): void { $mine = $this->makeDoctor(); $other = $this->makeDoctor(); $foreign = $this->rule('doctor', $other->getId(), 'قانون بیگانه'); $this->em->clear(); $this->enableFilterFor('doctor', $mine->getId()); self::assertNull( $this->em()->getRepository(DiscountRule::class)->findOneBy(['uuid' => $foreign->getUuid()]), ); } /** * find() با کلید اصلی هم فیلتر می‌خورد — در Doctrine ORM 3 برخلاف نسخه‌های قدیمی * که این مسیر را استثنا می‌کردند. تضمین قوی‌تری است، ولی چون رفتارِ کتابخانه است * نه قرارداد ما، اینجا تثبیتش می‌کنیم: اگر روزی برگردد، این تست خبر می‌دهد و سند * tenancy باید به‌روز شود. */ public function testFindByPrimaryKeyIsAlsoFiltered(): void { $mine = $this->makeDoctor(); $other = $this->makeDoctor(); $foreign = $this->rule('doctor', $other->getId(), 'قانون بیگانه'); $id = $foreign->getId(); $this->em->clear(); $this->enableFilterFor('doctor', $mine->getId()); self::assertNull($this->em()->find(DiscountRule::class, $id)); } /** * ⚠️ محدودیت واقعی: entity که از قبل در identity map است دوباره کوئری نمی‌شود، * پس فیلتر آن را نمی‌بیند. به همین دلیل فیلتر جایگزین authorization نیست. */ public function testAnEntityAlreadyInMemoryIsNotHiddenByTheFilter(): void { $mine = $this->makeDoctor(); $other = $this->makeDoctor(); $foreign = $this->rule('doctor', $other->getId(), 'قانون بیگانه'); $id = $foreign->getId(); $this->enableFilterFor('doctor', $mine->getId()); self::assertNotNull($this->em()->find(DiscountRule::class, $id), 'از identity map برمی‌گردد، نه از دیتابیس'); } /** ⚠️ مسیر عمومی مارکت‌پلیس کاربر پنل ندارد؛ فیلتر نباید سایت را خالی کند. */ public function testPublicDoctorSearchStaysCrossTenant(): void { $this->makeDoctor(); $this->makeDoctor(); $this->client->request('GET', '/api/v1/doctors?limit=5'); self::assertSame(200, $this->client->getResponse()->getStatusCode()); } /** ادمین سراسری است و فیلتر نمی‌خورد. */ public function testAdminIsNotFiltered(): void { $admin = $this->createUser(['ROLE_ADMIN']); $doctor = $this->makeDoctor(); $clinic = $this->makeClinic(); $this->rule('doctor', $doctor->getId(), 'قانون پزشک'); $this->rule('clinic', $clinic->getId(), 'قانون کلینیک'); $this->authJson('GET', '/api/v1/admin/discount-rules', $admin); self::assertNotSame(500, $this->responseCode(), 'ادمین نباید با فیلتر بشکند'); } /** * ⚠️ کاربری که هنوز محیطی انتخاب نکرده فیلتر نمی‌خورد — در هیچ محیطی «نیست» و * دسترسی‌اش را همان checkerهای دامنه تعیین می‌کنند. */ public function testUserWithoutAChosenContextIsNotFiltered(): void { $doctor = $this->makeDoctor(); $clinic = $this->makeClinic(); $clinic->getDoctors()->add($doctor); $this->em->persist(new ClinicDoctorPermission($clinic, $doctor)); $this->em->flush(); $resolver = static::getContainer()->get(\App\Shared\Context\EntityContextResolver::class); self::assertFalse( $resolver->resolve($doctor->getUser())->chosen, 'محیطِ برآمده از نقش، انتخاب کاربر نیست', ); } /** محیطی که کاربر صریحاً انتخاب کرده، «انتخاب‌شده» علامت می‌خورد. */ public function testStoredActiveContextCountsAsChosen(): void { $doctor = $this->makeDoctor(); $clinic = $this->makeClinic(); $clinic->getDoctors()->add($doctor); $this->em->persist(new ClinicDoctorPermission($clinic, $doctor)); $this->em->persist(new UserActiveContext( $doctor->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC, )); $this->em->flush(); $resolver = static::getContainer()->get(\App\Shared\Context\EntityContextResolver::class); $context = $resolver->resolve($doctor->getUser()); self::assertTrue($context->chosen); self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair()); } }