Files
clinicpro/tests/Representation/RepresentationAddressEditTest.php
T
hamedandClaude Opus 5 fb1cb20c11 feat(representation): let registering reps edit their doctors and clinics
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>
2026-08-08 15:50:17 +03:30

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