feat(doctor): complete IRIMC import feature — claim flow, least-privilege importer, unique import key
- Extract import logic from AdminApiController into DoctorImportService (thin DoctorImportController keeps the same route/contract) - Surrogate users get marker role ROLE_UNCLAIMED_DOCTOR (+ backfill command app:doctors:backfill-surrogate-role) enabling safe deletion after claim - DB-level UNIQUE (source, medical_system_code) + concurrent-import retry - Doctor profile claim flow (climed.md): shahkar + PersonInfo identity checks via existing ApiIrService, Persian name normalization (PersianText), pessimistic-lock race protection, DoctorClaimRequest audit table (national code hashed, mobile masked), doctor_claim rate limiter, public claim-info endpoint, welcome SMS - Admin support tools: manual transfer endpoint + paginated doctor-claims audit list + owner_status filter/fields in admin doctors list - Least privilege: system owner now gets ROLE_IMPORTER (ROLE_ADMIN stripped), import endpoint accepts ADMIN|IMPORTER, isStaff includes IMPORTER - Headless crawler login: X-Service-Token header bypasses captcha only (rate limit + password checks intact; empty env = no bypass) - docs: doctor-claim.md (new), doctor-import.md, admin.md, doctor.md - tests: DoctorImportTest (6), DoctorClaimTest (11), PersianTextTest (5) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
<?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 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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Doctor;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Regression contract for POST /api/v1/admin/doctors/import (IRIMC import).
|
||||
* Written BEFORE extracting the logic into DoctorImportService — the HTTP
|
||||
* contract (routes, statuses, {uuid, created, skipped} payload) must not change.
|
||||
*/
|
||||
class DoctorImportTest extends ApiTestCase
|
||||
{
|
||||
private function importPayload(string $code): array
|
||||
{
|
||||
return [
|
||||
'name' => 'دکتر تست ایمپورت',
|
||||
'medical_system_code' => $code,
|
||||
'source_ref' => 'https://membersearch.irimc.org/member/profile?id=test',
|
||||
'gender' => 'man',
|
||||
'degree' => 'general',
|
||||
'info' => 'دکترای حرفهای پزشکی',
|
||||
];
|
||||
}
|
||||
|
||||
/** db_test is never reset — randomise the natural key per run. */
|
||||
private function freshCode(): string
|
||||
{
|
||||
return 'T' . random_int(100_000, 999_999) . random_int(100, 999);
|
||||
}
|
||||
|
||||
public function testImportCreatesUnclaimedDoctorWithSurrogateUser(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
$code = $this->freshCode();
|
||||
|
||||
$data = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->importPayload($code));
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$this->assertTrue($data['success']);
|
||||
$this->assertTrue($data['data']['created']);
|
||||
$this->assertNotEmpty($data['data']['uuid']);
|
||||
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $data['data']['uuid']]);
|
||||
$this->assertSame('unclaimed', $doctor->getOwnerStatus());
|
||||
$this->assertSame('irimc', $doctor->getSource());
|
||||
$this->assertFalse($doctor->isActiveDoctorAppointment());
|
||||
|
||||
$surrogate = $doctor->getUser();
|
||||
$this->assertStringStartsWith('imp_', $surrogate->getMobileNumber());
|
||||
$this->assertSame(0, $surrogate->getStatus());
|
||||
$this->assertTrue($surrogate->hasRole('ROLE_UNCLAIMED_DOCTOR'));
|
||||
}
|
||||
|
||||
public function testReimportUpdatesInsteadOfDuplicating(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
$code = $this->freshCode();
|
||||
|
||||
$first = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->importPayload($code));
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
|
||||
$payload = $this->importPayload($code);
|
||||
$payload['name'] = 'دکتر تست ویرایششده';
|
||||
$second = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $payload);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertFalse($second['data']['created']);
|
||||
$this->assertSame($first['data']['uuid'], $second['data']['uuid']);
|
||||
|
||||
$count = $this->em->getRepository(Doctor::class)->count(['source' => 'irimc', 'medicalSystemCode' => $code]);
|
||||
$this->assertSame(1, $count);
|
||||
|
||||
$this->em->clear();
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $first['data']['uuid']]);
|
||||
$this->assertSame('دکتر تست ویرایششده', $doctor->getName());
|
||||
}
|
||||
|
||||
public function testClaimedDoctorIsNeverOverwritten(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
$code = $this->freshCode();
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $this->importPayload($code));
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
|
||||
$owner = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor->transferOwnershipTo($owner);
|
||||
$this->em->flush();
|
||||
$originalName = $doctor->getName();
|
||||
|
||||
$payload = $this->importPayload($code);
|
||||
$payload['name'] = 'دکتر بازنویسی ممنوع';
|
||||
$reimport = $this->authJson('POST', '/api/v1/admin/doctors/import', $admin, $payload);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertSame('claimed', $reimport['data']['skipped']);
|
||||
|
||||
$this->em->clear();
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]);
|
||||
$this->assertSame($originalName, $doctor->getName());
|
||||
$this->assertSame('claimed', $doctor->getOwnerStatus());
|
||||
}
|
||||
|
||||
public function testValidationErrors(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
|
||||
$this->authJson('POST', '/api/v1/admin/doctors/import', $admin, ['medical_system_code' => $this->freshCode()]);
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', '/api/v1/admin/doctors/import', $admin, ['name' => 'دکتر بیکد']);
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testNonAdminIsRejected(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER']);
|
||||
$this->authJson('POST', '/api/v1/admin/doctors/import', $user, $this->importPayload($this->freshCode()));
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testImporterRoleCanImportButNothingElse(): void
|
||||
{
|
||||
$importer = $this->createUser(['ROLE_USER', 'ROLE_IMPORTER']);
|
||||
|
||||
$this->authJson('POST', '/api/v1/admin/doctors/import', $importer, $this->importPayload($this->freshCode()));
|
||||
$this->assertSame(201, $this->responseCode(), 'ROLE_IMPORTER must be able to import');
|
||||
|
||||
$this->authJson('GET', '/api/v1/admin/users', $importer);
|
||||
$this->assertSame(403, $this->responseCode(), 'ROLE_IMPORTER must NOT reach other admin endpoints');
|
||||
|
||||
$this->authJson('GET', '/api/v1/admin/doctor-claims', $importer);
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Shared;
|
||||
|
||||
use App\Shared\Util\PersianText;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class PersianTextTest extends TestCase
|
||||
{
|
||||
public function testArabicYehAndKafAreUnified(): void
|
||||
{
|
||||
$this->assertTrue(PersianText::sameName("علي اكبري", 'علی اکبری'));
|
||||
}
|
||||
|
||||
public function testHalfSpaceAndExtraWhitespace(): void
|
||||
{
|
||||
$this->assertTrue(PersianText::sameName("محمد\u{200C}رضا کریمی ", 'محمد رضا کریمی'));
|
||||
}
|
||||
|
||||
public function testPersianAndArabicDigits(): void
|
||||
{
|
||||
$this->assertSame('1371/1/1', PersianText::normalize('۱۳۷۱/۱/۱'));
|
||||
$this->assertSame('0912', PersianText::normalize('٠٩١٢'));
|
||||
}
|
||||
|
||||
public function testStripDoctorTitle(): void
|
||||
{
|
||||
$this->assertSame('فرخنده حسینی', PersianText::stripDoctorTitle('دکتر فرخنده حسینی'));
|
||||
$this->assertSame('فرخنده حسینی', PersianText::stripDoctorTitle(' دکتر فرخنده حسینی '));
|
||||
$this->assertSame('فرخنده حسینی', PersianText::stripDoctorTitle('فرخنده حسینی'));
|
||||
}
|
||||
|
||||
public function testDifferentNamesStayDifferent(): void
|
||||
{
|
||||
$this->assertFalse(PersianText::sameName('علی اکبری', 'ولی اکبری'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user