createUser() drew a random mobile and recovered from a collision by catching the unique-constraint violation and calling resetManager(). That hands back a brand-new EntityManager, which detaches every entity the running test had built so far; its next flush died with "Multiple non-persisted new entities were found", always in a different test and never reproducible in isolation. The number is now checked before the insert, so the collision never reaches the database and the manager stays open. testParentIdAddsNoQueryPerSpecialty counted queries on the first request of each size, so one-shot per-process caches — site config, subscription plan, Doctrine metadata — landed inside the count or not depending on which tests had run before it. Both requests are now warmed first; the assertion measures steady-state growth, which is what it was always about. The 23 PHPUnit notices were all one complaint: doubles created with createMock() that never had an expectation. The ones that only stub return values became createStub(); in SmsServiceLookupOnlyTest the provider and the bus got the expectations they were missing, since "dispatch does not touch the provider" and "sendNow does not enqueue" are exactly what that suite is there to prove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
261 lines
11 KiB
PHP
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->createStub(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());
|
|
}
|
|
}
|