Files
clinicpro/tests/Shared/EntityContextResolverTest.php
T
hamedandClaude Opus 5 1a7bf53577 refactor(tenant): make EntityContextResolver the single context resolver
Phase 1 of the tenant-marking series. The "which environment is this user
working in?" decision was reimplemented in six places, each reading
UserActiveContext.db_uuid and then guessing whether the uuid belongs to a
clinic or a doctor. Every copy was a place the roles could silently diverge.

EntityContextResolver already encoded the right precedence (explicit
clinic_uuid > stored active context > role) but only five files used it, and
it did not recognise secretaries at all: canActInClinic accepted admins,
clinic owners and member doctors, so a secretary's active clinic context
always collapsed to unknown. That gap is why SecretaryAccessChecker carried
its own copy of the logic.

- canActInClinic now also accepts an active DoctorSecretary relation, and a
  matching canActForDoctor covers the personal-practice branch.
- AppointmentAccessChecker, ClinicDoctorAccessChecker, SecretaryAccessChecker,
  PatientRecordScopeResolver, MyAppointmentsController and the secretary
  dashboard all resolve through it now.
- PatientRecordScopeResolver keeps only its real responsibility: which
  doctors' patients are visible inside the resolved environment.
- The resolver answers "where"; ClinicDoctorPermissionChecker and
  SecretaryPermissionChecker still answer "what may you do".

Left deliberately untouched, with the reason recorded at each site:
SubscriptionController, InventoryController and TenantTagController check
ROLE_DOCTOR unconditionally and ignore the active context, so a member doctor
sees personal inventory/tags/subscription even inside a clinic. Switching them
changes what users see, which is a product decision, not a refactor.
AuthController keeps its repository because it writes the active context.

tests/ApiTestCase now seeds the "free" subscription plan. db_test had no such
row, so getEffectivePlan returned null, every hasFeature() was false and 83
tests across Patient, ClinicService, Insurance and Appointment failed with 403.

No schema, route, request, response or error code changed.

Tests: 813 passing (was 730 passing / 83 failing). PHPStan clean on all
changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 10:59:22 +03:30

256 lines
9.8 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): void
{
$this->em->persist(new UserActiveContext($user, $dbUuid));
$this->em->flush();
}
// ── سناریو ۱: پزشک مستقل ────────────────────────────────────────────────
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->setActiveContext($doctor->getUser(), $clinic->getUuid());
$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->setActiveContext($doctor->getUser(), $doctor->getUuid());
$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->setActiveContext($doctor->getUser(), $clinic->getUuid());
$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->setActiveContext($user, $clinic->getUuid());
$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->setActiveContext($user, $doctor->getUuid());
$context = $this->resolver()->resolve($user);
self::assertSame(['doctor', $doctor->getId()], $context->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, DoctorSecretary::OWNER_CLINIC, $clinic));
$this->em->flush();
$this->setActiveContext($secretary, $clinic->getUuid());
$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->setActiveContext($secretary, $doctor->getUuid());
$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, DoctorSecretary::OWNER_CLINIC, $clinic);
$relation->setActive(false);
$this->em->persist($relation);
$this->em->flush();
$this->setActiveContext($secretary, $clinic->getUuid());
$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->setActiveContext($doctor->getUser(), $clinicA->getUuid());
$context = $this->resolver()->resolve($doctor->getUser(), $clinicB->getUuid());
self::assertSame(['clinic', $clinicB->getId()], $context->toEntityPair());
}
}