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>
200 lines
8.6 KiB
PHP
200 lines
8.6 KiB
PHP
<?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(), 'ویرایش ادمین نباید لاگ نماینده بسازد');
|
|
}
|
|
}
|