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>
331 lines
13 KiB
PHP
331 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\Shared\Context\EntityContext;
|
|
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, $clinic);
|
|
if ($permissionPatch !== []) {
|
|
$secretary->mergePermissions(['resources' => ['appointments' => $permissionPatch]]);
|
|
}
|
|
$this->em->persist($secretary);
|
|
$this->em->flush();
|
|
|
|
static::getContainer()->get(UserActiveContextRepository::class)
|
|
->upsert($user, $clinic->getUuid(), EntityContext::TYPE_CLINIC);
|
|
|
|
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());
|
|
}
|
|
}
|