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