feat(validation): enforce naming rules for doctors and clinics to prevent placeholders

This commit is contained in:
hamed
2026-07-19 08:38:11 +03:30
parent 44b89d7add
commit d780b5cbb6
9 changed files with 461 additions and 3 deletions
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Tests\Doctor;
use App\Clinic\Entity\Clinic;
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
use App\Doctor\Entity\Doctor;
use App\Shared\Exception\AppException;
use App\Tests\ApiTestCase;
/**
* A doctor/clinic name reaches the public site's <title> and search results, so a
* phone number or "test" must never be storable. The guard lives on the entity
* because eight different call sites construct a Doctor.
*/
class PollutedNameRejectionTest extends ApiTestCase
{
public function testDoctorCannotBeCreatedWithPhoneNumberAsName(): void
{
$this->expectException(AppException::class);
new Doctor($this->createUser(['ROLE_DOCTOR']), '09390039833');
}
public function testDoctorCannotBeRenamedToPlaceholder(): void
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر علی احمدی');
$this->expectException(AppException::class);
$doctor->setName('test');
}
public function testClinicCannotBeNamedAfterPhoneNumber(): void
{
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$this->expectException(AppException::class);
$clinic->setName('09398631203');
}
public function testClinicNameMayStayNullBeforeItIsSet(): void
{
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$clinic->setName(null);
$this->assertNull($clinic->getName());
}
public function testAdminCreatingDoctorWithPhoneNameGets422(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$this->em->flush();
$this->authJson('POST', '/api/v1/admin/doctors', $admin, [
'name' => '09390039833',
'mobile_number' => '0912' . random_int(1000000, 9999999),
]);
$this->assertSame(422, $this->responseCode());
}
/**
* ریشهٔ آلودگی تولیدی: دعوت پزشک فقط با موبایل، شماره را به‌عنوان نام می‌نشاند.
*/
public function testInvitingDoctorByMobileDoesNotUseMobileAsName(): void
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$this->em->flush();
$mobile = '0912' . random_int(1000000, 9999999);
$this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
'mobile' => $mobile,
]);
$this->assertSame(201, $this->responseCode());
$invitation = $this->em->getRepository(ClinicDoctorInvitation::class)
->findOneBy(['mobile' => $mobile]);
$this->assertNotNull($invitation, 'invitation was not created');
$this->client->request('POST', "/api/v1/clinic-invitation/{$invitation->getToken()}/accept");
$this->assertSame(200, $this->responseCode());
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['mobileNumber' => $mobile]);
$this->assertNotNull($doctor, 'invited doctor was not created');
$this->assertNotSame($mobile, $doctor->getName(), 'mobile number leaked into the doctor name');
$this->assertSame('پزشک دعوت‌شده', $doctor->getName());
}
public function testInvitedNameIsUsedWhenTheClinicProvidesOne(): void
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$this->em->flush();
$mobile = '0912' . random_int(1000000, 9999999);
$this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
'mobile' => $mobile,
'name' => 'دکتر مریم رضایی',
]);
$this->assertSame(201, $this->responseCode());
$invitation = $this->em->getRepository(ClinicDoctorInvitation::class)
->findOneBy(['mobile' => $mobile]);
$this->client->request('POST', "/api/v1/clinic-invitation/{$invitation->getToken()}/accept");
$this->assertSame(200, $this->responseCode());
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['mobileNumber' => $mobile]);
$this->assertSame('دکتر مریم رضایی', $doctor->getName());
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Tests\Shared;
use App\Shared\Exception\AppException;
use App\Shared\Util\DisplayName;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* These rules must stay in sync with nobat724_front/lib/entityQuality.js — the
* public site noindexes exactly the records this class refuses to create.
*/
class DisplayNameTest extends TestCase
{
/** @return array<string, array{string}> */
public static function pollutedNames(): array
{
return [
'mobile' => ['09390039833'],
'mobile without leading' => ['9390039833'],
'mobile with spaces' => ['0939 003 9833'],
'mobile with dashes' => ['0939-003-9833'],
'test latin' => ['test'],
'test latin uppercase' => ['TEST'],
'test persian' => ['تست'],
'dash' => ['-'],
'empty' => [''],
'whitespace only' => [' '],
'single char' => ['a'],
'literal null' => ['null'],
];
}
#[DataProvider('pollutedNames')]
public function testPollutedNamesAreRejected(string $name): void
{
// «{$name}» — بدون آکولاد، PHP کاراکتر » را جزو نام متغیر می‌گیرد
$this->assertTrue(DisplayName::isPlaceholder($name), "«{$name}» باید نامعتبر باشد");
$this->expectException(AppException::class);
DisplayName::assertReal($name);
}
/** @return array<string, array{string}> */
public static function realNames(): array
{
return [
'persian full name' => ['سیده مهدیه کشاورز'],
'with title' => ['دکتر علی احمدی'],
'clinic name' => ['کلینیک تخصصی امید تبریز'],
'short persian' => ['رضا'],
'latin name' => ['John Smith'],
// شماره‌ای که الگوی موبایل ایران نیست، نام عجیبی است ولی سانسور نمی‌شود
'landline' => ['02191550875'],
];
}
#[DataProvider('realNames')]
public function testRealNamesPass(string $name): void
{
$this->assertFalse(DisplayName::isPlaceholder($name), "«{$name}» باید معتبر باشد");
DisplayName::assertReal($name);
$this->addToAssertionCount(1);
}
public function testNullIsTreatedAsPlaceholder(): void
{
$this->assertTrue(DisplayName::isPlaceholder(null));
}
public function testRejectionCarries422(): void
{
try {
DisplayName::assertReal('09390039833');
$this->fail('expected AppException');
} catch (AppException $e) {
$this->assertSame(422, $e->getHttpStatus());
$this->assertStringContainsString('نام معتبر نیست', $e->getMessage());
}
}
}