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>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Representation;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* `can_edit` روی GET جزئیات — تنها منبع حقیقتِ پنل برای «فرم را باز کن یا نکن».
|
||||
*
|
||||
* هر دو اندپوینت عمومیاند و سایت عمومی هم مصرفشان میکند، پس بدون توکن باید
|
||||
* `false` بدهند و هیچ بخش دیگری از پاسخ عوض نشود.
|
||||
*/
|
||||
class ProfileCanEditFlagTest extends ApiTestCase
|
||||
{
|
||||
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'];
|
||||
}
|
||||
|
||||
/** GET بدون هیچ توکنی. */
|
||||
private function anonymousGet(string $uri): array
|
||||
{
|
||||
$this->client->request('GET', $uri);
|
||||
|
||||
return json_decode($this->client->getResponse()->getContent(), true) ?? [];
|
||||
}
|
||||
|
||||
/** پاسخ جزئیات دولایه است: کنترلر `success(['data' => …])` میدهد. */
|
||||
private function payload(array $body): array
|
||||
{
|
||||
return $body['data']['data'];
|
||||
}
|
||||
|
||||
private function canEdit(array $body): bool
|
||||
{
|
||||
self::assertArrayHasKey('can_edit', $this->payload($body), 'پاسخ باید همیشه can_edit داشته باشد');
|
||||
|
||||
return $this->payload($body)['can_edit'];
|
||||
}
|
||||
|
||||
// ── پزشک ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testDoctorFlagIsTrueForTheOwningRepresentativeOnly(): void
|
||||
{
|
||||
$owner = $this->newRepresentative();
|
||||
$stranger = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($owner);
|
||||
|
||||
self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $owner)));
|
||||
self::assertFalse($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $stranger)));
|
||||
}
|
||||
|
||||
public function testDoctorFlagIsFalseWithoutAToken(): void
|
||||
{
|
||||
$owner = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($owner);
|
||||
|
||||
$body = $this->anonymousGet('/api/v1/doctor/' . $uuid);
|
||||
|
||||
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
||||
self::assertFalse($this->canEdit($body));
|
||||
// بقیهٔ پاسخ نباید عوض شده باشد — سایت عمومی همین را مصرف میکند.
|
||||
self::assertArrayHasKey('uuid', $this->payload($body));
|
||||
self::assertArrayHasKey('clinics', $this->payload($body));
|
||||
}
|
||||
|
||||
public function testDoctorFlagIsTrueForTheDoctorAndForAnAdmin(): void
|
||||
{
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'پزشک فلگ');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
$uuid = $doctor->getUuid();
|
||||
|
||||
self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $doctorUser)));
|
||||
self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $this->createUser(['ROLE_ADMIN']))));
|
||||
self::assertFalse($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $this->createUser(['ROLE_USER']))));
|
||||
}
|
||||
|
||||
// ── کلینیک ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testClinicFlagIsTrueForTheOwningRepresentativeOnly(): void
|
||||
{
|
||||
$owner = $this->newRepresentative();
|
||||
$stranger = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($owner);
|
||||
|
||||
self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $owner)));
|
||||
self::assertFalse($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $stranger)));
|
||||
}
|
||||
|
||||
public function testClinicFlagIsFalseWithoutAToken(): void
|
||||
{
|
||||
$owner = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($owner);
|
||||
|
||||
$body = $this->anonymousGet('/api/v1/clinic/' . $uuid);
|
||||
|
||||
self::assertSame(200, $this->client->getResponse()->getStatusCode());
|
||||
self::assertFalse($this->canEdit($body));
|
||||
self::assertArrayHasKey('uuid', $this->payload($body));
|
||||
}
|
||||
|
||||
public function testClinicFlagIsTrueForTheOwnerAndForAnAdmin(): void
|
||||
{
|
||||
$ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($ownerUser);
|
||||
$clinic->setName('کلینیک فلگ');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
$uuid = $clinic->getUuid();
|
||||
|
||||
self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $ownerUser)));
|
||||
self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $this->createUser(['ROLE_ADMIN']))));
|
||||
self::assertFalse($this->canEdit($this->authJson('GET', '/api/v1/clinic/' . $uuid, $this->createUser(['ROLE_USER']))));
|
||||
}
|
||||
|
||||
// ── مرزی ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testFlagMatchesWhatThePatchActuallyAllows(): void
|
||||
{
|
||||
// اگر فلگ true بدهد ولی PATCH ۴۰۳ کند، پنل دکمهٔ مرده نشان میدهد.
|
||||
$owner = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($owner);
|
||||
|
||||
self::assertTrue($this->canEdit($this->authJson('GET', '/api/v1/doctor/' . $uuid, $owner)));
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $owner, ['info' => 'x']);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Representation;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* نمایندهٔ ثبتکننده روی کلینیکِ زیرمجموعهاش ویرایش میکند.
|
||||
*
|
||||
* قرینهٔ RepresentationProfileEditTest برای کلینیک، با دو تفاوت: فیلد ممنوعِ
|
||||
* شاخص اینجا `doctors` است (عضویت، نه محتوا)، و مسیر مجوز از
|
||||
* ClinicDoctorPermissionChecker میگذرد که عمداً دستنخورده مانده.
|
||||
*/
|
||||
class RepresentationClinicEditTest extends ApiTestCase
|
||||
{
|
||||
private function newRepresentative(): User
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
$this->em->persist(new Representation($user, 'نمایندهٔ ' . uniqid()));
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** کلینیکی که همان نماینده ثبتش کرده؛ 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'];
|
||||
}
|
||||
|
||||
private function editLogCount(): int
|
||||
{
|
||||
return (int) static::getContainer()->get(Connection::class)
|
||||
->fetchOne("SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'");
|
||||
}
|
||||
|
||||
private function reloadClinic(string $uuid): Clinic
|
||||
{
|
||||
$this->em->clear();
|
||||
|
||||
return $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
// ── مسیر موفق ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function testOwningRepresentativeCanEditLogoAndDescription(): void
|
||||
{
|
||||
$repUser = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($repUser);
|
||||
$before = $this->editLogCount();
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $repUser, [
|
||||
'clinic_logo' => 'https://example.test/logo.png',
|
||||
'info' => 'معرفی تازهٔ کلینیک',
|
||||
'telephone' => '03511111111',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($body['success']);
|
||||
|
||||
$clinic = $this->reloadClinic($uuid);
|
||||
self::assertSame('https://example.test/logo.png', $clinic->getClinicLogo());
|
||||
self::assertSame('معرفی تازهٔ کلینیک', $clinic->getInfo());
|
||||
self::assertSame($before + 1, $this->editLogCount());
|
||||
}
|
||||
|
||||
public function testTheWholePayloadTheEditFormSendsIsAccepted(): void
|
||||
{
|
||||
// فرم ویرایش کلینیک همیشه specialties و doctor_services و insurance را
|
||||
// میفرستد؛ اگر بیرون از whitelist باشند هر ذخیرهای ۴۰۳ میشود.
|
||||
$repUser = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($repUser);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $repUser, [
|
||||
'name' => 'کلینیک با نام تازه',
|
||||
'telephone' => '03514444444',
|
||||
'info' => 'توضیحات',
|
||||
'24_7' => true,
|
||||
'specialties' => [],
|
||||
'insurance' => [],
|
||||
'doctor_services' => [],
|
||||
'social_media' => [
|
||||
'instagram' => 'https://instagram.com/test',
|
||||
'telegram' => null,
|
||||
'aparat' => null,
|
||||
'youtube' => null,
|
||||
'linkedin' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('کلینیک با نام تازه', $this->reloadClinic($uuid)->getName());
|
||||
}
|
||||
|
||||
public function testSecretaryPreCheckDoesNotBlockARepresentative(): void
|
||||
{
|
||||
// denyUnlessGranted پیش از واکشی رکورد اجرا میشود؛ این تست تثبیت میکند که
|
||||
// نقشِ غیرمنشی از آن رد میشود و ۴۰۳ زودهنگام نمیگیرد.
|
||||
$repUser = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($repUser);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $repUser, ['info' => 'x']);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── مسیر خطا ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function testAnotherRepresentativeIsForbidden(): void
|
||||
{
|
||||
$owner = $this->newRepresentative();
|
||||
$stranger = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($owner);
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $stranger, ['info' => 'نباید ذخیره شود']);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
self::assertSame('ERR_AUTH_006', $body['errors'][0]['code']);
|
||||
self::assertNotSame('نباید ذخیره شود', $this->reloadClinic($uuid)->getInfo());
|
||||
}
|
||||
|
||||
public function testChangingClinicMembershipIsForbiddenForRepresentative(): void
|
||||
{
|
||||
$repUser = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($repUser);
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $repUser, [
|
||||
'info' => 'این هم نباید ذخیره شود',
|
||||
'doctors' => [1],
|
||||
]);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
self::assertSame('doctors', $body['errors'][0]['field']);
|
||||
self::assertNotSame('این هم نباید ذخیره شود', $this->reloadClinic($uuid)->getInfo());
|
||||
}
|
||||
|
||||
// ── مرزی ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testClinicWithoutARepresentationIsNotEditableByAnyRepresentative(): void
|
||||
{
|
||||
$repUser = $this->newRepresentative();
|
||||
|
||||
$ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$orphan = new Clinic($ownerUser);
|
||||
$orphan->setName('کلینیک بینماینده');
|
||||
$this->em->persist($orphan);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/clinic/' . $orphan->getUuid(), $repUser, ['info' => 'x']);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testClinicOwnerIsUnaffectedByTheWhitelist(): void
|
||||
{
|
||||
$ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($ownerUser);
|
||||
$clinic->setName('کلینیک خودگردان');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
$uuid = $clinic->getUuid();
|
||||
$before = $this->editLogCount();
|
||||
|
||||
// `doctors` برای نماینده ممنوع است اما برای مالک نه؛ آرایهٔ خالی یعنی «پاک کن».
|
||||
$this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $ownerUser, [
|
||||
'doctors' => [],
|
||||
'info' => 'مالک آزادانه ویرایش میکند',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('مالک آزادانه ویرایش میکند', $this->reloadClinic($uuid)->getInfo());
|
||||
self::assertSame($before, $this->editLogCount(), 'ویرایش مالک نباید لاگ نماینده بسازد');
|
||||
}
|
||||
|
||||
public function testAdminIsUnaffectedByTheWhitelist(): void
|
||||
{
|
||||
$repUser = $this->newRepresentative();
|
||||
$uuid = $this->clinicCreatedBy($repUser);
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$before = $this->editLogCount();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/clinic/' . $uuid, $admin, ['doctors' => [], 'info' => 'ادمین']);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('ادمین', $this->reloadClinic($uuid)->getInfo());
|
||||
self::assertSame($before, $this->editLogCount(), 'ویرایش ادمین نباید لاگ نماینده بسازد');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Representation;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Representation\Security\RepresentationEditLogger;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
/**
|
||||
* لاگِ ویرایشِ نماینده — یک ردیف app_log با channel اختصاصی، و هیچ ردیفی برای
|
||||
* کاربری که نماینده نیست.
|
||||
*/
|
||||
class RepresentationEditLoggerTest extends ApiTestCase
|
||||
{
|
||||
/**
|
||||
* سرویس فقط توسط کنترلرها مصرف میشود، پس کانتینر inlineاش میکند و از تست
|
||||
* قابل get نیست. public کردنش صرفاً برای تست، پیکربندی production را آلوده
|
||||
* میکرد؛ اینجا با همان وابستگیهای واقعی ساخته میشود.
|
||||
*/
|
||||
private function logger(): RepresentationEditLogger
|
||||
{
|
||||
return new RepresentationEditLogger(
|
||||
static::getContainer()->get(Connection::class),
|
||||
$this->em->getRepository(Representation::class),
|
||||
new RequestStack(),
|
||||
);
|
||||
}
|
||||
|
||||
private function editLogCount(): int
|
||||
{
|
||||
return (int) static::getContainer()->get(Connection::class)
|
||||
->fetchOne("SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'");
|
||||
}
|
||||
|
||||
private function newRepresentative(): User
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
$this->em->persist(new Representation($user, 'نمایندهٔ لاگ'));
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function testEditByRepresentativeWritesOneRow(): void
|
||||
{
|
||||
$user = $this->newRepresentative();
|
||||
$before = $this->editLogCount();
|
||||
|
||||
$this->logger()->logEdit($user, 'doctor', 'uuid-under-test', ['info' => 'x', 'images' => []]);
|
||||
|
||||
self::assertSame($before + 1, $this->editLogCount());
|
||||
}
|
||||
|
||||
public function testTheRowNamesTheRepresentativeAndTheChangedFields(): void
|
||||
{
|
||||
$user = $this->newRepresentative();
|
||||
$uuid = 'uuid-' . uniqid();
|
||||
|
||||
$this->logger()->logEdit($user, 'clinic', $uuid, ['clinic_logo' => 'https://a/b.png', 'info' => 'y']);
|
||||
|
||||
$row = static::getContainer()->get(Connection::class)->fetchAssociative(
|
||||
"SELECT message, context, level FROM app_log WHERE channel = 'representation_edit' ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
|
||||
self::assertSame('info', $row['level']);
|
||||
self::assertStringContainsString($uuid, $row['message']);
|
||||
|
||||
$context = json_decode((string) $row['context'], true);
|
||||
self::assertSame('clinic', $context['entity_type']);
|
||||
self::assertSame(['clinic_logo', 'info'], $context['fields']);
|
||||
// فقط کلیدها ثبت میشوند، نه مقادیر.
|
||||
self::assertStringNotContainsString('https://a/b.png', (string) $row['context']);
|
||||
}
|
||||
|
||||
public function testUserWithoutARepresentationRowWritesNothing(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER']);
|
||||
$before = $this->editLogCount();
|
||||
|
||||
$this->logger()->logEdit($user, 'doctor', 'uuid-x', ['info' => 'x']);
|
||||
|
||||
self::assertSame($before, $this->editLogCount());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Representation;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Representation\Repository\RepresentationRepository;
|
||||
use App\Representation\Security\RepresentationEditPolicy;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* سیاست ویرایش نماینده — بدون دیتابیس و بدون kernel.
|
||||
*
|
||||
* تنها قاعدهای که این کلاس نگه میدارد: مالکیت از representation_id میآید،
|
||||
* نه از نقش. داشتن ROLE_REPRESENTATION بهتنهایی هیچ اجازهای نمیدهد.
|
||||
*/
|
||||
class RepresentationEditPolicyTest extends TestCase
|
||||
{
|
||||
/** Representation با id مشخص — id در entity خصوصی و بدون setter است. */
|
||||
private function repWithId(User $user, int $id): Representation
|
||||
{
|
||||
$rep = new Representation($user, 'نمایندهٔ آزمون');
|
||||
|
||||
$ref = new \ReflectionProperty(Representation::class, 'id');
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($rep, $id);
|
||||
|
||||
return $rep;
|
||||
}
|
||||
|
||||
private function policyReturning(?Representation $rep): RepresentationEditPolicy
|
||||
{
|
||||
$repo = $this->createStub(RepresentationRepository::class);
|
||||
$repo->method('findByUser')->willReturn($rep);
|
||||
|
||||
return new RepresentationEditPolicy($repo);
|
||||
}
|
||||
|
||||
private function doctorOwnedBy(?int $representationId): Doctor
|
||||
{
|
||||
$doctor = new Doctor(new User('09120000001'), 'پزشک آزمون');
|
||||
$doctor->setRepresentationId($representationId);
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
private function clinicOwnedBy(?int $representationId): Clinic
|
||||
{
|
||||
$clinic = new Clinic(new User('09120000002'));
|
||||
$clinic->setRepresentationId($representationId);
|
||||
|
||||
return $clinic;
|
||||
}
|
||||
|
||||
// ── مالکیت ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testRepresentationOwningTheDoctorIsAllowed(): void
|
||||
{
|
||||
$user = new User('09120000003');
|
||||
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
|
||||
$policy = $this->policyReturning($this->repWithId($user, 7));
|
||||
|
||||
self::assertTrue($policy->ownsDoctor($user, $this->doctorOwnedBy(7)));
|
||||
}
|
||||
|
||||
public function testAnotherRepresentationIsRejected(): void
|
||||
{
|
||||
$user = new User('09120000004');
|
||||
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
|
||||
$policy = $this->policyReturning($this->repWithId($user, 7));
|
||||
|
||||
self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(8)));
|
||||
}
|
||||
|
||||
public function testDoctorWithoutRepresentationIsNeverOwned(): void
|
||||
{
|
||||
$user = new User('09120000005');
|
||||
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
|
||||
$policy = $this->policyReturning($this->repWithId($user, 7));
|
||||
|
||||
self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(null)));
|
||||
}
|
||||
|
||||
public function testUserWithoutTheRoleIsRejectedWithoutHittingTheRepository(): void
|
||||
{
|
||||
$user = new User('09120000006');
|
||||
$user->setRoles(['ROLE_USER']);
|
||||
|
||||
$repo = $this->createMock(RepresentationRepository::class);
|
||||
$repo->expects(self::never())->method('findByUser');
|
||||
|
||||
$policy = new RepresentationEditPolicy($repo);
|
||||
|
||||
self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(7)));
|
||||
}
|
||||
|
||||
public function testRoleWithoutRepresentationRowIsRejectedNotFatal(): void
|
||||
{
|
||||
$user = new User('09120000007');
|
||||
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
|
||||
$policy = $this->policyReturning(null);
|
||||
|
||||
self::assertFalse($policy->ownsDoctor($user, $this->doctorOwnedBy(7)));
|
||||
}
|
||||
|
||||
public function testClinicOwnershipFollowsTheSameRule(): void
|
||||
{
|
||||
$user = new User('09120000008');
|
||||
$user->setRoles(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
|
||||
$policy = $this->policyReturning($this->repWithId($user, 3));
|
||||
|
||||
self::assertTrue($policy->ownsClinic($user, $this->clinicOwnedBy(3)));
|
||||
self::assertFalse($policy->ownsClinic($user, $this->clinicOwnedBy(4)));
|
||||
}
|
||||
|
||||
// ── whitelist ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function testForbiddenClinicFieldIsNamed(): void
|
||||
{
|
||||
$policy = $this->policyReturning(null);
|
||||
|
||||
self::assertSame(
|
||||
'doctors',
|
||||
$policy->firstForbiddenField(['info' => 'x', 'doctors' => []], RepresentationEditPolicy::CLINIC_FIELDS),
|
||||
);
|
||||
}
|
||||
|
||||
public function testAllowedClinicPayloadPasses(): void
|
||||
{
|
||||
$policy = $this->policyReturning(null);
|
||||
|
||||
self::assertNull(
|
||||
$policy->firstForbiddenField(
|
||||
['info' => 'x', 'clinic_logo' => 'https://a/b.png', '24_7' => true],
|
||||
RepresentationEditPolicy::CLINIC_FIELDS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function testForbiddenDoctorFieldsAreNamed(): void
|
||||
{
|
||||
$policy = $this->policyReturning(null);
|
||||
|
||||
self::assertSame(
|
||||
'medical_system_code',
|
||||
$policy->firstForbiddenField(['medical_system_code' => '123'], RepresentationEditPolicy::DOCTOR_FIELDS),
|
||||
);
|
||||
self::assertSame(
|
||||
'active',
|
||||
$policy->firstForbiddenField(['info' => 'x', 'active' => true], RepresentationEditPolicy::DOCTOR_FIELDS),
|
||||
);
|
||||
}
|
||||
|
||||
public function testEmptyPayloadHasNoForbiddenField(): void
|
||||
{
|
||||
$policy = $this->policyReturning(null);
|
||||
|
||||
self::assertNull($policy->firstForbiddenField([], RepresentationEditPolicy::DOCTOR_FIELDS));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Representation;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Representation\Entity\Representation;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* نمایندهٔ ثبتکننده روی پزشکِ زیرمجموعهاش ویرایش میکند — و فقط فیلدهای محتوایی.
|
||||
*
|
||||
* دادهٔ تست از راه اندپوینت واقعیِ نماینده ساخته میشود تا representation_id
|
||||
* همانطور بنشیند که در تولید مینشیند.
|
||||
*/
|
||||
class RepresentationProfileEditTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Representation} */
|
||||
private function newRepresentative(): array
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
$rep = new Representation($user, 'نمایندهٔ ' . uniqid());
|
||||
$this->em->persist($rep);
|
||||
$this->em->flush();
|
||||
|
||||
return [$user, $rep];
|
||||
}
|
||||
|
||||
/** پزشکی که همان نماینده ثبتش کرده؛ uuid برمیگردد. */
|
||||
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 editLogCount(): int
|
||||
{
|
||||
return (int) static::getContainer()->get(Connection::class)
|
||||
->fetchOne("SELECT COUNT(*) FROM app_log WHERE channel = 'representation_edit'");
|
||||
}
|
||||
|
||||
private function reloadDoctor(string $uuid): Doctor
|
||||
{
|
||||
$this->em->clear();
|
||||
|
||||
return $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
// ── مسیر موفق ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function testOwningRepresentativeCanEditContentFields(): void
|
||||
{
|
||||
[$repUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($repUser);
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, [
|
||||
'info' => 'متن معرفی تازه',
|
||||
'degree' => 'متخصص پوست',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($body['success']);
|
||||
self::assertSame('متن معرفی تازه', $this->reloadDoctor($uuid)->getInfo());
|
||||
}
|
||||
|
||||
public function testTheWholePayloadTheEditFormSendsIsAccepted(): void
|
||||
{
|
||||
// همان کلیدهایی که DoctorDetailPage در حالت نماینده میفرستد؛ اگر یکی
|
||||
// بیرون از whitelist بماند، هر ذخیرهای ۴۰۳ میشود.
|
||||
[$repUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($repUser);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, [
|
||||
'title' => 'دکتر نام تازه',
|
||||
'gender' => 'man',
|
||||
'degree' => 'specialist',
|
||||
'mobile_number' => '09121111111',
|
||||
'info' => 'توضیحات',
|
||||
'activity_time' => 1600000000,
|
||||
'specialties' => [],
|
||||
'doctor_services' => [],
|
||||
'social_media' => [
|
||||
'instagram' => 'https://instagram.com/test',
|
||||
'telegram' => null,
|
||||
'aparat' => null,
|
||||
'youtube' => null,
|
||||
'linkedin' => null,
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('نام تازه', $this->reloadDoctor($uuid)->getName());
|
||||
}
|
||||
|
||||
public function testASuccessfulEditWritesExactlyOneLogRow(): void
|
||||
{
|
||||
[$repUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($repUser);
|
||||
$before = $this->editLogCount();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, ['info' => 'x']);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($before + 1, $this->editLogCount());
|
||||
}
|
||||
|
||||
// ── مسیر خطا ──────────────────────────────────────────────────────────────
|
||||
|
||||
public function testAnotherRepresentativeIsForbidden(): void
|
||||
{
|
||||
[$ownerUser] = $this->newRepresentative();
|
||||
[$strangerUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($ownerUser);
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $strangerUser, ['info' => 'نباید ذخیره شود']);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
self::assertSame('ERR_AUTH_006', $body['errors'][0]['code']);
|
||||
self::assertNotSame('نباید ذخیره شود', $this->reloadDoctor($uuid)->getInfo());
|
||||
}
|
||||
|
||||
public function testForbiddenFieldIsRejectedAndNothingIsSaved(): void
|
||||
{
|
||||
[$repUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($repUser);
|
||||
$before = $this->reloadDoctor($uuid)->getMedicalSystemCode();
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, [
|
||||
'info' => 'این هم نباید ذخیره شود',
|
||||
'medical_system_code' => '999999',
|
||||
]);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
self::assertSame('medical_system_code', $body['errors'][0]['field']);
|
||||
|
||||
$doctor = $this->reloadDoctor($uuid);
|
||||
self::assertSame($before, $doctor->getMedicalSystemCode());
|
||||
self::assertNotSame('این هم نباید ذخیره شود', $doctor->getInfo());
|
||||
}
|
||||
|
||||
public function testTogglingActiveThroughPatchIsForbiddenForRepresentative(): void
|
||||
{
|
||||
[$repUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($repUser);
|
||||
|
||||
$body = $this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $repUser, ['active' => true]);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
self::assertSame('active', $body['errors'][0]['field']);
|
||||
}
|
||||
|
||||
// ── مرزی ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testDoctorWithoutARepresentationIsNotEditableByAnyRepresentative(): void
|
||||
{
|
||||
[$repUser] = $this->newRepresentative();
|
||||
|
||||
$orphanUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$orphan = new Doctor($orphanUser, 'پزشک بینماینده');
|
||||
$this->em->persist($orphan);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/doctor/' . $orphan->getUuid(), $repUser, ['info' => 'x']);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testRoleWithoutARepresentationRowIsForbiddenNotFatal(): void
|
||||
{
|
||||
[$ownerUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($ownerUser);
|
||||
|
||||
$rowless = $this->createUser(['ROLE_USER', 'ROLE_REPRESENTATION']);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $rowless, ['info' => 'x']);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testDoctorEditingOwnProfileIsUnaffectedByTheWhitelist(): void
|
||||
{
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'پزشک خودگردان');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
$uuid = $doctor->getUuid();
|
||||
|
||||
$before = $this->editLogCount();
|
||||
// یکتا per-run: doctors.source_code ایندکس یکتا دارد و db_test هرگز پاک نمیشود.
|
||||
$code = 'mc' . substr(uniqid(), -8);
|
||||
|
||||
// کد نظام پزشکی برای نماینده ممنوع است اما برای خودِ پزشک نه.
|
||||
$this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $doctorUser, ['medical_system_code' => $code]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($code, $this->reloadDoctor($uuid)->getMedicalSystemCode());
|
||||
self::assertSame($before, $this->editLogCount(), 'ویرایش خودِ پزشک نباید لاگ نماینده بسازد');
|
||||
}
|
||||
|
||||
public function testAdminIsUnaffectedByTheWhitelist(): void
|
||||
{
|
||||
[$repUser] = $this->newRepresentative();
|
||||
$uuid = $this->doctorCreatedBy($repUser);
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$before = $this->editLogCount();
|
||||
$code = 'ac' . substr(uniqid(), -8);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/doctor/' . $uuid, $admin, ['medical_system_code' => $code]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($code, $this->reloadDoctor($uuid)->getMedicalSystemCode());
|
||||
self::assertSame($before, $this->editLogCount(), 'ویرایش ادمین نباید لاگ نماینده بسازد');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user