Merge branch 'dev' into main

# Conflicts:
#	docs/api/doctor.md
This commit is contained in:
hamed
2026-07-19 16:15:30 +03:30
1026 changed files with 190049 additions and 15130 deletions
@@ -0,0 +1,119 @@
<?php
namespace App\Tests\Doctor;
use App\Appointment\Entity\WeeklySchedule;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* The public doctor payload must aggregate booking state across every weekly
* schedule (personal + each clinic). A doctor whose personal schedule is empty
* but who is bookable at a clinic used to be reported as «نوبت‌دهی غیرفعال».
*/
class DoctorBookingStateAggregationTest extends ApiTestCase
{
/** پاسخ عمومی doctor به شکل {data:{data:{…}}} است. */
private function doctorPayload(): array
{
$json = json_decode($this->client->getResponse()->getContent(), true) ?? [];
return $json['data']['data'] ?? [];
}
private function week(array $session): array
{
$days = array_fill_keys(range(0, 6), ['sessions' => []]);
$days[0] = ['sessions' => [$session]];
return $days;
}
private function session(bool $active, int $locationId): array
{
return [
'active' => $active,
'location_id' => $locationId,
'start_time' => '09:00',
'end_time' => '13:00',
'duration_per_patient' => 20,
];
}
/** @return array{doctor: Doctor, personal: WeeklySchedule, clinic: WeeklySchedule} */
private function makeDoctorWithInactivePersonalAndClinic(): array
{
$doctor = new Doctor($this->createUser(['ROLE_USER', 'ROLE_DOCTOR']), 'دکتر تجمیع');
$this->em->persist($doctor);
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
$clinic->setName('کلینیک تجمیع');
$clinic->getDoctors()->add($doctor);
$this->em->persist($clinic);
$this->em->flush();
$personalAddress = DoctorAddress::forDoctor($doctor);
$this->em->persist($personalAddress);
$clinicAddress = DoctorAddress::forClinic($clinic->getId());
$this->em->persist($clinicAddress);
$this->em->flush();
$personal = new WeeklySchedule($doctor, $this->week($this->session(false, $personalAddress->getId())));
$clinicSchedule = new WeeklySchedule(
$doctor,
$this->week($this->session(true, $clinicAddress->getId())),
$clinic
);
$this->em->persist($personal);
$this->em->persist($clinicSchedule);
$this->em->flush();
return ['doctor' => $doctor, 'personal' => $personal, 'clinic' => $clinicSchedule];
}
public function testClinicScheduleKeepsDoctorBookableDespiteEmptyPersonalSchedule(): void
{
['doctor' => $doctor] = $this->makeDoctorWithInactivePersonalAndClinic();
$this->client->request('GET', '/api/v1/doctor/' . $doctor->getUuid());
$data = $this->doctorPayload();
$this->assertTrue($data['active']);
$this->assertStringContainsString('شنبه', $data['free_turn']);
}
public function testAllSchedulesDisabledReportsBookingDisabled(): void
{
['doctor' => $doctor, 'personal' => $personal, 'clinic' => $clinicSchedule]
= $this->makeDoctorWithInactivePersonalAndClinic();
$personal->setMeta(['online_booking_enabled' => false]);
$clinicSchedule->setMeta(['online_booking_enabled' => false]);
$this->em->flush();
$this->client->request('GET', '/api/v1/doctor/' . $doctor->getUuid());
$data = $this->doctorPayload();
$this->assertFalse($data['active']);
$this->assertSame('نوبت‌دهی آنلاین غیرفعال است', $data['free_turn']);
}
public function testDisabledClinicScheduleDoesNotMaskActivePersonalSchedule(): void
{
['doctor' => $doctor, 'personal' => $personal, 'clinic' => $clinicSchedule]
= $this->makeDoctorWithInactivePersonalAndClinic();
// برعکسِ سناریوی اول: شخصی فعال، کلینیکی خاموش — ترتیب ردیف‌ها نباید مهم باشد.
$personal->setSetting($this->week($this->session(true, 0)));
$clinicSchedule->setMeta(['online_booking_enabled' => false]);
$this->em->flush();
$this->client->request('GET', '/api/v1/doctor/' . $doctor->getUuid());
$data = $this->doctorPayload();
$this->assertTrue($data['active']);
$this->assertStringContainsString('شنبه', $data['free_turn']);
}
}
+190
View File
@@ -0,0 +1,190 @@
<?php
namespace App\Tests\Doctor;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Doctor\Repository\DoctorRepository;
use App\Location\Entity\City;
use App\Location\Entity\Province;
use App\Tests\ApiTestCase;
/**
* The public doctor list must expose each doctor's city/state so multi-domain
* consumers (nobat724_front sitemap) can tell which city domain owns a doctor.
*
* Location resolution mirrors the city_id/state_id filter in
* DoctorRepository::findWithFilters — own address first, clinic address as
* fallback — otherwise a doctor could match the filter but report no city.
*/
class DoctorListLocationTest extends ApiTestCase
{
private function makeCity(string $cityName, string $provinceName): City
{
$province = new Province($provinceName);
$this->em->persist($province);
$city = new City($cityName, $province);
$this->em->persist($city);
return $city;
}
private function makeDoctor(string $name): Doctor
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), $name);
$this->em->persist($doctor);
return $doctor;
}
private function giveOwnAddress(Doctor $doctor, City $city): void
{
$address = DoctorAddress::forDoctor($doctor);
$address->setCity($city)->setProvince($city->getProvince());
$this->em->persist($address);
}
/** @return array<int, array> map of doctor name => list payload */
private function fetchListByName(string $name): array
{
$this->client->request('GET', '/api/v1/doctors?limit=50&name=' . urlencode($name));
$this->assertSame(200, $this->responseCode());
$payload = json_decode($this->client->getResponse()->getContent(), true);
$byName = [];
foreach ($payload['data'] as $row) {
$byName[$row['name']] = $row;
}
return $byName;
}
public function testDoctorWithOwnAddressReportsItsCity(): void
{
$city = $this->makeCity('یاسوج', 'کهگیلویه و بویراحمد');
$marker = 'loc-own-' . bin2hex(random_bytes(4));
$doctor = $this->makeDoctor($marker);
$this->giveOwnAddress($doctor, $city);
$this->em->flush();
$row = $this->fetchListByName($marker)[$marker] ?? null;
$this->assertNotNull($row, 'doctor missing from list');
$this->assertCount(1, $row['city']);
$this->assertSame('یاسوج', $row['city'][0]['name']);
$this->assertSame((string) $city->getId(), $row['city'][0]['id']);
$this->assertSame((string) $city->getProvince()->getId(), $row['city'][0]['parent']);
$this->assertCount(1, $row['state']);
$this->assertSame('کهگیلویه و بویراحمد', $row['state'][0]['name']);
}
public function testDoctorWithoutOwnAddressFallsBackToClinicCity(): void
{
$city = $this->makeCity('تبریز', 'آذربایجان شرقی');
$marker = 'loc-clinic-' . bin2hex(random_bytes(4));
$doctor = $this->makeDoctor($marker);
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$this->em->persist($clinic);
$clinic->getDoctors()->add($doctor);
$this->em->flush();
// آدرس کلینیک ردیفی از DoctorAddress با doctor NULL و clinicId پرشده است.
$clinicAddress = DoctorAddress::forClinic($clinic->getId());
$clinicAddress->setCity($city)->setProvince($city->getProvince());
$this->em->persist($clinicAddress);
$this->em->flush();
$row = $this->fetchListByName($marker)[$marker] ?? null;
$this->assertNotNull($row, 'doctor missing from list');
$this->assertCount(1, $row['city'], 'clinic-located doctor must still report a city');
$this->assertSame('تبریز', $row['city'][0]['name']);
}
public function testDoctorWithNoLocationReportsEmptyArrays(): void
{
$marker = 'loc-none-' . bin2hex(random_bytes(4));
$this->makeDoctor($marker);
$this->em->flush();
$row = $this->fetchListByName($marker)[$marker] ?? null;
$this->assertNotNull($row, 'doctor missing from list');
$this->assertSame([], $row['city']);
$this->assertSame([], $row['state']);
}
public function testReportedCityMatchesCityIdFilter(): void
{
$city = $this->makeCity('یزد', 'یزد');
$marker = 'loc-filter-' . bin2hex(random_bytes(4));
$doctor = $this->makeDoctor($marker);
$this->giveOwnAddress($doctor, $city);
$this->em->flush();
$this->client->request('GET', '/api/v1/doctors?limit=50&city_id=' . $city->getId());
$this->assertSame(200, $this->responseCode());
$payload = json_decode($this->client->getResponse()->getContent(), true);
$this->assertNotEmpty($payload['data']);
foreach ($payload['data'] as $row) {
$this->assertNotEmpty(
$row['city'],
"doctor {$row['name']} matched city_id filter but reports no city"
);
$this->assertSame((string) $city->getId(), $row['city'][0]['id']);
}
}
/**
* سهمِ خودِ حل‌مکان اندازه‌گیری می‌شود، نه کل اندپوینت: پاسخ لیست lazy-loadهای
* قدیمی (specialties) هم دارد که با تعداد پزشک رشد می‌کنند و ربطی به این تغییر
* ندارند. پس مستقیم ریپازیتوری تست می‌شود — باید حداکثر ۲ کوئری باشد، ثابت.
*/
public function testLocationResolutionCostIsConstant(): void
{
$city = $this->makeCity('اصفهان', 'اصفهان');
$repo = static::getContainer()->get(DoctorRepository::class);
$makeDoctors = function (int $count) use ($city): array {
$doctors = [];
for ($i = 0; $i < $count; $i++) {
$doctor = $this->makeDoctor('loc-cost-' . bin2hex(random_bytes(4)));
$this->giveOwnAddress($doctor, $city);
$doctors[] = $doctor;
}
$this->em->flush();
return $doctors;
};
$few = $makeDoctors(2);
$many = $makeDoctors(12);
$qFew = $this->countQueries(fn () => $repo->findLocationsByDoctors($few));
$qMany = $this->countQueries(fn () => $repo->findLocationsByDoctors($many));
$this->assertSame($qFew, $qMany, "location resolution scales with doctor count: $qFew -> $qMany");
$this->assertLessThanOrEqual(2, $qMany, 'location resolution must cost at most 2 queries');
// و همچنان درست کار کند
$resolved = $repo->findLocationsByDoctors($many);
$this->assertCount(12, $resolved);
foreach ($many as $doctor) {
$this->assertSame('اصفهان', $resolved[$doctor->getId()]['city']['name']);
}
}
public function testPaginationMetaExposesAppliedLimit(): void
{
$this->client->request('GET', '/api/v1/doctors?limit=500');
$this->assertSame(200, $this->responseCode());
$payload = json_decode($this->client->getResponse()->getContent(), true);
// سقف ریپازیتوری ۵۰ است؛ meta باید مقدار واقعاً اعمال‌شده را بگوید نه ۵۰۰.
$this->assertSame(50, $payload['meta']['limit']);
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace App\Tests\Doctor;
use App\Doctor\Entity\Doctor;
use App\Shared\Util\PersianText;
use App\Tests\ApiTestCase;
/**
* A doctor's stored name never carries the «دکتر» title — the display layer
* decides how to present it. DoctorImportService and DoctorClaimService already
* enforced this; the ordinary registration paths did not, so a name typed as
* «دکتر حامد حسینی» was stored verbatim and then rendered «دکتر دکتر حامد حسینی».
*/
class DoctorNameTitleTest extends ApiTestCase
{
/** db_test is never reset, so a fixed mobile collides across runs. */
private function freshMobile(): string
{
return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
}
private function createDoctorAs(string $name): ?Doctor
{
$admin = $this->createUser(['ROLE_ADMIN']);
$mobile = $this->freshMobile();
$this->authJson('POST', '/api/v1/admin/doctors', $admin, [
'mobile' => $mobile,
'name' => $name,
]);
$this->assertSame(201, $this->responseCode());
return $this->em->getRepository(Doctor::class)
->createQueryBuilder('d')
->join('d.user', 'u')->where('u.mobileNumber = :m')->setParameter('m', $mobile)
->getQuery()->getOneOrNullResult();
}
public function testAdminCreateStripsTheTitle(): void
{
$doctor = $this->createDoctorAs('دکتر حامد حسینی');
$this->assertNotNull($doctor);
$this->assertSame('حامد حسینی', $doctor->getName());
}
public function testPlainNameIsLeftAlone(): void
{
$doctor = $this->createDoctorAs('حامد حسینی');
$this->assertNotNull($doctor);
$this->assertSame('حامد حسینی', $doctor->getName());
}
#[\PHPUnit\Framework\Attributes\DataProvider('titleVariants')]
public function testStripDoctorTitle(string $input, string $expected): void
{
$this->assertSame($expected, PersianText::stripDoctorTitle($input));
}
public static function titleVariants(): array
{
return [
'plain' => ['حامد حسینی', 'حامد حسینی'],
'single title' => ['دکتر حامد حسینی', 'حامد حسینی'],
'repeated title' => ['دکتر دکتر حامد حسینی', 'حامد حسینی'],
'extra whitespace' => [' دکتر حامد حسینی ', 'حامد حسینی'],
'title inside' => ['حامد دکتر حسینی', 'حامد دکتر حسینی'],
];
}
}
+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());
}
}