Merge branch 'dev' into main
# Conflicts: # docs/api/doctor.md
This commit is contained in:
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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' => ['حامد دکتر حسینی', 'حامد دکتر حسینی'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user