The API and React components were already parameterized by doctor uuid, but 14 copy-pasted identity checks limited every endpoint to "the doctor themselves or an admin", so a clinic owner could not touch a member doctor's booking setup. - Replaces those 14 checks with one denyDoctorAccess() that also admits the owner of a clinic the doctor belongs to, and a member doctor holding the clinic's appointment_settings permission (view for GET, update for writes). A doctor's own settings short-circuit before any permission lookup. - Moves ScheduleSection and its tabs out of DoctorDetailPage into components/schedule/ScheduleSection.tsx so the doctor panel and the new clinic page render the same module instead of one page importing another. Pure relocation — no logic changed. - Adds ClinicAppointmentSettingsPage: one tab per clinic doctor, each rendering that same section. The tab wrapper is keyed by doctor uuid so in-progress schedule edits cannot leak onto the wrong doctor. - insurance-pricing accepts an optional doctor_uuid (query on GET, body on PUT) under the same access rule, so the visit-price card works inside the clinic tabs. Fixes saveInsurancePricing calling getInsurancePricing with the wrong argument by extracting the shared pricingPayload(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
178 lines
7.2 KiB
PHP
178 lines
7.2 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Appointment;
|
|
|
|
use App\Appointment\Entity\WeeklySchedule;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Doctor\Entity\DoctorAddress;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* Appointment settings are reachable by the doctor, an admin, and the owner of a
|
|
* clinic the doctor belongs to. A member doctor's own access is governed by the
|
|
* clinic's appointment_settings permission. Edits never leak across doctors.
|
|
*/
|
|
class ClinicOwnerScheduleAccessTest 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;
|
|
}
|
|
|
|
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 addressFor(Doctor $doctor): DoctorAddress
|
|
{
|
|
$address = DoctorAddress::forDoctor($doctor);
|
|
$this->em->persist($address);
|
|
$this->em->flush();
|
|
|
|
return $address;
|
|
}
|
|
|
|
private function schedulePayload(Doctor $doctor, int $locationId, string $start): array
|
|
{
|
|
return [
|
|
'doctor_uuid' => $doctor->getUuid(),
|
|
'schedule' => [
|
|
['day' => 'saturday', 'sessions' => [
|
|
['active' => true, 'location_id' => $locationId, 'start' => $start, 'end' => '12:00'],
|
|
]],
|
|
],
|
|
];
|
|
}
|
|
|
|
public function testClinicOwnerCanReadAndWriteMemberDoctorSchedule(): void
|
|
{
|
|
$doctor = $this->makeDoctor('دکتر عضو');
|
|
[$owner] = $this->makeClinicWith($doctor);
|
|
$address = $this->addressFor($doctor);
|
|
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $address->getId(), '09:00'));
|
|
self::assertSame(201, $this->responseCode());
|
|
|
|
$this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $owner);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$this->authJson('PATCH', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $owner, [
|
|
'schedule' => [
|
|
['day' => 'saturday', 'sessions' => [
|
|
['active' => true, 'location_id' => $address->getId(), 'start' => '10:00', 'end' => '13:00'],
|
|
]],
|
|
],
|
|
]);
|
|
self::assertSame(200, $this->responseCode());
|
|
}
|
|
|
|
public function testClinicOwnerCannotTouchOutsideDoctor(): void
|
|
{
|
|
$member = $this->makeDoctor('دکتر عضو');
|
|
[$owner] = $this->makeClinicWith($member);
|
|
$stranger = $this->makeDoctor('دکتر بیرونی');
|
|
$address = $this->addressFor($stranger);
|
|
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($stranger, $address->getId(), '09:00'));
|
|
|
|
self::assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
public function testDoctorKeepsFullAccessToOwnSchedule(): void
|
|
{
|
|
$doctor = $this->makeDoctor('دکتر مستقل');
|
|
$address = $this->addressFor($doctor);
|
|
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $address->getId(), '09:00'));
|
|
self::assertSame(201, $this->responseCode());
|
|
|
|
$this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $doctor->getUser());
|
|
self::assertSame(200, $this->responseCode());
|
|
}
|
|
|
|
public function testDoctorCannotTouchAnotherDoctorSchedule(): void
|
|
{
|
|
$mine = $this->makeDoctor('دکتر یک');
|
|
$theirs = $this->makeDoctor('دکتر دو');
|
|
$address = $this->addressFor($theirs);
|
|
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $mine->getUser(), $this->schedulePayload($theirs, $address->getId(), '09:00'));
|
|
|
|
self::assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
public function testAdminCanWriteAnyDoctorSchedule(): void
|
|
{
|
|
$doctor = $this->makeDoctor('دکتر هدف');
|
|
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
|
$address = $this->addressFor($doctor);
|
|
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $admin, $this->schedulePayload($doctor, $address->getId(), '09:00'));
|
|
|
|
self::assertSame(201, $this->responseCode());
|
|
}
|
|
|
|
public function testMemberDoctorLosesAccessWhenPermissionRevoked(): void
|
|
{
|
|
$doctor = $this->makeDoctor('دکتر عضو');
|
|
$other = $this->makeDoctor('دکتر دیگر');
|
|
[, $clinic] = $this->makeClinicWith($doctor, $other);
|
|
$address = $this->addressFor($other);
|
|
|
|
$perm = static::getContainer()->get(ClinicDoctorPermissionRepository::class)->getOrCreate($clinic, $doctor);
|
|
$perm->mergePermissions(['resources' => ['appointment_settings' => ['update' => false, 'view' => false]]]);
|
|
$this->em->flush();
|
|
|
|
// پزشک همچنان به برنامهٔ خودش دسترسی دارد؛ مجوز کلینیک فقط دیگران را محدود میکند
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($other, $address->getId(), '09:00'));
|
|
|
|
self::assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
public function testEditingOneDoctorDoesNotAffectAnother(): void
|
|
{
|
|
$first = $this->makeDoctor('دکتر اول');
|
|
$second = $this->makeDoctor('دکتر دوم');
|
|
[$owner] = $this->makeClinicWith($first, $second);
|
|
$addrFirst = $this->addressFor($first);
|
|
$addrSecond = $this->addressFor($second);
|
|
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($first, $addrFirst->getId(), '08:00'));
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($second, $addrSecond->getId(), '16:00'));
|
|
|
|
$this->authJson('PATCH', "/api/v1/appointment-settings/weekly-schedule/{$first->getUuid()}", $owner, [
|
|
'schedule' => [
|
|
['day' => 'saturday', 'sessions' => [
|
|
['active' => true, 'location_id' => $addrFirst->getId(), 'start' => '11:00', 'end' => '15:00'],
|
|
]],
|
|
],
|
|
]);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$this->em->clear();
|
|
$reloaded = $this->em->getRepository(WeeklySchedule::class)->findOneBy([
|
|
'doctor' => $this->em->getRepository(Doctor::class)->find($second->getId()),
|
|
]);
|
|
|
|
self::assertSame('16:00', $reloaded->getSetting()[0]['sessions'][0]['start'], "the other doctor's schedule is untouched");
|
|
}
|
|
}
|