Files
clinicpro/tests/Doctor/DoctorClaimTest.php
T
hamedandClaude Opus 4.8 2f0131171d feat(doctor): claim captcha+mobile, owner profile delete, admin map zoom fix
- DoctorClaimController: ALTCHA CaptchaGuard on /claim (dev no-op via
  ALTCHA_ENABLED=false); optional `mobile` field must match the logged-in
  user's number (422 ERR_CONFLICT_001 on mismatch)
- DoctorController::delete: now IS_AUTHENTICATED_FULLY — admin (any) or the
  owner of a claimed profile (IDOR-guarded); FK appointment guard kept
- DoctorDetailPage address map: MapController calls map.invalidateSize()
  before flyTo (fixes needing to pick a city twice on a freshly-mounted map);
  geocode retries once (nominatim empty/429 on first hit)
- tests: mobile mismatch, owner-delete allowed + others 403, unclaimed not
  deletable by random user
- docs: doctor-claim.md (mobile+captcha), doctor.md (delete permission)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:57:41 +03:30

261 lines
11 KiB
PHP

<?php
namespace App\Tests\Doctor;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorClaimRequest;
use App\Shared\Service\ApiIrService;
use App\Tests\ApiTestCase;
/**
* جریان تصاحب پروفایل پزشک ایمپورت‌شده. ApiIrService همیشه mock می‌شود —
* تست‌ها هرگز به سرویس واقعی api.ir درخواست نمی‌زنند.
*/
class DoctorClaimTest extends ApiTestCase
{
protected function setUp(): void
{
parent::setUp();
$this->client->disableReboot();
}
private function mockApiIr(bool $shahkar = true, ?array $person = ['firstName' => 'تست', 'lastName' => 'ایمپورت', 'alive' => true]): void
{
$mock = $this->createMock(ApiIrService::class);
$mock->method('isConfigured')->willReturn(true);
$mock->method('shahkarMatch')->willReturn($shahkar);
$mock->method('personInfo')->willReturn($person);
static::getContainer()->set(ApiIrService::class, $mock);
}
private function importUnclaimedDoctor(): string
{
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$code = 'C' . random_int(100_000, 999_999) . random_int(100, 999);
$data = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, [
'name' => 'دکتر تست ایمپورت',
'medical_system_code' => $code,
]);
return $data['data']['uuid'];
}
private function claimBody(): array
{
// db_test هرگز reset نمی‌شود و users.national_code یکتاست → کد ملی هر تست تصادفی
return [
'national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
'birth_date' => '1371/1/1',
'first_name' => 'تست',
'last_name' => 'ایمپورت',
];
}
public function testSuccessfulClaimTransfersOwnershipAndDeletesSurrogate(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$surrogateId = $doctor->getUser()->getId();
$claimer = $this->createUser(['ROLE_USER']);
$res = $this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
$this->assertSame(200, $this->responseCode());
$this->assertSame('claimed', $res['data']['status']);
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$this->assertSame('claimed', $doctor->getOwnerStatus());
$this->assertSame($claimer->getId(), $doctor->getUser()->getId());
$this->assertNull($doctor->getManagedBy());
$claimer = $this->em->getRepository(User::class)->find($claimer->getId());
$this->assertTrue($claimer->hasRole('ROLE_DOCTOR'));
$this->assertTrue($claimer->isNationalCodeVerified());
$this->assertNull($this->em->getRepository(User::class)->find($surrogateId), 'surrogate must be deleted');
$claim = $this->em->getRepository(DoctorClaimRequest::class)->findOneBy(['doctor' => $doctor]);
$this->assertSame(DoctorClaimRequest::STATUS_COMPLETED, $claim->getStatus());
}
public function testNameMismatchRevertsToUnclaimed(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr(person: ['firstName' => 'کس', 'lastName' => 'دیگری', 'alive' => true]);
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
$this->assertSame(422, $this->responseCode());
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$this->assertSame('unclaimed', $doctor->getOwnerStatus(), 'must be free for the real doctor to retry');
$this->assertStringStartsWith('imp_', $doctor->getUser()->getMobileNumber(), 'surrogate must survive');
$claim = $this->em->getRepository(DoctorClaimRequest::class)->findOneBy(['doctor' => $doctor]);
$this->assertSame(DoctorClaimRequest::STATUS_FAILED, $claim->getStatus());
$this->assertNotNull($claim->getFailureReason());
}
public function testDeceasedPersonIsRejected(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr(person: ['firstName' => 'تست', 'lastName' => 'ایمپورت', 'alive' => false]);
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
$this->assertSame(422, $this->responseCode());
}
public function testShahkarMismatchIsRejected(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr(shahkar: false);
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
$this->assertSame(422, $this->responseCode());
}
public function testAlreadyClaimedIsConflict(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr();
$first = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $first, $this->claimBody());
$this->assertSame(200, $this->responseCode());
$second = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $second, $this->claimBody());
$this->assertSame(409, $this->responseCode());
}
public function testUserWhoAlreadyOwnsADoctorCannotClaim(): void
{
$uuidA = $this->importUnclaimedDoctor();
$uuidB = $this->importUnclaimedDoctor();
$this->mockApiIr();
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuidA}/claim", $claimer, $this->claimBody());
$this->assertSame(200, $this->responseCode());
$this->authJson('POST', "/api/v1/doctor/{$uuidB}/claim", $claimer, $this->claimBody());
$this->assertSame(409, $this->responseCode());
$this->em->clear();
$doctorB = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuidB]);
$this->assertSame('unclaimed', $doctorB->getOwnerStatus());
}
public function testValidationErrors(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr();
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, ['national_code' => '123', 'birth_date' => '1371/1/1', 'first_name' => 'الف', 'last_name' => 'ب']);
$this->assertSame(422, $this->responseCode());
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, ['national_code' => '0010007700', 'birth_date' => 'invalid', 'first_name' => 'الف', 'last_name' => 'ب']);
$this->assertSame(422, $this->responseCode());
}
public function testMobileMismatchIsRejected(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr();
$claimer = $this->createUser(['ROLE_USER']);
$body = $this->claimBody();
$body['mobile'] = '09990000000'; // متفاوت با موبایل کاربر
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $body);
$this->assertSame(422, $this->responseCode());
}
public function testOwnerCanDeleteOwnProfileButOthersCannot(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->mockApiIr();
$claimer = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/doctor/{$uuid}/claim", $claimer, $this->claimBody());
$this->assertSame(200, $this->responseCode());
// کاربر دیگر → 403
$other = $this->createUser(['ROLE_USER']);
$this->authJson('DELETE', "/api/v1/doctor/{$uuid}", $other);
$this->assertSame(403, $this->responseCode());
// مالک → 200
$this->authJson('DELETE', "/api/v1/doctor/{$uuid}", $claimer);
$this->assertSame(200, $this->responseCode());
$this->em->clear();
$this->assertNull($this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]));
}
public function testUnclaimedProfileNotDeletableByRandomUser(): void
{
$uuid = $this->importUnclaimedDoctor();
$user = $this->createUser(['ROLE_USER']);
$this->authJson('DELETE', "/api/v1/doctor/{$uuid}", $user);
$this->assertSame(403, $this->responseCode());
}
public function testClaimInfoIsPublic(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->client->request('GET', "/api/v1/doctor/{$uuid}/claim-info");
$this->assertSame(200, $this->responseCode());
$data = json_decode($this->client->getResponse()->getContent(), true);
$this->assertTrue($data['data']['claimable']);
$this->assertSame('unclaimed', $data['data']['owner_status']);
}
public function testAdminTransferHappyPath(): void
{
$uuid = $this->importUnclaimedDoctor();
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
$res = $this->authJson('POST', "/api/v1/admin/doctors/{$uuid}/transfer", $admin, ['mobile' => $mobile]);
$this->assertSame(200, $this->responseCode());
$this->assertSame('claimed', $res['data']['owner_status']);
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
$this->assertSame('claimed', $doctor->getOwnerStatus());
$this->assertSame($mobile, $doctor->getUser()->getMobileNumber());
$this->assertTrue($doctor->getUser()->hasRole('ROLE_DOCTOR'));
// transfer دوباره → 409
$this->authJson('POST', "/api/v1/admin/doctors/{$uuid}/transfer", $admin, ['mobile' => $mobile]);
$this->assertSame(409, $this->responseCode());
}
public function testAdminTransferRequiresAdmin(): void
{
$uuid = $this->importUnclaimedDoctor();
$user = $this->createUser(['ROLE_USER']);
$this->authJson('POST', "/api/v1/admin/doctors/{$uuid}/transfer", $user, ['mobile' => '09121234567']);
$this->assertSame(403, $this->responseCode());
}
public function testClaimRequiresAuthentication(): void
{
$uuid = $this->importUnclaimedDoctor();
$this->client->request('POST', "/api/v1/doctor/{$uuid}/claim", server: ['CONTENT_TYPE' => 'application/json'], content: json_encode($this->claimBody()));
$this->assertSame(401, $this->responseCode());
}
}