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>
162 lines
6.8 KiB
PHP
162 lines
6.8 KiB
PHP
<?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());
|
|
}
|
|
}
|