refactor(tenant): give every table one spelling of the tenant pair

Phase 3 of the tenant-marking series. The same concept was written four ways,
and the Doctrine filter arriving in phase 4 keys on the field name — so the
tables using a different spelling would have been skipped silently, which is
exactly the leak this work exists to prevent.

- discount_rules: owner_type/owner_id renamed to entity_type/entity_id. Pure
  rename, no data moves.
- doctor_secretaries: owner_type plus a nullable clinic_id replaced by the
  shared pair. The environment now comes from the clinic argument alone, so the
  inconsistent combination (owner_type='clinic', clinic_id=NULL) can no longer
  be constructed, and the redundant constructor parameter is gone.
- user_active_context: added db_type, so resolving an environment is one lookup
  instead of "try clinics, then try doctors". Filled from the type already
  present in available_contexts.
- entity_type is VARCHAR(10) in all twenty tenant tables; four of them were 20.

Behaviour change, the only one in this series: the doctor_secretaries unique key
went from (doctor_id, secretary_id, owner_type) to (doctor_id, secretary_id,
entity_type, entity_id). With clinic_id outside the key, one secretary could not
be assigned to the same doctor in two clinics — the second row collided on
owner_type='clinic'. The duplicate check in SecretaryController had the same
blind spot and would have rejected the request before the database saw it; both
are fixed together.

Correcting an assumption from the phase-3 plan: mobile_verification_otp.entity_type
really is a tenant pair. NotificationMobileController validates the target against
['doctor','clinic'] and stores that entity's id, so the column was normalised with
the rest rather than treated as unrelated.

TenantOwnedTrait gained assignTenantPair() for callers that resolved the pair as
scalars and hold no entity — building an EntityContext from scalars would produce
one where isClinic() is true but ->clinic is null, breaking consumers silently.

tests/ApiTestCase::createUser now retries on a duplicate mobile. db_test is never
reset and already holds ~38k users, so the 9-digit random draw collided often
enough to fail unrelated tests a few percent of runs.

Tests: 830 passing. PHPStan reports no new errors on the changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-28 11:56:57 +03:30
co-authored by Claude Opus 5
parent d53874ff50
commit 2e0888e0ef
33 changed files with 728 additions and 120 deletions
+26 -3
View File
@@ -10,6 +10,7 @@ use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Shared\Context\EntityContext;
use App\Subscription\Entity\SubscriptionPlan;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
@@ -64,9 +65,31 @@ abstract class ApiTestCase extends WebTestCase
*/
protected function createUser(array $roles = ['ROLE_USER'], ?string $mobile = null): User
{
// 9 random digits after 09 (full ^09\d{9}$ space) — db_test is never reset,
// so a narrower space eventually collides on the unique mobile.
$mobile ??= '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
if ($mobile !== null) {
return $this->persistUser($mobile, $roles);
}
// 9 random digits after 09 (full ^09\d{9}$ space). db_test is never reset and
// already holds tens of thousands of users, so a draw does collide now and
// then; retry rather than fail an unrelated test on a birthday collision.
for ($attempt = 0; ; $attempt++) {
try {
return $this->persistUser(
'09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
$roles,
);
} catch (UniqueConstraintViolationException $e) {
if ($attempt >= 4) {
throw $e;
}
// The failed INSERT closed the EntityManager; reopen before retrying.
$this->em = static::getContainer()->get(EntityManagerInterface::class);
}
}
}
private function persistUser(string $mobile, array $roles): User
{
$user = new User($mobile);
$user->setRoles($roles);
$user->setStatus(1);
@@ -9,6 +9,7 @@ use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
use App\Doctor\Entity\Doctor;
use App\Secretary\Entity\DoctorSecretary;
use App\Shared\Context\EntityContext;
use App\Tests\ApiTestCase;
/**
@@ -59,14 +60,15 @@ class ClinicAppointmentAccessTest extends ApiTestCase
private function makeClinicSecretary(Clinic $clinic, Doctor $doctor, array $permissionPatch = []): User
{
$user = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
$secretary = new DoctorSecretary($doctor, $user, DoctorSecretary::OWNER_CLINIC, $clinic);
$secretary = new DoctorSecretary($doctor, $user, $clinic);
if ($permissionPatch !== []) {
$secretary->mergePermissions(['resources' => ['appointments' => $permissionPatch]]);
}
$this->em->persist($secretary);
$this->em->flush();
static::getContainer()->get(UserActiveContextRepository::class)->upsert($user, $clinic->getUuid());
static::getContainer()->get(UserActiveContextRepository::class)
->upsert($user, $clinic->getUuid(), EntityContext::TYPE_CLINIC);
return $user;
}
@@ -30,7 +30,7 @@ class ClinicDoctorPermissionEnforcementTest extends ApiTestCase
$perm = new ClinicDoctorPermission($clinic, $doctor);
$this->em->persist($perm);
// محیطِ فعالِ پزشک = کلینیک، تا memberClinicId او را به کلینیک ببرد.
$this->em->persist(new UserActiveContext($doctorUser, $clinic->getUuid()));
$this->em->persist(new UserActiveContext($doctorUser, $clinic->getUuid(), 'clinic'));
return [$doctorUser, $perm];
}
+11 -10
View File
@@ -10,6 +10,7 @@ use App\Clinic\Repository\ClinicDoctorPermissionRepository;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Secretary\Entity\DoctorSecretary;
use App\Shared\Context\EntityContext;
use App\Tests\ApiTestCase;
/**
@@ -47,9 +48,9 @@ class ClinicRecordAccessTest extends ApiTestCase
return [$owner, $clinic];
}
private function activeContext(User $user, string $dbUuid): void
private function activeContext(User $user, string $dbUuid, string $dbType): void
{
static::getContainer()->get(UserActiveContextRepository::class)->upsert($user, $dbUuid);
static::getContainer()->get(UserActiveContextRepository::class)->upsert($user, $dbUuid, $dbType);
}
/** پروندهٔ کلینیکی بیمار + نوبتی که او را به این پزشک وصل می‌کند. */
@@ -82,7 +83,7 @@ class ClinicRecordAccessTest extends ApiTestCase
[, $clinic] = $this->makeClinicWith($doctor);
$record = $this->makeClinicRecordFor($clinic, $doctor);
$this->activeContext($doctor->getUser(), $clinic->getUuid());
$this->activeContext($doctor->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC);
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $doctor->getUser());
@@ -97,7 +98,7 @@ class ClinicRecordAccessTest extends ApiTestCase
[, $clinic] = $this->makeClinicWith($mine, $theirs);
$foreign = $this->makeClinicRecordFor($clinic, $theirs);
$this->activeContext($mine->getUser(), $clinic->getUuid());
$this->activeContext($mine->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC);
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $mine->getUser());
self::assertNotContains($foreign->getUuid(), $this->uuidsFromList($res));
@@ -112,7 +113,7 @@ class ClinicRecordAccessTest extends ApiTestCase
[, $clinic] = $this->makeClinicWith($doctor);
$record = $this->makeClinicRecordFor($clinic, $doctor);
$this->activeContext($doctor->getUser(), $clinic->getUuid());
$this->activeContext($doctor->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC);
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser());
self::assertSame(200, $this->responseCode());
@@ -130,7 +131,7 @@ class ClinicRecordAccessTest extends ApiTestCase
$clinicRecord = $this->makeClinicRecordFor($clinic, $doctor);
// بدون محیط فعالِ کلینیک ⇒ مطب شخصی.
$this->activeContext($doctor->getUser(), $doctor->getUuid());
$this->activeContext($doctor->getUser(), $doctor->getUuid(), EntityContext::TYPE_DOCTOR);
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $doctor->getUser());
@@ -146,7 +147,7 @@ class ClinicRecordAccessTest extends ApiTestCase
[$owner, $clinic] = $this->makeClinicWith($doctor);
$record = $this->makeClinicRecordFor($clinic, $doctor);
$this->activeContext($doctor->getUser(), $clinic->getUuid());
$this->activeContext($doctor->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC);
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser());
self::assertSame(200, $this->responseCode(), 'پزشک فعال دسترسی دارد');
@@ -197,11 +198,11 @@ class ClinicRecordAccessTest extends ApiTestCase
$foreign = $this->makeClinicRecordFor($clinic, $theirs);
$secretaryUser = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
$relation = new DoctorSecretary($mine, $secretaryUser, DoctorSecretary::OWNER_CLINIC, $clinic);
$relation = new DoctorSecretary($mine, $secretaryUser, $clinic);
$this->em->persist($relation);
$this->em->flush();
$this->activeContext($secretaryUser, $clinic->getUuid());
$this->activeContext($secretaryUser, $clinic->getUuid(), EntityContext::TYPE_CLINIC);
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $secretaryUser);
$uuids = $this->uuidsFromList($res);
@@ -226,7 +227,7 @@ class ClinicRecordAccessTest extends ApiTestCase
$this->em->flush();
// محیط فعال را قبل از confirm ست کن: آن درخواست EntityManager را پاک می‌کند.
$this->activeContext($doctor->getUser(), $clinic->getUuid());
$this->activeContext($doctor->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC);
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $owner, [
'version' => $appointment->getVersion(),
+6 -4
View File
@@ -200,12 +200,14 @@ class PaymentMethodTest extends ApiTestCase
$clinic->getDoctors()->add($doctor);
$secretary = $this->createUser(['ROLE_SECRETARY']);
$rel = new \App\Secretary\Entity\DoctorSecretary(
$doctor, $secretary, \App\Secretary\Entity\DoctorSecretary::OWNER_CLINIC, $clinic
);
$rel = new \App\Secretary\Entity\DoctorSecretary($doctor, $secretary, $clinic);
$rel->mergePermissions(['resources' => ['payments' => $payments]]);
$this->em->persist($rel);
$this->em->persist(new \App\Auth\Entity\UserActiveContext($secretary, $clinic->getUuid()));
$this->em->persist(new \App\Auth\Entity\UserActiveContext(
$secretary,
$clinic->getUuid(),
\App\Shared\Context\EntityContext::TYPE_CLINIC,
));
$this->em->flush();
return $secretary;
@@ -30,11 +30,11 @@ class SecretaryAppointmentScopeTest extends ApiTestCase
// منشی فقط به دکتر A تخصیص داده شده
$secretaryUser = $this->createUser(['ROLE_SECRETARY']);
$rel = new DoctorSecretary($doctorA, $secretaryUser, DoctorSecretary::OWNER_CLINIC, $clinic);
$rel = new DoctorSecretary($doctorA, $secretaryUser, $clinic);
$this->em->persist($rel);
// scope فعالِ منشی = این کلینیک
$this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid()));
$this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid(), 'clinic'));
// یک نوبت برای هر پزشک
$patient = $this->createUser(['ROLE_USER']);
@@ -62,7 +62,7 @@ class SecretaryAppointmentScopeTest extends ApiTestCase
// منشی context کلینیک دارد ولی رابطه‌ی فعال ندارد
$secretaryUser = $this->createUser(['ROLE_SECRETARY']);
$this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid()));
$this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid(), 'clinic'));
$patient = $this->createUser(['ROLE_USER']);
$start = time() + 3600;
@@ -22,7 +22,7 @@ class SecretaryListNPlusOneTest extends ApiTestCase
for ($i = 0; $i < $count; $i++) {
$secUser = $this->createUser(['ROLE_SECRETARY']);
$this->em->persist(new DoctorSecretary($doctor, $secUser, DoctorSecretary::OWNER_DOCTOR));
$this->em->persist(new DoctorSecretary($doctor, $secUser));
}
$this->em->flush();
@@ -0,0 +1,107 @@
<?php
namespace App\Tests\Secretary;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Secretary\Entity\DoctorSecretary;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Tests\ApiTestCase;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
/**
* The scope key used to be (doctor_id, secretary_id, owner_type), which left
* clinic_id out. A secretary assigned to one doctor in two clinics collided on
* the second row because both said owner_type = 'clinic'. Phase 3 folded the
* tenant pair into the key, so the environment is part of the identity.
*/
class SecretaryMultiClinicScopeTest extends ApiTestCase
{
private function makeDoctor(): Doctor
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر چند-کلینیک');
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
private function makeClinic(Doctor $doctor): Clinic
{
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$clinic->setName('کلینیک تست منشی');
$clinic->getDoctors()->add($doctor);
$this->em->persist($clinic);
$this->em->flush();
return $clinic;
}
public function testSameSecretaryCanServeSameDoctorInTwoDifferentClinics(): void
{
$doctor = $this->makeDoctor();
$clinicA = $this->makeClinic($doctor);
$clinicB = $this->makeClinic($doctor);
$secretary = $this->createUser(['ROLE_SECRETARY']);
$this->em->persist(new DoctorSecretary($doctor, $secretary, $clinicA));
$this->em->persist(new DoctorSecretary($doctor, $secretary, $clinicB));
$this->em->flush();
/** @var DoctorSecretaryRepository $repo */
$repo = $this->em->getRepository(DoctorSecretary::class);
$inA = $repo->findActiveClinicRow($secretary, $clinicA, $doctor);
$inB = $repo->findActiveClinicRow($secretary, $clinicB, $doctor);
self::assertNotNull($inA, 'رابطهٔ کلینیک اول باید پیدا شود');
self::assertNotNull($inB, 'رابطهٔ کلینیک دوم باید پیدا شود');
self::assertNotSame($inA->getId(), $inB->getId(), 'دو ردیف مستقل، نه یک ردیف مشترک');
self::assertSame($clinicA->getId(), $inA->getEntityId());
self::assertSame($clinicB->getId(), $inB->getEntityId());
}
/** همان پزشک، همان منشی، همان کلینیک — هنوز تکراری است. */
public function testDuplicateAssignmentInTheSameClinicIsStillRejected(): void
{
$doctor = $this->makeDoctor();
$clinic = $this->makeClinic($doctor);
$secretary = $this->createUser(['ROLE_SECRETARY']);
$this->em->persist(new DoctorSecretary($doctor, $secretary, $clinic));
$this->em->flush();
$this->em->persist(new DoctorSecretary($doctor, $secretary, $clinic));
$this->expectException(UniqueConstraintViolationException::class);
$this->em->flush();
}
/** محیط شخصی و محیط کلینیک دو مالک متفاوت‌اند، پس هر دو کنار هم می‌نشینند. */
public function testPersonalAndClinicAssignmentsCoexist(): void
{
$doctor = $this->makeDoctor();
$clinic = $this->makeClinic($doctor);
$secretary = $this->createUser(['ROLE_SECRETARY']);
$personal = new DoctorSecretary($doctor, $secretary);
$inClinic = new DoctorSecretary($doctor, $secretary, $clinic);
$this->em->persist($personal);
$this->em->persist($inClinic);
$this->em->flush();
self::assertSame(['doctor', $doctor->getId()], [$personal->getEntityType(), $personal->getEntityId()]);
self::assertSame(['clinic', $clinic->getId()], [$inClinic->getEntityType(), $inClinic->getEntityId()]);
}
/** بدون کلینیک، محیط همان مطب شخصی است — ترکیب ناسازگار اصلاً بیان‌شدنی نیست. */
public function testAssignmentWithoutAClinicBelongsToThePersonalPractice(): void
{
$doctor = $this->makeDoctor();
$secretary = $this->createUser(['ROLE_SECRETARY']);
$relation = new DoctorSecretary($doctor, $secretary, null);
self::assertSame('doctor', $relation->getEntityType());
self::assertSame($doctor->getId(), $relation->getEntityId());
}
}
+1 -6
View File
@@ -46,12 +46,7 @@ class SecretaryOnlineShareTest extends ApiTestCase
private function makeSecretary(Doctor $doctor, float $percent, bool $enabled = true, ?Clinic $clinic = null): DoctorSecretary
{
$user = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
$relation = new DoctorSecretary(
$doctor,
$user,
$clinic !== null ? DoctorSecretary::OWNER_CLINIC : DoctorSecretary::OWNER_DOCTOR,
$clinic,
);
$relation = new DoctorSecretary($doctor, $user, $clinic);
$relation->setOnlineShareEnabled($enabled)->setOnlineSharePercent($percent);
$this->em->persist($relation);
$this->em->flush();
@@ -26,9 +26,9 @@ class SecretaryResourceEnforcementTest extends ApiTestCase
$clinic->getDoctors()->add($doctor);
$secretary = $this->createUser(['ROLE_SECRETARY']);
$rel = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic);
$rel = new DoctorSecretary($doctor, $secretary, $clinic);
$this->em->persist($rel);
$this->em->persist(new UserActiveContext($secretary, $clinic->getUuid()));
$this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic'));
return [$secretary, $rel];
}
@@ -283,8 +283,8 @@ class SecretaryResourceEnforcementTest extends ApiTestCase
$clinic->getDoctors()->add($unassigned);
$secretary = $this->createUser(['ROLE_SECRETARY']);
$this->em->persist(new DoctorSecretary($assigned, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic));
$this->em->persist(new UserActiveContext($secretary, $clinic->getUuid()));
$this->em->persist(new DoctorSecretary($assigned, $secretary, $clinic));
$this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic'));
$this->em->flush();
// اندپوینتِ احرازشدهٔ پنل (نه /clinic/doctor-list که عمومی است).
@@ -308,9 +308,9 @@ class SecretaryResourceEnforcementTest extends ApiTestCase
$clinic->getDoctors()->add($doctor);
$secretary = $this->createUser(['ROLE_SECRETARY']);
$rel = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic);
$rel = new DoctorSecretary($doctor, $secretary, $clinic);
$this->em->persist($rel);
$this->em->persist(new UserActiveContext($secretary, $clinic->getUuid()));
$this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic'));
return [$secretary, $rel, $clinic, $doctor];
}
+23 -13
View File
@@ -55,12 +55,22 @@ class EntityContextResolverTest extends ApiTestCase
$this->em->flush();
}
private function setActiveContext(User $user, string $dbUuid): void
private function setActiveContext(User $user, string $dbUuid, string $dbType): void
{
$this->em->persist(new UserActiveContext($user, $dbUuid));
$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
@@ -79,7 +89,7 @@ class EntityContextResolverTest extends ApiTestCase
$doctor = $this->makeDoctor();
$clinic = $this->makeClinic();
$this->joinClinic($clinic, $doctor);
$this->setActiveContext($doctor->getUser(), $clinic->getUuid());
$this->setClinicContext($doctor->getUser(), $clinic);
$context = $this->resolver()->resolve($doctor->getUser());
@@ -91,7 +101,7 @@ class EntityContextResolverTest extends ApiTestCase
$doctor = $this->makeDoctor();
$clinic = $this->makeClinic();
$this->joinClinic($clinic, $doctor);
$this->setActiveContext($doctor->getUser(), $doctor->getUuid());
$this->setDoctorContext($doctor->getUser(), $doctor);
$context = $this->resolver()->resolve($doctor->getUser());
@@ -114,7 +124,7 @@ class EntityContextResolverTest extends ApiTestCase
$permission->setActive(false);
$this->em->flush();
$this->setActiveContext($doctor->getUser(), $clinic->getUuid());
$this->setClinicContext($doctor->getUser(), $clinic);
$context = $this->resolver()->resolve($doctor->getUser());
@@ -128,7 +138,7 @@ class EntityContextResolverTest extends ApiTestCase
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
$doctor = $this->makeDoctor($user);
$clinic = $this->makeClinic($user);
$this->setActiveContext($user, $clinic->getUuid());
$this->setClinicContext($user, $clinic);
$context = $this->resolver()->resolve($user);
@@ -141,7 +151,7 @@ class EntityContextResolverTest extends ApiTestCase
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
$doctor = $this->makeDoctor($user);
$this->makeClinic($user);
$this->setActiveContext($user, $doctor->getUuid());
$this->setDoctorContext($user, $doctor);
$context = $this->resolver()->resolve($user);
@@ -178,9 +188,9 @@ class EntityContextResolverTest extends ApiTestCase
$this->joinClinic($clinic, $doctor);
$secretary = $this->createUser(['ROLE_SECRETARY']);
$this->em->persist(new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic));
$this->em->persist(new DoctorSecretary($doctor, $secretary, $clinic));
$this->em->flush();
$this->setActiveContext($secretary, $clinic->getUuid());
$this->setClinicContext($secretary, $clinic);
$context = $this->resolver()->resolve($secretary);
@@ -194,7 +204,7 @@ class EntityContextResolverTest extends ApiTestCase
$this->em->persist(new DoctorSecretary($doctor, $secretary));
$this->em->flush();
$this->setActiveContext($secretary, $doctor->getUuid());
$this->setDoctorContext($secretary, $doctor);
$context = $this->resolver()->resolve($secretary);
@@ -209,11 +219,11 @@ class EntityContextResolverTest extends ApiTestCase
$this->joinClinic($clinic, $doctor);
$secretary = $this->createUser(['ROLE_SECRETARY']);
$relation = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic);
$relation = new DoctorSecretary($doctor, $secretary, $clinic);
$relation->setActive(false);
$this->em->persist($relation);
$this->em->flush();
$this->setActiveContext($secretary, $clinic->getUuid());
$this->setClinicContext($secretary, $clinic);
$context = $this->resolver()->resolve($secretary);
@@ -246,7 +256,7 @@ class EntityContextResolverTest extends ApiTestCase
$clinicB = $this->makeClinic();
$this->joinClinic($clinicA, $doctor);
$this->joinClinic($clinicB, $doctor);
$this->setActiveContext($doctor->getUser(), $clinicA->getUuid());
$this->setClinicContext($doctor->getUser(), $clinicA);
$context = $this->resolver()->resolve($doctor->getUser(), $clinicB->getUuid());
+187
View File
@@ -0,0 +1,187 @@
<?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\Discount\Entity\DiscountRule;
use App\Doctor\Entity\Doctor;
use App\Shared\Context\EntityContext;
use App\Tests\ApiTestCase;
/**
* پس از یکسان‌سازی املای tenant، هر نقش باید فقط دادهٔ محیط خودش را ببیند.
*
* روی discount-rules سنجیده می‌شود چون تنها جدولی بود که با owner_type نوشته شده
* بود و تا فاز ۳ از هر فیلتر مبتنی بر entity_type جا می‌ماند.
*/
class TenantIsolationMatrixTest extends ApiTestCase
{
private const LIST_URL = '/api/v1/admin/discount-rules';
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;
}
/** عضویت به‌علاوهٔ مجوز discounts.view که در DEFAULT_PERMISSIONS خاموش است. */
private function joinClinic(Clinic $clinic, Doctor $doctor): void
{
$clinic->getDoctors()->add($doctor);
$permission = new ClinicDoctorPermission($clinic, $doctor);
$permission->mergePermissions(['resources' => ['discounts' => ['view' => true]]]);
$this->em->persist($permission);
$this->em->flush();
}
private function setClinicContext(User $user, Clinic $clinic): void
{
$this->em->persist(new UserActiveContext($user, $clinic->getUuid(), EntityContext::TYPE_CLINIC));
$this->em->flush();
}
private function rule(string $entityType, int $entityId, string $name): DiscountRule
{
$rule = new DiscountRule($entityType, $entityId, $name, DiscountRule::TYPE_INVOICE_AMOUNT);
$this->em->persist($rule);
$this->em->flush();
return $rule;
}
/**
* پاسخ این endpoint با success(['data' => …]) ساخته می‌شود، پس یک لایه
* تودرتوی اضافه دارد — همان دام double-nesting که در CLAUDE.md ثبت شده.
*
* @return string[]
*/
private function listedNames(User $user): array
{
$res = $this->authJson('GET', self::LIST_URL, $user);
self::assertSame(200, $this->responseCode());
return array_column($res['data']['data'] ?? [], 'name');
}
public function testIndependentDoctorSeesOnlyItsOwnRules(): void
{
$mine = $this->makeDoctor();
$other = $this->makeDoctor();
$clinic = $this->makeClinic();
$this->rule('doctor', $mine->getId(), 'مالِ من');
$this->rule('doctor', $other->getId(), 'مالِ پزشک دیگر');
$this->rule('clinic', $clinic->getId(), 'مالِ کلینیک');
$names = $this->listedNames($mine->getUser());
self::assertContains('مالِ من', $names);
self::assertNotContains('مالِ پزشک دیگر', $names);
self::assertNotContains('مالِ کلینیک', $names);
}
public function testClinicManagerSeesOnlyItsClinicRules(): void
{
$clinic = $this->makeClinic();
$otherClinic = $this->makeClinic();
$doctor = $this->makeDoctor();
$this->rule('clinic', $clinic->getId(), 'کلینیک خودم');
$this->rule('clinic', $otherClinic->getId(), 'کلینیک دیگر');
$this->rule('doctor', $doctor->getId(), 'مطب یک پزشک');
$names = $this->listedNames($clinic->getUser());
self::assertContains('کلینیک خودم', $names);
self::assertNotContains('کلینیک دیگر', $names);
self::assertNotContains('مطب یک پزشک', $names);
}
/** پزشکِ عضو در محیط کلینیک، قوانین کلینیک را می‌بیند نه مطب شخصی‌اش. */
public function testMemberDoctorInClinicContextSeesClinicRules(): void
{
$doctor = $this->makeDoctor();
$clinic = $this->makeClinic();
$this->joinClinic($clinic, $doctor);
$this->setClinicContext($doctor->getUser(), $clinic);
$this->rule('clinic', $clinic->getId(), 'قانون کلینیک');
$this->rule('doctor', $doctor->getId(), 'قانون مطب شخصی');
$names = $this->listedNames($doctor->getUser());
self::assertContains('قانون کلینیک', $names);
self::assertNotContains('قانون مطب شخصی', $names);
}
/** همان پزشک بیرون از محیط کلینیک، فقط مطب شخصی. */
public function testMemberDoctorOutsideClinicContextSeesOwnPracticeRules(): void
{
$doctor = $this->makeDoctor();
$clinic = $this->makeClinic();
$this->joinClinic($clinic, $doctor);
$this->rule('clinic', $clinic->getId(), 'قانون کلینیک');
$this->rule('doctor', $doctor->getId(), 'قانون مطب شخصی');
$names = $this->listedNames($doctor->getUser());
self::assertContains('قانون مطب شخصی', $names);
self::assertNotContains('قانون کلینیک', $names);
}
/** پزشکی که مالک کلینیک هم هست: محیط فعال تعیین‌کننده است، نه نقش. */
public function testDoctorWhoOwnsClinicSeesClinicRulesInClinicContext(): void
{
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
$doctor = $this->makeDoctor($user);
$clinic = $this->makeClinic($user);
$this->joinClinic($clinic, $doctor);
$this->setClinicContext($user, $clinic);
$this->rule('clinic', $clinic->getId(), 'قانون کلینیکِ خودش');
$this->rule('doctor', $doctor->getId(), 'قانون مطبِ خودش');
$names = $this->listedNames($user);
self::assertContains('قانون کلینیکِ خودش', $names);
self::assertNotContains('قانون مطبِ خودش', $names);
}
/** ❌ بدون توکن، لیست اصلاً باز نمی‌شود. */
public function testAnonymousCannotListRules(): void
{
$this->client->request('GET', self::LIST_URL);
self::assertSame(401, $this->client->getResponse()->getStatusCode());
}
/** ⚠️ محیط حل‌نشده: کاربر بدون نقش پنل، نه خطای ۵۰۰ می‌گیرد نه دادهٔ کسی را. */
public function testUserWithoutAPanelRoleGetsNoRules(): void
{
$doctor = $this->makeDoctor();
$this->rule('doctor', $doctor->getId(), 'قانون یک پزشک');
$stranger = $this->createUser(['ROLE_USER']);
$this->authJson('GET', self::LIST_URL, $stranger);
self::assertContains($this->responseCode(), [403, 404], 'دسترسی رد شود، نه خطای سرور');
}
}