Files
clinicpro/tests/Patient/ClinicRecordAccessTest.php
T
hamedandClaude Opus 5 2e0888e0ef 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>
2026-07-28 11:56:57 +03:30

251 lines
11 KiB
PHP

<?php
namespace App\Tests\Patient;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Entity\Clinic;
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;
/**
* پروندهٔ کلینیکی per-بیمار است (یکتایی clinic+user) و مدیر کلینیک همه را می‌بیند.
* پزشکِ عضو هم باید پرونده‌های بیمارانِ خودش در همان کلینیک را ببیند — رابطه از
* نوبت‌های همان پزشک در همان کلینیک می‌آید، نه از ستونی روی پرونده.
*
* با غیرفعال شدن پزشک، دسترسی‌اش قطع می‌شود ولی پرونده‌ها دست‌نخورده می‌مانند.
*/
class ClinicRecordAccessTest extends ApiTestCase
{
private function makeDoctor(string $name = 'دکتر تست'): Doctor
{
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($user, $name);
$doctor->setMobileNumber($user->getMobileNumber());
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
/** @return array{0: User, 1: Clinic} */
private function makeClinicWith(Doctor ...$doctors): array
{
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($owner);
$clinic->setName('کلینیک تست');
foreach ($doctors as $d) {
$clinic->getDoctors()->add($d);
}
$this->em->persist($clinic);
$this->em->flush();
return [$owner, $clinic];
}
private function activeContext(User $user, string $dbUuid, string $dbType): void
{
static::getContainer()->get(UserActiveContextRepository::class)->upsert($user, $dbUuid, $dbType);
}
/** پروندهٔ کلینیکی بیمار + نوبتی که او را به این پزشک وصل می‌کند. */
private function makeClinicRecordFor(Clinic $clinic, Doctor $doctor, ?User $patient = null): PatientRecord
{
$patient ??= $this->createUser();
$record = new PatientRecord('clinic', $clinic->getId(), $patient, 'system', $clinic->getId());
$this->em->persist($record);
$start = strtotime('+60 days') + random_int(0, 500_000) * 7;
$appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic);
$this->em->persist($appointment);
$this->em->flush();
return $record;
}
private function uuidsFromList(array $response): array
{
return array_map(fn(array $row) => $row['uuid'], $response['data'] ?? []);
}
// ── پزشک عضو ─────────────────────────────────────────────────────────────
public function testMemberDoctorSeesOwnPatientsClinicRecord(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$record = $this->makeClinicRecordFor($clinic, $doctor);
$this->activeContext($doctor->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC);
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $doctor->getUser());
self::assertSame(200, $this->responseCode());
self::assertContains($record->getUuid(), $this->uuidsFromList($res));
}
public function testMemberDoctorCannotSeeAnotherDoctorsClinicRecord(): void
{
$mine = $this->makeDoctor('پزشک من');
$theirs = $this->makeDoctor('پزشک دیگر');
[, $clinic] = $this->makeClinicWith($mine, $theirs);
$foreign = $this->makeClinicRecordFor($clinic, $theirs);
$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));
$this->authJson('GET', "/api/v1/patient/{$foreign->getUuid()}", $mine->getUser());
self::assertSame(404, $this->responseCode(), 'پروندهٔ بیمارِ پزشک دیگر برای او وجود ندارد');
}
public function testMemberDoctorCanOpenAndManageOwnPatientRecord(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$record = $this->makeClinicRecordFor($clinic, $doctor);
$this->activeContext($doctor->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC);
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser());
self::assertSame(200, $this->responseCode());
$this->authJson('POST', "/api/v1/patient/{$record->getUuid()}/note", $doctor->getUser(), [
'body' => 'یادداشت پزشک عضو',
]);
self::assertSame(201, $this->responseCode(), 'پزشک عضو فعال پرونده را مدیریت هم می‌کند');
}
public function testDoctorInPersonalContextSeesOnlyOwnOfficeRecords(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$clinicRecord = $this->makeClinicRecordFor($clinic, $doctor);
// بدون محیط فعالِ کلینیک ⇒ مطب شخصی.
$this->activeContext($doctor->getUser(), $doctor->getUuid(), EntityContext::TYPE_DOCTOR);
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $doctor->getUser());
self::assertSame(200, $this->responseCode());
self::assertNotContains($clinicRecord->getUuid(), $this->uuidsFromList($res));
}
// ── پایان همکاری ─────────────────────────────────────────────────────────
public function testDeactivatedDoctorLosesClinicRecordsButOwnerKeepsThem(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$record = $this->makeClinicRecordFor($clinic, $doctor);
$this->activeContext($doctor->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC);
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser());
self::assertSame(200, $this->responseCode(), 'پزشک فعال دسترسی دارد');
$permissions = static::getContainer()->get(ClinicDoctorPermissionRepository::class);
$permissions->getOrCreate($clinic, $doctor)->setActive(false);
$this->em->flush();
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser());
self::assertSame(404, $this->responseCode(), 'بعد از پایان همکاری، دسترسی قطع می‌شود');
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $owner);
self::assertSame(200, $this->responseCode(), 'مدیر کلینیک دسترسی کامل دارد');
$this->em->clear();
self::assertNotNull(
$this->em->getRepository(PatientRecord::class)->find($record->getId()),
'پرونده حذف یا منتقل نمی‌شود',
);
}
// ── مدیر کلینیک ──────────────────────────────────────────────────────────
public function testClinicOwnerSeesEveryDoctorsRecords(): void
{
$first = $this->makeDoctor('پزشک اول');
$second = $this->makeDoctor('پزشک دوم');
[$owner, $clinic] = $this->makeClinicWith($first, $second);
$firstRecord = $this->makeClinicRecordFor($clinic, $first);
$secondRecord = $this->makeClinicRecordFor($clinic, $second);
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $owner);
$uuids = $this->uuidsFromList($res);
self::assertSame(200, $this->responseCode());
self::assertContains($firstRecord->getUuid(), $uuids);
self::assertContains($secondRecord->getUuid(), $uuids);
}
// ── منشی ─────────────────────────────────────────────────────────────────
public function testClinicSecretaryIsLimitedToAssignedDoctors(): void
{
$mine = $this->makeDoctor('پزشک من');
$theirs = $this->makeDoctor('پزشک دیگر');
[, $clinic] = $this->makeClinicWith($mine, $theirs);
$ownRecord = $this->makeClinicRecordFor($clinic, $mine);
$foreign = $this->makeClinicRecordFor($clinic, $theirs);
$secretaryUser = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
$relation = new DoctorSecretary($mine, $secretaryUser, $clinic);
$this->em->persist($relation);
$this->em->flush();
$this->activeContext($secretaryUser, $clinic->getUuid(), EntityContext::TYPE_CLINIC);
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $secretaryUser);
$uuids = $this->uuidsFromList($res);
self::assertSame(200, $this->responseCode());
self::assertContains($ownRecord->getUuid(), $uuids);
self::assertNotContains($foreign->getUuid(), $uuids);
}
// ── قطعی‌کردن نوبت کلینیکی، دیده‌شده توسط هر دو نقش ───────────────────────
public function testConfirmedClinicAppointmentRecordIsVisibleToBothRoles(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$patient = $this->createUser();
$start = strtotime('+70 days') + random_int(0, 500_000) * 7;
$appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic);
$appointment->setVisitPriceRials(3_000_000);
$this->em->persist($appointment);
$this->em->flush();
// محیط فعال را قبل از confirm ست کن: آن درخواست EntityManager را پاک می‌کند.
$this->activeContext($doctor->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC);
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $owner, [
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
$record = $this->em->getRepository(PatientRecord::class)->findOneBy([
'entityType' => 'clinic',
'entityId' => $clinic->getId(),
'user' => $patient,
]);
self::assertNotNull($record);
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $owner);
self::assertSame(200, $this->responseCode(), 'مدیر کلینیک');
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser());
self::assertSame(200, $this->responseCode(), 'پزشکِ همان نوبت');
}
}