A user who is both a doctor and a clinic owner always resolved to the doctor: SubscriptionController had its own role-first resolveEntity, and ownedEntity() returned the doctor whenever one existed. So a subscription granted to that user's clinic was stored correctly but never surfaced — /subscription/my kept reporting the free plan and the panel kept the feature-gated menu items locked. ownedEntity() now disambiguates with the active context when the user owns both environments, and the controller delegates to it instead of re-deriving the pair from roles. Payment already used ownedEntity(), so display, purchase and admin grant now agree on one environment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
311 lines
12 KiB
PHP
311 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Shared;
|
|
|
|
use App\Auth\Entity\User;
|
|
use App\Auth\Entity\UserActiveContext;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\Clinic\Entity\ClinicDoctorPermission;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Secretary\Entity\DoctorSecretary;
|
|
use App\Shared\Context\EntityContext;
|
|
use App\Shared\Context\EntityContextResolver;
|
|
use App\Shared\Exception\AppException;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* ماتریس نقشها: «این کاربر در کدام محیط کار میکند؟»
|
|
*
|
|
* قرارداد اجرایی فازهای بعدیِ نشانهگذاری tenant است — هر assert روی
|
|
* toEntityPair() است، نه روی جزئیات داخلی رزولور.
|
|
*
|
|
* رزولور فقط محیط را تعیین میکند؛ مجوزها (ClinicDoctorPermission /
|
|
* DoctorSecretary.permission) لایهٔ جداگانهاند و اینجا سنجیده نمیشوند.
|
|
*/
|
|
class EntityContextResolverTest extends ApiTestCase
|
|
{
|
|
private function resolver(): EntityContextResolver
|
|
{
|
|
return static::getContainer()->get(EntityContextResolver::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 joinClinic(Clinic $clinic, Doctor $doctor): void
|
|
{
|
|
$clinic->getDoctors()->add($doctor);
|
|
$this->em->persist(new ClinicDoctorPermission($clinic, $doctor));
|
|
$this->em->flush();
|
|
}
|
|
|
|
private function setActiveContext(User $user, string $dbUuid, string $dbType): void
|
|
{
|
|
$this->em->persist(new UserActiveContext($user, $dbUuid, $dbType));
|
|
$this->em->flush();
|
|
}
|
|
|
|
private function setClinicContext(User $user, Clinic $clinic): void
|
|
{
|
|
$this->setActiveContext($user, $clinic->getUuid(), EntityContext::TYPE_CLINIC);
|
|
}
|
|
|
|
private function setDoctorContext(User $user, Doctor $doctor): void
|
|
{
|
|
$this->setActiveContext($user, $doctor->getUuid(), EntityContext::TYPE_DOCTOR);
|
|
}
|
|
|
|
// ── سناریو ۱: پزشک مستقل ────────────────────────────────────────────────
|
|
|
|
public function testIndependentDoctorAlwaysResolvesToOwnPractice(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
|
|
$context = $this->resolver()->resolve($doctor->getUser());
|
|
|
|
self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair());
|
|
}
|
|
|
|
// ── سناریو ۲: پزشکِ مستقلِ عضو کلینیک ───────────────────────────────────
|
|
|
|
public function testClinicMemberDoctorResolvesToClinicWhenActiveContextIsClinic(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$clinic = $this->makeClinic();
|
|
$this->joinClinic($clinic, $doctor);
|
|
$this->setClinicContext($doctor->getUser(), $clinic);
|
|
|
|
$context = $this->resolver()->resolve($doctor->getUser());
|
|
|
|
self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair());
|
|
}
|
|
|
|
public function testClinicMemberDoctorFallsBackToOwnPracticeWhenActiveContextIsSelf(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$clinic = $this->makeClinic();
|
|
$this->joinClinic($clinic, $doctor);
|
|
$this->setDoctorContext($doctor->getUser(), $doctor);
|
|
|
|
$context = $this->resolver()->resolve($doctor->getUser());
|
|
|
|
self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair());
|
|
}
|
|
|
|
/**
|
|
* مجوزِ غیرفعال محیط را عوض نمیکند — عضویت در clinic_doctors تعیینکنندهٔ
|
|
* «کجا»ست و ClinicDoctorPermission تعیینکنندهٔ «چه کاری». محدودسازی در
|
|
* PatientRecordScopeResolver اتفاق میافتد، نه اینجا.
|
|
*/
|
|
public function testInactivePermissionStillResolvesToClinicEnvironment(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$clinic = $this->makeClinic();
|
|
$this->joinClinic($clinic, $doctor);
|
|
|
|
$permission = $this->em->getRepository(ClinicDoctorPermission::class)
|
|
->findOneBy(['clinic' => $clinic, 'doctor' => $doctor]);
|
|
$permission->setActive(false);
|
|
$this->em->flush();
|
|
|
|
$this->setClinicContext($doctor->getUser(), $clinic);
|
|
|
|
$context = $this->resolver()->resolve($doctor->getUser());
|
|
|
|
self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair());
|
|
}
|
|
|
|
// ── سناریو ۳: پزشکی که هم عضو و هم مالک کلینیک است ──────────────────────
|
|
|
|
public function testDoctorWhoOwnsClinicResolvesToClinicWithoutMembershipRow(): void
|
|
{
|
|
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
|
|
$doctor = $this->makeDoctor($user);
|
|
$clinic = $this->makeClinic($user);
|
|
$this->setClinicContext($user, $clinic);
|
|
|
|
$context = $this->resolver()->resolve($user);
|
|
|
|
self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair());
|
|
self::assertTrue($this->resolver()->canActInClinic($user, $clinic));
|
|
}
|
|
|
|
public function testDoctorWhoOwnsClinicResolvesToOwnPracticeWhenActiveContextIsSelf(): void
|
|
{
|
|
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
|
|
$doctor = $this->makeDoctor($user);
|
|
$this->makeClinic($user);
|
|
$this->setDoctorContext($user, $doctor);
|
|
|
|
$context = $this->resolver()->resolve($user);
|
|
|
|
self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair());
|
|
}
|
|
|
|
// ── محیطِ صاحبشده (اشتراک) ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* اشتراک روی محیطی مینشیند که کاربر صاحبش است. کاربری که هم پزشک است و هم
|
|
* مالک کلینیک، دو محیط صاحبشده دارد و محیط فعال تعیین میکند کدامیک؛ وگرنه
|
|
* اشتراکِ اعطاشده به کلینیک هرگز در پنل دیده نمیشود.
|
|
*/
|
|
public function testOwnedEntityFollowsActiveContextWhenUserOwnsBoth(): void
|
|
{
|
|
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
|
|
$this->makeDoctor($user);
|
|
$clinic = $this->makeClinic($user);
|
|
$this->setClinicContext($user, $clinic);
|
|
|
|
self::assertSame(['clinic', $clinic->getId()], $this->resolver()->ownedEntity($user)->toEntityPair());
|
|
}
|
|
|
|
public function testOwnedEntityIsOwnPracticeWhenActiveContextIsSelf(): void
|
|
{
|
|
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
|
|
$doctor = $this->makeDoctor($user);
|
|
$this->makeClinic($user);
|
|
$this->setDoctorContext($user, $doctor);
|
|
|
|
self::assertSame(['doctor', $doctor->getId()], $this->resolver()->ownedEntity($user)->toEntityPair());
|
|
}
|
|
|
|
/** بدون محیط فعال، مطب شخصی همان پیشفرض قبلی میماند. */
|
|
public function testOwnedEntityFallsBackToOwnPracticeWithoutActiveContext(): void
|
|
{
|
|
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
|
|
$doctor = $this->makeDoctor($user);
|
|
$this->makeClinic($user);
|
|
|
|
self::assertSame(['doctor', $doctor->getId()], $this->resolver()->ownedEntity($user)->toEntityPair());
|
|
}
|
|
|
|
/** مالک کلینیکِ غیرپزشک تنها یک محیط دارد و محیط فعال چیزی را عوض نمیکند. */
|
|
public function testOwnedEntityIsClinicForNonDoctorOwner(): void
|
|
{
|
|
$clinic = $this->makeClinic();
|
|
|
|
self::assertSame(['clinic', $clinic->getId()], $this->resolver()->ownedEntity($clinic->getUser())->toEntityPair());
|
|
}
|
|
|
|
// ── سناریو ۴: مدیر/مالک کلینیک (غیرپزشک) ────────────────────────────────
|
|
|
|
public function testClinicManagerAlwaysResolvesToClinic(): void
|
|
{
|
|
$clinic = $this->makeClinic();
|
|
|
|
$context = $this->resolver()->resolve($clinic->getUser());
|
|
|
|
self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair());
|
|
}
|
|
|
|
// ── سناریو ۵: منشی ──────────────────────────────────────────────────────
|
|
|
|
public function testSecretaryWithoutActiveContextResolvesToUnknown(): void
|
|
{
|
|
$user = $this->createUser(['ROLE_SECRETARY']);
|
|
|
|
$context = $this->resolver()->resolve($user);
|
|
|
|
self::assertSame(EntityContext::TYPE_UNKNOWN, $context->type);
|
|
self::assertFalse($context->isResolved());
|
|
}
|
|
|
|
public function testSecretaryWithActiveClinicRelationResolvesToThatClinic(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$clinic = $this->makeClinic();
|
|
$this->joinClinic($clinic, $doctor);
|
|
$secretary = $this->createUser(['ROLE_SECRETARY']);
|
|
|
|
$this->em->persist(new DoctorSecretary($doctor, $secretary, $clinic));
|
|
$this->em->flush();
|
|
$this->setClinicContext($secretary, $clinic);
|
|
|
|
$context = $this->resolver()->resolve($secretary);
|
|
|
|
self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair());
|
|
}
|
|
|
|
public function testSecretaryWithActiveDoctorRelationResolvesToThatPractice(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$secretary = $this->createUser(['ROLE_SECRETARY']);
|
|
|
|
$this->em->persist(new DoctorSecretary($doctor, $secretary));
|
|
$this->em->flush();
|
|
$this->setDoctorContext($secretary, $doctor);
|
|
|
|
$context = $this->resolver()->resolve($secretary);
|
|
|
|
self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair());
|
|
}
|
|
|
|
/** رابطهٔ غیرفعال = پایان همکاری؛ محیط دیگر حل نمیشود. */
|
|
public function testSecretaryWithInactiveRelationResolvesToUnknown(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$clinic = $this->makeClinic();
|
|
$this->joinClinic($clinic, $doctor);
|
|
$secretary = $this->createUser(['ROLE_SECRETARY']);
|
|
|
|
$relation = new DoctorSecretary($doctor, $secretary, $clinic);
|
|
$relation->setActive(false);
|
|
$this->em->persist($relation);
|
|
$this->em->flush();
|
|
$this->setClinicContext($secretary, $clinic);
|
|
|
|
$context = $this->resolver()->resolve($secretary);
|
|
|
|
self::assertFalse($context->isResolved());
|
|
}
|
|
|
|
// ── مسیر clinic_uuid صریح ───────────────────────────────────────────────
|
|
|
|
public function testExplicitClinicUuidForNonMemberThrowsAccessDenied(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$clinic = $this->makeClinic();
|
|
|
|
$this->expectException(AppException::class);
|
|
$this->resolver()->resolve($doctor->getUser(), $clinic->getUuid());
|
|
}
|
|
|
|
public function testTryResolveReturnsNullInsteadOfThrowingForNonMember(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$clinic = $this->makeClinic();
|
|
|
|
self::assertNull($this->resolver()->tryResolve($doctor->getUser(), $clinic->getUuid()));
|
|
}
|
|
|
|
public function testExplicitClinicUuidWinsOverStoredActiveContext(): void
|
|
{
|
|
$doctor = $this->makeDoctor();
|
|
$clinicA = $this->makeClinic();
|
|
$clinicB = $this->makeClinic();
|
|
$this->joinClinic($clinicA, $doctor);
|
|
$this->joinClinic($clinicB, $doctor);
|
|
$this->setClinicContext($doctor->getUser(), $clinicA);
|
|
|
|
$context = $this->resolver()->resolve($doctor->getUser(), $clinicB->getUuid());
|
|
|
|
self::assertSame(['clinic', $clinicB->getId()], $context->toEntityPair());
|
|
}
|
|
}
|