Files
clinicpro/tests/Appointment/ClinicAppointmentAccessTest.php
T
hamedandClaude Opus 5 d53874ff50 feat(tenant): mark the booking tables with their owning environment
Phase 2 of the tenant-marking series. appointments, weekly_schedules and
date_overrides kept their environment implicit in a nullable clinic_id, so
every query that wanted "this environment's rows" had to rebuild
clinic_id IS NULL ? doctor : clinic itself. The two calendar tables also
depended on a MariaDB-only generated column, clinic_key = IFNULL(clinic_id, 0),
purely to make a unique key work across NULLs.

All three now carry the (entity_type, entity_id) pair that service_sections,
patient_records and clinic_staff already use, via a shared TenantOwnedTrait.
The pair is a deliberate denormalisation of clinic_id/doctor_id: the automatic
tenant filter and the tenant-leading indexes both need a real column, and
neither can be built on an IF() expression.

- Unique keys keep doctor_id alongside the pair. A clinic has several doctors
  and each has their own schedule, so (entity_type, entity_id) alone would
  reject the second doctor.
- clinic_key is gone from both calendar tables.
- appointments gained tenant-leading indexes; EXPLAIN on the panel's list query
  now picks idx_appointments_tenant_slot.

Deliberately unchanged, both with the reason already recorded in the code:
active_slot_key stays keyed on doctor + slot, since adding the environment
would let one doctor be booked in their own practice and a clinic at the same
moment. holidays keeps its nullable clinic_id, where NULL means "every
environment" rather than "personal practice" — a meaning the pair cannot carry.

The migration adds the columns nullable, backfills, aborts if any row is left
without an owner, and only then tightens to NOT NULL. It creates each
replacement unique index before dropping the old one, so the tables are never
left unprotected — MariaDB commits implicitly on DDL, so ordering is the only
safety net. It runs its statements through the connection rather than addSql()
because the guard has to sit between the backfill and the NOT NULL change.

Columns are NOT NULL with no default on purpose: a construction site that
forgets assignTenant() fails at flush instead of silently writing entity_id 0,
which the phase 4 filter would then hide from everyone.

Verified on the dev database: 0 rows without a tenant, 0 personal bookings
mismatched against their doctor, 0 clinic bookings mismatched against their
clinic.

Tests: 819 passing (813 + 6 new in BookingTenantTest). PHPStan clean on every
changed file.

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

329 lines
13 KiB
PHP

<?php
namespace App\Tests\Appointment;
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\Secretary\Entity\DoctorSecretary;
use App\Tests\ApiTestCase;
/**
* دسترسی به «یک نوبت مشخص» بر پایهٔ محیطِ خود نوبت (appointment.clinic) است، نه نقش
* کاربر. پیش از این مسیرهای تک‌نوبت فقط بیمار، پزشکِ مالک و ادمین را می‌شناختند و
* کاربر کلینیک روی نوبتی که خودش ساخته بود ۴۰۳ می‌گرفت.
*/
class ClinicAppointmentAccessTest 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 makeAppointment(Doctor $doctor, ?Clinic $clinic, ?User $patient = null): Appointment
{
$patient ??= $this->createUser();
$start = strtotime('+3 days 10:00');
$appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic);
$this->em->persist($appointment);
$this->em->flush();
return $appointment;
}
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);
if ($permissionPatch !== []) {
$secretary->mergePermissions(['resources' => ['appointments' => $permissionPatch]]);
}
$this->em->persist($secretary);
$this->em->flush();
static::getContainer()->get(UserActiveContextRepository::class)->upsert($user, $clinic->getUuid());
return $user;
}
public function testClinicOwnerCanViewClinicAppointment(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $owner);
self::assertSame(200, $this->responseCode());
}
public function testClinicOwnerCanUpdateAndMoveClinicAppointment(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$newStart = strtotime('+4 days 11:00');
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}", $owner, [
'slot_start' => $newStart,
'slot_end' => $newStart + 900,
'note' => 'جابه‌جا شد',
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
}
public function testClinicOwnerCanChangeStatusOfClinicAppointment(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $owner, [
'status' => Appointment::STATUS_CONFIRMED,
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
}
public function testClinicOwnerCanTransferAppointmentToReserveAndBack(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$midnight = strtotime('+3 days 00:00');
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}", $owner, [
'is_reserve' => true,
'slot_start' => $midnight,
'slot_end' => $midnight,
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode(), 'انتقال به لیست رزرو');
$this->em->clear();
$reloaded = $this->em->getRepository(Appointment::class)->find($appointment->getId());
self::assertTrue($reloaded->isReserve());
$back = strtotime('+5 days 09:00');
$this->authJson('PATCH', "/api/v1/appointment/{$reloaded->getUuid()}", $owner, [
'is_reserve' => false,
'slot_start' => $back,
'slot_end' => $back + 900,
'version' => $reloaded->getVersion(),
]);
self::assertSame(200, $this->responseCode(), 'بازگشت از لیست رزرو');
}
public function testClinicOwnerCanReadAppointmentEvents(): void
{
$doctor = $this->makeDoctor();
[$owner, $clinic] = $this->makeClinicWith($doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/events", $owner);
self::assertSame(200, $this->responseCode());
}
public function testClinicOwnerCannotTouchDoctorPersonalAppointment(): void
{
$doctor = $this->makeDoctor();
[$owner] = $this->makeClinicWith($doctor);
$appointment = $this->makeAppointment($doctor, null);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $owner);
self::assertSame(403, $this->responseCode(), 'مطب شخصی پزشک از دسترس کلینیک خارج است');
}
public function testForeignClinicOwnerIsDenied(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
[$otherOwner] = $this->makeClinicWith($this->makeDoctor('دکتر دیگر'));
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $otherOwner);
self::assertSame(403, $this->responseCode());
}
public function testMemberDoctorLosesAccessWhenDeactivated(): void
{
$member = $this->makeDoctor('دکتر عضو');
$colleague = $this->makeDoctor('همکار');
[, $clinic] = $this->makeClinicWith($member, $colleague);
$appointment = $this->makeAppointment($colleague, $clinic);
$permissions = static::getContainer()->get(ClinicDoctorPermissionRepository::class);
$permissions->getOrCreate($clinic, $member);
$this->em->flush();
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $member->getUser());
self::assertSame(200, $this->responseCode(), 'پزشک فعالِ کلینیک نوبت‌های همان کلینیک را می‌بیند');
$permissions->getOrCreate($clinic, $member)->setActive(false);
$this->em->flush();
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $member->getUser());
self::assertSame(403, $this->responseCode(), 'پس از پایان همکاری دسترسی قطع می‌شود');
}
public function testClinicSecretaryCanManageAssignedDoctorAppointment(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$secretary = $this->makeClinicSecretary($clinic, $doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $secretary);
self::assertSame(200, $this->responseCode());
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $secretary, [
'status' => Appointment::STATUS_CONFIRMED,
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
}
public function testClinicSecretaryCannotTouchUnassignedDoctorAppointment(): void
{
$mine = $this->makeDoctor('پزشک من');
$theirs = $this->makeDoctor('پزشک دیگر');
[, $clinic] = $this->makeClinicWith($mine, $theirs);
$secretary = $this->makeClinicSecretary($clinic, $mine);
$appointment = $this->makeAppointment($theirs, $clinic);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $secretary);
self::assertSame(403, $this->responseCode(), 'منشی فقط پزشکان تخصیص‌یافتهٔ خودش را دارد');
}
public function testSecretaryCancelRequiresCancelPermission(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$secretary = $this->makeClinicSecretary($clinic, $doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $secretary, [
'status' => Appointment::STATUS_CANCELLED_BY_DOCTOR,
'version' => $appointment->getVersion(),
]);
self::assertSame(403, $this->responseCode(), 'لغو به‌صورت پیش‌فرض برای منشی خاموش است');
}
public function testSecretaryWithCancelPermissionCanCancel(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$secretary = $this->makeClinicSecretary($clinic, $doctor, ['cancel' => true]);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $secretary, [
'status' => Appointment::STATUS_CANCELLED_BY_DOCTOR,
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
}
public function testInlineStatusCannotBypassCancelGate(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$secretary = $this->makeClinicSecretary($clinic, $doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}", $secretary, [
'status' => Appointment::STATUS_CANCELLED_BY_DOCTOR,
'version' => $appointment->getVersion(),
]);
self::assertSame(403, $this->responseCode(), 'status درون‌خطی همان گیت لغو را دارد');
}
public function testOwnerDoctorKeepsFullAccessToOwnClinicAppointment(): void
{
$doctor = $this->makeDoctor();
[, $clinic] = $this->makeClinicWith($doctor);
$appointment = $this->makeAppointment($doctor, $clinic);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $doctor->getUser());
self::assertSame(200, $this->responseCode());
}
public function testPatientCanViewButNotRescheduleOwnAppointment(): void
{
$doctor = $this->makeDoctor();
$patient = $this->createUser();
$appointment = $this->makeAppointment($doctor, null, $patient);
$newStart = strtotime('+6 days 10:00');
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $patient);
self::assertSame(200, $this->responseCode());
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}", $patient, [
'slot_start' => $newStart,
'slot_end' => $newStart + 900,
'version' => $appointment->getVersion(),
]);
self::assertSame(403, $this->responseCode(), 'بیمار نوبت خودش را جابه‌جا نمی‌کند');
}
public function testPatientCanCancelOwnAppointment(): void
{
$doctor = $this->makeDoctor();
$patient = $this->createUser();
$appointment = $this->makeAppointment($doctor, null, $patient);
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $patient, [
'status' => Appointment::STATUS_CANCELLED_BY_USER,
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
}
public function testStrangerIsDenied(): void
{
$doctor = $this->makeDoctor();
$appointment = $this->makeAppointment($doctor, null);
$stranger = $this->createUser();
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $stranger);
self::assertSame(403, $this->responseCode());
}
}