Files
clinicpro/tests/Doctor/DoctorImportTest.php
T
hamedandClaude Opus 4.8 af125572c9 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>
2026-07-11 11:39:15 +03:30

139 lines
5.8 KiB
PHP

<?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());
}
}