Files
clinicpro/tests/Appointment/ClinicOwnerScheduleAccessTest.php
T
hamedandClaude Opus 4.8 c103c393f3 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>
2026-07-18 10:02:27 +03:30

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");
}
}