A doctor working both at their own practice and at a clinic had to write two independent schedules and neither panel could see the other, so the clinic showed an empty form even though the doctor had configured their practice. The schedule is now a single record owned by the doctor. What varies between days is the place: the context of a shift is read from its location_id, not from the record it lives in. Booking in a context therefore sees only that context's days, so a personal-practice secretary still cannot book a clinic day. The caller's own context decides which addresses they may assign: the doctor gets every place of theirs, a clinic manager only its own, and shifts outside their reach are returned for display but preserved verbatim on save. Existing per-clinic rows are merged by migration; location_id was already stored on every shift, so no context information is lost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
266 lines
12 KiB
PHP
266 lines
12 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;
|
|
|
|
/**
|
|
* برنامهٔ هفتگی یکی است و مالِ خودِ پزشک؛ آنچه بین روزها فرق میکند مکان است.
|
|
*
|
|
* دسترسی همچنان per-context است، ولی روی *مکان* اعمال میشود نه روی *رکورد*: کلینیک
|
|
* فقط آدرس خودش را روی برنامه مینشاند و شیفتهای مطب شخصی را نه میبیندشان که پاک
|
|
* کند نه اجازهٔ ویرایششان دارد؛ خودِ پزشک هر دو را دارد.
|
|
*/
|
|
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 clinicAddress(Clinic $clinic): DoctorAddress
|
|
{
|
|
$address = DoctorAddress::forClinic($clinic->getId());
|
|
$this->em->persist($address);
|
|
$this->em->flush();
|
|
|
|
return $address;
|
|
}
|
|
|
|
private function schedulePayload(Doctor $doctor, int $locationId, string $start, ?Clinic $clinic = null): array
|
|
{
|
|
return array_filter([
|
|
'doctor_uuid' => $doctor->getUuid(),
|
|
'clinic_uuid' => $clinic?->getUuid(),
|
|
'schedule' => [
|
|
['day' => 'saturday', 'sessions' => [
|
|
['active' => true, 'location_id' => $locationId, 'start' => $start, 'end' => '12:00'],
|
|
]],
|
|
],
|
|
], fn($v) => $v !== null);
|
|
}
|
|
|
|
public function testClinicOwnerCanReadAndWriteMemberDoctorScheduleInClinicContext(): void
|
|
{
|
|
$doctor = $this->makeDoctor('دکتر عضو');
|
|
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
|
$address = $this->clinicAddress($clinic);
|
|
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $address->getId(), '09:00', $clinic));
|
|
self::assertSame(201, $this->responseCode());
|
|
|
|
$this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}?clinic_uuid={$clinic->getUuid()}", $owner);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$this->authJson('PATCH', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $owner, [
|
|
'clinic_uuid' => $clinic->getUuid(),
|
|
'schedule' => [
|
|
['day' => 'saturday', 'sessions' => [
|
|
['active' => true, 'location_id' => $address->getId(), 'start' => '10:00', 'end' => '13:00'],
|
|
]],
|
|
],
|
|
]);
|
|
self::assertSame(200, $this->responseCode());
|
|
}
|
|
|
|
public function testClinicOwnerCannotTouchDoctorPersonalSchedule(): 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(403, $this->responseCode(), 'مطب شخصی پزشک از دسترس کلینیک خارج است');
|
|
}
|
|
|
|
public function testClinicContextRejectsPersonalAddress(): void
|
|
{
|
|
$doctor = $this->makeDoctor('دکتر عضو');
|
|
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
|
$personal = $this->addressFor($doctor);
|
|
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $personal->getId(), '09:00', $clinic));
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
|
|
public function testDoctorMayPlaceTheirClinicAddressOnTheirOwnSchedule(): void
|
|
{
|
|
$doctor = $this->makeDoctor('دکتر عضو');
|
|
[, $clinic] = $this->makeClinicWith($doctor);
|
|
$clinicAddress = $this->clinicAddress($clinic);
|
|
|
|
// پزشک روز به روز جا عوض میکند؛ برنامهاش یکی است، پس هر دو آدرسش انتخابشدنیاند.
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $clinicAddress->getId(), '09:00'));
|
|
|
|
self::assertSame(201, $this->responseCode());
|
|
}
|
|
|
|
public function testDoctorCannotUseAnAddressOfAClinicTheyDoNotBelongTo(): void
|
|
{
|
|
$doctor = $this->makeDoctor('دکتر مستقل');
|
|
$outsider = $this->makeDoctor('دکتر دیگر');
|
|
[, $clinic] = $this->makeClinicWith($outsider);
|
|
$foreign = $this->clinicAddress($clinic);
|
|
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $foreign->getId(), '09:00'));
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
}
|
|
|
|
public function testStrangerClinicUuidIsRejected(): void
|
|
{
|
|
$doctor = $this->makeDoctor('دکتر مستقل');
|
|
$outsider = $this->makeDoctor('دکتر دیگر');
|
|
[, $clinic] = $this->makeClinicWith($outsider);
|
|
$address = $this->addressFor($doctor);
|
|
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $address->getId(), '09:00', $clinic));
|
|
|
|
self::assertSame(422, $this->responseCode(), 'پزشک عضو این کلینیک نیست');
|
|
}
|
|
|
|
public function testOneScheduleHoldsShiftsOfBothPlaces(): void
|
|
{
|
|
$doctor = $this->makeDoctor('دکتر دو-محیطی');
|
|
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
|
$personal = $this->addressFor($doctor);
|
|
$inClinic = $this->clinicAddress($clinic);
|
|
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $personal->getId(), '08:00'));
|
|
self::assertSame(201, $this->responseCode());
|
|
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $inClinic->getId(), '16:00', $clinic));
|
|
self::assertSame(201, $this->responseCode());
|
|
|
|
$this->em->clear();
|
|
$reloadedDoctor = $this->em->getRepository(Doctor::class)->find($doctor->getId());
|
|
$schedules = $this->em->getRepository(WeeklySchedule::class)->findAllByDoctor($reloadedDoctor);
|
|
|
|
self::assertCount(1, $schedules, 'یک برنامه برای هر پزشک، نه یکی به ازای هر محیط');
|
|
|
|
$byLocation = [];
|
|
foreach ($schedules[0]->getSetting()[0]['sessions'] as $session) {
|
|
$byLocation[(int) $session['location_id']] = $session['start'];
|
|
}
|
|
|
|
// نوشتنِ کلینیک، شیفت مطب شخصی را پاک نکرده — همان چیزی که ادغام باید تضمین کند.
|
|
self::assertSame('08:00', $byLocation[$personal->getId()] ?? null);
|
|
self::assertSame('16:00', $byLocation[$inClinic->getId()] ?? null);
|
|
}
|
|
|
|
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->clinicAddress($clinic);
|
|
|
|
$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', $clinic));
|
|
|
|
self::assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
public function testEditingOneDoctorDoesNotAffectAnother(): void
|
|
{
|
|
$first = $this->makeDoctor('دکتر اول');
|
|
$second = $this->makeDoctor('دکتر دوم');
|
|
[$owner, $clinic] = $this->makeClinicWith($first, $second);
|
|
$address = $this->clinicAddress($clinic);
|
|
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($first, $address->getId(), '08:00', $clinic));
|
|
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($second, $address->getId(), '16:00', $clinic));
|
|
|
|
$this->authJson('PATCH', "/api/v1/appointment-settings/weekly-schedule/{$first->getUuid()}", $owner, [
|
|
'clinic_uuid' => $clinic->getUuid(),
|
|
'schedule' => [
|
|
['day' => 'saturday', 'sessions' => [
|
|
['active' => true, 'location_id' => $address->getId(), 'start' => '11:00', 'end' => '15:00'],
|
|
]],
|
|
],
|
|
]);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$this->em->clear();
|
|
$reloaded = $this->em->getRepository(WeeklySchedule::class)->findByDoctorAndClinic(
|
|
$this->em->getRepository(Doctor::class)->find($second->getId()),
|
|
$this->em->getRepository(Clinic::class)->find($clinic->getId()),
|
|
);
|
|
|
|
self::assertSame('16:00', $reloaded->getSetting()[0]['sessions'][0]['start'], "the other doctor's schedule is untouched");
|
|
}
|
|
}
|