feat(appointment-settings): let clinics manage each member doctor's booking
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>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
<?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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user