A representative could create a doctor or clinic but not finish its profile:
PATCH /api/v1/doctor/{uuid} accepted only the doctor or an admin, and the
clinic gate ran through ClinicDoctorPermissionChecker, which asks about clinic
membership — a representative is not a member. Onboarding stopped at an empty
public record.
Grant is permanent while representation_id points at the rep, and limited to
content: RepresentationEditPolicy holds ownership plus the field whitelist.
Sending a key outside it aborts the whole request with 403 and names the field,
rather than filtering the payload silently, so a rep never believes a change
saved when it did not. medical_system_code, `active` and clinic `doctors` stay
out — credential, and membership, belong to the record's owner. `active` already
has a dedicated rep endpoint.
ClinicDoctorPermissionChecker is untouched on purpose; folding a second concept
into it would give it two reasons to change.
Doctor/clinic detail responses now carry can_edit, computed by the same policy
the PATCH gate uses, so the panel reads authorization instead of re-deriving it
and drifting. Both endpoints stay public: no token means can_edit false and an
otherwise unchanged payload, which is what nobat724_front consumes.
Address endpoints follow the same policy. createAddress now resolves its target
from an explicit doctor_uuid instead of findByUser first — a representative who
also has a doctor profile was silently writing the address onto their own.
Every rep edit writes one app_log row (channel representation_edit) recording
who, what, and which field names — never values. Owner and admin edits write
nothing, keeping /admin/logs readable.
Docs corrected where they already disagreed with the code: 403/404 error codes
on both PATCH routes, a non-existent "cannot delete the last clinic address"
409, and the missing gallery-size 422.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
265 lines
11 KiB
PHP
265 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Representation;
|
|
|
|
use App\Auth\Entity\User;
|
|
use App\Clinic\Entity\Clinic;
|
|
use App\Doctor\Entity\Doctor;
|
|
use App\Doctor\Entity\DoctorAddress;
|
|
use App\Representation\Entity\Representation;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* آدرسها اندپوینت جدا دارند و whitelist ندارند — کل رکورد آدرس محتوایی است.
|
|
*
|
|
* حالت مرزیِ مهم: نمایندهای که خودش پزشک هم هست. پیش از این تغییر، createAddress
|
|
* اول findByUser میزد و چنین کاربری بیصدا آدرس را روی پروفایل خودش میساخت.
|
|
*/
|
|
class RepresentationAddressEditTest extends ApiTestCase
|
|
{
|
|
/**
|
|
* پاسخِ ساختِ آدرس دولایه است — کنترلر `success(['data' => …])` میدهد و
|
|
* BaseController خودش یک لایهٔ `data` دیگر میگذارد.
|
|
*/
|
|
private function createdAddress(array $body): array
|
|
{
|
|
return $body['data']['data'];
|
|
}
|
|
|
|
private function createdAddressId(array $body): int
|
|
{
|
|
return (int) $this->createdAddress($body)['id'];
|
|
}
|
|
|
|
private function createdAddressUuid(array $body): string
|
|
{
|
|
return $this->createdAddress($body)['uuid'];
|
|
}
|
|
|
|
private function newRepresentative(): User
|
|
{
|
|
$user = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
|
$this->em->persist(new Representation($user, 'نمایندهٔ ' . uniqid()));
|
|
$this->em->flush();
|
|
|
|
return $user;
|
|
}
|
|
|
|
private function doctorCreatedBy(User $repUser): string
|
|
{
|
|
$body = $this->authJson('POST', '/api/v1/representation/doctor', $repUser, [
|
|
'mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
|
'name' => 'دکتر آدرس ' . uniqid(),
|
|
]);
|
|
self::assertSame(201, $this->responseCode());
|
|
|
|
return $body['data']['uuid'];
|
|
}
|
|
|
|
private function clinicCreatedBy(User $repUser): string
|
|
{
|
|
$body = $this->authJson('POST', '/api/v1/representation/clinic', $repUser, [
|
|
'owner_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
|
'name' => 'کلینیک آدرس ' . uniqid(),
|
|
]);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
return $body['data']['uuid'];
|
|
}
|
|
|
|
// ── آدرس پزشک ─────────────────────────────────────────────────────────────
|
|
|
|
public function testRepresentativeCreatesUpdatesAndDeletesADoctorAddress(): void
|
|
{
|
|
$repUser = $this->newRepresentative();
|
|
$uuid = $this->doctorCreatedBy($repUser);
|
|
|
|
$created = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $repUser, [
|
|
'doctor_uuid' => $uuid,
|
|
'name' => 'مطب مرکزی',
|
|
'address' => 'یزد، خیابان آزمون',
|
|
'telephone' => '03512222222',
|
|
]);
|
|
self::assertSame(201, $this->responseCode());
|
|
$addressId = $this->createdAddressId($created);
|
|
|
|
$this->authJson('PATCH', '/api/v1/clinic-pro/doctor-address/' . $addressId, $repUser, [
|
|
'address' => 'یزد، خیابان تازه',
|
|
]);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$this->em->clear();
|
|
self::assertSame(
|
|
'یزد، خیابان تازه',
|
|
$this->em->getRepository(DoctorAddress::class)->find($addressId)->getAddress(),
|
|
);
|
|
|
|
$this->authJson('DELETE', '/api/v1/clinic-pro/doctor-address/' . $addressId, $repUser);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$this->em->clear();
|
|
self::assertNull($this->em->getRepository(DoctorAddress::class)->find($addressId));
|
|
}
|
|
|
|
public function testAnotherRepresentativeCannotTouchTheAddress(): void
|
|
{
|
|
$owner = $this->newRepresentative();
|
|
$stranger = $this->newRepresentative();
|
|
$uuid = $this->doctorCreatedBy($owner);
|
|
|
|
$created = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $owner, [
|
|
'doctor_uuid' => $uuid,
|
|
'address' => 'اصلی',
|
|
]);
|
|
$addressId = $this->createdAddressId($created);
|
|
|
|
$this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $stranger, [
|
|
'doctor_uuid' => $uuid,
|
|
'address' => 'نفوذی',
|
|
]);
|
|
self::assertSame(403, $this->responseCode());
|
|
|
|
$this->authJson('PATCH', '/api/v1/clinic-pro/doctor-address/' . $addressId, $stranger, ['address' => 'نفوذی']);
|
|
self::assertSame(403, $this->responseCode());
|
|
|
|
$this->authJson('DELETE', '/api/v1/clinic-pro/doctor-address/' . $addressId, $stranger);
|
|
self::assertSame(403, $this->responseCode());
|
|
|
|
$this->em->clear();
|
|
self::assertSame(
|
|
'اصلی',
|
|
$this->em->getRepository(DoctorAddress::class)->find($addressId)->getAddress(),
|
|
);
|
|
}
|
|
|
|
public function testDoctorUuidWinsOverTheSenderOwnProfile(): void
|
|
{
|
|
// نمایندهای که خودش پزشک هم هست: آدرس باید روی پزشکِ زیرمجموعه بنشیند،
|
|
// نه روی پروفایل خودش.
|
|
$repUser = $this->newRepresentative();
|
|
$selfDoc = new Doctor($repUser, 'پزشکِ خودِ نماینده');
|
|
$this->em->persist($selfDoc);
|
|
$this->em->flush();
|
|
$selfDocId = $selfDoc->getId();
|
|
|
|
$targetUuid = $this->doctorCreatedBy($repUser);
|
|
|
|
$created = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $repUser, [
|
|
'doctor_uuid' => $targetUuid,
|
|
'address' => 'باید روی پزشک زیرمجموعه بنشیند',
|
|
]);
|
|
self::assertSame(201, $this->responseCode());
|
|
|
|
$this->em->clear();
|
|
$address = $this->em->getRepository(DoctorAddress::class)->find($this->createdAddressId($created));
|
|
$target = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $targetUuid]);
|
|
|
|
self::assertSame($target->getId(), $address->getDoctor()->getId());
|
|
self::assertNotSame($selfDocId, $address->getDoctor()->getId());
|
|
}
|
|
|
|
public function testRepresentativeWithoutDoctorUuidGetsAValidationError(): void
|
|
{
|
|
$repUser = $this->newRepresentative();
|
|
|
|
$body = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $repUser, ['address' => 'بیهدف']);
|
|
|
|
self::assertSame(422, $this->responseCode());
|
|
self::assertSame('doctor_uuid', $body['errors'][0]['field']);
|
|
}
|
|
|
|
public function testPlainUserStillGetsForbidden(): void
|
|
{
|
|
$plain = $this->createUser(['ROLE_USER']);
|
|
|
|
$this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $plain, ['address' => 'x']);
|
|
|
|
self::assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
public function testDoctorCanStillCreateTheirOwnAddressWithoutUuid(): void
|
|
{
|
|
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
|
$doctor = new Doctor($doctorUser, 'پزشک خودگردان');
|
|
$this->em->persist($doctor);
|
|
$this->em->flush();
|
|
$doctorId = $doctor->getId();
|
|
|
|
$created = $this->authJson('POST', '/api/v1/clinic-pro/doctor-address', $doctorUser, ['address' => 'مطب خودم']);
|
|
|
|
self::assertSame(201, $this->responseCode());
|
|
|
|
$this->em->clear();
|
|
$address = $this->em->getRepository(DoctorAddress::class)->find($this->createdAddressId($created));
|
|
self::assertSame($doctorId, $address->getDoctor()->getId());
|
|
}
|
|
|
|
public function testClinicAddressIsStillUnreachableThroughTheDoctorAddressRoute(): void
|
|
{
|
|
$repUser = $this->newRepresentative();
|
|
$clinicUuid = $this->clinicCreatedBy($repUser);
|
|
|
|
$clinic = $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $clinicUuid]);
|
|
$address = DoctorAddress::forClinic($clinic->getId());
|
|
$address->setAddress('آدرس کلینیک');
|
|
$this->em->persist($address);
|
|
$this->em->flush();
|
|
|
|
$this->authJson('PATCH', '/api/v1/clinic-pro/doctor-address/' . $address->getId(), $repUser, ['address' => 'x']);
|
|
|
|
self::assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
// ── آدرس کلینیک ───────────────────────────────────────────────────────────
|
|
|
|
public function testRepresentativeCreatesUpdatesAndDeletesAClinicAddress(): void
|
|
{
|
|
$repUser = $this->newRepresentative();
|
|
$uuid = $this->clinicCreatedBy($repUser);
|
|
|
|
$created = $this->authJson('POST', '/api/v1/clinic/' . $uuid . '/address', $repUser, [
|
|
'address' => 'یزد، بلوار آزمون',
|
|
'telephone' => '03513333333',
|
|
]);
|
|
self::assertSame(201, $this->responseCode());
|
|
$addressUuid = $this->createdAddressUuid($created);
|
|
|
|
$this->authJson('PATCH', '/api/v1/clinic/' . $uuid . '/address/' . $addressUuid, $repUser, [
|
|
'address' => 'یزد، بلوار تازه',
|
|
]);
|
|
self::assertSame(200, $this->responseCode());
|
|
|
|
$this->authJson('DELETE', '/api/v1/clinic/' . $uuid . '/address/' . $addressUuid, $repUser);
|
|
self::assertSame(200, $this->responseCode());
|
|
}
|
|
|
|
public function testAnotherRepresentativeCannotTouchTheClinicAddress(): void
|
|
{
|
|
$owner = $this->newRepresentative();
|
|
$stranger = $this->newRepresentative();
|
|
$uuid = $this->clinicCreatedBy($owner);
|
|
|
|
$created = $this->authJson('POST', '/api/v1/clinic/' . $uuid . '/address', $owner, ['address' => 'اصلی']);
|
|
$addressUuid = $this->createdAddressUuid($created);
|
|
|
|
$this->authJson('PATCH', '/api/v1/clinic/' . $uuid . '/address/' . $addressUuid, $stranger, ['address' => 'نفوذی']);
|
|
self::assertSame(403, $this->responseCode());
|
|
|
|
$this->authJson('DELETE', '/api/v1/clinic/' . $uuid . '/address/' . $addressUuid, $stranger);
|
|
self::assertSame(403, $this->responseCode());
|
|
}
|
|
|
|
public function testClinicOwnerIsUnaffected(): void
|
|
{
|
|
$ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
|
$clinic = new Clinic($ownerUser);
|
|
$clinic->setName('کلینیک خودگردان');
|
|
$this->em->persist($clinic);
|
|
$this->em->flush();
|
|
|
|
$this->authJson('POST', '/api/v1/clinic/' . $clinic->getUuid() . '/address', $ownerUser, ['address' => 'مال خودم']);
|
|
|
|
self::assertSame(201, $this->responseCode());
|
|
}
|
|
}
|