Merge branch 'dev' into main
# Conflicts: # docs/api/doctor.md
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* «ثبتشده» → «قطعی»: POST /api/v1/appointment/{uuid}/confirm.
|
||||
*
|
||||
* یک عملِ اتمیک — وضعیت نوبت، پروندهٔ همان محیط با سرویسهای نوبت، و پرداخت کامل یا
|
||||
* جزئی روی همان مراجعه.
|
||||
*/
|
||||
class AppointmentConfirmFlowTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctor(string $name = 'دکتر تست'): Doctor
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($user, $name);
|
||||
$doctor->setMobileNumber($user->getMobileNumber());
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
/** @return array{0: User, 1: Clinic} */
|
||||
private function makeClinicWith(Doctor ...$doctors): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$clinic->setName('کلینیک تست');
|
||||
foreach ($doctors as $d) {
|
||||
$clinic->getDoctors()->add($d);
|
||||
}
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $clinic];
|
||||
}
|
||||
|
||||
private function makeAppointment(
|
||||
Doctor $doctor,
|
||||
?Clinic $clinic = null,
|
||||
?User $patient = null,
|
||||
int $visitPriceRials = 5_000_000,
|
||||
): Appointment {
|
||||
$patient ??= $this->createUser();
|
||||
// اسلات یکتا بهازای هر نوبت: db_test بین اجراها پاک نمیشود.
|
||||
$start = strtotime('+30 days') + random_int(0, 500_000) * 7;
|
||||
|
||||
$appointment = new Appointment($doctor, $patient, $start, $start + 900);
|
||||
$appointment->setClinic($clinic);
|
||||
$appointment->setVisitPriceRials($visitPriceRials);
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
|
||||
private function reload(Appointment $a): Appointment
|
||||
{
|
||||
$this->em->clear();
|
||||
|
||||
return $this->em->getRepository(Appointment::class)->find($a->getId());
|
||||
}
|
||||
|
||||
// ── ساخت پنلی «ثبتشده» است، نه قطعی ─────────────────────────────────────
|
||||
|
||||
public function testPanelBookingIsCreatedPending(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$start = strtotime('+40 days') + random_int(0, 500_000) * 7;
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $doctor->getUser(), [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $start + 1_800,
|
||||
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
||||
'patient_name' => 'بیمار تست',
|
||||
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame(Appointment::STATUS_PENDING, $res['data']['status']);
|
||||
}
|
||||
|
||||
// ── قطعیکردن: بدون پرداخت / جزئی / کامل ─────────────────────────────────
|
||||
|
||||
public function testConfirmWithoutPaymentMovesToConfirmedAndOpensSession(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$appointment = $this->makeAppointment($doctor);
|
||||
|
||||
$res = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(Appointment::STATUS_CONFIRMED, $res['data']['appointment']['status']);
|
||||
self::assertSame(5_000_000, $res['data']['session']['final_price_rials']);
|
||||
self::assertSame(0, $res['data']['session']['paid_total_rials']);
|
||||
self::assertSame(5_000_000, $res['data']['session']['remaining_rials']);
|
||||
self::assertFalse($res['data']['session']['is_paid']);
|
||||
}
|
||||
|
||||
public function testConfirmWithPartialPaymentLeavesRemainder(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$appointment = $this->makeAppointment($doctor);
|
||||
|
||||
$res = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
||||
'version' => $appointment->getVersion(),
|
||||
'payments' => [['method' => 'cash', 'amount_rials' => 2_000_000]],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(2_000_000, $res['data']['session']['paid_total_rials']);
|
||||
self::assertSame(3_000_000, $res['data']['session']['remaining_rials']);
|
||||
self::assertFalse($res['data']['session']['is_paid']);
|
||||
}
|
||||
|
||||
public function testConfirmWithFullPaymentSettlesSession(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$appointment = $this->makeAppointment($doctor);
|
||||
|
||||
$res = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
||||
'version' => $appointment->getVersion(),
|
||||
'payments' => [['method' => 'pos', 'amount_rials' => 5_000_000]],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(0, $res['data']['session']['remaining_rials']);
|
||||
self::assertTrue($res['data']['session']['is_paid']);
|
||||
}
|
||||
|
||||
public function testConfirmAcceptsSeveralPaymentRows(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$appointment = $this->makeAppointment($doctor);
|
||||
|
||||
$res = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
||||
'version' => $appointment->getVersion(),
|
||||
'payments' => [
|
||||
['method' => 'cash', 'amount_rials' => 1_000_000],
|
||||
['method' => 'pos', 'amount_rials' => 4_000_000],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(5_000_000, $res['data']['session']['paid_total_rials']);
|
||||
self::assertTrue($res['data']['session']['is_paid']);
|
||||
}
|
||||
|
||||
// ── پرونده: استفادهٔ مجدد یا ساخت ────────────────────────────────────────
|
||||
|
||||
public function testConfirmReusesExistingRecordOfSameDoctor(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$patient = $this->createUser();
|
||||
|
||||
$first = $this->makeAppointment($doctor, null, $patient);
|
||||
$this->authJson('POST', "/api/v1/appointment/{$first->getUuid()}/confirm", $doctor->getUser(), [
|
||||
'version' => $first->getVersion(),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$second = $this->makeAppointment($doctor, null, $patient);
|
||||
$this->authJson('POST', "/api/v1/appointment/{$second->getUuid()}/confirm", $doctor->getUser(), [
|
||||
'version' => $second->getVersion(),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$records = $this->em->getRepository(PatientRecord::class)->findBy([
|
||||
'entityType' => 'doctor',
|
||||
'entityId' => $doctor->getId(),
|
||||
'user' => $patient,
|
||||
]);
|
||||
|
||||
self::assertCount(1, $records, 'پروندهٔ همان پزشک دوباره ساخته نمیشود');
|
||||
|
||||
$sessions = $this->em->getRepository(PatientSession::class)->findBy(['record' => $records[0]]);
|
||||
self::assertCount(2, $sessions, 'هر نوبت مراجعهٔ خودش را دارد');
|
||||
}
|
||||
|
||||
public function testConfirmInClinicFilesUnderClinicRecord(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
||||
$patient = $this->createUser();
|
||||
$appointment = $this->makeAppointment($doctor, $clinic, $patient);
|
||||
|
||||
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $owner, [
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$clinicRecord = $this->em->getRepository(PatientRecord::class)->findOneBy([
|
||||
'entityType' => 'clinic',
|
||||
'entityId' => $clinic->getId(),
|
||||
'user' => $patient,
|
||||
]);
|
||||
$doctorRecord = $this->em->getRepository(PatientRecord::class)->findOneBy([
|
||||
'entityType' => 'doctor',
|
||||
'entityId' => $doctor->getId(),
|
||||
'user' => $patient,
|
||||
]);
|
||||
|
||||
self::assertNotNull($clinicRecord, 'نوبت کلینیکی در پروندهٔ کلینیک مینشیند');
|
||||
self::assertNull($doctorRecord, 'و در مطب شخصی پزشک پروندهٔ موازی نمیسازد');
|
||||
}
|
||||
|
||||
// ── خطاها ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testPaymentAboveTotalIsRejectedAndNothingIsCommitted(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$appointment = $this->makeAppointment($doctor);
|
||||
|
||||
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
||||
'version' => $appointment->getVersion(),
|
||||
'payments' => [['method' => 'cash', 'amount_rials' => 9_000_000]],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame(Appointment::STATUS_PENDING, $this->reload($appointment)->getStatus(), 'تراکنش برگشته');
|
||||
}
|
||||
|
||||
public function testUnknownPaymentMethodIs422(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$appointment = $this->makeAppointment($doctor);
|
||||
|
||||
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
||||
'version' => $appointment->getVersion(),
|
||||
'payments' => [['method' => 'bitcoin', 'amount_rials' => 1_000]],
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame(Appointment::STATUS_PENDING, $this->reload($appointment)->getStatus());
|
||||
}
|
||||
|
||||
public function testStaleVersionIs409(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$appointment = $this->makeAppointment($doctor);
|
||||
|
||||
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
||||
'version' => $appointment->getVersion() + 5,
|
||||
]);
|
||||
|
||||
self::assertSame(409, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testConfirmingAnAlreadyConfirmedAppointmentIs422(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$appointment = $this->makeAppointment($doctor);
|
||||
|
||||
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $doctor->getUser(), [
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$fresh = $this->reload($appointment);
|
||||
$this->authJson('POST', "/api/v1/appointment/{$fresh->getUuid()}/confirm", $doctor->getUser(), [
|
||||
'version' => $fresh->getVersion(),
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode(), 'confirmed → confirmed گذار مجاز نیست');
|
||||
}
|
||||
|
||||
public function testStrangerCannotConfirm(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$appointment = $this->makeAppointment($doctor);
|
||||
|
||||
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $this->createUser(), [
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testDetailExposesServicePricesForTheModal(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$appointment = $this->makeAppointment($doctor);
|
||||
|
||||
$res = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $doctor->getUser());
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(5_000_000, $res['data']['data']['visit_price_rials']);
|
||||
self::assertArrayHasKey('service_items', $res['data']['data']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* POST /api/v1/my/appointment extensions (workflow fields + reserve entries)
|
||||
* and the GET /api/v1/my/appointments ?reserve list split.
|
||||
*/
|
||||
class AppointmentCreateReserveTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: Doctor} */
|
||||
private function doctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
public function testCreateWithWorkflowFields(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'زیبایی');
|
||||
$item = new ServiceItem($section, 'لیزر فول بادی');
|
||||
$staff = new ClinicStaff('doctor', $doctor->getId(), 'سحر ایمانی');
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->persist($staff);
|
||||
$this->em->flush();
|
||||
|
||||
$start = time() + 86_400;
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $start + 2_400,
|
||||
'patient_mobile' => '09' . random_int(100000000, 999999999),
|
||||
'patient_name' => 'مریم اسکندری',
|
||||
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
|
||||
'service_section_uuid' => $section->getUuid(),
|
||||
'service_item_uuid' => $item->getUuid(),
|
||||
'staff_uuid' => $staff->getUuid(),
|
||||
'deposit_required' => true,
|
||||
'deposit_amount_rials' => 5_000_000,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertFalse($res['data']['is_reserve']);
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/my/appointments?limit=50', $owner);
|
||||
$row = $list['data'][0];
|
||||
self::assertSame('لیزر فول بادی', $row['service_item']['name']);
|
||||
self::assertSame('سحر ایمانی', $row['staff']['full_name']);
|
||||
self::assertTrue($row['deposit_required']);
|
||||
}
|
||||
|
||||
public function testReserveEntriesSkipSlotRulesAndAreListedSeparately(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$day = strtotime('today midnight'); // past for a slot booking — fine for a reserve
|
||||
|
||||
// two reserves on the same day must both succeed (no slot occupation)
|
||||
foreach (['ساغر صابری', 'پریسا همتی'] as $name) {
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'slot_start' => $day,
|
||||
'slot_end' => $day,
|
||||
'patient_mobile' => '09' . random_int(100000000, 999999999),
|
||||
'patient_name' => $name,
|
||||
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
|
||||
'is_reserve' => true,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
}
|
||||
|
||||
$reserves = $this->authJson('GET', '/api/v1/my/appointments?reserve=1&limit=50', $owner);
|
||||
self::assertCount(2, $reserves['data']);
|
||||
self::assertTrue($reserves['data'][0]['is_reserve']);
|
||||
|
||||
// the regular list must not contain reserve entries
|
||||
$regular = $this->authJson('GET', '/api/v1/my/appointments?limit=50', $owner);
|
||||
self::assertCount(0, $regular['data']);
|
||||
}
|
||||
|
||||
public function testExistingProfileNameWinsOverModalInput(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$nationalCode = '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT);
|
||||
$mobile = '09' . random_int(100000000, 999999999);
|
||||
$start = time() + 86_400;
|
||||
|
||||
// First booking registers the patient's profile under their real name.
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $start + 1_800,
|
||||
'patient_mobile' => $mobile,
|
||||
'patient_name' => 'علی احمدی',
|
||||
'patient_national_code' => $nationalCode,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
// Second booking uses the same national code but a mistyped modal name.
|
||||
$start2 = $start + 3_600;
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'slot_start' => $start2,
|
||||
'slot_end' => $start2 + 1_800,
|
||||
'patient_mobile' => $mobile,
|
||||
'patient_name' => 'نام غلط',
|
||||
'patient_national_code' => $nationalCode,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
// Every listed appointment must show the profile name, never the mistype.
|
||||
$list = $this->authJson('GET', '/api/v1/my/appointments?limit=50', $owner);
|
||||
$names = array_column($list['data'], 'patient_name');
|
||||
self::assertContains('علی احمدی', $names);
|
||||
self::assertNotContains('نام غلط', $names);
|
||||
}
|
||||
|
||||
public function testUnknownServiceUuidIs422(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$start = time() + 86_400;
|
||||
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $start + 1_800,
|
||||
'patient_mobile' => '09' . random_int(100000000, 999999999),
|
||||
'patient_name' => 'x',
|
||||
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
|
||||
'service_item_uuid' => 'missing-uuid',
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ class AppointmentExpiryServiceTest extends ApiTestCase
|
||||
// distinct past slots — one live booking per (doctor, slot)
|
||||
$slotStart = $past - $i * 1000;
|
||||
$appt = new Appointment($doctor, $patient, $slotStart, $slotStart + 900);
|
||||
// مثل مسیر واقعیِ رزرو آنلاین: نگهداشتِ موقت تا پرداخت درگاه.
|
||||
$appt->markPendingWithTtl(-1);
|
||||
$this->em->persist($appt);
|
||||
|
||||
$payment = new Payment($patient, 100_000, 'mellat', 'appointment');
|
||||
@@ -53,4 +55,27 @@ class AppointmentExpiryServiceTest extends ApiTestCase
|
||||
$this->assertSame(Payment::STATUS_CANCELED, $freshPay->getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* نوبت «ثبتشده»ی پنل TTL ندارد؛ گذشتنِ ساعتِ نوبت نباید خودبهخود منقضیاش کند —
|
||||
* قطعی/لغو کردنش تصمیم اپراتور است.
|
||||
*/
|
||||
public function testPanelRegisteredPendingSurvivesExpiry(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر پنل');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$slotStart = time() - 7200;
|
||||
$appt = new Appointment($doctor, $this->createUser(['ROLE_USER']), $slotStart, $slotStart + 900);
|
||||
$this->em->persist($appt);
|
||||
$this->em->flush();
|
||||
|
||||
static::getContainer()->get(AppointmentExpiryService::class)->expireStale();
|
||||
|
||||
$this->em->clear();
|
||||
$fresh = $this->em->getRepository(Appointment::class)->find($appt->getId());
|
||||
|
||||
$this->assertSame(Appointment::STATUS_PENDING, $fresh->getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* چند سرویس روی یک نوبت: در حالت اسلاتی سرویسها فقط پیوست میشوند و ساعت پایانِ
|
||||
* دستی حفظ میماند؛ در حالت سرویسی (duration_from_services) slot_end از مجموع مدت
|
||||
* سرویسها بازمحاسبه میشود.
|
||||
*/
|
||||
class AppointmentMultiServiceTest extends ApiTestCase
|
||||
{
|
||||
private function doctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
private function serviceItem(Doctor $doctor, string $name, int $minutes): ServiceItem
|
||||
{
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name);
|
||||
$this->em->persist($section);
|
||||
$item = new ServiceItem($section, $name, 500_000);
|
||||
$item->setDurationMinutes($minutes)->setBookable(true);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
private function nationalCode(): string
|
||||
{
|
||||
return '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function body(Doctor $doctor, int $start, int $end, array $extra): array
|
||||
{
|
||||
return array_merge([
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $end,
|
||||
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
||||
'patient_name' => 'بیمار تست',
|
||||
'patient_national_code' => $this->nationalCode(),
|
||||
], $extra);
|
||||
}
|
||||
|
||||
private function reload(string $uuid): Appointment
|
||||
{
|
||||
$this->em->clear();
|
||||
return $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]);
|
||||
}
|
||||
|
||||
public function testSlotModeAttachesMultipleServicesAndKeepsManualEnd(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$a = $this->serviceItem($doctor, 'تزریق ژل', 30);
|
||||
$b = $this->serviceItem($doctor, 'کندلا', 20);
|
||||
|
||||
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
||||
$end = $start + 3_600; // ساعت پایانِ دستی: ۶۰ دقیقه
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor, $start, $end, [
|
||||
'service_item_uuids' => [$a->getUuid(), $b->getUuid()],
|
||||
]));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$appt = $this->reload($res['data']['uuid']);
|
||||
self::assertCount(2, $appt->getServiceItems());
|
||||
// ساعت پایان دستنخورده (بدون بازمحاسبه از مدت سرویسها)
|
||||
self::assertSame($end, $appt->getSlotEnd());
|
||||
// سرویسِ اصلی = اولین سرویس
|
||||
self::assertSame($a->getUuid(), $appt->getServiceItem()?->getUuid());
|
||||
}
|
||||
|
||||
public function testServiceModeRecomputesEndFromDurations(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$a = $this->serviceItem($doctor, 'تزریق ژل', 30);
|
||||
$b = $this->serviceItem($doctor, 'کندلا', 20);
|
||||
|
||||
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor, $start, $start + 60, [
|
||||
'service_item_uuids' => [$a->getUuid(), $b->getUuid()],
|
||||
'duration_from_services' => true,
|
||||
]));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$appt = $this->reload($res['data']['uuid']);
|
||||
self::assertCount(2, $appt->getServiceItems());
|
||||
// مجموع مدت ۳۰+۲۰=۵۰ دقیقه → slot_end بازمحاسبهشده
|
||||
self::assertSame($start + 50 * 60, $appt->getSlotEnd());
|
||||
}
|
||||
|
||||
public function testUnknownServiceUuidIsRejected(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
||||
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor, $start, $start + 1_800, [
|
||||
'service_item_uuids' => ['no-such-uuid'],
|
||||
]));
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Tests\ApiTestCase;
|
||||
use App\UserProfile\Entity\UserProfile;
|
||||
|
||||
/**
|
||||
* POST /api/v1/my/appointment must identify the patient by national code.
|
||||
* National code is stored on the patient's UserProfile (profiles.national_code,
|
||||
* unique), so the patient User — and thus the case-file — is resolved by that
|
||||
* profile first. One person keeps a single record even when booked under
|
||||
* several mobiles.
|
||||
*/
|
||||
class AppointmentNationalCodeTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Doctor} */
|
||||
private function doctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
private function nationalCode(): string
|
||||
{
|
||||
return '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function mobile(): string
|
||||
{
|
||||
return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function body(string $doctorUuid, string $mobile, string $nationalCode): array
|
||||
{
|
||||
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
||||
|
||||
return [
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $start + 1_800,
|
||||
'patient_mobile' => $mobile,
|
||||
'patient_name' => 'بیمار تست',
|
||||
'patient_national_code' => $nationalCode,
|
||||
];
|
||||
}
|
||||
|
||||
private function profileByNationalCode(string $nc): ?UserProfile
|
||||
{
|
||||
return $this->em->getRepository(UserProfile::class)->findOneBy(['nationalCode' => $nc]);
|
||||
}
|
||||
|
||||
public function testStoresNationalCodeOnProfile(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$nc = $this->nationalCode();
|
||||
$mobile = $this->mobile();
|
||||
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $mobile, $nc));
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
$profile = $this->profileByNationalCode($nc);
|
||||
self::assertNotNull($profile);
|
||||
self::assertSame($mobile, $profile->getUser()->getMobileNumber());
|
||||
}
|
||||
|
||||
public function testSameNationalCodeDifferentMobileReusesSinglePatient(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$nc = $this->nationalCode();
|
||||
|
||||
// First booking under mobile A creates the patient + profile.
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $this->mobile(), $nc));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
// Second booking under a *different* mobile but the same national code
|
||||
// must resolve to the same patient — the case-file stays unique.
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $this->mobile(), $nc));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$profiles = $this->em->getRepository(UserProfile::class)->findBy(['nationalCode' => $nc]);
|
||||
self::assertCount(1, $profiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* The reported bug: a patient already exists whose national code sits on
|
||||
* their profile. Booking under a brand-new mobile with that same national
|
||||
* code must attach to the existing patient, not stamp the code onto a new
|
||||
* user.
|
||||
*/
|
||||
public function testExistingProfileNationalCodeIsReusedNotDuplicated(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$nc = $this->nationalCode();
|
||||
|
||||
// Existing patient with the national code on their profile.
|
||||
$existing = $this->createUser(['ROLE_USER'], $this->mobile());
|
||||
$profile = new UserProfile($existing);
|
||||
$profile->setNationalCode($nc);
|
||||
$this->em->persist($profile);
|
||||
$this->em->flush();
|
||||
|
||||
// Book under a different mobile but the same national code.
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $this->mobile(), $nc));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
// No duplicate profile, and the appointment is attached to the existing user.
|
||||
$profiles = $this->em->getRepository(UserProfile::class)->findBy(['nationalCode' => $nc]);
|
||||
self::assertCount(1, $profiles);
|
||||
|
||||
$appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $res['data']['uuid']]);
|
||||
self::assertSame($existing->getId(), $appointment->getUser()->getId());
|
||||
}
|
||||
|
||||
public function testMissingNationalCodeIs422(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$body = $this->body($doctor->getUuid(), $this->mobile(), $this->nationalCode());
|
||||
unset($body['patient_national_code']);
|
||||
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, $body);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testInvalidNationalCodeIs422(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $this->mobile(), '123'));
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testMobileBelongingToAnotherNationalCodeIsRejected(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$mobile = $this->mobile();
|
||||
|
||||
// First booking binds this mobile to national code A.
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $mobile, $this->nationalCode()));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
// Same mobile, a *different* national code → identity conflict.
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid(), $mobile, $this->nationalCode()));
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame(ErrorCodes::ERR_PROFILE_MOBILE_TAKEN, $res['errors'][0]['code']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/appointment/{uuid} — general update used by ویرایش,
|
||||
* جا به جایی, انتقال به رزرو and جایگزینی نوبت.
|
||||
*/
|
||||
class AppointmentUpdateTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: Doctor, 2: Appointment} */
|
||||
private function booking(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$appointment = new Appointment($doctor, $this->createUser(), time() + 86_400, time() + 86_400 + 1_800);
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor, $appointment];
|
||||
}
|
||||
|
||||
public function testUpdatesWorkflowFields(): void
|
||||
{
|
||||
[$owner, $doctor, $appointment] = $this->booking();
|
||||
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'زیبایی');
|
||||
$item = new ServiceItem($section, 'لیزر توتال');
|
||||
$staff = new ClinicStaff('doctor', $doctor->getId(), 'سحر ایمانی');
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->persist($staff);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
|
||||
'service_section_uuid' => $section->getUuid(),
|
||||
'service_item_uuid' => $item->getUuid(),
|
||||
'staff_uuid' => $staff->getUuid(),
|
||||
'deposit_required' => true,
|
||||
'deposit_amount_rials' => 5_000_000,
|
||||
'note' => 'یادداشت',
|
||||
'patient_name' => 'مریم خلیلی',
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$d = $res['data']['data'];
|
||||
self::assertSame('زیبایی', $d['service_section']['name']);
|
||||
self::assertSame('لیزر توتال', $d['service_item']['name']);
|
||||
self::assertSame('سحر ایمانی', $d['staff']['full_name']);
|
||||
self::assertTrue($d['deposit_required']);
|
||||
self::assertSame('مریم خلیلی', $d['patient_name']);
|
||||
}
|
||||
|
||||
public function testRescheduleMovesSlot(): void
|
||||
{
|
||||
[$owner, , $appointment] = $this->booking();
|
||||
$newStart = time() + 2 * 86_400;
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
|
||||
'slot_start' => $newStart, 'slot_end' => $newStart + 1_800,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($newStart, $res['data']['data']['slot_start']);
|
||||
}
|
||||
|
||||
public function testRescheduleRejectsTakenSlot(): void
|
||||
{
|
||||
[$owner, $doctor, $appointment] = $this->booking();
|
||||
|
||||
$otherStart = time() + 3 * 86_400;
|
||||
$this->em->persist(new Appointment($doctor, $this->createUser(), $otherStart, $otherStart + 1_800));
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
|
||||
'slot_start' => $otherStart, 'slot_end' => $otherStart + 1_800,
|
||||
]);
|
||||
self::assertSame(409, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testTransferToReserveAndBack(): void
|
||||
{
|
||||
[$owner, , $appointment] = $this->booking();
|
||||
$day = strtotime('tomorrow midnight');
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
|
||||
'is_reserve' => true, 'slot_start' => $day, 'slot_end' => $day,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($res['data']['data']['is_reserve']);
|
||||
|
||||
$back = time() + 4 * 86_400;
|
||||
$version = $res['data']['data']['version'];
|
||||
$res2 = $this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
|
||||
'is_reserve' => false, 'slot_start' => $back, 'slot_end' => $back + 1_800, 'version' => $version,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertFalse($res2['data']['data']['is_reserve']);
|
||||
}
|
||||
|
||||
public function testRejectsHalfSlotPair(): void
|
||||
{
|
||||
[$owner, , $appointment] = $this->booking();
|
||||
$this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
|
||||
'slot_start' => time() + 86_400,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testForbiddenForStranger(): void
|
||||
{
|
||||
[, , $appointment] = $this->booking();
|
||||
$stranger = $this->createUser(['ROLE_DOCTOR']);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $stranger, ['note' => 'x']);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testUnknownStaffUuidIs422(): void
|
||||
{
|
||||
[$owner, , $appointment] = $this->booking();
|
||||
$this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
|
||||
'staff_uuid' => 'no-such-uuid',
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Clinic-workflow additions on Appointment: following_up/salon statuses,
|
||||
* service section/item + staff relations, deposit fields, reserve flag,
|
||||
* and rescheduleTo keeping active_slot_key consistent.
|
||||
*/
|
||||
class AppointmentWorkflowFieldsTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctor(): Doctor
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
private function newBooking(Doctor $doctor, int $start): Appointment
|
||||
{
|
||||
$a = new Appointment($doctor, $this->createUser(), $start, $start + 1_800);
|
||||
$this->em->persist($a);
|
||||
$this->em->flush();
|
||||
|
||||
return $a;
|
||||
}
|
||||
|
||||
public function testDayOfWorkflowTransitions(): void
|
||||
{
|
||||
$a = $this->newBooking($this->makeDoctor(), time() + 86_400);
|
||||
|
||||
$a->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$a->transitionTo(Appointment::STATUS_FOLLOWING_UP);
|
||||
$a->transitionTo(Appointment::STATUS_SALON);
|
||||
$a->transitionTo(Appointment::STATUS_COMPLETED);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertSame(Appointment::STATUS_COMPLETED, $a->getStatus());
|
||||
}
|
||||
|
||||
public function testPendingCannotJumpToSalon(): void
|
||||
{
|
||||
$a = $this->newBooking($this->makeDoctor(), time() + 86_400);
|
||||
|
||||
$this->expectException(\LogicException::class);
|
||||
$a->transitionTo(Appointment::STATUS_SALON);
|
||||
}
|
||||
|
||||
public function testWorkflowFieldsPersistAndSerialize(): void
|
||||
{
|
||||
$a = $this->newBooking($this->makeDoctor(), time() + 86_400);
|
||||
|
||||
$a->setDepositRequired(true)
|
||||
->setDepositAmountRials(5_000_000);
|
||||
$a->rescheduleTo($a->getSlotStart(), $a->getSlotEnd(), true);
|
||||
$this->em->flush();
|
||||
$this->em->clear();
|
||||
|
||||
$reloaded = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $a->getUuid()]);
|
||||
$arr = $reloaded->toArray();
|
||||
|
||||
self::assertTrue($arr['deposit_required']);
|
||||
self::assertSame(5_000_000, $arr['deposit_amount_rials']);
|
||||
self::assertTrue($arr['is_reserve']);
|
||||
self::assertNull($arr['service_section']);
|
||||
self::assertNull($arr['staff']);
|
||||
}
|
||||
|
||||
public function testRescheduleFreesTheOldSlot(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$start = time() + 86_400;
|
||||
|
||||
$first = $this->newBooking($doctor, $start);
|
||||
$first->rescheduleTo($start + 3_600, $start + 5_400);
|
||||
$this->em->flush();
|
||||
|
||||
// old slot must be free again — a new live booking on it succeeds
|
||||
$second = $this->newBooking($doctor, $start);
|
||||
self::assertNotNull($second->getId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* یک «محل نوبتدهی» فقط وقتی وجود دارد که هم آدرس داشته باشد و هم شیفتی روی همان
|
||||
* آدرس. برنامهای که به آدرسِ محیط دیگر (یا هیچ آدرسی) اشاره میکند قابل رزرو
|
||||
* نیست و نباید به بیمار پیشنهاد شود.
|
||||
*
|
||||
* همچنین خالیبودن یک روز چهار دلیل متفاوت دارد و پنل باید بتواند تفکیکشان کند.
|
||||
*/
|
||||
class BookingLocationValidityTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctor(): Doctor
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($user, 'دکتر محل');
|
||||
$doctor->setMobileNumber($user->getMobileNumber());
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
private function makeClinicWith(Doctor $doctor): Clinic
|
||||
{
|
||||
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
|
||||
$clinic->setName('کلینیک محل');
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return $clinic;
|
||||
}
|
||||
|
||||
/** برنامهای که همهٔ روزها یک شیفت روی $locationId دارد. */
|
||||
private function scheduleFor(Doctor $doctor, ?Clinic $clinic, ?int $locationId): WeeklySchedule
|
||||
{
|
||||
$day = ['sessions' => [array_filter([
|
||||
'active' => true,
|
||||
'location_id' => $locationId,
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '13:00',
|
||||
'duration_per_patient' => 20,
|
||||
], fn($v) => $v !== null)]];
|
||||
|
||||
$schedule = new WeeklySchedule(
|
||||
$doctor,
|
||||
array_fill_keys(array_map('strval', range(0, 6)), $day),
|
||||
$clinic
|
||||
);
|
||||
$this->em->persist($schedule);
|
||||
$this->em->flush();
|
||||
|
||||
return $schedule;
|
||||
}
|
||||
|
||||
private function locations(Doctor $doctor, string $query = ''): array
|
||||
{
|
||||
$body = $this->authJson(
|
||||
'GET',
|
||||
'/api/v1/appointment-booking-locations/' . $doctor->getUuid() . $query,
|
||||
$doctor->getUser()
|
||||
);
|
||||
|
||||
return $body['data']['booking_locations'] ?? [];
|
||||
}
|
||||
|
||||
public function testLocationWithoutAnyAddressIsNotReturned(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$this->scheduleFor($doctor, null, null);
|
||||
|
||||
self::assertSame([], $this->locations($doctor), 'محلی که آدرس ندارد نباید محل به حساب بیاید');
|
||||
}
|
||||
|
||||
public function testLocationWhoseShiftsPointOutsideItsContextIsNotReturned(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$clinic = $this->makeClinicWith($doctor);
|
||||
|
||||
$clinicAddress = DoctorAddress::forClinic($clinic->getId());
|
||||
$personal = DoctorAddress::forDoctor($doctor);
|
||||
$this->em->persist($clinicAddress);
|
||||
$this->em->persist($personal);
|
||||
$this->em->flush();
|
||||
|
||||
// برنامهٔ شخصی که شیفتش روی آدرس کلینیک نشسته — دقیقاً حالتی که در dev دیده شد.
|
||||
$this->scheduleFor($doctor, null, $clinicAddress->getId());
|
||||
|
||||
self::assertSame([], $this->locations($doctor));
|
||||
}
|
||||
|
||||
public function testValidLocationIsReturnedWithItsOpeningHours(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$address = DoctorAddress::forDoctor($doctor);
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
$this->scheduleFor($doctor, null, $address->getId());
|
||||
|
||||
$locations = $this->locations($doctor);
|
||||
self::assertCount(1, $locations);
|
||||
self::assertSame('personal', $locations[0]['type']);
|
||||
self::assertSame($address->getUuid(), $locations[0]['location_uuid']);
|
||||
self::assertCount(7, $locations[0]['opening_hours']);
|
||||
self::assertSame($address->getId(), $locations[0]['opening_hours'][0]['location_id']);
|
||||
}
|
||||
|
||||
public function testDateParameterReportsPerDayAvailability(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$address = DoctorAddress::forDoctor($doctor);
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
// فقط شنبه (اندیس 0) شیفت فعال دارد.
|
||||
$active = ['sessions' => [[
|
||||
'active' => true, 'location_id' => $address->getId(),
|
||||
'start_time' => '09:00', 'end_time' => '13:00', 'duration_per_patient' => 20,
|
||||
]]];
|
||||
$setting = array_fill_keys(array_map('strval', range(0, 6)), ['sessions' => []]);
|
||||
$setting['0'] = $active;
|
||||
$this->em->persist(new WeeklySchedule($doctor, $setting));
|
||||
$this->em->flush();
|
||||
|
||||
$saturday = $this->nextWeekday(6); // date('w'): 6 = Saturday
|
||||
$sunday = $this->nextWeekday(0);
|
||||
|
||||
$onSaturday = $this->locations($doctor, '?date=' . $saturday);
|
||||
$onSunday = $this->locations($doctor, '?date=' . $sunday);
|
||||
|
||||
self::assertTrue($onSaturday[0]['available_on_date'] ?? null);
|
||||
self::assertFalse($onSunday[0]['available_on_date'] ?? null);
|
||||
}
|
||||
|
||||
public function testInvalidDateIsRejected(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
|
||||
$this->authJson('GET', '/api/v1/appointment-booking-locations/' . $doctor->getUuid() . '?date=2026-13-99', $doctor->getUser());
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testEmptyReasonDistinguishesTheCauses(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$today = date('Y-m-d');
|
||||
|
||||
// بدون هیچ برنامهای
|
||||
$body = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$today}", $doctor->getUser());
|
||||
self::assertSame('no_schedule', $body['data']['empty_reason'] ?? null);
|
||||
|
||||
// برنامه هست ولی این روز شیفت ندارد
|
||||
$address = DoctorAddress::forDoctor($doctor);
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
$setting = array_fill_keys(array_map('strval', range(0, 6)), ['sessions' => []]);
|
||||
$this->em->persist(new WeeklySchedule($doctor, $setting));
|
||||
$this->em->flush();
|
||||
|
||||
$body = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$today}", $doctor->getUser());
|
||||
self::assertSame('day_off', $body['data']['empty_reason'] ?? null);
|
||||
|
||||
// تاریخ خارج از بازهٔ نوبتدهی
|
||||
$farFuture = date('Y-m-d', strtotime('+2 years'));
|
||||
$body = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$farFuture}", $doctor->getUser());
|
||||
self::assertSame('outside_window', $body['data']['empty_reason'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* اولین وقوعِ آن روز هفته در آینده. از فردا شروع میشود چون شیفتهای امروز
|
||||
* ممکن است گذشته باشند و getAvailableSlots اسلات گذشته را برنمیگرداند —
|
||||
* وگرنه تست بسته به ساعت اجرا نتیجهٔ متفاوت میدهد.
|
||||
*
|
||||
* @param int $phpDow خروجی date('w') — 0=یکشنبه ... 6=شنبه
|
||||
*/
|
||||
private function nextWeekday(int $phpDow): string
|
||||
{
|
||||
for ($i = 1; $i <= 7; $i++) {
|
||||
$ts = strtotime("+{$i} day");
|
||||
if ((int) date('w', $ts) === $phpDow) {
|
||||
return date('Y-m-d', $ts);
|
||||
}
|
||||
}
|
||||
|
||||
return date('Y-m-d');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* `next_available_at` scans ahead for the first free slot per location.
|
||||
*
|
||||
* The scan prefetches the schedule, holidays, overrides and taken appointments
|
||||
* once per location and resolves the rest in memory, so its cost must not grow
|
||||
* with the number of days walked or slots inspected — only with the number of
|
||||
* locations, by a small constant.
|
||||
*/
|
||||
class BookingLocationsScanTest extends ApiTestCase
|
||||
{
|
||||
private function weekOfSessions(int $locationId, string $start, string $end): array
|
||||
{
|
||||
$day = ['sessions' => [[
|
||||
'active' => true,
|
||||
'location_id' => $locationId,
|
||||
'start_time' => $start,
|
||||
'end_time' => $end,
|
||||
'duration_per_patient' => 20,
|
||||
]]];
|
||||
|
||||
return array_fill_keys(array_map('strval', range(0, 6)), $day);
|
||||
}
|
||||
|
||||
private function makeDoctorWithSchedules(int $clinicCount): Doctor
|
||||
{
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'دکتر پرمحل');
|
||||
$doctor->setMobileNumber($doctorUser->getMobileNumber());
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$personalAddress = DoctorAddress::forDoctor($doctor);
|
||||
$this->em->persist($personalAddress);
|
||||
$this->em->flush();
|
||||
|
||||
$this->em->persist(new WeeklySchedule(
|
||||
$doctor,
|
||||
$this->weekOfSessions($personalAddress->getId(), '09:00', '13:00')
|
||||
));
|
||||
|
||||
for ($i = 0; $i < $clinicCount; $i++) {
|
||||
$clinic = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
|
||||
$clinic->setName("کلینیک $i");
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
$this->em->persist(new WeeklySchedule(
|
||||
$doctor,
|
||||
$this->weekOfSessions($address->getId(), '16:00', '20:00'),
|
||||
$clinic
|
||||
));
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
public function testQueryCountGrowsOnlyPerLocation(): void
|
||||
{
|
||||
// Keep one kernel so the shared query logger stays consistent.
|
||||
$this->client->disableReboot();
|
||||
|
||||
$few = $this->makeDoctorWithSchedules(1);
|
||||
$many = $this->makeDoctorWithSchedules(5);
|
||||
|
||||
$qFew = $this->countQueries(fn () => $this->client->request(
|
||||
'GET', '/api/v1/appointment-booking-locations/' . $few->getUuid()
|
||||
));
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$qMany = $this->countQueries(fn () => $this->client->request(
|
||||
'GET', '/api/v1/appointment-booking-locations/' . $many->getUuid()
|
||||
));
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
// 2 locations -> 6 locations. Each extra one costs a fixed handful of
|
||||
// queries (its schedule, holidays, overrides, blocking intervals,
|
||||
// address). The regression this guards against is a per-day or per-slot
|
||||
// query, which on a schedule active every day would add hundreds.
|
||||
$extraLocations = 4;
|
||||
$budgetEach = 8;
|
||||
self::assertLessThanOrEqual(
|
||||
$qFew + $extraLocations * $budgetEach,
|
||||
$qMany,
|
||||
"next_available_at scales badly: $qFew queries for 2 locations, $qMany for 6"
|
||||
);
|
||||
}
|
||||
|
||||
public function testNextAvailableIsReportedPerLocation(): void
|
||||
{
|
||||
$doctor = $this->makeDoctorWithSchedules(1);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/appointment-booking-locations/' . $doctor->getUuid(), $doctor->getUser());
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$locations = $body['data']['booking_locations'] ?? [];
|
||||
self::assertCount(2, $locations);
|
||||
|
||||
foreach ($locations as $location) {
|
||||
self::assertNotNull(
|
||||
$location['next_available_at'],
|
||||
'a schedule active every day must expose a next free slot'
|
||||
);
|
||||
self::assertGreaterThanOrEqual(time(), $location['next_available_at']);
|
||||
}
|
||||
|
||||
// Sorted by earliest opening — the site relies on booking_locations[0].
|
||||
$starts = array_column($locations, 'next_available_at');
|
||||
$sorted = $starts;
|
||||
sort($sorted);
|
||||
self::assertSame($sorted, $starts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* نوع نوبتدهی (booking_mode) پس از اولین ثبت غیرقابلتغییر است.
|
||||
*/
|
||||
class BookingModeImmutableTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر قفل');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
public function testFirstSaveCommitsAndSecondSaveKeepsSameMode(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->makeDoctor();
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'schedule' => [],
|
||||
'meta' => ['booking_mode' => 'slot'],
|
||||
]);
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
|
||||
// همان mode دوباره → مجاز
|
||||
$this->authJson('PATCH', '/api/v1/appointment-settings/weekly-schedule/' . $doctor->getUuid(), $owner, [
|
||||
'meta' => ['booking_mode' => 'slot'],
|
||||
]);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testCannotChangeModeAfterCommit(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->makeDoctor();
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'schedule' => [],
|
||||
'meta' => ['booking_mode' => 'slot'],
|
||||
]);
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
|
||||
// تلاش برای تغییر به سرویس → 422 قفل
|
||||
$res = $this->authJson('PATCH', '/api/v1/appointment-settings/weekly-schedule/' . $doctor->getUuid(), $owner, [
|
||||
'meta' => ['booking_mode' => 'service'],
|
||||
]);
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
$this->assertStringContainsString('قابل تغییر نیست', json_encode($res, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,12 @@ class BookingScopeTest extends ApiTestCase
|
||||
$start = time() + 86_400;
|
||||
|
||||
return [
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $start + 1_800,
|
||||
'patient_mobile' => '09120000000',
|
||||
'patient_name' => 'بیمار تست',
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $start + 1_800,
|
||||
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
||||
'patient_name' => 'بیمار تست',
|
||||
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* عمومی: GET /api/v1/appointment-booking-services/{doctorUuid} روش نوبتدهی و
|
||||
* سرویسهای bookable را بدون احراز هویت برمیگرداند (نوبتگیری آنلاین سرویسی).
|
||||
*/
|
||||
class BookingServicesPublicTest extends ApiTestCase
|
||||
{
|
||||
public function testReturnsModeAndBookableServicesWithoutAuth(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر سرویس');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش');
|
||||
$this->em->persist($section);
|
||||
|
||||
$bookable = (new ServiceItem($section, 'عصبکشی', 500000))->setDurationMinutes(30)->setBookable(true);
|
||||
$hidden = (new ServiceItem($section, 'معاینه داخلی', 100000))->setDurationMinutes(15)->setBookable(false);
|
||||
$this->em->persist($bookable);
|
||||
$this->em->persist($hidden);
|
||||
|
||||
$schedule = new WeeklySchedule($doctor, []);
|
||||
$schedule->setMeta(['booking_mode' => 'service', 'buffer_minutes' => 5]);
|
||||
$this->em->persist($schedule);
|
||||
$this->em->flush();
|
||||
|
||||
// بدون هدر Authorization
|
||||
$this->client->request('GET', '/api/v1/appointment-booking-services/' . $doctor->getUuid());
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
|
||||
$body = json_decode($this->client->getResponse()->getContent(), true);
|
||||
$data = $body['data'] ?? [];
|
||||
$this->assertSame('service', $data['booking_mode']);
|
||||
$this->assertSame(5, $data['buffer_minutes']);
|
||||
// فقط سرویس bookable برمیگردد
|
||||
$this->assertCount(1, $data['services']);
|
||||
$this->assertSame('عصبکشی', $data['services'][0]['name']);
|
||||
$this->assertSame(30, $data['services'][0]['duration_minutes']);
|
||||
}
|
||||
|
||||
public function testSlotModeReturnsEmptyServices(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر اسلاتی');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$this->client->request('GET', '/api/v1/appointment-booking-services/' . $doctor->getUuid());
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
|
||||
$data = json_decode($this->client->getResponse()->getContent(), true)['data'];
|
||||
$this->assertSame('slot', $data['booking_mode']);
|
||||
$this->assertSame([], $data['services']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* دسترسی به «یک نوبت مشخص» بر پایهٔ محیطِ خود نوبت (appointment.clinic) است، نه نقش
|
||||
* کاربر. پیش از این مسیرهای تکنوبت فقط بیمار، پزشکِ مالک و ادمین را میشناختند و
|
||||
* کاربر کلینیک روی نوبتی که خودش ساخته بود ۴۰۳ میگرفت.
|
||||
*/
|
||||
class ClinicAppointmentAccessTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctor(string $name = 'دکتر تست'): Doctor
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($user, $name);
|
||||
$doctor->setMobileNumber($user->getMobileNumber());
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
/** @return array{0: User, 1: Clinic} */
|
||||
private function makeClinicWith(Doctor ...$doctors): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$clinic->setName('کلینیک تست');
|
||||
foreach ($doctors as $d) {
|
||||
$clinic->getDoctors()->add($d);
|
||||
}
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $clinic];
|
||||
}
|
||||
|
||||
private function makeAppointment(Doctor $doctor, ?Clinic $clinic, ?User $patient = null): Appointment
|
||||
{
|
||||
$patient ??= $this->createUser();
|
||||
$start = strtotime('+3 days 10:00');
|
||||
|
||||
$appointment = new Appointment($doctor, $patient, $start, $start + 900);
|
||||
$appointment->setClinic($clinic);
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
|
||||
private function makeClinicSecretary(Clinic $clinic, Doctor $doctor, array $permissionPatch = []): User
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
|
||||
$secretary = new DoctorSecretary($doctor, $user, DoctorSecretary::OWNER_CLINIC, $clinic);
|
||||
if ($permissionPatch !== []) {
|
||||
$secretary->mergePermissions(['resources' => ['appointments' => $permissionPatch]]);
|
||||
}
|
||||
$this->em->persist($secretary);
|
||||
$this->em->flush();
|
||||
|
||||
static::getContainer()->get(UserActiveContextRepository::class)->upsert($user, $clinic->getUuid());
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function testClinicOwnerCanViewClinicAppointment(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
||||
$appointment = $this->makeAppointment($doctor, $clinic);
|
||||
|
||||
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $owner);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testClinicOwnerCanUpdateAndMoveClinicAppointment(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
||||
$appointment = $this->makeAppointment($doctor, $clinic);
|
||||
$newStart = strtotime('+4 days 11:00');
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}", $owner, [
|
||||
'slot_start' => $newStart,
|
||||
'slot_end' => $newStart + 900,
|
||||
'note' => 'جابهجا شد',
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testClinicOwnerCanChangeStatusOfClinicAppointment(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
||||
$appointment = $this->makeAppointment($doctor, $clinic);
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $owner, [
|
||||
'status' => Appointment::STATUS_CONFIRMED,
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testClinicOwnerCanTransferAppointmentToReserveAndBack(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
||||
$appointment = $this->makeAppointment($doctor, $clinic);
|
||||
$midnight = strtotime('+3 days 00:00');
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}", $owner, [
|
||||
'is_reserve' => true,
|
||||
'slot_start' => $midnight,
|
||||
'slot_end' => $midnight,
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode(), 'انتقال به لیست رزرو');
|
||||
|
||||
$this->em->clear();
|
||||
$reloaded = $this->em->getRepository(Appointment::class)->find($appointment->getId());
|
||||
self::assertTrue($reloaded->isReserve());
|
||||
|
||||
$back = strtotime('+5 days 09:00');
|
||||
$this->authJson('PATCH', "/api/v1/appointment/{$reloaded->getUuid()}", $owner, [
|
||||
'is_reserve' => false,
|
||||
'slot_start' => $back,
|
||||
'slot_end' => $back + 900,
|
||||
'version' => $reloaded->getVersion(),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode(), 'بازگشت از لیست رزرو');
|
||||
}
|
||||
|
||||
public function testClinicOwnerCanReadAppointmentEvents(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
||||
$appointment = $this->makeAppointment($doctor, $clinic);
|
||||
|
||||
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/events", $owner);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testClinicOwnerCannotTouchDoctorPersonalAppointment(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[$owner] = $this->makeClinicWith($doctor);
|
||||
$appointment = $this->makeAppointment($doctor, null);
|
||||
|
||||
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $owner);
|
||||
|
||||
self::assertSame(403, $this->responseCode(), 'مطب شخصی پزشک از دسترس کلینیک خارج است');
|
||||
}
|
||||
|
||||
public function testForeignClinicOwnerIsDenied(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[, $clinic] = $this->makeClinicWith($doctor);
|
||||
[$otherOwner] = $this->makeClinicWith($this->makeDoctor('دکتر دیگر'));
|
||||
$appointment = $this->makeAppointment($doctor, $clinic);
|
||||
|
||||
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $otherOwner);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testMemberDoctorLosesAccessWhenDeactivated(): void
|
||||
{
|
||||
$member = $this->makeDoctor('دکتر عضو');
|
||||
$colleague = $this->makeDoctor('همکار');
|
||||
[, $clinic] = $this->makeClinicWith($member, $colleague);
|
||||
$appointment = $this->makeAppointment($colleague, $clinic);
|
||||
|
||||
$permissions = static::getContainer()->get(ClinicDoctorPermissionRepository::class);
|
||||
$permissions->getOrCreate($clinic, $member);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $member->getUser());
|
||||
self::assertSame(200, $this->responseCode(), 'پزشک فعالِ کلینیک نوبتهای همان کلینیک را میبیند');
|
||||
|
||||
$permissions->getOrCreate($clinic, $member)->setActive(false);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $member->getUser());
|
||||
self::assertSame(403, $this->responseCode(), 'پس از پایان همکاری دسترسی قطع میشود');
|
||||
}
|
||||
|
||||
public function testClinicSecretaryCanManageAssignedDoctorAppointment(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[, $clinic] = $this->makeClinicWith($doctor);
|
||||
$secretary = $this->makeClinicSecretary($clinic, $doctor);
|
||||
$appointment = $this->makeAppointment($doctor, $clinic);
|
||||
|
||||
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $secretary);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $secretary, [
|
||||
'status' => Appointment::STATUS_CONFIRMED,
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testClinicSecretaryCannotTouchUnassignedDoctorAppointment(): void
|
||||
{
|
||||
$mine = $this->makeDoctor('پزشک من');
|
||||
$theirs = $this->makeDoctor('پزشک دیگر');
|
||||
[, $clinic] = $this->makeClinicWith($mine, $theirs);
|
||||
$secretary = $this->makeClinicSecretary($clinic, $mine);
|
||||
$appointment = $this->makeAppointment($theirs, $clinic);
|
||||
|
||||
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $secretary);
|
||||
|
||||
self::assertSame(403, $this->responseCode(), 'منشی فقط پزشکان تخصیصیافتهٔ خودش را دارد');
|
||||
}
|
||||
|
||||
public function testSecretaryCancelRequiresCancelPermission(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[, $clinic] = $this->makeClinicWith($doctor);
|
||||
$secretary = $this->makeClinicSecretary($clinic, $doctor);
|
||||
$appointment = $this->makeAppointment($doctor, $clinic);
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $secretary, [
|
||||
'status' => Appointment::STATUS_CANCELLED_BY_DOCTOR,
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
|
||||
self::assertSame(403, $this->responseCode(), 'لغو بهصورت پیشفرض برای منشی خاموش است');
|
||||
}
|
||||
|
||||
public function testSecretaryWithCancelPermissionCanCancel(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[, $clinic] = $this->makeClinicWith($doctor);
|
||||
$secretary = $this->makeClinicSecretary($clinic, $doctor, ['cancel' => true]);
|
||||
$appointment = $this->makeAppointment($doctor, $clinic);
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $secretary, [
|
||||
'status' => Appointment::STATUS_CANCELLED_BY_DOCTOR,
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testInlineStatusCannotBypassCancelGate(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[, $clinic] = $this->makeClinicWith($doctor);
|
||||
$secretary = $this->makeClinicSecretary($clinic, $doctor);
|
||||
$appointment = $this->makeAppointment($doctor, $clinic);
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}", $secretary, [
|
||||
'status' => Appointment::STATUS_CANCELLED_BY_DOCTOR,
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
|
||||
self::assertSame(403, $this->responseCode(), 'status درونخطی همان گیت لغو را دارد');
|
||||
}
|
||||
|
||||
public function testOwnerDoctorKeepsFullAccessToOwnClinicAppointment(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[, $clinic] = $this->makeClinicWith($doctor);
|
||||
$appointment = $this->makeAppointment($doctor, $clinic);
|
||||
|
||||
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $doctor->getUser());
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testPatientCanViewButNotRescheduleOwnAppointment(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$patient = $this->createUser();
|
||||
$appointment = $this->makeAppointment($doctor, null, $patient);
|
||||
$newStart = strtotime('+6 days 10:00');
|
||||
|
||||
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $patient);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}", $patient, [
|
||||
'slot_start' => $newStart,
|
||||
'slot_end' => $newStart + 900,
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
self::assertSame(403, $this->responseCode(), 'بیمار نوبت خودش را جابهجا نمیکند');
|
||||
}
|
||||
|
||||
public function testPatientCanCancelOwnAppointment(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$patient = $this->createUser();
|
||||
$appointment = $this->makeAppointment($doctor, null, $patient);
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $patient, [
|
||||
'status' => Appointment::STATUS_CANCELLED_BY_USER,
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testStrangerIsDenied(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$appointment = $this->makeAppointment($doctor, null);
|
||||
$stranger = $this->createUser();
|
||||
|
||||
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}", $stranger);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* تنظیمات نوبتدهی per-context است: مطب شخصی پزشک (بدون clinic_uuid) فقط برای خود
|
||||
* پزشک و ادمین باز است، و کلینیک با clinic_uuid فقط برنامهٔ همان کلینیک را
|
||||
* میبیند/مینویسد. این دو برنامهٔ جدا هستند و روی هم اثر نمیگذارند.
|
||||
*/
|
||||
class ClinicOwnerScheduleAccessTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctor(string $name): Doctor
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($user, $name);
|
||||
$doctor->setMobileNumber($user->getMobileNumber());
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
private function makeClinicWith(Doctor ...$doctors): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$clinic->setName('کلینیک تست');
|
||||
foreach ($doctors as $d) {
|
||||
$clinic->getDoctors()->add($d);
|
||||
}
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $clinic];
|
||||
}
|
||||
|
||||
private function addressFor(Doctor $doctor): DoctorAddress
|
||||
{
|
||||
$address = DoctorAddress::forDoctor($doctor);
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
return $address;
|
||||
}
|
||||
|
||||
private function clinicAddress(Clinic $clinic): DoctorAddress
|
||||
{
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
return $address;
|
||||
}
|
||||
|
||||
private function schedulePayload(Doctor $doctor, int $locationId, string $start, ?Clinic $clinic = null): array
|
||||
{
|
||||
return array_filter([
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'clinic_uuid' => $clinic?->getUuid(),
|
||||
'schedule' => [
|
||||
['day' => 'saturday', 'sessions' => [
|
||||
['active' => true, 'location_id' => $locationId, 'start' => $start, 'end' => '12:00'],
|
||||
]],
|
||||
],
|
||||
], fn($v) => $v !== null);
|
||||
}
|
||||
|
||||
public function testClinicOwnerCanReadAndWriteMemberDoctorScheduleInClinicContext(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor('دکتر عضو');
|
||||
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
||||
$address = $this->clinicAddress($clinic);
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $address->getId(), '09:00', $clinic));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}?clinic_uuid={$clinic->getUuid()}", $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $owner, [
|
||||
'clinic_uuid' => $clinic->getUuid(),
|
||||
'schedule' => [
|
||||
['day' => 'saturday', 'sessions' => [
|
||||
['active' => true, 'location_id' => $address->getId(), 'start' => '10:00', 'end' => '13:00'],
|
||||
]],
|
||||
],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testClinicOwnerCannotTouchDoctorPersonalSchedule(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor('دکتر عضو');
|
||||
[$owner] = $this->makeClinicWith($doctor);
|
||||
$address = $this->addressFor($doctor);
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $address->getId(), '09:00'));
|
||||
|
||||
self::assertSame(403, $this->responseCode(), 'مطب شخصی پزشک از دسترس کلینیک خارج است');
|
||||
}
|
||||
|
||||
public function testClinicContextRejectsPersonalAddress(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor('دکتر عضو');
|
||||
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
||||
$personal = $this->addressFor($doctor);
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $personal->getId(), '09:00', $clinic));
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testPersonalContextRejectsClinicAddress(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor('دکتر عضو');
|
||||
[, $clinic] = $this->makeClinicWith($doctor);
|
||||
$clinicAddress = $this->clinicAddress($clinic);
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $clinicAddress->getId(), '09:00'));
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testStrangerClinicUuidIsRejected(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor('دکتر مستقل');
|
||||
$outsider = $this->makeDoctor('دکتر دیگر');
|
||||
[, $clinic] = $this->makeClinicWith($outsider);
|
||||
$address = $this->addressFor($doctor);
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $address->getId(), '09:00', $clinic));
|
||||
|
||||
self::assertSame(422, $this->responseCode(), 'پزشک عضو این کلینیک نیست');
|
||||
}
|
||||
|
||||
public function testPersonalAndClinicSchedulesCoexistIndependently(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor('دکتر دو-محیطی');
|
||||
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
||||
$personal = $this->addressFor($doctor);
|
||||
$inClinic = $this->clinicAddress($clinic);
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $personal->getId(), '08:00'));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $inClinic->getId(), '16:00', $clinic));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$reloadedDoctor = $this->em->getRepository(Doctor::class)->find($doctor->getId());
|
||||
$schedules = $this->em->getRepository(WeeklySchedule::class)->findAllByDoctor($reloadedDoctor);
|
||||
|
||||
self::assertCount(2, $schedules, 'یک برنامه به ازای هر محیط');
|
||||
|
||||
$byContext = [];
|
||||
foreach ($schedules as $schedule) {
|
||||
$byContext[$schedule->getClinic() === null ? 'personal' : 'clinic'] = $schedule->getSetting()[0]['sessions'][0]['start'];
|
||||
}
|
||||
|
||||
self::assertSame('08:00', $byContext['personal']);
|
||||
self::assertSame('16:00', $byContext['clinic']);
|
||||
}
|
||||
|
||||
public function testDoctorKeepsFullAccessToOwnSchedule(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor('دکتر مستقل');
|
||||
$address = $this->addressFor($doctor);
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $address->getId(), '09:00'));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $doctor->getUser());
|
||||
self::assertSame(200, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testDoctorCannotTouchAnotherDoctorSchedule(): void
|
||||
{
|
||||
$mine = $this->makeDoctor('دکتر یک');
|
||||
$theirs = $this->makeDoctor('دکتر دو');
|
||||
$address = $this->addressFor($theirs);
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $mine->getUser(), $this->schedulePayload($theirs, $address->getId(), '09:00'));
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAdminCanWriteAnyDoctorSchedule(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor('دکتر هدف');
|
||||
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
|
||||
$address = $this->addressFor($doctor);
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $admin, $this->schedulePayload($doctor, $address->getId(), '09:00'));
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testMemberDoctorLosesAccessWhenPermissionRevoked(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor('دکتر عضو');
|
||||
$other = $this->makeDoctor('دکتر دیگر');
|
||||
[, $clinic] = $this->makeClinicWith($doctor, $other);
|
||||
$address = $this->clinicAddress($clinic);
|
||||
|
||||
$perm = static::getContainer()->get(ClinicDoctorPermissionRepository::class)->getOrCreate($clinic, $doctor);
|
||||
$perm->mergePermissions(['resources' => ['appointment_settings' => ['update' => false, 'view' => false]]]);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($other, $address->getId(), '09:00', $clinic));
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testEditingOneDoctorDoesNotAffectAnother(): void
|
||||
{
|
||||
$first = $this->makeDoctor('دکتر اول');
|
||||
$second = $this->makeDoctor('دکتر دوم');
|
||||
[$owner, $clinic] = $this->makeClinicWith($first, $second);
|
||||
$address = $this->clinicAddress($clinic);
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($first, $address->getId(), '08:00', $clinic));
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($second, $address->getId(), '16:00', $clinic));
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/appointment-settings/weekly-schedule/{$first->getUuid()}", $owner, [
|
||||
'clinic_uuid' => $clinic->getUuid(),
|
||||
'schedule' => [
|
||||
['day' => 'saturday', 'sessions' => [
|
||||
['active' => true, 'location_id' => $address->getId(), 'start' => '11:00', 'end' => '15:00'],
|
||||
]],
|
||||
],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$reloaded = $this->em->getRepository(WeeklySchedule::class)->findByDoctorAndClinic(
|
||||
$this->em->getRepository(Doctor::class)->find($second->getId()),
|
||||
$this->em->getRepository(Clinic::class)->find($clinic->getId()),
|
||||
);
|
||||
|
||||
self::assertSame('16:00', $reloaded->getSetting()[0]['sessions'][0]['start'], "the other doctor's schedule is untouched");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Filtered + paginated doctor appointment list backing the dashboard filter bar
|
||||
* (AppointmentRepository::searchByDoctor).
|
||||
*/
|
||||
class DoctorAppointmentFilterTest extends ApiTestCase
|
||||
{
|
||||
private function repo(): AppointmentRepository
|
||||
{
|
||||
return $this->em->getRepository(Appointment::class);
|
||||
}
|
||||
|
||||
private function makeDoctor(): Doctor
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
private function booking(Doctor $doctor, int $start, ?string $status = null, ?string $name = null): Appointment
|
||||
{
|
||||
$a = new Appointment($doctor, $this->createUser(), $start, $start + 1_800);
|
||||
if ($name !== null) {
|
||||
$a->setPatientName($name);
|
||||
}
|
||||
if ($status === Appointment::STATUS_COMPLETED) {
|
||||
$a->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$a->transitionTo(Appointment::STATUS_COMPLETED);
|
||||
} elseif ($status !== null) {
|
||||
$a->transitionTo($status);
|
||||
}
|
||||
$this->em->persist($a);
|
||||
$this->em->flush();
|
||||
|
||||
return $a;
|
||||
}
|
||||
|
||||
public function testStatusFilterExcludesVisited(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$base = time() + 86_400;
|
||||
$this->booking($doctor, $base); // pending
|
||||
$this->booking($doctor, $base + 3_600, Appointment::STATUS_CONFIRMED);
|
||||
$this->booking($doctor, $base + 7_200, Appointment::STATUS_COMPLETED);
|
||||
|
||||
$res = $this->repo()->searchByDoctor(
|
||||
$doctor,
|
||||
[Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED],
|
||||
);
|
||||
|
||||
self::assertSame(2, $res['total']);
|
||||
foreach ($res['items'] as $a) {
|
||||
self::assertNotSame(Appointment::STATUS_COMPLETED, $a->getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
public function testDateRangeAndNameSearch(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$base = time() + 86_400;
|
||||
$this->booking($doctor, $base, null, 'علی رضایی');
|
||||
$this->booking($doctor, $base + 200_000, null, 'مریم کاظمی');
|
||||
|
||||
$inRange = $this->repo()->searchByDoctor($doctor, [], null, $base - 60, $base + 60);
|
||||
self::assertSame(1, $inRange['total']);
|
||||
self::assertSame('علی رضایی', $inRange['items'][0]->getPatientName());
|
||||
|
||||
$byName = $this->repo()->searchByDoctor($doctor, [], null, null, null, 'کاظمی');
|
||||
self::assertSame(1, $byName['total']);
|
||||
self::assertSame('مریم کاظمی', $byName['items'][0]->getPatientName());
|
||||
}
|
||||
|
||||
public function testPaginationSlicesAndReportsFullTotal(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$base = time() + 86_400;
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$this->booking($doctor, $base + $i * 3_600);
|
||||
}
|
||||
|
||||
$page2 = $this->repo()->searchByDoctor($doctor, [], null, null, null, null, null, 2, 2);
|
||||
|
||||
self::assertSame(5, $page2['total'], 'total باید کل نتایج باشد نه اندازهٔ صفحه');
|
||||
self::assertCount(2, $page2['items']);
|
||||
}
|
||||
|
||||
public function testEmptyResultForUnmatchedFilter(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$this->booking($doctor, time() + 86_400);
|
||||
|
||||
$res = $this->repo()->searchByDoctor($doctor, [Appointment::STATUS_NO_SHOW]);
|
||||
|
||||
self::assertSame(0, $res['total']);
|
||||
self::assertSame([], $res['items']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Tests\ApiTestCase;
|
||||
use App\UserProfile\Entity\UserProfile;
|
||||
|
||||
/**
|
||||
* GET /api/v1/my/appointment/patient-lookup — mobile-first patient search used
|
||||
* by the booking form before asking for national code / name.
|
||||
*/
|
||||
class PatientLookupTest extends ApiTestCase
|
||||
{
|
||||
private function booker(): User
|
||||
{
|
||||
return $this->createUser(['ROLE_DOCTOR']);
|
||||
}
|
||||
|
||||
private function mobile(): string
|
||||
{
|
||||
return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function nationalCode(): string
|
||||
{
|
||||
return '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
public function testFoundWithNationalCode(): void
|
||||
{
|
||||
$mobile = $this->mobile();
|
||||
$nc = $this->nationalCode();
|
||||
$patient = $this->createUser(['ROLE_USER'], $mobile);
|
||||
$patient->setRealName('علی محمدی');
|
||||
$profile = new UserProfile($patient);
|
||||
$profile->setNationalCode($nc);
|
||||
$this->em->persist($profile);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/appointment/patient-lookup?mobile=' . $mobile, $this->booker());
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($res['data']['found']);
|
||||
self::assertSame('علی محمدی', $res['data']['name']);
|
||||
self::assertSame($nc, $res['data']['national_code']);
|
||||
}
|
||||
|
||||
public function testFoundWithoutNationalCode(): void
|
||||
{
|
||||
$mobile = $this->mobile();
|
||||
$patient = $this->createUser(['ROLE_USER'], $mobile);
|
||||
$patient->setRealName('بدون کدملی');
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/appointment/patient-lookup?mobile=' . $mobile, $this->booker());
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($res['data']['found']);
|
||||
self::assertNull($res['data']['national_code']);
|
||||
}
|
||||
|
||||
public function testNotFound(): void
|
||||
{
|
||||
$res = $this->authJson('GET', '/api/v1/my/appointment/patient-lookup?mobile=' . $this->mobile(), $this->booker());
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertFalse($res['data']['found']);
|
||||
}
|
||||
|
||||
public function testInvalidMobileIs422(): void
|
||||
{
|
||||
$this->authJson('GET', '/api/v1/my/appointment/patient-lookup?mobile=123', $this->booker());
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testPlainUserIsForbidden(): void
|
||||
{
|
||||
$this->authJson('GET', '/api/v1/my/appointment/patient-lookup?mobile=' . $this->mobile(), $this->createUser(['ROLE_USER']));
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\Appointment\Service\SlotCalculatorService;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* حالت نوبتدهی سرویسی: getServiceStartTimes باید فضای خالیِ داخل شیفت را با توجه
|
||||
* به مدت سرویس (+ بافر) بچیند و بازههای اشغالشده را رد کند. همچنین متای mode/buffer.
|
||||
*/
|
||||
class ServiceBasedSlotsTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctorWithServiceSchedule(int $buffer = 5): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر سرویس');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
// فردا در بازهٔ booking-window قرار دارد و روز گذشته نیست.
|
||||
$date = date('Y-m-d', strtotime('tomorrow'));
|
||||
$dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7);
|
||||
|
||||
$schedule = new WeeklySchedule($doctor, [
|
||||
$dayKey => ['sessions' => [[
|
||||
'active' => true,
|
||||
'start_time' => '15:00',
|
||||
'end_time' => '17:00',
|
||||
'duration_per_patient' => 20,
|
||||
'location_id' => 1,
|
||||
]]],
|
||||
]);
|
||||
$schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => $buffer]);
|
||||
$this->em->persist($schedule);
|
||||
$this->em->flush();
|
||||
|
||||
return [$doctor, $date];
|
||||
}
|
||||
|
||||
public function testGapPackingWithBuffer(): void
|
||||
{
|
||||
[$doctor, $date] = $this->makeDoctorWithServiceSchedule(5);
|
||||
|
||||
$calc = static::getContainer()->get(SlotCalculatorService::class);
|
||||
$slots = $calc->getServiceStartTimes($doctor, $date, 30);
|
||||
|
||||
// پنجره 15:00–17:00، سرویس 30 + بافر 5 → گام 35 دقیقه: 15:00, 15:35, 16:10
|
||||
$times = array_column($slots, 'start_time');
|
||||
$this->assertSame(['15:00', '15:35', '16:10'], $times);
|
||||
|
||||
// زمان پایانِ ذخیرهشده بدون بافر است.
|
||||
$this->assertSame(strtotime($date . ' 15:00') + 30 * 60, $slots[0]['end']);
|
||||
}
|
||||
|
||||
public function testBookedIntervalIsSkipped(): void
|
||||
{
|
||||
[$doctor, $date] = $this->makeDoctorWithServiceSchedule(5);
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$start = strtotime($date . ' 15:00');
|
||||
$appt = new Appointment($doctor, $patient, $start, $start + 30 * 60);
|
||||
$appt->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$this->em->persist($appt);
|
||||
$this->em->flush();
|
||||
|
||||
$calc = static::getContainer()->get(SlotCalculatorService::class);
|
||||
$times = array_column($calc->getServiceStartTimes($doctor, $date, 30), 'start_time');
|
||||
|
||||
// 15:00 اشغال است → از 15:30 شروع میشود.
|
||||
$this->assertNotContains('15:00', $times);
|
||||
$this->assertContains('15:30', $times);
|
||||
}
|
||||
|
||||
public function testNoRoomReturnsEmpty(): void
|
||||
{
|
||||
[$doctor, $date] = $this->makeDoctorWithServiceSchedule(0);
|
||||
|
||||
// سرویس 200 دقیقه در پنجرهٔ 120 دقیقهای جا نمیشود.
|
||||
$calc = static::getContainer()->get(SlotCalculatorService::class);
|
||||
$this->assertSame([], $calc->getServiceStartTimes($doctor, $date, 200));
|
||||
}
|
||||
|
||||
public function testMetaDefaultsAndWhitelist(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر متا');
|
||||
$schedule = new WeeklySchedule($doctor, []);
|
||||
|
||||
// پیشفرض = اسلاتی
|
||||
$this->assertSame(WeeklySchedule::MODE_SLOT, $schedule->getMeta()['booking_mode']);
|
||||
$this->assertSame(0, $schedule->getMeta()['buffer_minutes']);
|
||||
|
||||
$schedule->setMeta(['booking_mode' => 'service', 'buffer_minutes' => 7]);
|
||||
$this->assertSame('service', $schedule->getMeta()['booking_mode']);
|
||||
$this->assertSame(7, $schedule->getMeta()['buffer_minutes']);
|
||||
|
||||
// مقدار نامعتبر mode نادیده گرفته میشود (whitelist)، بافر منفی → صفر.
|
||||
$schedule->setMeta(['booking_mode' => 'bogus', 'buffer_minutes' => -3]);
|
||||
$this->assertSame('service', $schedule->getMeta()['booking_mode']);
|
||||
$this->assertSame(0, $schedule->getMeta()['buffer_minutes']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* نوبتدهی سرویسی، سرویسهای همان محیط را میخواهد. باگ اصلی این بود که شمارش
|
||||
* همیشه با entity_type='doctor' انجام میشد، پس کلینیکی که سرویس bookable داشت هم
|
||||
* خطای «حداقل یک سرویس لازم است» میگرفت.
|
||||
*/
|
||||
class ServiceModeContextTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctorInClinic(): array
|
||||
{
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'دکتر تست');
|
||||
$doctor->setMobileNumber($doctorUser->getMobileNumber());
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$ownerUser = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($ownerUser);
|
||||
$clinic->setName('کلینیک تست');
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return [$doctor, $clinic, $ownerUser];
|
||||
}
|
||||
|
||||
private function bookableServiceFor(string $entityType, int $entityId): void
|
||||
{
|
||||
$section = new ServiceSection($entityType, $entityId, 'بخش تست');
|
||||
$this->em->persist($section);
|
||||
|
||||
$item = new ServiceItem($section, 'ویزیت', 500_000);
|
||||
$item->setBookable(true);
|
||||
$item->setDurationMinutes(20);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function servicePayload(Doctor $doctor, int $locationId, ?Clinic $clinic): array
|
||||
{
|
||||
return array_filter([
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'clinic_uuid' => $clinic?->getUuid(),
|
||||
'schedule' => [
|
||||
['day' => 'saturday', 'sessions' => [
|
||||
['active' => true, 'location_id' => $locationId, 'start' => '09:00', 'end' => '12:00'],
|
||||
]],
|
||||
],
|
||||
'meta' => ['booking_mode' => 'service'],
|
||||
], fn($v) => $v !== null);
|
||||
}
|
||||
|
||||
public function testClinicServiceModeAcceptsClinicOwnedService(): void
|
||||
{
|
||||
[$doctor, $clinic, $owner] = $this->makeDoctorInClinic();
|
||||
$this->bookableServiceFor('clinic', $clinic->getId());
|
||||
|
||||
$address = DoctorAddress::forClinic($clinic->getId());
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->servicePayload($doctor, $address->getId(), $clinic));
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testPersonalServiceModeIgnoresClinicServices(): void
|
||||
{
|
||||
[$doctor, $clinic] = $this->makeDoctorInClinic();
|
||||
$this->bookableServiceFor('clinic', $clinic->getId());
|
||||
|
||||
$address = DoctorAddress::forDoctor($doctor);
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->servicePayload($doctor, $address->getId(), null));
|
||||
|
||||
self::assertSame(422, $this->responseCode(), 'سرویس کلینیک نباید مطب شخصی را راضی کند');
|
||||
self::assertSame('booking_mode', $body['errors'][0]['field'] ?? null);
|
||||
}
|
||||
|
||||
public function testPersonalServiceModeAcceptsOwnService(): void
|
||||
{
|
||||
[$doctor] = $this->makeDoctorInClinic();
|
||||
$this->bookableServiceFor('doctor', $doctor->getId());
|
||||
|
||||
$address = DoctorAddress::forDoctor($doctor);
|
||||
$this->em->persist($address);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->servicePayload($doctor, $address->getId(), null));
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testBookingModeLocksPerContextNotGlobally(): void
|
||||
{
|
||||
[$doctor, $clinic, $owner] = $this->makeDoctorInClinic();
|
||||
$this->bookableServiceFor('clinic', $clinic->getId());
|
||||
|
||||
$personal = DoctorAddress::forDoctor($doctor);
|
||||
$inClinic = DoctorAddress::forClinic($clinic->getId());
|
||||
$this->em->persist($personal);
|
||||
$this->em->persist($inClinic);
|
||||
$this->em->flush();
|
||||
|
||||
// مطب شخصی: اسلاتی
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'schedule' => [['day' => 'saturday', 'sessions' => [
|
||||
['active' => true, 'location_id' => $personal->getId(), 'start' => '09:00', 'end' => '12:00'],
|
||||
]]],
|
||||
'meta' => ['booking_mode' => 'slot'],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
// همان پزشک در کلینیک: سرویسی — قفلِ محیط دیگر نباید مانع شود
|
||||
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->servicePayload($doctor, $inClinic->getId(), $clinic));
|
||||
self::assertSame(201, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Appointment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Entity\WeeklySchedule;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* حالت نوبتدهی سرویسی: پاسخ booking-services باید بخش هر سرویس را بدهد؛ اسلاتها و
|
||||
* ثبت نوبت باید مدتِ override منشی را (فقط برای همان نوبت) لحاظ کنند بدون تغییر پیشفرضِ سرویس.
|
||||
*/
|
||||
class ServiceModeSectionDurationTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0:\App\Auth\Entity\User,1:Doctor,2:string} */
|
||||
private function serviceDoctor(int $serviceMinutes = 30): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر سرویس');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$date = date('Y-m-d', strtotime('tomorrow'));
|
||||
$dayKey = (string) (((int) date('w', strtotime($date)) + 1) % 7);
|
||||
$schedule = new WeeklySchedule($doctor, [
|
||||
$dayKey => ['sessions' => [[
|
||||
'active' => true, 'start_time' => '15:00', 'end_time' => '19:00',
|
||||
'duration_per_patient' => 20, 'location_id' => 1,
|
||||
]]],
|
||||
]);
|
||||
$schedule->setMeta(['booking_mode' => WeeklySchedule::MODE_SERVICE, 'buffer_minutes' => 0]);
|
||||
$this->em->persist($schedule);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor, $date];
|
||||
}
|
||||
|
||||
private function service(Doctor $doctor, string $name, int $minutes): ServiceItem
|
||||
{
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش ' . $name);
|
||||
$this->em->persist($section);
|
||||
$item = new ServiceItem($section, $name, 0);
|
||||
$item->setDurationMinutes($minutes)->setBookable(true);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
public function testBookingServicesReturnsSection(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->serviceDoctor();
|
||||
$svc = $this->service($doctor, 'بوتاکس', 30);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/appointment-booking-services/' . $doctor->getUuid(), $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$row = $res['data']['services'][0];
|
||||
self::assertSame($svc->getUuid(), $row['uuid']);
|
||||
self::assertArrayHasKey('service_section', $row);
|
||||
self::assertSame($svc->getSection()->getUuid(), $row['service_section']['uuid']);
|
||||
self::assertSame($svc->getSection()->getName(), $row['service_section']['name']);
|
||||
}
|
||||
|
||||
public function testServiceSlotsHonorsDurationOverride(): void
|
||||
{
|
||||
[$owner, $doctor, $date] = $this->serviceDoctor();
|
||||
$svc = $this->service($doctor, 'فیلر', 30);
|
||||
|
||||
// بدون override: مدت کل = ۳۰
|
||||
$base = $this->authJson('GET', sprintf(
|
||||
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s',
|
||||
$doctor->getUuid(), $date, $svc->getUuid()
|
||||
), $owner);
|
||||
self::assertSame(30, $base['data']['total_duration_minutes']);
|
||||
|
||||
// با override = ۹۰
|
||||
$over = $this->authJson('GET', sprintf(
|
||||
'/api/v1/appointment-service-slots?doctor_uuid=%s&date=%s&service_item_uuids[]=%s&durations[%s]=90',
|
||||
$doctor->getUuid(), $date, $svc->getUuid(), $svc->getUuid()
|
||||
), $owner);
|
||||
self::assertSame(90, $over['data']['total_duration_minutes']);
|
||||
|
||||
// پیشفرضِ سرویس در DB تغییر نکرده
|
||||
$this->em->clear();
|
||||
$reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $svc->getUuid()]);
|
||||
self::assertSame(30, $reloaded->getDurationMinutes());
|
||||
}
|
||||
|
||||
public function testCreateAppliesDurationOverrideToSlotEnd(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->serviceDoctor();
|
||||
$svc = $this->service($doctor, 'لیزر', 30);
|
||||
|
||||
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
||||
$nc = '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $start + 60, // نادیده گرفته میشود (بازمحاسبه)
|
||||
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
||||
'patient_name' => 'بیمار تست',
|
||||
'patient_national_code' => $nc,
|
||||
'service_item_uuids' => [$svc->getUuid()],
|
||||
'duration_from_services' => true,
|
||||
'service_durations' => [$svc->getUuid() => 75],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$appt = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $res['data']['uuid']]);
|
||||
// slot_end = start + 75 دقیقه (override)، نه ۳۰ پیشفرض
|
||||
self::assertSame($start + 75 * 60, $appt->getSlotEnd());
|
||||
|
||||
// پیشفرضِ سرویس دستنخورده
|
||||
$reloaded = $this->em->getRepository(ServiceItem::class)->findOneBy(['uuid' => $svc->getUuid()]);
|
||||
self::assertSame(30, $reloaded->getDurationMinutes());
|
||||
}
|
||||
}
|
||||
@@ -75,4 +75,39 @@ class SlotUniquenessTest extends ApiTestCase
|
||||
$this->expectException(SlotTakenException::class);
|
||||
$repo->bookAtomically($this->newBooking($doctor, $start));
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: a slot whose only booking has already been consumed (completed,
|
||||
* no_show, …) must still read as taken in the availability view — otherwise the
|
||||
* public site offers an occupied slot as free. isSlotTaken counts the broader
|
||||
* SLOT_BLOCKING_STATUSES, not just live pending/confirmed.
|
||||
*/
|
||||
public function testConsumedBookingStillMarksSlotTaken(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$start = time() + 86_400;
|
||||
$repo = $this->em->getRepository(Appointment::class);
|
||||
|
||||
$appt = $this->newBooking($doctor, $start);
|
||||
$appt->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$appt->transitionTo(Appointment::STATUS_COMPLETED);
|
||||
$this->em->persist($appt);
|
||||
$this->em->flush();
|
||||
|
||||
$this->assertTrue($repo->isSlotTaken($doctor, $start, $start + 1_800));
|
||||
}
|
||||
|
||||
public function testCancelledBookingLeavesSlotFreeForAvailability(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$start = time() + 86_400;
|
||||
$repo = $this->em->getRepository(Appointment::class);
|
||||
|
||||
$appt = $this->newBooking($doctor, $start);
|
||||
$appt->transitionTo(Appointment::STATUS_CANCELLED_BY_USER);
|
||||
$this->em->persist($appt);
|
||||
$this->em->flush();
|
||||
|
||||
$this->assertFalse($repo->isSlotTaken($doctor, $start, $start + 1_800));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Tests\Audit;
|
||||
|
||||
use App\Auth\Entity\PreRegistration;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
@@ -15,11 +14,10 @@ class LowTierFixesTest extends ApiTestCase
|
||||
{
|
||||
public function testUpdateSessionEnforcesPatientGate(): void
|
||||
{
|
||||
// a doctor with no subscription → no patient_records feature
|
||||
// ROLE_DOCTOR بدون رکورد Doctor → resolveEntity مقدار entityId=null میدهد
|
||||
// و گیت با ERR_FORBIDDEN_001 رد میکند. (به features پلن وابسته نیست: پلن
|
||||
// free از Version20260705070546 به بعد patient_records را میدهد.)
|
||||
$doctorUser = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
// gate fires before the session lookup → 403 (not 404)
|
||||
$this->authJson('PATCH', '/api/v1/session/nonexistent-uuid', $doctorUser, ['notes' => 'x']);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Auth;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* POST /api/v1/user/change-password — authenticated password change.
|
||||
*/
|
||||
class ChangePasswordTest extends ApiTestCase
|
||||
{
|
||||
private function userWithPassword(string $password): User
|
||||
{
|
||||
$user = $this->createUser(['ROLE_DOCTOR']);
|
||||
$hasher = static::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
$user->setPasswordHash($hasher->hashPassword($user, $password));
|
||||
$this->em->flush();
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function testChangesPasswordWithCorrectCurrent(): void
|
||||
{
|
||||
$user = $this->userWithPassword('oldpass12');
|
||||
|
||||
$this->authJson('POST', '/api/v1/user/change-password', $user, [
|
||||
'current_password' => 'oldpass12',
|
||||
'new_password' => 'newpass34',
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$hasher = static::getContainer()->get(UserPasswordHasherInterface::class);
|
||||
$this->em->clear();
|
||||
$reloaded = $this->em->getRepository(User::class)->find($user->getId());
|
||||
self::assertTrue($hasher->isPasswordValid($reloaded, 'newpass34'));
|
||||
}
|
||||
|
||||
public function testRejectsWrongCurrentPassword(): void
|
||||
{
|
||||
$user = $this->userWithPassword('oldpass12');
|
||||
|
||||
$this->authJson('POST', '/api/v1/user/change-password', $user, [
|
||||
'current_password' => 'wrongpass',
|
||||
'new_password' => 'newpass34',
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testRejectsShortNewPassword(): void
|
||||
{
|
||||
$user = $this->userWithPassword('oldpass12');
|
||||
|
||||
$this->authJson('POST', '/api/v1/user/change-password', $user, [
|
||||
'current_password' => 'oldpass12',
|
||||
'new_password' => 'short',
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testRejectsSameAsCurrent(): void
|
||||
{
|
||||
$user = $this->userWithPassword('oldpass12');
|
||||
|
||||
$this->authJson('POST', '/api/v1/user/change-password', $user, [
|
||||
'current_password' => 'oldpass12',
|
||||
'new_password' => 'oldpass12',
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Billing;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Billing\Entity\Claim;
|
||||
use App\Billing\Entity\ClaimItem;
|
||||
use App\Billing\Entity\Invoice;
|
||||
use App\Billing\Entity\InvoiceItem;
|
||||
use App\Billing\ValueObject\ShareBreakdown;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/billing/claims/by-patient — the claims dashboard's first level.
|
||||
*
|
||||
* A claim reaches its patient only through claim_item → invoice_item → invoice →
|
||||
* patient_record, and one invoice can carry both a base and a supplementary claim.
|
||||
* These tests pin the aggregation against double-counting the service amount.
|
||||
*/
|
||||
class ClaimsByPatientTest extends ApiTestCase
|
||||
{
|
||||
private User $owner;
|
||||
private Doctor $doctor;
|
||||
private PatientRecord $record;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$this->doctor = new Doctor($this->owner, 'دکتر تست');
|
||||
$this->em->persist($this->doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$this->record = new PatientRecord('doctor', $this->doctor->getId(), $patient, 'doctor', $this->doctor->getId());
|
||||
$this->em->persist($this->record);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
/** Invoice of $total split into insurance/patient shares, with its claim(s). */
|
||||
private function invoiceWithClaims(int $total, int $baseShare, int $suppShare, array $statuses = ['pending']): Invoice
|
||||
{
|
||||
$patient = $total - $baseShare - $suppShare;
|
||||
|
||||
$invoice = new Invoice('doctor', $this->doctor->getId());
|
||||
$invoice->setPatientRecordId((int) $this->record->getId());
|
||||
$item = new InvoiceItem($invoice, 'جراحی', $total, 1, new ShareBreakdown($total, $baseShare, $suppShare, $patient));
|
||||
$invoice->addItem($item);
|
||||
$invoice->recalculateTotals();
|
||||
$this->em->persist($invoice);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
foreach ($statuses as $i => $status) {
|
||||
$kind = $i === 0 ? Claim::KIND_BASE : Claim::KIND_SUPPLEMENTARY;
|
||||
$share = $i === 0 ? $baseShare : $suppShare;
|
||||
|
||||
$claim = new Claim('doctor', $this->doctor->getId(), 1 + $i, $kind);
|
||||
$claimItem = new ClaimItem($claim, (int) $item->getId(), $share);
|
||||
$claim->addItem($claimItem);
|
||||
if ($status !== Claim::STATUS_PENDING) {
|
||||
$claim->submit();
|
||||
}
|
||||
if ($status === Claim::STATUS_PAID) {
|
||||
$claim->approve($share);
|
||||
$claim->pay($share);
|
||||
}
|
||||
$this->em->persist($claim);
|
||||
$this->em->persist($claimItem);
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
public function testAggregatesOneRowPerPatientWithConsistentShares(): void
|
||||
{
|
||||
$this->invoiceWithClaims(10_000_000, 7_000_000, 0);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/billing/claims/by-patient', $this->owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(1, $res['data']);
|
||||
|
||||
$row = $res['data'][0];
|
||||
self::assertSame(1, $row['claims_count']);
|
||||
self::assertSame(10_000_000, $row['total_services_rials']);
|
||||
self::assertSame(7_000_000, $row['total_insurance_rials']);
|
||||
self::assertSame(3_000_000, $row['total_patient_rials']);
|
||||
self::assertSame('pending', $row['overall_status']);
|
||||
}
|
||||
|
||||
public function testServiceTotalIsNotDoubleCountedWhenAnInvoiceHasTwoClaims(): void
|
||||
{
|
||||
// پایه و مکمل روی یک صورتحساب: مبلغ خدمات باید یکبار شمرده شود.
|
||||
$this->invoiceWithClaims(10_000_000, 6_000_000, 2_000_000, ['pending', 'pending']);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/billing/claims/by-patient', $this->owner);
|
||||
$row = $res['data'][0];
|
||||
|
||||
self::assertSame(2, $row['claims_count']);
|
||||
self::assertSame(10_000_000, $row['total_services_rials']);
|
||||
self::assertSame(8_000_000, $row['total_insurance_rials']);
|
||||
self::assertSame(
|
||||
$row['total_services_rials'],
|
||||
$row['total_insurance_rials'] + $row['total_patient_rials'],
|
||||
);
|
||||
}
|
||||
|
||||
public function testOverallStatusIsMixedWhenClaimsDisagree(): void
|
||||
{
|
||||
$this->invoiceWithClaims(10_000_000, 6_000_000, 2_000_000, [Claim::STATUS_PAID, Claim::STATUS_PENDING]);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/billing/claims/by-patient', $this->owner);
|
||||
|
||||
self::assertSame('mixed', $res['data'][0]['overall_status']);
|
||||
}
|
||||
|
||||
public function testStatusFilterNarrowsTheAggregation(): void
|
||||
{
|
||||
$this->invoiceWithClaims(10_000_000, 7_000_000, 0);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/billing/claims/by-patient?status=paid', $this->owner);
|
||||
|
||||
self::assertSame(0, $res['meta']['totalRecords']);
|
||||
}
|
||||
|
||||
public function testDetailListsClaimsWithCoverageAndTimeline(): void
|
||||
{
|
||||
$this->invoiceWithClaims(10_000_000, 7_000_000, 0);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $this->owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$claim = $res['data']['claims'][0];
|
||||
// JSON یک float گِرد را به int تبدیل میکند؛ مقایسهی نوعمحور اینجا معنا ندارد.
|
||||
self::assertEquals(70, $claim['coverage_percent']);
|
||||
self::assertSame(7_000_000, $claim['insurance_share_rials']);
|
||||
self::assertSame(3_000_000, $claim['patient_share_rials']);
|
||||
self::assertSame(['submitted'], $claim['allowed_transitions']);
|
||||
}
|
||||
|
||||
public function testDetailRefusesARecordOfAnotherTenant(): void
|
||||
{
|
||||
$otherOwner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$otherDoctor = new Doctor($otherOwner, 'دکتر دیگر');
|
||||
$this->em->persist($otherDoctor);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $otherOwner);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testSubmitStoresTheTrackingNumberAndLogsTheTransition(): void
|
||||
{
|
||||
$this->invoiceWithClaims(10_000_000, 7_000_000, 0);
|
||||
$detail = $this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $this->owner);
|
||||
$uuid = $detail['data']['claims'][0]['uuid'];
|
||||
|
||||
$this->authJson('POST', '/api/v1/billing/claims/' . $uuid . '/submit', $this->owner, [
|
||||
'tracking_number' => 'TM-1',
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$after = $this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $this->owner);
|
||||
$claim = $after['data']['claims'][0];
|
||||
|
||||
self::assertSame('TM-1', $claim['tracking_number']);
|
||||
self::assertSame('submitted', $claim['status']);
|
||||
self::assertSame('submitted', end($claim['logs'])['to_status']);
|
||||
}
|
||||
|
||||
public function testRejectWithoutAReasonIsRefused(): void
|
||||
{
|
||||
$this->invoiceWithClaims(10_000_000, 7_000_000, 0, [Claim::STATUS_SUBMITTED]);
|
||||
$detail = $this->authJson('GET', '/api/v1/billing/claims/by-patient/' . $this->record->getUuid(), $this->owner);
|
||||
$uuid = $detail['data']['claims'][0]['uuid'];
|
||||
|
||||
$this->authJson('POST', '/api/v1/billing/claims/' . $uuid . '/reject', $this->owner, []);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Billing;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Billing\Entity\Invoice;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Inventory\Entity\InventoryItem;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Patient\Entity\SessionConsumable;
|
||||
use App\Patient\Entity\SessionPayment;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/billing/invoices/{uuid} — invoice detail enriched with its
|
||||
* source session (`session` key: payments, consumables, discount, paid totals).
|
||||
*/
|
||||
class InvoiceShowSessionTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Doctor} owner user + their doctor profile */
|
||||
private function doctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
private function patientRecord(Doctor $doctor): PatientRecord
|
||||
{
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function setField(object $obj, string $prop, mixed $value): void
|
||||
{
|
||||
$ref = new \ReflectionProperty($obj, $prop);
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($obj, $value);
|
||||
}
|
||||
|
||||
public function testShowIncludesSessionPaymentsConsumablesAndDiscount(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor);
|
||||
|
||||
$session = new PatientSession($record);
|
||||
$session->setServicesTotalRials(2_400_000)
|
||||
->setFinalPriceRials(2_200_000)
|
||||
->setDiscount('amount', 200_000, 200_000)
|
||||
->setSessionAt(1_700_000_000)
|
||||
->setPaidAt(1_700_100_000);
|
||||
$this->em->persist($session);
|
||||
$this->em->flush();
|
||||
|
||||
$payment = new SessionPayment($session, 'wallet', 1_500_000, 1_700_100_000);
|
||||
$payment->setCreatedByName('منشی تست');
|
||||
$this->em->persist($payment);
|
||||
$session->addPayment($payment);
|
||||
|
||||
$item = new InventoryItem('doctor', $doctor->getId(), 'عینک');
|
||||
$item->setPrice(20_000);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
$consumable = new SessionConsumable($session, $item, 2);
|
||||
$this->em->persist($consumable);
|
||||
$session->addConsumable($consumable);
|
||||
$this->em->flush();
|
||||
|
||||
$invoice = new Invoice('doctor', $doctor->getId());
|
||||
$invoice->setPatientSessionId($session->getId())
|
||||
->setPatientRecordId($record->getId());
|
||||
$this->em->persist($invoice);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/billing/invoices/' . $invoice->getUuid(), $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$data = $res['data']['data'];
|
||||
self::assertSame($invoice->getUuid(), $data['uuid']);
|
||||
self::assertIsArray($data['session']);
|
||||
|
||||
$s = $data['session'];
|
||||
self::assertSame($session->getUuid(), $s['uuid']);
|
||||
self::assertSame(1_700_000_000, $s['session_at']);
|
||||
self::assertSame(1_700_100_000, $s['paid_at']);
|
||||
self::assertSame(2_400_000, $s['services_total_rials']);
|
||||
self::assertSame(2_200_000, $s['final_price_rials']);
|
||||
self::assertSame(200_000, $s['discount_rials']);
|
||||
|
||||
// payments — real rows, not heuristics
|
||||
self::assertCount(1, $s['payments']);
|
||||
self::assertSame('wallet', $s['payments'][0]['method']);
|
||||
self::assertSame(1_500_000, $s['payments'][0]['amount_rials']);
|
||||
self::assertSame('منشی تست', $s['payments'][0]['created_by_name']);
|
||||
self::assertSame(1_500_000, $s['paid_total_rials']);
|
||||
|
||||
// consumables — snapshot price × quantity
|
||||
self::assertCount(1, $s['consumables']);
|
||||
self::assertSame('عینک', $s['consumables'][0]['item_name']);
|
||||
self::assertSame(2, $s['consumables'][0]['quantity']);
|
||||
self::assertSame(40_000, $s['consumables'][0]['line_total_rials']);
|
||||
self::assertSame(40_000, $s['consumables_total_rials']);
|
||||
}
|
||||
|
||||
public function testShowSessionNullWhenInvoiceHasNoSession(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor);
|
||||
|
||||
$invoice = new Invoice('doctor', $doctor->getId());
|
||||
$invoice->setPatientRecordId($record->getId());
|
||||
$this->em->persist($invoice);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/billing/invoices/' . $invoice->getUuid(), $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertNull($res['data']['data']['session']);
|
||||
}
|
||||
|
||||
public function testShowNotFoundForUnknownUuid(): void
|
||||
{
|
||||
[$owner] = $this->doctor();
|
||||
$this->authJson('GET', '/api/v1/billing/invoices/00000000-0000-0000-0000-000000000000', $owner);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testShowNotFoundForOtherTenant(): void
|
||||
{
|
||||
[, $doctor] = $this->doctor();
|
||||
$invoice = new Invoice('doctor', $doctor->getId());
|
||||
$this->em->persist($invoice);
|
||||
$this->em->flush();
|
||||
|
||||
[$other] = $this->doctor();
|
||||
$this->authJson('GET', '/api/v1/billing/invoices/' . $invoice->getUuid(), $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testShowSessionEmptyPaymentsAndConsumables(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor);
|
||||
|
||||
$session = new PatientSession($record);
|
||||
$this->em->persist($session);
|
||||
$this->em->flush();
|
||||
|
||||
$invoice = new Invoice('doctor', $doctor->getId());
|
||||
$invoice->setPatientSessionId($session->getId());
|
||||
$this->em->persist($invoice);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/billing/invoices/' . $invoice->getUuid(), $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$s = $res['data']['data']['session'];
|
||||
self::assertSame([], $s['payments']);
|
||||
self::assertSame([], $s['consumables']);
|
||||
self::assertSame(0, $s['paid_total_rials']);
|
||||
self::assertSame(0, $s['discount_rials']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Billing;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Billing\Entity\Invoice;
|
||||
use App\Billing\Entity\InvoiceItem;
|
||||
use App\Billing\ValueObject\ShareBreakdown;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Patient\Entity\SessionPayment;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/my/billing/payments — flat, tenant-scoped list of recorded
|
||||
* invoices, and GET .../patients/{uuid}/invoices — one patient's invoices.
|
||||
*/
|
||||
class PatientPaymentsTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Doctor} owner user + their doctor profile */
|
||||
private function doctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
// national_code is UNIQUE and db_test is never reset, so randomise it per
|
||||
// patient (like mobile) to avoid cross-run collisions; read it back from the
|
||||
// record's user when a test needs to filter by it.
|
||||
private function patientRecord(Doctor $doctor, string $realName): PatientRecord
|
||||
{
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$this->setField($patient, 'realName', $realName);
|
||||
$this->setField($patient, 'nationalCode', str_pad((string) random_int(0, 9_999_999_999), 10, '0', STR_PAD_LEFT));
|
||||
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function nationalCodeOf(PatientRecord $record): string
|
||||
{
|
||||
return $record->getUser()->getNationalCode();
|
||||
}
|
||||
|
||||
/**
|
||||
* `$status` چرخهی حیات صورتحساب است (draft/finalized/void). وضعیت پرداختِ
|
||||
* نمایشدادهشده از `$paidRials` مشتق میشود — پرداخت واقعی روی مراجعه.
|
||||
*/
|
||||
private function invoice(
|
||||
Doctor $doctor,
|
||||
PatientRecord $record,
|
||||
string $status,
|
||||
int $patientRials,
|
||||
?int $issuedAt = null,
|
||||
?string $itemTitle = null,
|
||||
int $totalRials = 0,
|
||||
int $paidRials = 0,
|
||||
): Invoice {
|
||||
$invoice = new Invoice('doctor', $doctor->getId());
|
||||
$invoice->setPatientRecordId($record->getId());
|
||||
if ($paidRials > 0) {
|
||||
$invoice->setPatientSessionId($this->paidSession($record, $paidRials)->getId());
|
||||
}
|
||||
if ($itemTitle !== null) {
|
||||
// Item added only for its title (service_title); totals set below by hand.
|
||||
$invoice->addItem(new InvoiceItem($invoice, $itemTitle, $totalRials, 1, new ShareBreakdown($totalRials, 0, 0, $patientRials), null));
|
||||
}
|
||||
$this->setField($invoice, 'status', $status);
|
||||
$this->setField($invoice, 'patientRials', $patientRials);
|
||||
$this->setField($invoice, 'totalRials', $totalRials);
|
||||
if ($issuedAt !== null) {
|
||||
$this->setField($invoice, 'issuedAt', $issuedAt);
|
||||
}
|
||||
$this->em->persist($invoice);
|
||||
$this->em->flush();
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
/** مراجعهای با یک پرداخت نقدی ثبتشده — منبع واقعیِ «چقدر وصول شده». */
|
||||
private function paidSession(PatientRecord $record, int $paidRials): PatientSession
|
||||
{
|
||||
$session = new PatientSession($record);
|
||||
$this->em->persist($session);
|
||||
$this->em->flush();
|
||||
|
||||
$payment = new SessionPayment($session, 'cash', $paidRials, time());
|
||||
$this->em->persist($payment);
|
||||
$this->em->flush();
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
private function setField(object $obj, string $prop, mixed $value): void
|
||||
{
|
||||
$ref = new \ReflectionProperty($obj, $prop);
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($obj, $value);
|
||||
}
|
||||
|
||||
public function testListsFlatInvoicesNewestFirst(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
|
||||
$a = $this->patientRecord($doctor, 'دنیا خلیلی');
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_FINALIZED, 100000, 2000, null, 0, 100000); // fully paid
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_FINALIZED, 50000, 1000); // nothing paid
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_DRAFT, 999999, 3000); // excluded
|
||||
$this->invoice($doctor, $a, Invoice::STATUS_VOID, 999999, 3000); // excluded
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(2, $res['meta']['totalRecords']); // draft/void excluded
|
||||
|
||||
// newest (issued_at DESC) first
|
||||
self::assertSame('دنیا خلیلی', $res['data'][0]['patient_name']);
|
||||
self::assertSame($this->nationalCodeOf($a), $res['data'][0]['national_code']);
|
||||
self::assertSame(100000, $res['data'][0]['amount_rials']);
|
||||
self::assertSame('paid', $res['data'][0]['status']);
|
||||
self::assertArrayHasKey('invoice_uuid', $res['data'][0]);
|
||||
self::assertSame($a->getUuid(), $res['data'][0]['patient_uuid']);
|
||||
|
||||
self::assertSame(50000, $res['data'][1]['amount_rials']);
|
||||
self::assertSame('unsettled', $res['data'][1]['status']);
|
||||
}
|
||||
|
||||
public function testFiltersByNationalCodeAndStatus(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$paid = $this->patientRecord($doctor, 'بیمار پرداخت');
|
||||
$unpaid = $this->patientRecord($doctor, 'بیمار بدهکار');
|
||||
$partial = $this->patientRecord($doctor, 'بیمار نیمهپرداخت');
|
||||
$this->invoice($doctor, $paid, Invoice::STATUS_FINALIZED, 100000, null, null, 0, 100000);
|
||||
$this->invoice($doctor, $unpaid, Invoice::STATUS_FINALIZED, 100000);
|
||||
$this->invoice($doctor, $partial, Invoice::STATUS_FINALIZED, 100000, null, null, 0, 40000);
|
||||
|
||||
$byCode = $this->authJson('GET', '/api/v1/my/billing/payments?national_code=' . $this->nationalCodeOf($paid), $owner);
|
||||
self::assertSame(1, $byCode['meta']['totalRecords']);
|
||||
self::assertSame($paid->getUuid(), $byCode['data'][0]['patient_uuid']);
|
||||
|
||||
$onlyPaid = $this->authJson('GET', '/api/v1/my/billing/payments?status=paid', $owner);
|
||||
self::assertSame(1, $onlyPaid['meta']['totalRecords']);
|
||||
self::assertSame('paid', $onlyPaid['data'][0]['status']);
|
||||
|
||||
$onlyUnsettled = $this->authJson('GET', '/api/v1/my/billing/payments?status=unsettled', $owner);
|
||||
self::assertSame(1, $onlyUnsettled['meta']['totalRecords']);
|
||||
self::assertSame($unpaid->getUuid(), $onlyUnsettled['data'][0]['patient_uuid']);
|
||||
|
||||
$onlyPartial = $this->authJson('GET', '/api/v1/my/billing/payments?status=partial', $owner);
|
||||
self::assertSame(1, $onlyPartial['meta']['totalRecords']);
|
||||
self::assertSame($partial->getUuid(), $onlyPartial['data'][0]['patient_uuid']);
|
||||
self::assertSame('partial', $onlyPartial['data'][0]['status']);
|
||||
self::assertSame(40000, $onlyPartial['data'][0]['paid_rials']);
|
||||
}
|
||||
|
||||
public function testEmptyWhenNoInvoices(): void
|
||||
{
|
||||
[$owner] = $this->doctor();
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(0, $res['meta']['totalRecords']);
|
||||
self::assertCount(0, $res['data']);
|
||||
}
|
||||
|
||||
public function testForbiddenWithoutProfile(): void
|
||||
{
|
||||
$orphan = $this->createUser(['ROLE_DOCTOR']); // ROLE_DOCTOR but no Doctor row
|
||||
$this->authJson('GET', '/api/v1/my/billing/payments', $orphan);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── Node 2: a patient's recorded invoices ────────────────────────────────
|
||||
|
||||
public function testListsPatientInvoicesWithHeaderAndDerivedStatus(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor, 'دنیا خلیلی');
|
||||
|
||||
$this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 235000, 1000, 'روکش دندان', 235000, 235000);
|
||||
$this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 600000, 2000, 'طرح لبخند', 600000);
|
||||
$this->invoice($doctor, $record, Invoice::STATUS_DRAFT, 111, 3000, 'پیشنویس', 111); // excluded
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
// header
|
||||
self::assertSame('دنیا خلیلی', $res['data']['patient']['name']);
|
||||
self::assertSame($this->nationalCodeOf($record), $res['data']['patient']['national_code']);
|
||||
|
||||
// invoices — draft excluded, newest (issued_at DESC) first
|
||||
self::assertSame(2, $res['data']['meta']['totalRecords']);
|
||||
$rows = $res['data']['data'];
|
||||
self::assertCount(2, $rows);
|
||||
self::assertSame('طرح لبخند', $rows[0]['service_title']);
|
||||
self::assertSame('unsettled', $rows[0]['status']);
|
||||
self::assertSame(600000, $rows[0]['total_rials']);
|
||||
self::assertSame('روکش دندان', $rows[1]['service_title']);
|
||||
self::assertSame('paid', $rows[1]['status']);
|
||||
}
|
||||
|
||||
public function testPatientInvoiceRowsCarryTheirPaymentMethods(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor, 'دنیا خلیلی');
|
||||
$session = $this->paidSession($record, 60000);
|
||||
|
||||
$extra = new SessionPayment($session, 'pos', 40000, time() + 60);
|
||||
$this->em->persist($extra);
|
||||
$this->em->flush();
|
||||
|
||||
$invoice = $this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 100000, 1000, 'روکش دندان', 100000);
|
||||
$invoice->setPatientSessionId($session->getId());
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner);
|
||||
$row = $res['data']['data'][0];
|
||||
|
||||
self::assertSame('paid', $row['status']);
|
||||
self::assertSame(100000, $row['paid_rials']);
|
||||
self::assertCount(2, $row['payments']);
|
||||
// oldest payment first
|
||||
self::assertSame('cash', $row['payments'][0]['method']);
|
||||
self::assertSame(60000, $row['payments'][0]['amount_rials']);
|
||||
self::assertSame('pos', $row['payments'][1]['method']);
|
||||
self::assertSame(40000, $row['payments'][1]['amount_rials']);
|
||||
}
|
||||
|
||||
public function testPatientInvoiceWithoutSessionHasNoPayments(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor, 'بدون مراجعه');
|
||||
$this->invoice($doctor, $record, Invoice::STATUS_FINALIZED, 100000, 1000, 'ویزیت', 100000);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner);
|
||||
$row = $res['data']['data'][0];
|
||||
|
||||
self::assertSame([], $row['payments']);
|
||||
self::assertSame(0, $row['paid_rials']);
|
||||
self::assertSame('unsettled', $row['status']);
|
||||
}
|
||||
|
||||
public function testPatientInvoicesNotFoundForOtherTenant(): void
|
||||
{
|
||||
[, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor, 'بیمار');
|
||||
|
||||
[$other] = $this->doctor();
|
||||
$this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testPatientInvoicesEmpty(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor, 'بدون فاکتور');
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(0, $res['data']['meta']['totalRecords']);
|
||||
self::assertCount(0, $res['data']['data']);
|
||||
self::assertSame('بدون فاکتور', $res['data']['patient']['name']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Billing;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Billing\Entity\Invoice;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Patient\Entity\SessionPayment;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/my/billing/payments/summary — aggregate totals over the same
|
||||
* filtered set as the payments list, feeding the admin page's stat cards.
|
||||
*/
|
||||
class PaymentsSummaryTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Doctor} owner user + their doctor profile */
|
||||
private function doctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
private function patientRecord(Doctor $doctor, ?string $nationalCode = null): PatientRecord
|
||||
{
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
if ($nationalCode !== null) {
|
||||
$patient->setNationalCode($nationalCode);
|
||||
}
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function setField(object $obj, string $prop, mixed $value): void
|
||||
{
|
||||
$ref = new \ReflectionProperty($obj, $prop);
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($obj, $value);
|
||||
}
|
||||
|
||||
/** مراجعهای با یک پرداخت نقدی ثبتشده — منبع واقعیِ «چقدر وصول شده». */
|
||||
private function paidSession(PatientRecord $record, int $paidRials): PatientSession
|
||||
{
|
||||
$session = new PatientSession($record);
|
||||
$this->em->persist($session);
|
||||
$this->em->flush();
|
||||
|
||||
$payment = new SessionPayment($session, 'cash', $paidRials, 1_700_000_000);
|
||||
$this->em->persist($payment);
|
||||
$this->em->flush();
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* `totalRials` دو برابر سهم بیمار است تا دو خلاصه از هم قابلتفکیک بمانند.
|
||||
* `$paidRials` پرداخت واقعی روی مراجعه است؛ وضعیت پرداخت از همین مشتق میشود.
|
||||
*/
|
||||
private function invoice(
|
||||
Doctor $doctor,
|
||||
PatientRecord $record,
|
||||
int $patientRials,
|
||||
string $status,
|
||||
int $issuedAt,
|
||||
int $paidRials = 0,
|
||||
): Invoice {
|
||||
$invoice = new Invoice('doctor', $doctor->getId());
|
||||
$invoice->setPatientRecordId($record->getId());
|
||||
if ($paidRials > 0) {
|
||||
$invoice->setPatientSessionId($this->paidSession($record, $paidRials)->getId());
|
||||
}
|
||||
$this->setField($invoice, 'totalRials', $patientRials * 2);
|
||||
$this->setField($invoice, 'patientRials', $patientRials);
|
||||
$this->setField($invoice, 'status', $status);
|
||||
$this->setField($invoice, 'issuedAt', $issuedAt);
|
||||
$this->em->persist($invoice);
|
||||
$this->em->flush();
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
public function testSummarySplitsPaidAndUnsettled(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor);
|
||||
|
||||
$this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 1_000_000);
|
||||
$this->invoice($doctor, $record, 400_000, Invoice::STATUS_FINALIZED, 1_700_000_100);
|
||||
// draft invoices are not part of the payments list, so they must not count
|
||||
$this->invoice($doctor, $record, 999_000, Invoice::STATUS_DRAFT, 1_700_000_200, 999_000);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$data = $res['data'];
|
||||
self::assertSame(1_400_000, $data['total_rials']);
|
||||
self::assertSame(1_000_000, $data['paid_rials']);
|
||||
self::assertSame(400_000, $data['unsettled_rials']);
|
||||
self::assertSame(2, $data['invoices_count']);
|
||||
}
|
||||
|
||||
public function testSummaryHonoursStatusAndDateFilters(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor);
|
||||
|
||||
$this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 1_000_000);
|
||||
$this->invoice($doctor, $record, 400_000, Invoice::STATUS_FINALIZED, 1_700_000_000);
|
||||
$this->invoice($doctor, $record, 700_000, Invoice::STATUS_FINALIZED, 1_800_000_000, 700_000);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary?status=paid', $owner);
|
||||
self::assertSame(1_700_000, $res['data']['total_rials']);
|
||||
self::assertSame(1_700_000, $res['data']['paid_rials']);
|
||||
self::assertSame(0, $res['data']['unsettled_rials']);
|
||||
self::assertSame(2, $res['data']['invoices_count']);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary?from=1700000000&to=1700000001', $owner);
|
||||
self::assertSame(1_400_000, $res['data']['total_rials']);
|
||||
self::assertSame(2, $res['data']['invoices_count']);
|
||||
}
|
||||
|
||||
public function testSummaryFiltersByNationalCode(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
// db_test is never reset — a fixed code would eventually collide on the unique column
|
||||
$code = (string) random_int(1_000_000_000, 9_999_999_999);
|
||||
$mine = $this->patientRecord($doctor, $code);
|
||||
$other = $this->patientRecord($doctor, (string) random_int(1_000_000_000, 9_999_999_999));
|
||||
|
||||
$this->invoice($doctor, $mine, 500_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 500_000);
|
||||
$this->invoice($doctor, $other, 800_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 800_000);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary?national_code=' . $code, $owner);
|
||||
self::assertSame(500_000, $res['data']['total_rials']);
|
||||
self::assertSame(1, $res['data']['invoices_count']);
|
||||
}
|
||||
|
||||
public function testSummaryIsZeroWhenNothingMatches(): void
|
||||
{
|
||||
[$owner] = $this->doctor();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(
|
||||
['total_rials' => 0, 'paid_rials' => 0, 'unsettled_rials' => 0, 'invoices_count' => 0],
|
||||
$res['data'],
|
||||
);
|
||||
}
|
||||
|
||||
public function testPatientInvoicesCarryTheirOwnSummary(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor);
|
||||
|
||||
$this->invoice($doctor, $record, 1_000_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 1_000_000);
|
||||
$this->invoice($doctor, $record, 500_000, Invoice::STATUS_FINALIZED, 1_700_000_100);
|
||||
$this->invoice($doctor, $record, 900_000, Invoice::STATUS_DRAFT, 1_700_000_200, 900_000);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/patients/' . $record->getUuid() . '/invoices', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
// the patient view sums total_rials (invoice grand total), not the patient share
|
||||
$summary = $res['data']['summary'];
|
||||
self::assertSame(3_000_000, $summary['total_rials']);
|
||||
self::assertSame(2_000_000, $summary['paid_rials']);
|
||||
self::assertSame(1_000_000, $summary['unsettled_rials']);
|
||||
self::assertSame(2, $summary['invoices_count']);
|
||||
self::assertSame(2, $res['data']['meta']['totalRecords']);
|
||||
}
|
||||
|
||||
public function testSummaryExcludesOtherTenants(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$record = $this->patientRecord($doctor);
|
||||
$this->invoice($doctor, $record, 300_000, Invoice::STATUS_FINALIZED, 1_700_000_000, 300_000);
|
||||
|
||||
[$otherOwner] = $this->doctor();
|
||||
$res = $this->authJson('GET', '/api/v1/my/billing/payments/summary', $otherOwner);
|
||||
self::assertSame(0, $res['data']['total_rials']);
|
||||
self::assertSame(0, $res['data']['invoices_count']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Blog;
|
||||
|
||||
use App\Blog\Entity\Blog;
|
||||
use App\Location\Entity\City;
|
||||
use App\Location\Entity\Province;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Blog posts carry an optional city so the multi-domain public site can scope
|
||||
* them. NULL is a permanent, meaningful state — a nationwide post, canonical on
|
||||
* the main domain but listed on every city domain.
|
||||
*/
|
||||
class BlogCityScopeTest extends ApiTestCase
|
||||
{
|
||||
private function makeCity(string $name): City
|
||||
{
|
||||
$province = new Province($name);
|
||||
$this->em->persist($province);
|
||||
$city = new City($name, $province);
|
||||
$this->em->persist($city);
|
||||
|
||||
return $city;
|
||||
}
|
||||
|
||||
private function makePost(string $title, ?City $city): Blog
|
||||
{
|
||||
$blog = new Blog($this->createUser(['ROLE_ADMIN']), $title, 'متن آزمایشی مقاله برای تست');
|
||||
$blog->setStatus(Blog::STATUS_PUBLISHED)->setCity($city);
|
||||
$this->em->persist($blog);
|
||||
|
||||
return $blog;
|
||||
}
|
||||
|
||||
/** @return array<string, array> published list keyed by title */
|
||||
private function listBy(string $query = ''): array
|
||||
{
|
||||
$this->client->request('GET', '/api/v1/blogs?limit=50' . $query);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$payload = json_decode($this->client->getResponse()->getContent(), true);
|
||||
|
||||
$byTitle = [];
|
||||
foreach ($payload['data'] as $row) {
|
||||
$byTitle[$row['title']] = $row;
|
||||
}
|
||||
|
||||
return $byTitle;
|
||||
}
|
||||
|
||||
public function testCityScopedListReturnsCityPostsPlusNationwide(): void
|
||||
{
|
||||
$yasuj = $this->makeCity('یاسوج');
|
||||
$tabriz = $this->makeCity('تبریز');
|
||||
$tag = bin2hex(random_bytes(4));
|
||||
|
||||
$this->makePost("یاسوجی-$tag", $yasuj);
|
||||
$this->makePost("تبریزی-$tag", $tabriz);
|
||||
$this->makePost("سراسری-$tag", null);
|
||||
$this->em->flush();
|
||||
|
||||
$scoped = $this->listBy('&city_id=' . $yasuj->getId());
|
||||
|
||||
$this->assertArrayHasKey("یاسوجی-$tag", $scoped, 'city post missing');
|
||||
$this->assertArrayHasKey("سراسری-$tag", $scoped, 'nationwide post must appear on a city domain');
|
||||
$this->assertArrayNotHasKey("تبریزی-$tag", $scoped, 'another city\'s post leaked into the list');
|
||||
}
|
||||
|
||||
public function testUnscopedListReturnsEveryPublishedPost(): void
|
||||
{
|
||||
$yasuj = $this->makeCity('یاسوج');
|
||||
$tag = bin2hex(random_bytes(4));
|
||||
|
||||
$this->makePost("یاسوجی-$tag", $yasuj);
|
||||
$this->makePost("سراسری-$tag", null);
|
||||
$this->em->flush();
|
||||
|
||||
$all = $this->listBy();
|
||||
|
||||
$this->assertArrayHasKey("یاسوجی-$tag", $all);
|
||||
$this->assertArrayHasKey("سراسری-$tag", $all);
|
||||
}
|
||||
|
||||
public function testCityAppearsInListAndDetailPayload(): void
|
||||
{
|
||||
$yasuj = $this->makeCity('یاسوج');
|
||||
$tag = bin2hex(random_bytes(4));
|
||||
|
||||
$cityPost = $this->makePost("یاسوجی-$tag", $yasuj);
|
||||
$natPost = $this->makePost("سراسری-$tag", null);
|
||||
$this->em->flush();
|
||||
|
||||
$list = $this->listBy();
|
||||
$this->assertSame(
|
||||
['id' => (string) $yasuj->getId(), 'name' => 'یاسوج'],
|
||||
$list["یاسوجی-$tag"]['city']
|
||||
);
|
||||
$this->assertNull($list["سراسری-$tag"]['city'], 'nationwide post must report city: null');
|
||||
|
||||
$this->client->request('GET', '/api/v1/blog/' . $cityPost->getSlug());
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$detail = json_decode($this->client->getResponse()->getContent(), true)['data']['data'];
|
||||
$this->assertSame('یاسوج', $detail['city']['name']);
|
||||
|
||||
$this->client->request('GET', '/api/v1/blog/' . $natPost->getSlug());
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$natDetail = json_decode($this->client->getResponse()->getContent(), true)['data']['data'];
|
||||
$this->assertNull($natDetail['city']);
|
||||
}
|
||||
|
||||
public function testAdminCanCreatePostWithAndWithoutCity(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$yasuj = $this->makeCity('یاسوج');
|
||||
$this->em->flush();
|
||||
|
||||
$withCity = $this->authJson('POST', '/api/v1/blog', $admin, [
|
||||
'title' => 'مقاله شهری ' . bin2hex(random_bytes(3)),
|
||||
'body' => 'متن آزمایشی مقاله برای تست',
|
||||
'city_id' => $yasuj->getId(),
|
||||
]);
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$this->assertSame('یاسوج', $withCity['data']['data']['city']['name']);
|
||||
|
||||
$nationwide = $this->authJson('POST', '/api/v1/blog', $admin, [
|
||||
'title' => 'مقاله سراسری ' . bin2hex(random_bytes(3)),
|
||||
'body' => 'متن آزمایشی مقاله برای تست',
|
||||
]);
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$this->assertNull($nationwide['data']['data']['city'], 'omitting city_id must mean nationwide');
|
||||
}
|
||||
|
||||
public function testAdminCanMovePostBetweenCityAndNationwide(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$yasuj = $this->makeCity('یاسوج');
|
||||
$post = $this->makePost('مقاله ' . bin2hex(random_bytes(3)), null);
|
||||
$this->em->flush();
|
||||
|
||||
$assigned = $this->authJson('PATCH', '/api/v1/blog/' . $post->getUuid(), $admin, [
|
||||
'city_id' => $yasuj->getId(),
|
||||
]);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertSame('یاسوج', $assigned['data']['data']['city']['name']);
|
||||
|
||||
$cleared = $this->authJson('PATCH', '/api/v1/blog/' . $post->getUuid(), $admin, [
|
||||
'city_id' => null,
|
||||
]);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertNull($cleared['data']['data']['city'], 'city_id: null must make the post nationwide again');
|
||||
}
|
||||
|
||||
public function testPatchWithoutCityIdLeavesCityUntouched(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$yasuj = $this->makeCity('یاسوج');
|
||||
$post = $this->makePost('مقاله ' . bin2hex(random_bytes(3)), $yasuj);
|
||||
$this->em->flush();
|
||||
|
||||
$updated = $this->authJson('PATCH', '/api/v1/blog/' . $post->getUuid(), $admin, [
|
||||
'summary' => 'خلاصه جدید',
|
||||
]);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertSame('یاسوج', $updated['data']['data']['city']['name'], 'PATCH must not silently clear the city');
|
||||
}
|
||||
|
||||
public function testUnknownCityIdIsRejected(): void
|
||||
{
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('POST', '/api/v1/blog', $admin, [
|
||||
'title' => 'مقاله ' . bin2hex(random_bytes(3)),
|
||||
'body' => 'متن آزمایشی مقاله برای تست',
|
||||
'city_id' => 999999,
|
||||
]);
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Clinic;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Entity\ClinicDoctorPermission;
|
||||
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Per-doctor permissions inside a clinic: owner-only management, deep-merge
|
||||
* semantics, lazy provisioning for pre-existing members, and context exposure.
|
||||
*/
|
||||
class ClinicDoctorPermissionTest extends ApiTestCase
|
||||
{
|
||||
private function createClinicWithDoctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$clinic->setName('کلینیک تست');
|
||||
|
||||
$docUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($docUser, 'دکتر عضو');
|
||||
$doctor->setMobileNumber($docUser->getMobileNumber());
|
||||
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $clinic, $doctor, $docUser];
|
||||
}
|
||||
|
||||
private function permRepo(): ClinicDoctorPermissionRepository
|
||||
{
|
||||
return static::getContainer()->get(ClinicDoctorPermissionRepository::class);
|
||||
}
|
||||
|
||||
public function testOwnerReadsLazilyProvisionedDefaults(): void
|
||||
{
|
||||
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
||||
|
||||
$res = $this->authJson('GET', "/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions", $owner);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($res['data']['active']);
|
||||
self::assertSame(
|
||||
ClinicDoctorPermission::DEFAULT_PERMISSIONS['resources'],
|
||||
$res['data']['permissions']['resources'],
|
||||
'a member added before this feature gets defaults on first read',
|
||||
);
|
||||
}
|
||||
|
||||
public function testPatchOnlyTouchesProvidedKeys(): void
|
||||
{
|
||||
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
||||
|
||||
$res = $this->authJson(
|
||||
'PATCH',
|
||||
"/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions",
|
||||
$owner,
|
||||
['permissions' => ['resources' => ['payments' => ['create' => true]]]],
|
||||
);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$resources = $res['data']['permissions']['resources'];
|
||||
self::assertTrue($resources['payments']['create']);
|
||||
self::assertFalse($resources['payments']['delete'], 'untouched actions keep their value');
|
||||
self::assertTrue($resources['appointments']['view'], 'untouched resources keep their value');
|
||||
}
|
||||
|
||||
public function testUnknownResourceAndActionAreIgnored(): void
|
||||
{
|
||||
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
||||
|
||||
$res = $this->authJson(
|
||||
'PATCH',
|
||||
"/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions",
|
||||
$owner,
|
||||
['permissions' => ['resources' => ['bogus' => ['view' => true], 'payments' => ['fly' => true]]]],
|
||||
);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertArrayNotHasKey('bogus', $res['data']['permissions']['resources']);
|
||||
self::assertArrayNotHasKey('fly', $res['data']['permissions']['resources']['payments']);
|
||||
}
|
||||
|
||||
public function testDeactivationRevokesEverything(): void
|
||||
{
|
||||
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
||||
|
||||
$this->authJson(
|
||||
'PATCH',
|
||||
"/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions",
|
||||
$owner,
|
||||
['active' => false],
|
||||
);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$perm = $this->permRepo()->findOneFor(
|
||||
$this->em->getRepository(Clinic::class)->find($clinic->getId()),
|
||||
$this->em->getRepository(Doctor::class)->find($doctor->getId()),
|
||||
);
|
||||
self::assertFalse($perm->isActive());
|
||||
self::assertFalse($perm->can('appointments', 'view'), 'inactive membership grants nothing');
|
||||
}
|
||||
|
||||
public function testMemberDoctorCannotEditOwnPermissions(): void
|
||||
{
|
||||
[, $clinic, $doctor, $docUser] = $this->createClinicWithDoctor();
|
||||
|
||||
$this->authJson(
|
||||
'PATCH',
|
||||
"/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}/permissions",
|
||||
$docUser,
|
||||
['permissions' => ['resources' => ['payments' => ['delete' => true]]]],
|
||||
);
|
||||
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testDoctorOfAnotherClinicIsNotFound(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicWithDoctor();
|
||||
|
||||
$strangerUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$stranger = new Doctor($strangerUser, 'دکتر بیرونی');
|
||||
$this->em->persist($stranger);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', "/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$stranger->getUuid()}/permissions", $owner);
|
||||
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testOwnerIsNeverRestrictedByPermissions(): void
|
||||
{
|
||||
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
||||
|
||||
$checker = static::getContainer()->get(\App\Clinic\Security\ClinicDoctorPermissionChecker::class);
|
||||
$perm = $this->permRepo()->getOrCreate($clinic, $doctor);
|
||||
$perm->setActive(false);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertTrue($checker->can($owner, $clinic, 'clinic_info', 'update'));
|
||||
}
|
||||
|
||||
public function testMemberContextCarriesPermissionsAndOwnPracticeDoesNot(): void
|
||||
{
|
||||
[, $clinic, $doctor, $docUser] = $this->createClinicWithDoctor();
|
||||
|
||||
$res = $this->authJson('GET', '/oauth/userinfo', $docUser);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$contexts = $res['data']['available_contexts'];
|
||||
$personal = array_values(array_filter($contexts, fn($c) => $c['type'] === 'doctor'));
|
||||
$member = array_values(array_filter($contexts, fn($c) => $c['type'] === 'clinic'));
|
||||
|
||||
self::assertNotEmpty($personal);
|
||||
self::assertNotEmpty($member);
|
||||
self::assertNull($personal[0]['permissions'] ?? null, 'own practice is unrestricted');
|
||||
self::assertSame(
|
||||
ClinicDoctorPermission::DEFAULT_PERMISSIONS['resources'],
|
||||
$member[0]['permissions']['resources'],
|
||||
);
|
||||
}
|
||||
|
||||
public function testDetachingDoctorRemovesPermissionRow(): void
|
||||
{
|
||||
[$owner, $clinic, $doctor] = $this->createClinicWithDoctor();
|
||||
$this->permRepo()->getOrCreate($clinic, $doctor);
|
||||
|
||||
$this->authJson('DELETE', "/api/v1/admin/clinic/{$clinic->getUuid()}/doctor/{$doctor->getUuid()}", $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$reloadedClinic = $this->em->getRepository(Clinic::class)->find($clinic->getId());
|
||||
$reloadedDoctor = $this->em->getRepository(Doctor::class)->find($doctor->getId());
|
||||
self::assertNull($this->permRepo()->findOneFor($reloadedClinic, $reloadedDoctor));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Clinic;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* POST /api/v1/clinic is a self-service signup: any authenticated user may
|
||||
* register their own clinic. Two invariants it used to break —
|
||||
* · an empty body created a nameless clinic that no list can identify;
|
||||
* · a user could create unlimited clinics, while ClinicRepository::findByUser()
|
||||
* (which resolves their working context) is a findOneBy, so every clinic past
|
||||
* the first was unreachable data.
|
||||
*/
|
||||
class CreateClinicValidationTest extends ApiTestCase
|
||||
{
|
||||
public function testEmptyBodyIsRejected(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER']);
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/clinic', $user);
|
||||
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
$this->assertFalse($body['success']);
|
||||
$this->assertSame('name', $body['errors'][0]['field'] ?? null);
|
||||
}
|
||||
|
||||
public function testBlankNameIsRejected(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER']);
|
||||
|
||||
$this->authJson('POST', '/api/v1/clinic', $user, ['name' => ' ']);
|
||||
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testNamedClinicIsCreatedAndGrantsClinicRole(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER']);
|
||||
|
||||
$this->authJson('POST', '/api/v1/clinic', $user, ['name' => 'کلینیک شفا']);
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
|
||||
$this->em->refresh($user);
|
||||
$this->assertContains('ROLE_CLINIC', $user->getRoles());
|
||||
|
||||
$clinic = $this->em->getRepository(Clinic::class)->findOneBy(['user' => $user]);
|
||||
$this->assertNotNull($clinic);
|
||||
$this->assertSame('کلینیک شفا', $clinic->getName());
|
||||
}
|
||||
|
||||
public function testSecondClinicForTheSameUserIsRejected(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER']);
|
||||
|
||||
$this->authJson('POST', '/api/v1/clinic', $user, ['name' => 'کلینیک اول']);
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/clinic', $user, ['name' => 'کلینیک دوم']);
|
||||
|
||||
$this->assertSame(409, $this->responseCode());
|
||||
$this->assertSame('ERR_CONFLICT_001', $body['errors'][0]['code'] ?? null);
|
||||
|
||||
$count = $this->em->getRepository(Clinic::class)->count(['user' => $user]);
|
||||
$this->assertSame(1, $count, 'a user must never end up owning more than one clinic');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Clinic;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Detaching a doctor from a clinic is allowed for an admin OR the clinic owner.
|
||||
* Any other authenticated user (incl. a foreign clinic owner or a plain doctor)
|
||||
* must be rejected with 403.
|
||||
*/
|
||||
class DetachDoctorPermissionTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: Clinic, 1: Doctor} */
|
||||
private function makeClinicWithDoctor(): array
|
||||
{
|
||||
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
|
||||
$this->em->persist($clinic);
|
||||
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر آزمایشی');
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$clinic, $doctor];
|
||||
}
|
||||
|
||||
private function detachUri(Clinic $clinic, Doctor $doctor): string
|
||||
{
|
||||
return '/api/v1/admin/clinic/' . $clinic->getUuid() . '/doctor/' . $doctor->getUuid();
|
||||
}
|
||||
|
||||
public function testClinicOwnerCanDetachOwnDoctor(): void
|
||||
{
|
||||
[$clinic, $doctor] = $this->makeClinicWithDoctor();
|
||||
$owner = $clinic->getUser();
|
||||
|
||||
$body = $this->authJson('DELETE', $this->detachUri($clinic, $doctor), $owner);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertTrue($body['success']);
|
||||
}
|
||||
|
||||
public function testAdminCanDetachAnyDoctor(): void
|
||||
{
|
||||
[$clinic, $doctor] = $this->makeClinicWithDoctor();
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
$body = $this->authJson('DELETE', $this->detachUri($clinic, $doctor), $admin);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertTrue($body['success']);
|
||||
}
|
||||
|
||||
public function testForeignClinicOwnerIsForbidden(): void
|
||||
{
|
||||
[$clinic, $doctor] = $this->makeClinicWithDoctor();
|
||||
$intruder = $this->createUser(['ROLE_CLINIC']);
|
||||
|
||||
$body = $this->authJson('DELETE', $this->detachUri($clinic, $doctor), $intruder);
|
||||
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
$this->assertFalse($body['success']);
|
||||
$this->assertSame('ERR_ACCESS_DENIED', $body['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testPlainDoctorIsForbidden(): void
|
||||
{
|
||||
[$clinic, $doctor] = $this->makeClinicWithDoctor();
|
||||
$otherDoctor = $this->createUser(['ROLE_DOCTOR']);
|
||||
|
||||
$this->authJson('DELETE', $this->detachUri($clinic, $doctor), $otherDoctor);
|
||||
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\ClinicInvitation;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Covers the full clinic → doctor invitation lifecycle: inviting provisions a
|
||||
* doctor profile, accepting creates login credentials and links the doctor to
|
||||
* the clinic, and every admin action on an invitation stays reachable.
|
||||
*/
|
||||
class ClinicInvitationFlowTest extends ApiTestCase
|
||||
{
|
||||
private function createClinicOwner(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$clinic->setName('کلینیک تست');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $clinic];
|
||||
}
|
||||
|
||||
private function invitedMobile(): string
|
||||
{
|
||||
return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
public function testInviteCreatesDoctorProfileWithoutLinkingClinic(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicOwner();
|
||||
$mobile = $this->invitedMobile();
|
||||
|
||||
$res = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
|
||||
'mobile' => $mobile,
|
||||
'name' => 'دکتر تست',
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertNotNull($res['data']['doctor'], 'invite must provision a doctor profile');
|
||||
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
self::assertNotNull($doctor);
|
||||
self::assertSame('unclaimed', $doctor->getOwnerStatus());
|
||||
self::assertNull($doctor->getUser()->getPasswordHash(), 'no password before acceptance');
|
||||
|
||||
$this->em->refresh($clinic);
|
||||
self::assertFalse($clinic->getDoctors()->contains($doctor), 'clinic link happens only on accept');
|
||||
}
|
||||
|
||||
public function testAcceptLinksDoctorAndIssuesLoginCredentials(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicOwner();
|
||||
$mobile = $this->invitedMobile();
|
||||
|
||||
$this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
|
||||
'mobile' => $mobile,
|
||||
'name' => 'دکتر تست',
|
||||
]);
|
||||
|
||||
$inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['mobile' => $mobile]);
|
||||
$this->client->request('POST', "/api/v1/clinic-invitation/{$inv->getToken()}/accept");
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['mobile' => $mobile]);
|
||||
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
$user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
|
||||
self::assertSame(ClinicDoctorInvitation::STATUS_ACCEPTED, $inv->getStatus());
|
||||
self::assertSame($doctor->getId(), $inv->getDoctor()?->getId());
|
||||
self::assertSame('claimed', $doctor->getOwnerStatus());
|
||||
self::assertNotNull($user->getPasswordHash(), 'accepted doctor must be able to log in');
|
||||
self::assertContains('ROLE_DOCTOR', $user->getRoles());
|
||||
self::assertTrue($inv->getClinic()->getDoctors()->contains($doctor));
|
||||
}
|
||||
|
||||
public function testAcceptDoesNotOverwriteExistingPassword(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicOwner();
|
||||
$existing = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$existing->setPasswordHash('$2y$13$alreadySetHashValueForTesting.aaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
$this->em->flush();
|
||||
$mobile = $existing->getMobileNumber();
|
||||
|
||||
$this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
|
||||
'mobile' => $mobile,
|
||||
]);
|
||||
|
||||
$inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['mobile' => $mobile]);
|
||||
$this->client->request('POST', "/api/v1/clinic-invitation/{$inv->getToken()}/accept");
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$reloaded = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
self::assertStringStartsWith('$2y$13$alreadySet', $reloaded->getPasswordHash());
|
||||
}
|
||||
|
||||
public function testSuspendAndReactivateInvitation(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicOwner();
|
||||
$mobile = $this->invitedMobile();
|
||||
|
||||
$created = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
|
||||
'mobile' => $mobile,
|
||||
]);
|
||||
$invUuid = $created['data']['uuid'];
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/admin/clinic/invitation/{$invUuid}/status", $owner, ['status' => 'suspended']);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$res = $this->authJson('PATCH', "/api/v1/admin/clinic/invitation/{$invUuid}/status", $owner, ['status' => 'pending']);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('pending', $res['data']['status']);
|
||||
|
||||
$this->em->clear();
|
||||
$inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['uuid' => $invUuid]);
|
||||
self::assertTrue($inv->isUsable(), 'reactivated invitation must have a fresh usable token');
|
||||
}
|
||||
|
||||
public function testInvalidStatusIsRejected(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicOwner();
|
||||
$created = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
|
||||
'mobile' => $this->invitedMobile(),
|
||||
]);
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/admin/clinic/invitation/{$created['data']['uuid']}/status", $owner, ['status' => 'bogus']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testDeleteInvitationReturnsJsonBody(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicOwner();
|
||||
$created = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
|
||||
'mobile' => $this->invitedMobile(),
|
||||
]);
|
||||
|
||||
$res = $this->authJson('DELETE', "/api/v1/admin/clinic/invitation/{$created['data']['uuid']}", $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($res['success']);
|
||||
|
||||
$this->authJson('DELETE', "/api/v1/admin/clinic/invitation/{$created['data']['uuid']}", $owner);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAcceptedInvitationCannotReturnToPending(): void
|
||||
{
|
||||
[$owner, $clinic] = $this->createClinicOwner();
|
||||
$mobile = $this->invitedMobile();
|
||||
$created = $this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
|
||||
'mobile' => $mobile,
|
||||
]);
|
||||
|
||||
$inv = $this->em->getRepository(ClinicDoctorInvitation::class)->findOneBy(['mobile' => $mobile]);
|
||||
$this->client->request('POST', "/api/v1/clinic-invitation/{$inv->getToken()}/accept");
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/admin/clinic/invitation/{$created['data']['uuid']}/status", $owner, ['status' => 'pending']);
|
||||
self::assertSame(409, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\ClinicService;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/service-item/{uuid} backs the dedicated service detail page, which
|
||||
* must survive a hard refresh — hence a single-item fetch instead of filtering
|
||||
* the full list client-side.
|
||||
*/
|
||||
class ServiceItemDetailApiTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: ServiceItem} */
|
||||
private function makeDoctorWithItem(string $sectionName = 'تزریقات'): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست جزئیات');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), $sectionName);
|
||||
$item = new ServiceItem($section, 'سرم ۵۰۰cc');
|
||||
$item->setPriceRials(850_000)->setDurationMinutes(30)->setBookable(true);
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $item];
|
||||
}
|
||||
|
||||
public function testReturnsTheItemWithEverythingTheDetailPageNeeds(): void
|
||||
{
|
||||
[$owner, $item] = $this->makeDoctorWithItem();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$row = $body['data'];
|
||||
$this->assertSame($item->getUuid(), $row['uuid']);
|
||||
$this->assertSame('سرم ۵۰۰cc', $row['name']);
|
||||
$this->assertSame('تزریقات', $row['section_name'], 'breadcrumb به نام بخش نیاز دارد');
|
||||
$this->assertSame($item->getSection()->getUuid(), $row['section_uuid']);
|
||||
$this->assertSame(850_000, $row['price_rials']);
|
||||
$this->assertSame(30, $row['duration_minutes']);
|
||||
$this->assertTrue($row['bookable']);
|
||||
$this->assertIsInt($row['created_at']);
|
||||
$this->assertIsInt($row['updated_at']);
|
||||
$this->assertArrayHasKey('staff_members', $row);
|
||||
$this->assertArrayHasKey('insurance_covered', $row);
|
||||
}
|
||||
|
||||
public function testUnknownUuidReturns404(): void
|
||||
{
|
||||
[$owner] = $this->makeDoctorWithItem();
|
||||
|
||||
$this->authJson('GET', '/api/v1/service-item/00000000-0000-4000-8000-000000000000', $owner);
|
||||
|
||||
$this->assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAnotherTenantCannotReadTheItem(): void
|
||||
{
|
||||
[, $item] = $this->makeDoctorWithItem();
|
||||
|
||||
$stranger = $this->createUser(['ROLE_DOCTOR']);
|
||||
$this->em->persist(new Doctor($stranger, 'دکتر غریبه'));
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $stranger);
|
||||
|
||||
$this->assertSame(404, $this->responseCode(), 'وجود سرویس نباید به tenant دیگر لو برود');
|
||||
}
|
||||
|
||||
public function testRequiresAuthentication(): void
|
||||
{
|
||||
[, $item] = $this->makeDoctorWithItem();
|
||||
|
||||
$this->client->request('GET', '/api/v1/service-item/' . $item->getUuid());
|
||||
|
||||
$this->assertSame(401, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\ClinicService;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\ClinicService\Repository\ServiceItemRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Staff\Entity\ClinicStaff;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* A service can carry multiple personnel (staffMembers). toArray must expose
|
||||
* them as `staff_members`, keep the legacy single `staff` (first member) for
|
||||
* back-compat, and the section list must report `items_count`.
|
||||
*/
|
||||
class ServiceItemMultiStaffTest extends ApiTestCase
|
||||
{
|
||||
public function testMultiStaffToArrayAndSectionCount(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$s1 = new ClinicStaff('doctor', $doctor->getId(), 'مریم امینی');
|
||||
$s2 = new ClinicStaff('doctor', $doctor->getId(), 'سحر رحمانی');
|
||||
$this->em->persist($s1);
|
||||
$this->em->persist($s2);
|
||||
$this->em->flush();
|
||||
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'کندلا');
|
||||
$item = new ServiceItem($section, 'فول بادی', 3_500_000);
|
||||
$item->setStaffMembers([$s1, $s2]); // staff already managed (as in the controller)
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
$sectionUuid = $section->getUuid();
|
||||
$itemUuid = $item->getUuid();
|
||||
$this->em->clear();
|
||||
|
||||
/** @var ServiceItemRepository $repo */
|
||||
$repo = static::getContainer()->get(ServiceItemRepository::class);
|
||||
$reloaded = $repo->findByUuid($itemUuid); // uuid — db_test is never reset
|
||||
self::assertNotNull($reloaded);
|
||||
self::assertCount(2, $reloaded->getStaffMembers());
|
||||
|
||||
$arr = $reloaded->toArray();
|
||||
self::assertCount(2, $arr['staff_members']);
|
||||
// primary (legacy single) mirrors the first member
|
||||
self::assertSame('مریم امینی', $arr['staff']['full_name']);
|
||||
|
||||
// batch count
|
||||
$sectionEntity = $reloaded->getSection();
|
||||
$counts = $repo->countBySections([$sectionEntity]);
|
||||
self::assertSame(1, $counts[$sectionUuid]);
|
||||
|
||||
// endpoint exposes items_count
|
||||
$resp = $this->authJson('GET', '/api/v1/service-sections', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$row = $resp['data'][0] ?? [];
|
||||
self::assertSame(1, $row['items_count'] ?? null);
|
||||
}
|
||||
|
||||
public function testCreateAndUpdateItemWithMultipleStaffViaApi(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$a = new ClinicStaff('doctor', $doctor->getId(), 'الف');
|
||||
$b = new ClinicStaff('doctor', $doctor->getId(), 'ب');
|
||||
$c = new ClinicStaff('doctor', $doctor->getId(), 'ج');
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش');
|
||||
foreach ([$a, $b, $c, $section] as $e) { $this->em->persist($e); }
|
||||
$this->em->flush();
|
||||
|
||||
// create with two staff members
|
||||
$created = $this->authJson('POST', '/api/v1/service-item', $owner, [
|
||||
'section_uuid' => $section->getUuid(),
|
||||
'name' => 'سرویس چندنفره',
|
||||
'price_rials' => 1_000,
|
||||
'staff_uuids' => [$a->getUuid(), $b->getUuid()],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertCount(2, $created['data']['staff_members']);
|
||||
|
||||
// update: replace with a single different staff
|
||||
$itemUuid = $created['data']['uuid'];
|
||||
$updated = $this->authJson('PATCH', '/api/v1/service-item/' . $itemUuid, $owner, [
|
||||
'staff_uuids' => [$c->getUuid()],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(1, $updated['data']['staff_members']);
|
||||
self::assertSame('ج', $updated['data']['staff_members'][0]['full_name']);
|
||||
}
|
||||
|
||||
public function testLegacySingleStaffFallsBackInToArray(): void
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$staff = new ClinicStaff('doctor', $doctor->getId(), 'کاربر قدیمی');
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'بخش');
|
||||
$item = new ServiceItem($section, 'خدمت قدیمی');
|
||||
$item->setStaff($staff); // legacy single-staff path, no members
|
||||
$this->em->persist($staff);
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
$arr = $item->toArray();
|
||||
self::assertCount(1, $arr['staff_members']);
|
||||
self::assertSame('کاربر قدیمی', $arr['staff_members'][0]['full_name']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\ClinicService;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Inventory\Entity\InventoryItem;
|
||||
use App\Inventory\Entity\InventoryPackage;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* دو قابلیت صفحهی جزئیات سرویس:
|
||||
* ۱. اتصال اختیاری خدمت به یک پکیج کالای مصرفی (کالاهای مرتبط)
|
||||
* ۲. تاریخچهی تغییرات خدمت (service_item_audit_logs)
|
||||
*/
|
||||
class ServiceItemPackageAndAuditTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Doctor, 2: ServiceSection} */
|
||||
private function makeDoctorWithSection(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست پکیج');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'تزریقات');
|
||||
$this->em->persist($section);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor, $section];
|
||||
}
|
||||
|
||||
private function makePackage(Doctor $doctor, string $title = 'پکیج سرم'): InventoryPackage
|
||||
{
|
||||
$package = new InventoryPackage('doctor', $doctor->getId(), $title);
|
||||
$this->em->persist($package);
|
||||
$this->em->flush();
|
||||
|
||||
return $package;
|
||||
}
|
||||
|
||||
private function makeItem(ServiceSection $section, string $name = 'سرم ۵۰۰cc'): ServiceItem
|
||||
{
|
||||
$item = new ServiceItem($section, $name, 850_000);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
// ── کالاهای مرتبط ────────────────────────────────────────────────────────
|
||||
|
||||
public function testAttachingAPackageIsReflectedInTheDetailResponse(): void
|
||||
{
|
||||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||||
$package = $this->makePackage($doctor);
|
||||
$item = $this->makeItem($section);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||||
'inventory_package_uuid' => $package->getUuid(),
|
||||
]);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner);
|
||||
$this->assertSame($package->getUuid(), $body['data']['inventory_package_uuid']);
|
||||
$this->assertSame('پکیج سرم', $body['data']['inventory_package_title']);
|
||||
}
|
||||
|
||||
public function testDetachingWithNullClearsThePackage(): void
|
||||
{
|
||||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||||
$package = $this->makePackage($doctor);
|
||||
$item = $this->makeItem($section);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||||
'inventory_package_uuid' => $package->getUuid(),
|
||||
]);
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||||
'inventory_package_uuid' => null,
|
||||
]);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner);
|
||||
$this->assertNull($body['data']['inventory_package_uuid']);
|
||||
}
|
||||
|
||||
public function testPackageOfAnotherTenantIsRejected(): void
|
||||
{
|
||||
[$owner, , $section] = $this->makeDoctorWithSection();
|
||||
$item = $this->makeItem($section);
|
||||
|
||||
$stranger = $this->createUser(['ROLE_DOCTOR']);
|
||||
$other = new Doctor($stranger, 'دکتر غریبه');
|
||||
$this->em->persist($other);
|
||||
$this->em->flush();
|
||||
$foreignPackage = $this->makePackage($other, 'پکیج غریبه');
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||||
'inventory_package_uuid' => $foreignPackage->getUuid(),
|
||||
]);
|
||||
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testUnknownPackageUuidIsRejected(): void
|
||||
{
|
||||
[$owner, , $section] = $this->makeDoctorWithSection();
|
||||
$item = $this->makeItem($section);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||||
'inventory_package_uuid' => 'no-such-package',
|
||||
]);
|
||||
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── کالاهای تکی (مکمل پکیج) ──────────────────────────────────────────────
|
||||
|
||||
private function makeInventoryItem(Doctor $doctor, string $name, int $price = 50_000): InventoryItem
|
||||
{
|
||||
$inventoryItem = new InventoryItem('doctor', $doctor->getId(), $name);
|
||||
$inventoryItem->setPrice($price)->setStock(100);
|
||||
$this->em->persist($inventoryItem);
|
||||
$this->em->flush();
|
||||
|
||||
return $inventoryItem;
|
||||
}
|
||||
|
||||
public function testAServiceCanHaveBothAPackageAndIndividualItems(): void
|
||||
{
|
||||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||||
$package = $this->makePackage($doctor);
|
||||
$gauze = $this->makeInventoryItem($doctor, 'گاز استریل');
|
||||
$syringe = $this->makeInventoryItem($doctor, 'سرنگ ۵cc');
|
||||
$item = $this->makeItem($section);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||||
'inventory_package_uuid' => $package->getUuid(),
|
||||
'consumables' => [
|
||||
['item_uuid' => $gauze->getUuid(), 'amount' => 2],
|
||||
['item_uuid' => $syringe->getUuid(), 'amount' => 1],
|
||||
],
|
||||
]);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner);
|
||||
$row = $body['data'];
|
||||
|
||||
$this->assertSame($package->getUuid(), $row['inventory_package_uuid'], 'پکیج باید کنار اقلام تکی بماند');
|
||||
$this->assertCount(2, $row['consumables']);
|
||||
|
||||
$byName = array_column($row['consumables'], null, 'name');
|
||||
$this->assertSame(2, $byName['گاز استریل']['amount']);
|
||||
$this->assertSame(1, $byName['سرنگ ۵cc']['amount']);
|
||||
$this->assertSame(50_000, $byName['گاز استریل']['price']);
|
||||
}
|
||||
|
||||
public function testResendingConsumablesReplacesTheWholeList(): void
|
||||
{
|
||||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||||
$gauze = $this->makeInventoryItem($doctor, 'گاز استریل');
|
||||
$syringe = $this->makeInventoryItem($doctor, 'سرنگ ۵cc');
|
||||
$item = $this->makeItem($section);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||||
'consumables' => [
|
||||
['item_uuid' => $gauze->getUuid(), 'amount' => 2],
|
||||
['item_uuid' => $syringe->getUuid(), 'amount' => 1],
|
||||
],
|
||||
]);
|
||||
|
||||
// فقط یک قلم با تعداد جدید ارسال میشود
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||||
'consumables' => [['item_uuid' => $gauze->getUuid(), 'amount' => 5]],
|
||||
]);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner);
|
||||
$this->assertCount(1, $body['data']['consumables']);
|
||||
$this->assertSame('گاز استریل', $body['data']['consumables'][0]['name']);
|
||||
$this->assertSame(5, $body['data']['consumables'][0]['amount']);
|
||||
}
|
||||
|
||||
public function testEmptyConsumablesArrayClearsThem(): void
|
||||
{
|
||||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||||
$gauze = $this->makeInventoryItem($doctor, 'گاز استریل');
|
||||
$item = $this->makeItem($section);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||||
'consumables' => [['item_uuid' => $gauze->getUuid(), 'amount' => 2]],
|
||||
]);
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, ['consumables' => []]);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid(), $owner);
|
||||
$this->assertSame([], $body['data']['consumables']);
|
||||
}
|
||||
|
||||
public function testConsumableOfAnotherTenantIsRejected(): void
|
||||
{
|
||||
[$owner, , $section] = $this->makeDoctorWithSection();
|
||||
$item = $this->makeItem($section);
|
||||
|
||||
$stranger = $this->createUser(['ROLE_DOCTOR']);
|
||||
$other = new Doctor($stranger, 'دکتر غریبه');
|
||||
$this->em->persist($other);
|
||||
$this->em->flush();
|
||||
$foreignItem = $this->makeInventoryItem($other, 'کالای غریبه');
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||||
'consumables' => [['item_uuid' => $foreignItem->getUuid(), 'amount' => 1]],
|
||||
]);
|
||||
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testConsumablesCanBeSetAtCreationTime(): void
|
||||
{
|
||||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||||
$gauze = $this->makeInventoryItem($doctor, 'گاز استریل');
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/service-item', $owner, [
|
||||
'section_uuid' => $section->getUuid(),
|
||||
'name' => 'پانسمان',
|
||||
'price_rials' => 200_000,
|
||||
'consumables' => [['item_uuid' => $gauze->getUuid(), 'amount' => 3]],
|
||||
]);
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$this->assertCount(1, $created['data']['consumables']);
|
||||
$this->assertSame(3, $created['data']['consumables'][0]['amount']);
|
||||
}
|
||||
|
||||
public function testChangingConsumablesIsLogged(): void
|
||||
{
|
||||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||||
$gauze = $this->makeInventoryItem($doctor, 'گاز استریل');
|
||||
$item = $this->makeItem($section);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||||
'consumables' => [['item_uuid' => $gauze->getUuid(), 'amount' => 2]],
|
||||
]);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $owner);
|
||||
$byField = array_column($body['data'], null, 'field');
|
||||
|
||||
$this->assertArrayHasKey('consumables', $byField);
|
||||
$this->assertNull($byField['consumables']['old_value']);
|
||||
$this->assertSame('گاز استریل×2', $byField['consumables']['new_value']);
|
||||
}
|
||||
|
||||
// ── لاگ تغییرات ──────────────────────────────────────────────────────────
|
||||
|
||||
public function testCreatingAServiceWritesACreateLog(): void
|
||||
{
|
||||
[$owner, , $section] = $this->makeDoctorWithSection();
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/service-item', $owner, [
|
||||
'section_uuid' => $section->getUuid(),
|
||||
'name' => 'خدمت تازه',
|
||||
'price_rials' => 100_000,
|
||||
]);
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/service-item/' . $created['data']['uuid'] . '/audit-logs', $owner);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$logs = $body['data'];
|
||||
$this->assertCount(1, $logs);
|
||||
$this->assertSame('create', $logs[0]['operation']);
|
||||
$this->assertSame('خدمت تازه', $logs[0]['new_value']);
|
||||
}
|
||||
|
||||
public function testEachChangedFieldGetsItsOwnLogRow(): void
|
||||
{
|
||||
[$owner, , $section] = $this->makeDoctorWithSection();
|
||||
$item = $this->makeItem($section);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||||
'name' => 'نام جدید',
|
||||
'price_rials' => 990_000,
|
||||
]);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $owner);
|
||||
$fields = array_column($body['data'], 'field');
|
||||
sort($fields);
|
||||
|
||||
$this->assertSame(['name', 'price_rials'], $fields);
|
||||
|
||||
$byField = array_column($body['data'], null, 'field');
|
||||
$this->assertSame('سرم ۵۰۰cc', $byField['name']['old_value']);
|
||||
$this->assertSame('نام جدید', $byField['name']['new_value']);
|
||||
$this->assertSame('850000', $byField['price_rials']['old_value']);
|
||||
$this->assertSame('990000', $byField['price_rials']['new_value']);
|
||||
}
|
||||
|
||||
public function testUnchangedFieldsAreNotLogged(): void
|
||||
{
|
||||
[$owner, , $section] = $this->makeDoctorWithSection();
|
||||
$item = $this->makeItem($section);
|
||||
|
||||
// همان مقادیر فعلی دوباره ارسال میشوند
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||||
'name' => 'سرم ۵۰۰cc',
|
||||
'price_rials' => 850_000,
|
||||
]);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $owner);
|
||||
$this->assertSame([], $body['data']);
|
||||
}
|
||||
|
||||
public function testAttachingAPackageIsLogged(): void
|
||||
{
|
||||
[$owner, $doctor, $section] = $this->makeDoctorWithSection();
|
||||
$package = $this->makePackage($doctor);
|
||||
$item = $this->makeItem($section);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/service-item/' . $item->getUuid(), $owner, [
|
||||
'inventory_package_uuid' => $package->getUuid(),
|
||||
]);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $owner);
|
||||
$fields = array_column($body['data'], 'field');
|
||||
|
||||
$this->assertContains('inventory_package', $fields);
|
||||
}
|
||||
|
||||
public function testAuditLogsOfAnotherTenantAreNotReadable(): void
|
||||
{
|
||||
[, , $section] = $this->makeDoctorWithSection();
|
||||
$item = $this->makeItem($section);
|
||||
|
||||
$stranger = $this->createUser(['ROLE_DOCTOR']);
|
||||
$this->em->persist(new Doctor($stranger, 'دکتر غریبه'));
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', '/api/v1/service-item/' . $item->getUuid() . '/audit-logs', $stranger);
|
||||
|
||||
$this->assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\ClinicService;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/service-items resolves the caller's working context. A user with
|
||||
* neither a doctor profile nor a clinic (admin, secretary, representation, plain
|
||||
* patient) resolves to EntityContext::unknown(), whose id is null — which used
|
||||
* to reach ServiceItemRepository::findByEntity(int $entityId) and fatal with a
|
||||
* 500. It must be a 403 instead.
|
||||
*/
|
||||
class ServiceItemsUnresolvedContextTest extends ApiTestCase
|
||||
{
|
||||
public function testUserWithoutDoctorOrClinicGetsForbiddenNotServerError(): void
|
||||
{
|
||||
foreach ([['ROLE_ADMIN'], ['ROLE_SECRETARY'], ['ROLE_REPRESENTATION'], ['ROLE_USER']] as $roles) {
|
||||
$user = $this->createUser($roles);
|
||||
|
||||
$this->authJson('GET', '/api/v1/service-items', $user);
|
||||
|
||||
$this->assertSame(
|
||||
403,
|
||||
$this->responseCode(),
|
||||
sprintf('roles %s should be forbidden, not a server error', implode(',', $roles)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testDoctorStillGetsTheirOwnItems(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست محیط');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'تزریقات');
|
||||
$item = new ServiceItem($section, 'سرم ۵۰۰cc');
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/service-items', $owner);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertTrue($body['success']);
|
||||
$this->assertContains('سرم ۵۰۰cc', array_column($body['data'], 'name'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Dashboard;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/dashboard/{clinic,doctor} — chart series periods.
|
||||
*
|
||||
* «نمودار تعداد بیماران» is one Jalali month (day-by-day) and «میزان درآمد» is
|
||||
* one Jalali year (month-by-month); both default to the current Jalali period
|
||||
* and are overridable via patients_year / patients_month / revenue_year.
|
||||
*/
|
||||
class DashboardChartPeriodTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0:int, 1:int} [jalaliYear, jalaliMonth] */
|
||||
private function currentJalali(): array
|
||||
{
|
||||
$cal = \IntlCalendar::createInstance(new \DateTimeZone('Asia/Tehran'), 'fa_IR@calendar=persian');
|
||||
|
||||
return [$cal->get(\IntlCalendar::FIELD_YEAR), $cal->get(\IntlCalendar::FIELD_MONTH) + 1];
|
||||
}
|
||||
|
||||
private function jalaliMonthLength(int $year, int $month): int
|
||||
{
|
||||
$cal = \IntlCalendar::createInstance(new \DateTimeZone('Asia/Tehran'), 'fa_IR@calendar=persian');
|
||||
$cal->set(\IntlCalendar::FIELD_YEAR, $year);
|
||||
$cal->set(\IntlCalendar::FIELD_MONTH, $month - 1);
|
||||
$cal->set(\IntlCalendar::FIELD_DAY_OF_MONTH, 1);
|
||||
|
||||
return $cal->getActualMaximum(\IntlCalendar::FIELD_DAY_OF_MONTH);
|
||||
}
|
||||
|
||||
private function clinicWithDoctor(): array
|
||||
{
|
||||
$clinicOwner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($clinicOwner);
|
||||
$clinic->setName('کلینیک نمودار');
|
||||
|
||||
$docOwner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($docOwner, 'حمیدی');
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
|
||||
$this->em->persist($doctor);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return [$clinicOwner, $doctor];
|
||||
}
|
||||
|
||||
public function testClinicChartsDefaultToCurrentJalaliPeriod(): void
|
||||
{
|
||||
[$owner] = $this->clinicWithDoctor();
|
||||
[$year, $month] = $this->currentJalali();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/dashboard/clinic', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
self::assertSame(
|
||||
['patients_year' => $year, 'patients_month' => $month, 'revenue_year' => $year],
|
||||
$res['data']['charts_period']
|
||||
);
|
||||
self::assertCount($this->jalaliMonthLength($year, $month), $res['data']['charts']['appointments_by_day']);
|
||||
self::assertCount(12, $res['data']['charts']['revenue_by_month']);
|
||||
}
|
||||
|
||||
public function testClinicChartsHonourRequestedPeriod(): void
|
||||
{
|
||||
[$owner] = $this->clinicWithDoctor();
|
||||
|
||||
// اسفند ۱۴۰۳ سال کبیسه است → ۳۰ روز
|
||||
$res = $this->authJson('GET', '/api/v1/dashboard/clinic?patients_year=1403&patients_month=12&revenue_year=1402', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
self::assertSame(
|
||||
['patients_year' => 1403, 'patients_month' => 12, 'revenue_year' => 1402],
|
||||
$res['data']['charts_period']
|
||||
);
|
||||
self::assertCount(30, $res['data']['charts']['appointments_by_day']);
|
||||
}
|
||||
|
||||
public function testTodayAppointmentIsCountedOnItsJalaliDay(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->clinicWithDoctor();
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
|
||||
$start = time();
|
||||
$this->em->persist(new Appointment($doctor, $patient, $start, $start + 1_800));
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/dashboard/clinic', $owner);
|
||||
$days = $res['data']['charts']['appointments_by_day'];
|
||||
|
||||
$cal = \IntlCalendar::createInstance(new \DateTimeZone('Asia/Tehran'), 'fa_IR@calendar=persian');
|
||||
$todayIndex = $cal->get(\IntlCalendar::FIELD_DAY_OF_MONTH) - 1;
|
||||
|
||||
self::assertGreaterThanOrEqual(1, $days[$todayIndex]['count'], 'today\'s slot must land on today\'s bar');
|
||||
}
|
||||
|
||||
public function testOutOfRangeParamsAreClamped(): void
|
||||
{
|
||||
[$owner] = $this->clinicWithDoctor();
|
||||
[$year] = $this->currentJalali();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/dashboard/clinic?patients_month=99&patients_year=9999&revenue_year=0', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$period = $res['data']['charts_period'];
|
||||
self::assertSame(12, $period['patients_month']);
|
||||
self::assertSame(1500, $period['patients_year']);
|
||||
// revenue_year=0 is falsy → falls back to the current Jalali year
|
||||
self::assertSame($year, $period['revenue_year']);
|
||||
}
|
||||
|
||||
public function testDoctorChartsUseSameJalaliPeriod(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'حمیدی');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/dashboard/doctor?patients_year=1403&patients_month=1', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
self::assertSame(1403, $res['data']['charts_period']['patients_year']);
|
||||
self::assertCount(31, $res['data']['charts']['appointments_by_day']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Dashboard;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/dashboard/{doctor,clinic} — `today_appointments` rows must carry
|
||||
* the extended fields (patient_mobile, doctor_name, service_name, slot_end)
|
||||
* added for the ported «لیست نوبتهای جدید» dashboard table.
|
||||
*
|
||||
* NOTE: db_test is never reset, so patient mobiles use the ApiTestCase random
|
||||
* generator (never hardcoded) to avoid unique-constraint clashes across runs.
|
||||
*/
|
||||
class DashboardTodayAppointmentsTest extends ApiTestCase
|
||||
{
|
||||
private function todayAppointment(Doctor $doctor, User $patient): Appointment
|
||||
{
|
||||
// now() is guaranteed within [today midnight, tomorrow midnight-1]
|
||||
$start = time();
|
||||
$appt = new Appointment($doctor, $patient, $start, $start + 1_800);
|
||||
$this->em->persist($appt);
|
||||
$this->em->flush();
|
||||
|
||||
return $appt;
|
||||
}
|
||||
|
||||
public function testDoctorTodayAppointmentsExposeExtendedFields(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'حمیدی');
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
|
||||
$section = new ServiceSection('doctor', 1, 'عمومی');
|
||||
$item = new ServiceItem($section, 'ویزیت عمومی');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
$appt = $this->todayAppointment($doctor, $patient);
|
||||
$appt->setServiceItem($item);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/dashboard/doctor', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$rows = $res['data']['today_appointments'];
|
||||
self::assertNotEmpty($rows, 'today_appointments should contain the booked slot');
|
||||
$row = $rows[0];
|
||||
|
||||
self::assertSame($patient->getMobileNumber(), $row['patient_mobile']);
|
||||
self::assertSame('حمیدی', $row['doctor_name']);
|
||||
self::assertSame('ویزیت عمومی', $row['service_name']);
|
||||
self::assertSame($appt->getSlotStart(), $row['slot_start']);
|
||||
self::assertSame($appt->getSlotEnd(), $row['slot_end']);
|
||||
self::assertArrayHasKey('status', $row);
|
||||
}
|
||||
|
||||
public function testServiceNameNullWhenNoServiceItem(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'حمیدی');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$this->todayAppointment($doctor, $patient);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/dashboard/doctor', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$row = $res['data']['today_appointments'][0];
|
||||
|
||||
self::assertNull($row['service_name'], 'service_name is null when no service item is booked');
|
||||
self::assertSame($patient->getMobileNumber(), $row['patient_mobile']);
|
||||
}
|
||||
|
||||
public function testClinicTodayAppointmentsExposeExtendedFields(): void
|
||||
{
|
||||
$clinicOwner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($clinicOwner);
|
||||
$clinic->setName('کلینیک نمونه');
|
||||
|
||||
$docOwner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($docOwner, 'حمیدی');
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
|
||||
$section = new ServiceSection('doctor', 1, 'عمومی');
|
||||
$item = new ServiceItem($section, 'ویزیت عمومی');
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
|
||||
$this->em->persist($doctor);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->persist($section);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
$appt = $this->todayAppointment($doctor, $patient);
|
||||
$appt->setServiceItem($item);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/dashboard/clinic', $clinicOwner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$rows = $res['data']['today_appointments'];
|
||||
self::assertNotEmpty($rows);
|
||||
$row = $rows[0];
|
||||
|
||||
self::assertSame($patient->getMobileNumber(), $row['patient_mobile']);
|
||||
self::assertSame('حمیدی', $row['doctor_name']);
|
||||
self::assertSame('ویزیت عمومی', $row['service_name']);
|
||||
self::assertSame($appt->getSlotEnd(), $row['slot_end']);
|
||||
}
|
||||
|
||||
public function testDoctorNotFoundReturns404(): void
|
||||
{
|
||||
// ROLE_DOCTOR user without a Doctor entity
|
||||
$user = $this->createUser(['ROLE_DOCTOR']);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/dashboard/doctor', $user);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
self::assertFalse($res['success']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Dashboard;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* داشبورد پزشک در محیط کلینیک نباید هیچ رقم مالی برگرداند. مخفیکردن کارتها در
|
||||
* پنل کافی نیست — endpoint مستقیماً قابل صدا زدن است.
|
||||
*/
|
||||
class InvitedDoctorDashboardScopeTest extends ApiTestCase
|
||||
{
|
||||
private const FINANCIAL_KEYS = [
|
||||
'revenue_period_rials',
|
||||
'today_payments_rials',
|
||||
'week_payments_rials',
|
||||
'sms_wallet_balance',
|
||||
];
|
||||
|
||||
private function makeDoctorInClinic(): array
|
||||
{
|
||||
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($doctorUser, 'دکتر دعوتشده');
|
||||
$doctor->setMobileNumber($doctorUser->getMobileNumber());
|
||||
$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();
|
||||
|
||||
return [$doctor, $clinic];
|
||||
}
|
||||
|
||||
public function testClinicContextOmitsFinancialFields(): void
|
||||
{
|
||||
[$doctor, $clinic] = $this->makeDoctorInClinic();
|
||||
|
||||
$body = $this->authJson('GET', "/api/v1/dashboard/doctor?clinic_uuid={$clinic->getUuid()}", $doctor->getUser());
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$stats = $body['data']['stats'] ?? [];
|
||||
foreach (self::FINANCIAL_KEYS as $key) {
|
||||
self::assertArrayNotHasKey($key, $stats, "«{$key}» نباید در محیط کلینیک برگردد");
|
||||
}
|
||||
|
||||
self::assertArrayNotHasKey('revenue_by_day', $body['data']['charts'] ?? []);
|
||||
self::assertSame('clinic', $body['data']['context']['type'] ?? null);
|
||||
self::assertSame([], $body['data']['clinics'] ?? null, 'فهرست کلینیکها فقط در محیط شخصی معنا دارد');
|
||||
}
|
||||
|
||||
public function testPersonalContextStillReturnsFinancialFields(): void
|
||||
{
|
||||
[$doctor] = $this->makeDoctorInClinic();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/dashboard/doctor', $doctor->getUser());
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$stats = $body['data']['stats'] ?? [];
|
||||
foreach (self::FINANCIAL_KEYS as $key) {
|
||||
self::assertArrayHasKey($key, $stats);
|
||||
}
|
||||
|
||||
self::assertSame('personal', $body['data']['context']['type'] ?? null);
|
||||
}
|
||||
|
||||
public function testForeignClinicUuidFallsBackToPersonalScope(): void
|
||||
{
|
||||
[$doctor] = $this->makeDoctorInClinic();
|
||||
$outsider = new Clinic($this->createUser(['ROLE_USER', 'ROLE_CLINIC']));
|
||||
$outsider->setName('کلینیک بیگانه');
|
||||
$this->em->persist($outsider);
|
||||
$this->em->flush();
|
||||
|
||||
$body = $this->authJson('GET', "/api/v1/dashboard/doctor?clinic_uuid={$outsider->getUuid()}", $doctor->getUser());
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('personal', $body['data']['context']['type'] ?? null, 'کلینیکی که عضوش نیست، محیط نمیسازد');
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Insurance;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Insurance\Entity\EntityInsurancePricing;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* فلگ «الزامی کردن هزینه ویزیت» (require_visit_price روی ردیف free-visit):
|
||||
* - PUT /insurance-pricing: فلگ فعال بدون قیمت > 0 → 422؛ فلگ-فقط بدون ردیف → ردیف ساخته نشود.
|
||||
* - POST /patient/{uuid}/session: با فلگ فعال، visit_price_rials <= 0 → 422.
|
||||
* - POST /my/appointment و /admin/appointment: فلگ برای پزشکِ نوبت resolve میشود
|
||||
* (ردیف پزشک، وگرنه کلینیکِ واحد او)؛ بدون هزینه ویزیت → 422.
|
||||
*/
|
||||
class RequireVisitPriceTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Doctor} */
|
||||
private function doctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
private function pricingRow(string $type, int $entityId, int $priceRials, bool $require): EntityInsurancePricing
|
||||
{
|
||||
$row = new EntityInsurancePricing($type, $entityId, null, $priceRials);
|
||||
$row->setRequireVisitPrice($require);
|
||||
$this->em->persist($row);
|
||||
$this->em->flush();
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function recordFor(Doctor $doctor): PatientRecord
|
||||
{
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function appointmentBody(string $doctorUuid): array
|
||||
{
|
||||
$start = time() + 86_400 + random_int(0, 3_600) * 100;
|
||||
|
||||
return [
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
'slot_start' => $start,
|
||||
'slot_end' => $start + 1_800,
|
||||
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
|
||||
'patient_name' => 'بیمار تست',
|
||||
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
|
||||
];
|
||||
}
|
||||
|
||||
// ── PUT /api/v1/insurance-pricing ────────────────────────────────────────
|
||||
|
||||
public function testSaveFlagOnWithZeroPriceIs422(): void
|
||||
{
|
||||
[$owner] = $this->doctor();
|
||||
|
||||
$res = $this->authJson('PUT', '/api/v1/insurance-pricing', $owner, [
|
||||
'free_visit_price_rials' => 0,
|
||||
'require_visit_price' => true,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame(ErrorCodes::ERR_VALIDATION_001, $res['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testSaveFlagOnWithValidPricePersistsBoth(): void
|
||||
{
|
||||
[$owner] = $this->doctor();
|
||||
|
||||
$this->authJson('PUT', '/api/v1/insurance-pricing', $owner, [
|
||||
'free_visit_price_rials' => 500_000,
|
||||
'require_visit_price' => true,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/insurance-pricing', $owner);
|
||||
self::assertSame(500_000, $res['data']['free_visit_price_rials']);
|
||||
self::assertTrue($res['data']['require_visit_price']);
|
||||
}
|
||||
|
||||
public function testSaveFlagAloneKeepsStoredPrice(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 300_000, false);
|
||||
|
||||
$this->authJson('PUT', '/api/v1/insurance-pricing', $owner, ['require_visit_price' => true]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/insurance-pricing', $owner);
|
||||
self::assertSame(300_000, $res['data']['free_visit_price_rials']);
|
||||
self::assertTrue($res['data']['require_visit_price']);
|
||||
}
|
||||
|
||||
public function testSaveFlagAloneOnEnabledRowWithZeroStoredPriceIs422(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 0, false);
|
||||
|
||||
$this->authJson('PUT', '/api/v1/insurance-pricing', $owner, ['require_visit_price' => true]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testSaveFlagFalseWithoutRowDoesNotCreateRow(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
|
||||
$this->authJson('PUT', '/api/v1/insurance-pricing', $owner, ['require_visit_price' => false]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$row = $this->em->getRepository(EntityInsurancePricing::class)->findOneBy([
|
||||
'entityType' => EntityInsurancePricing::TYPE_DOCTOR,
|
||||
'entityId' => $doctor->getId(),
|
||||
'insuranceId' => null,
|
||||
]);
|
||||
self::assertNull($row);
|
||||
}
|
||||
|
||||
// ── POST /api/v1/patient/{uuid}/session ─────────────────────────────────
|
||||
|
||||
public function testSessionWithoutVisitPriceIs422WhenFlagOn(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 500_000, true);
|
||||
$record = $this->recordFor($doctor);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 0,
|
||||
]);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame(ErrorCodes::ERR_VALIDATION_001, $res['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testSessionWithVisitPriceIs201WhenFlagOn(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 500_000, true);
|
||||
$record = $this->recordFor($doctor);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 400_000,
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame(400_000, $res['data']['visit_price_rials']);
|
||||
}
|
||||
|
||||
public function testSessionWithZeroVisitPriceStaysAllowedWhenFlagOff(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 500_000, false);
|
||||
$record = $this->recordFor($doctor);
|
||||
|
||||
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 0,
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── POST /api/v1/my/appointment ─────────────────────────────────────────
|
||||
|
||||
public function testMyAppointmentWithoutVisitPriceIs422WhenFlagOn(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 500_000, true);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->appointmentBody($doctor->getUuid()));
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('visit_price_rials', $res['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testMyAppointmentWithVisitPriceIs201AndStored(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 500_000, true);
|
||||
|
||||
$body = $this->appointmentBody($doctor->getUuid());
|
||||
$body['visit_price_rials'] = 500_000;
|
||||
$res = $this->authJson('POST', '/api/v1/my/appointment', $owner, $body);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
$appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $res['data']['uuid']]);
|
||||
self::assertSame(500_000, $appointment->getVisitPriceRials());
|
||||
}
|
||||
|
||||
public function testMyAppointmentWithoutVisitPriceIs201WhenFlagOff(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->appointmentBody($doctor->getUuid()));
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testFlagFallsBackToSingleClinicOfDoctor(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
|
||||
$clinicOwner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($clinicOwner);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
$this->pricingRow(EntityInsurancePricing::TYPE_CLINIC, $clinic->getId(), 500_000, true);
|
||||
|
||||
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->appointmentBody($doctor->getUuid()));
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── POST /api/v1/admin/appointment ──────────────────────────────────────
|
||||
|
||||
public function testAdminAppointmentWithoutVisitPriceIs422WhenFlagOn(): void
|
||||
{
|
||||
[, $doctor] = $this->doctor();
|
||||
$this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 500_000, true);
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/admin/appointment', $admin, $this->appointmentBody($doctor->getUuid()));
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('visit_price_rials', $res['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testAdminAppointmentWithVisitPriceIs201(): void
|
||||
{
|
||||
[, $doctor] = $this->doctor();
|
||||
$this->pricingRow(EntityInsurancePricing::TYPE_DOCTOR, $doctor->getId(), 500_000, true);
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
|
||||
$body = $this->appointmentBody($doctor->getUuid());
|
||||
$body['visit_price_rials'] = 500_000;
|
||||
$res = $this->authJson('POST', '/api/v1/admin/appointment', $admin, $body);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
$appointment = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $res['data']['uuid']]);
|
||||
self::assertSame(500_000, $appointment->getVisitPriceRials());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Insurance;
|
||||
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Insurance\Entity\TenantInsurance;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* The «این خدمت شامل بیمه میشود» switch was removed from the service form, so
|
||||
* ServiceItem::insuranceCovered is no longer set by hand. Saving a coverage row
|
||||
* is now the single source of truth and must keep the flag in sync — session
|
||||
* pricing (CreateStep) reads it.
|
||||
*/
|
||||
class ServiceCoverageSyncsItemFlagTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: TenantInsurance, 2: ServiceItem} */
|
||||
private function makeDoctorContractAndItem(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست همگامسازی');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$contract = new TenantInsurance(TenantInsurance::TYPE_DOCTOR, $doctor->getId(), 1);
|
||||
$section = new ServiceSection(TenantInsurance::TYPE_DOCTOR, $doctor->getId(), 'بخش');
|
||||
$this->em->persist($contract);
|
||||
$this->em->persist($section);
|
||||
$this->em->flush();
|
||||
|
||||
$item = new ServiceItem($section, 'سرم ۵۰۰cc');
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $contract, $item];
|
||||
}
|
||||
|
||||
/** EntityManager بین requestها clear میشود، پس پرچم باید دوباره از DB خوانده شود. */
|
||||
private function isInsured(string $uuid): bool
|
||||
{
|
||||
return $this->em->getRepository(ServiceItem::class)
|
||||
->findOneBy(['uuid' => $uuid])
|
||||
->isInsuranceCovered();
|
||||
}
|
||||
|
||||
private function putCoverage(
|
||||
\App\Auth\Entity\User $owner,
|
||||
TenantInsurance $contract,
|
||||
ServiceItem $item,
|
||||
bool $covered,
|
||||
): void {
|
||||
$this->authJson(
|
||||
'PUT',
|
||||
'/api/v1/billing/tenant-insurances/' . $contract->getUuid() . '/service-coverage',
|
||||
$owner,
|
||||
['service_item_uuid' => $item->getUuid(), 'covered' => $covered, 'coverage_percent' => 70],
|
||||
);
|
||||
}
|
||||
|
||||
public function testSavingCoveredCoverageMarksItemAsInsured(): void
|
||||
{
|
||||
[$owner, $contract, $item] = $this->makeDoctorContractAndItem();
|
||||
$this->assertFalse($item->isInsuranceCovered(), 'یک خدمت تازه نباید تحت پوشش باشد');
|
||||
|
||||
$this->putCoverage($owner, $contract, $item, true);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertTrue($this->isInsured($item->getUuid()));
|
||||
}
|
||||
|
||||
public function testRemovingTheOnlyCoverageClearsTheFlag(): void
|
||||
{
|
||||
[$owner, $contract, $item] = $this->makeDoctorContractAndItem();
|
||||
$uuid = $item->getUuid();
|
||||
|
||||
$this->putCoverage($owner, $contract, $item, true);
|
||||
$this->assertTrue($this->isInsured($uuid));
|
||||
|
||||
$this->putCoverage($owner, $contract, $item, false);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertFalse($this->isInsured($uuid));
|
||||
}
|
||||
|
||||
public function testFlagStaysSetWhileAnotherContractStillCoversTheItem(): void
|
||||
{
|
||||
[$owner, $first, $item] = $this->makeDoctorContractAndItem();
|
||||
$uuid = $item->getUuid();
|
||||
|
||||
$second = new TenantInsurance(TenantInsurance::TYPE_DOCTOR, $first->getEntityId(), 2);
|
||||
$this->em->persist($second);
|
||||
$this->em->flush();
|
||||
|
||||
$this->putCoverage($owner, $first, $item, true);
|
||||
$this->putCoverage($owner, $second, $item, true);
|
||||
|
||||
$this->putCoverage($owner, $first, $item, false);
|
||||
|
||||
$this->assertTrue($this->isInsured($uuid), 'قرارداد دوم هنوز پوشش دارد');
|
||||
}
|
||||
|
||||
public function testUnknownServiceItemIsRejected(): void
|
||||
{
|
||||
[$owner, $contract] = $this->makeDoctorContractAndItem();
|
||||
|
||||
$this->authJson(
|
||||
'PUT',
|
||||
'/api/v1/billing/tenant-insurances/' . $contract->getUuid() . '/service-coverage',
|
||||
$owner,
|
||||
['service_item_uuid' => 'no-such-uuid', 'covered' => true],
|
||||
);
|
||||
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Insurance;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Insurance\Entity\Insurance;
|
||||
use App\Insurance\Enum\InsuranceType;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Covers the tenant insurance contract management flow used by the redesigned
|
||||
* /admin/insurance-pricing page: create with contract dates + kind, edit those
|
||||
* fields, toggle فعال/غیرفعال, and list returning inactive contracts too.
|
||||
*/
|
||||
class TenantInsuranceContractApiTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: Insurance} */
|
||||
private function makeDoctorAndInsurance(InsuranceType $type = InsuranceType::Basic): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست بیمه');
|
||||
$this->em->persist($doctor);
|
||||
|
||||
$insurance = new Insurance('بیمه ایران ' . random_int(1000, 9999), $type);
|
||||
$this->em->persist($insurance);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $insurance];
|
||||
}
|
||||
|
||||
public function testCreateContractPersistsDatesAndKind(): void
|
||||
{
|
||||
[$owner, $insurance] = $this->makeDoctorAndInsurance(InsuranceType::Basic);
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
|
||||
'insurance_id' => $insurance->getId(),
|
||||
'coverage_percent' => 70,
|
||||
'franchise_rials' => 500_000,
|
||||
'annual_ceiling_rials' => 20_000_000,
|
||||
'effective_from' => 1_700_000_000,
|
||||
'effective_to' => 1_800_000_000,
|
||||
'kind' => 'supplementary',
|
||||
]);
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$row = $body['data']['data'];
|
||||
$this->assertEquals(70.0, $row['coverage_percent']);
|
||||
$this->assertSame(500_000, $row['franchise_rials']);
|
||||
$this->assertSame(20_000_000, $row['annual_ceiling_rials']);
|
||||
$this->assertSame(1_700_000_000, $row['effective_from']);
|
||||
$this->assertSame(1_800_000_000, $row['effective_to']);
|
||||
$this->assertSame('supplementary', $row['kind']);
|
||||
$this->assertTrue($row['is_active']);
|
||||
}
|
||||
|
||||
public function testKindDefaultsToCatalogTypeWhenOmitted(): void
|
||||
{
|
||||
[$owner, $insurance] = $this->makeDoctorAndInsurance(InsuranceType::Basic);
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
|
||||
'insurance_id' => $insurance->getId(),
|
||||
'coverage_percent' => 50,
|
||||
]);
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$this->assertSame('basic', $body['data']['data']['kind']);
|
||||
}
|
||||
|
||||
public function testEditUpdatesDatesKindAndAmounts(): void
|
||||
{
|
||||
[$owner, $insurance] = $this->makeDoctorAndInsurance();
|
||||
$created = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
|
||||
'insurance_id' => $insurance->getId(),
|
||||
'coverage_percent' => 40,
|
||||
]);
|
||||
$uuid = $created['data']['data']['uuid'];
|
||||
|
||||
$body = $this->authJson('PATCH', "/api/v1/billing/tenant-insurances/$uuid", $owner, [
|
||||
'coverage_percent' => 90,
|
||||
'franchise_rials' => 123_000,
|
||||
'annual_ceiling_rials' => null,
|
||||
'effective_to' => 1_900_000_000,
|
||||
'kind' => 'supplementary',
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertEquals(90.0, $body['data']['data']['coverage_percent']);
|
||||
$this->assertSame(123_000, $body['data']['data']['franchise_rials']);
|
||||
$this->assertNull($body['data']['data']['annual_ceiling_rials']);
|
||||
$this->assertSame(1_900_000_000, $body['data']['data']['effective_to']);
|
||||
$this->assertSame('supplementary', $body['data']['data']['kind']);
|
||||
}
|
||||
|
||||
public function testToggleIsActiveDoesNotClobberEffectiveTo(): void
|
||||
{
|
||||
[$owner, $insurance] = $this->makeDoctorAndInsurance();
|
||||
$created = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
|
||||
'insurance_id' => $insurance->getId(),
|
||||
'coverage_percent' => 60,
|
||||
'effective_to' => 1_850_000_000,
|
||||
]);
|
||||
$uuid = $created['data']['data']['uuid'];
|
||||
|
||||
$off = $this->authJson('PATCH', "/api/v1/billing/tenant-insurances/$uuid", $owner, ['is_active' => false]);
|
||||
$this->assertFalse($off['data']['data']['is_active']);
|
||||
// Deactivating via the toggle must keep the user-set effective_to intact.
|
||||
$this->assertSame(1_850_000_000, $off['data']['data']['effective_to']);
|
||||
|
||||
$on = $this->authJson('PATCH', "/api/v1/billing/tenant-insurances/$uuid", $owner, ['is_active' => true]);
|
||||
$this->assertTrue($on['data']['data']['is_active']);
|
||||
}
|
||||
|
||||
public function testListReturnsInactiveContracts(): void
|
||||
{
|
||||
[$owner, $insurance] = $this->makeDoctorAndInsurance();
|
||||
$created = $this->authJson('POST', '/api/v1/billing/tenant-insurances', $owner, [
|
||||
'insurance_id' => $insurance->getId(),
|
||||
'coverage_percent' => 30,
|
||||
]);
|
||||
$uuid = $created['data']['data']['uuid'];
|
||||
$this->authJson('PATCH', "/api/v1/billing/tenant-insurances/$uuid", $owner, ['is_active' => false]);
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/billing/tenant-insurances', $owner);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
|
||||
$rows = $body['data']['data'] ?? $body['data'];
|
||||
$found = array_filter($rows, fn ($r) => $r['uuid'] === $uuid);
|
||||
$this->assertCount(1, $found, 'inactive contract must still appear in the list');
|
||||
$this->assertFalse(array_values($found)[0]['is_active']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Inventory;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Per-tenant inventory CRUD (items + packages), scoped to the caller's doctor
|
||||
* entity. Covers success, derived aggregates, validation, empty state and
|
||||
* cross-tenant isolation.
|
||||
*/
|
||||
class InventoryApiTest extends ApiTestCase
|
||||
{
|
||||
private function doctorUser(): array
|
||||
{
|
||||
$user = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($user, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
return [$user, $doctor];
|
||||
}
|
||||
|
||||
// ── Items ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testItemCreateListUpdateDelete(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/inventory-item', $user, [
|
||||
'name' => 'دستکش', 'unit' => 'عدد', 'price' => 250000, 'stock' => 150, 'alertThreshold' => 20,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('دستکش', $created['data']['name']);
|
||||
self::assertSame('in_stock', $created['data']['status']);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/inventory-items', $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(1, $list['data']['items']);
|
||||
self::assertSame(1, $list['data']['stats']['total']);
|
||||
self::assertSame(1, $list['data']['stats']['inStock']);
|
||||
|
||||
// drop stock below threshold → low_stock
|
||||
$this->authJson('PATCH', '/api/v1/inventory-item/' . $uuid, $user, ['stock' => 5]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$afterPatch = $this->authJson('GET', '/api/v1/inventory-items', $user);
|
||||
self::assertSame('low_stock', $afterPatch['data']['items'][0]['status']);
|
||||
self::assertSame(1, $afterPatch['data']['stats']['low']);
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/inventory-item/' . $uuid, $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$after = $this->authJson('GET', '/api/v1/inventory-items', $user);
|
||||
self::assertCount(0, $after['data']['items']);
|
||||
}
|
||||
|
||||
public function testStatusDerivation(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
|
||||
// zero stock → out_of_stock, regardless of threshold
|
||||
$out = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'تمامشده', 'stock' => 0, 'alertThreshold' => 5]);
|
||||
self::assertSame('out_of_stock', $out['data']['status']);
|
||||
|
||||
// stock above threshold → in_stock
|
||||
$in = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'موجود', 'stock' => 100, 'alertThreshold' => 10]);
|
||||
self::assertSame('in_stock', $in['data']['status']);
|
||||
}
|
||||
|
||||
public function testItemRejectsEmptyName(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => '']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testEmptyStateReturnsZeroStats(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$list = $this->authJson('GET', '/api/v1/inventory-items', $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(0, $list['data']['items']);
|
||||
self::assertSame(
|
||||
['total' => 0, 'low' => 0, 'inStock' => 0, 'outOfStock' => 0],
|
||||
$list['data']['stats']
|
||||
);
|
||||
}
|
||||
|
||||
public function testCategoriesReturnsDistinctUsedCategories(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'a', 'category' => 'دارو']);
|
||||
$this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'b', 'category' => 'دارو']);
|
||||
$this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'c', 'category' => 'لوازم آزمایشگاهی']);
|
||||
|
||||
$cats = $this->authJson('GET', '/api/v1/inventory-categories', $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(2, $cats['data']);
|
||||
self::assertContains('دارو', $cats['data']);
|
||||
self::assertContains('لوازم آزمایشگاهی', $cats['data']);
|
||||
}
|
||||
|
||||
public function testMetaReturnsUnitAndCategoryLists(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$meta = $this->authJson('GET', '/api/v1/inventory-meta', $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertContains('عدد', $meta['data']['units']);
|
||||
self::assertContains('سیسی', $meta['data']['units']);
|
||||
self::assertContains('دارو', $meta['data']['categories']);
|
||||
self::assertContains('سایر', $meta['data']['categories']);
|
||||
}
|
||||
|
||||
public function testCreateStoresValidUnitAndCategory(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$item = $this->authJson('POST', '/api/v1/inventory-item', $user, [
|
||||
'name' => 'سرنگ', 'unit' => 'بسته', 'category' => 'لوازم مصرفی و تزریقات',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('بسته', $item['data']['unit']);
|
||||
self::assertSame('لوازم مصرفی و تزریقات', $item['data']['category']);
|
||||
}
|
||||
|
||||
public function testCreateRejectsInvalidUnit(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'x', 'unit' => 'واحدجعلی']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testCreateRejectsInvalidCategory(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'x', 'category' => 'دستهجعلی']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testEmptyUnitFallsBackToDefault(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$item = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'x', 'unit' => '']);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('عدد', $item['data']['unit']);
|
||||
}
|
||||
|
||||
public function testCannotTouchAnotherTenantsItem(): void
|
||||
{
|
||||
[$ownerA] = $this->doctorUser();
|
||||
$created = $this->authJson('POST', '/api/v1/inventory-item', $ownerA, ['name' => 'مال A']);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
[$ownerB] = $this->doctorUser();
|
||||
$this->authJson('PATCH', '/api/v1/inventory-item/' . $uuid, $ownerB, ['name' => 'دزدی']);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
$this->authJson('DELETE', '/api/v1/inventory-item/' . $uuid, $ownerB);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── Packages ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function testPackageCreateComputesTotalAndAvailability(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
|
||||
$gel = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'ژل', 'price' => 1200000, 'stock' => 10])['data']['uuid'];
|
||||
$glove = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'دستکش', 'price' => 500000, 'stock' => 1])['data']['uuid'];
|
||||
|
||||
// 2×ژل (stock 10, ok) + 3×دستکش (stock 1, NOT enough) → unavailable
|
||||
$pkg = $this->authJson('POST', '/api/v1/inventory-package', $user, [
|
||||
'title' => 'پکیج یک',
|
||||
'items' => [
|
||||
['itemUuid' => $gel, 'amount' => 2],
|
||||
['itemUuid' => $glove, 'amount' => 3],
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame(2 * 1200000 + 3 * 500000, $pkg['data']['total']);
|
||||
self::assertFalse($pkg['data']['available']);
|
||||
self::assertCount(2, $pkg['data']['items']);
|
||||
|
||||
// list reflects the same package
|
||||
$list = $this->authJson('GET', '/api/v1/inventory-packages', $user);
|
||||
self::assertCount(1, $list['data']);
|
||||
self::assertSame('پکیج یک', $list['data'][0]['title']);
|
||||
}
|
||||
|
||||
public function testPackageUpdateReplacesItemsAndDelete(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$a = $this->authJson('POST', '/api/v1/inventory-item', $user, ['name' => 'A', 'price' => 1000, 'stock' => 50])['data']['uuid'];
|
||||
|
||||
$uuid = $this->authJson('POST', '/api/v1/inventory-package', $user, [
|
||||
'title' => 'p', 'items' => [['itemUuid' => $a, 'amount' => 1]],
|
||||
])['data']['uuid'];
|
||||
|
||||
$updated = $this->authJson('PATCH', '/api/v1/inventory-package/' . $uuid, $user, [
|
||||
'title' => 'p2', 'items' => [['itemUuid' => $a, 'amount' => 5]],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('p2', $updated['data']['title']);
|
||||
self::assertCount(1, $updated['data']['items']);
|
||||
self::assertSame(5, $updated['data']['items'][0]['amount']);
|
||||
self::assertSame(5000, $updated['data']['total']);
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/inventory-package/' . $uuid, $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$after = $this->authJson('GET', '/api/v1/inventory-packages', $user);
|
||||
self::assertCount(0, $after['data']);
|
||||
}
|
||||
|
||||
public function testPackageSkipsForeignItemReferences(): void
|
||||
{
|
||||
[$ownerA] = $this->doctorUser();
|
||||
$foreign = $this->authJson('POST', '/api/v1/inventory-item', $ownerA, ['name' => 'خارجی', 'price' => 999, 'stock' => 5])['data']['uuid'];
|
||||
|
||||
[$ownerB] = $this->doctorUser();
|
||||
$mine = $this->authJson('POST', '/api/v1/inventory-item', $ownerB, ['name' => 'مال من', 'price' => 100, 'stock' => 5])['data']['uuid'];
|
||||
|
||||
// package for B referencing A's item → foreign line dropped, only B's stays
|
||||
$pkg = $this->authJson('POST', '/api/v1/inventory-package', $ownerB, [
|
||||
'title' => 'mix',
|
||||
'items' => [
|
||||
['itemUuid' => $foreign, 'amount' => 1],
|
||||
['itemUuid' => $mine, 'amount' => 2],
|
||||
],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertCount(1, $pkg['data']['items']);
|
||||
self::assertSame($mine, $pkg['data']['items'][0]['itemUuid']);
|
||||
self::assertSame(200, $pkg['data']['total']);
|
||||
}
|
||||
|
||||
public function testPackageRejectsEmptyTitle(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
$this->authJson('POST', '/api/v1/inventory-package', $user, ['title' => '']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Appointment\Service\AppointmentConfirmationService;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Confirming an appointment files exactly one case file, in the practice where
|
||||
* the booking actually happened.
|
||||
*
|
||||
* Two records for one appointment means a single visit's revenue is counted
|
||||
* twice, and inferring the practice from the address (rather than the booking
|
||||
* context) files it under the wrong one.
|
||||
*/
|
||||
class AutoCreateSessionOnConfirmTest extends ApiTestCase
|
||||
{
|
||||
private function confirmation(): AppointmentConfirmationService
|
||||
{
|
||||
return static::getContainer()->get(AppointmentConfirmationService::class);
|
||||
}
|
||||
|
||||
private function makeDoctor(): Doctor
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر آزمون');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
private function makeClinic(Doctor $doctor): Clinic
|
||||
{
|
||||
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
|
||||
$clinic->setName('کلینیک آزمون');
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return $clinic;
|
||||
}
|
||||
|
||||
private function makeAppointment(Doctor $doctor, ?Clinic $clinic = null): Appointment
|
||||
{
|
||||
$appointment = new Appointment($doctor, $this->createUser(['ROLE_USER']), 1_790_000_000, 1_790_001_800);
|
||||
$appointment->setClinic($clinic);
|
||||
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
return $appointment;
|
||||
}
|
||||
|
||||
/** @return PatientRecord[] */
|
||||
private function recordsFor(Appointment $appointment): array
|
||||
{
|
||||
return $this->em->getRepository(PatientRecord::class)
|
||||
->findBy(['user' => $appointment->getUser()]);
|
||||
}
|
||||
|
||||
/** @return PatientSession[] */
|
||||
private function sessionsFor(Appointment $appointment): array
|
||||
{
|
||||
return $this->em->getRepository(PatientSession::class)
|
||||
->findBy(['appointment' => $appointment]);
|
||||
}
|
||||
|
||||
public function testClinicBookingFilesOnlyTheClinicRecord(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$clinic = $this->makeClinic($doctor);
|
||||
$appointment = $this->makeAppointment($doctor, $clinic);
|
||||
|
||||
$this->confirmation()->onConfirmed($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
$records = $this->recordsFor($appointment);
|
||||
self::assertCount(1, $records, 'یک نوبت باید دقیقاً یک پرونده بسازد');
|
||||
self::assertSame('clinic', $records[0]->getEntityType());
|
||||
self::assertSame($clinic->getId(), $records[0]->getEntityId());
|
||||
self::assertCount(1, $this->sessionsFor($appointment));
|
||||
}
|
||||
|
||||
public function testPersonalBookingFilesOnlyTheDoctorRecord(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$this->makeClinic($doctor); // عضویت کلینیک نباید نوبت شخصی را بدزدد
|
||||
$appointment = $this->makeAppointment($doctor, null);
|
||||
|
||||
$this->confirmation()->onConfirmed($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
$records = $this->recordsFor($appointment);
|
||||
self::assertCount(1, $records);
|
||||
self::assertSame('doctor', $records[0]->getEntityType());
|
||||
self::assertSame($doctor->getId(), $records[0]->getEntityId());
|
||||
}
|
||||
|
||||
public function testExistingRecordGetsAnotherSessionInsteadOfAnotherRecord(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$first = $this->makeAppointment($doctor, null);
|
||||
$patient = $first->getUser();
|
||||
|
||||
$this->confirmation()->onConfirmed($first);
|
||||
$this->em->flush();
|
||||
|
||||
$second = new Appointment($doctor, $patient, 1_790_100_000, 1_790_101_800);
|
||||
$second->transitionTo(Appointment::STATUS_CONFIRMED);
|
||||
$this->em->persist($second);
|
||||
$this->em->flush();
|
||||
|
||||
$this->confirmation()->onConfirmed($second);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertCount(1, $this->recordsFor($first), 'بیمار قبلاً پرونده دارد؛ پروندهٔ دوم ساخته نشود');
|
||||
self::assertCount(1, $this->sessionsFor($second), 'ولی مراجعهٔ جدید باید ثبت شود');
|
||||
}
|
||||
|
||||
public function testConfirmingTwiceDoesNotDuplicateTheSession(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$appointment = $this->makeAppointment($doctor, null);
|
||||
|
||||
$this->confirmation()->onConfirmed($appointment);
|
||||
$this->em->flush();
|
||||
$this->confirmation()->onConfirmed($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertCount(1, $this->sessionsFor($appointment));
|
||||
}
|
||||
|
||||
public function testDayLevelReserveIsNotFiled(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$appointment = $this->makeAppointment($doctor, null);
|
||||
$appointment->rescheduleTo(1_790_000_000, 1_790_001_800, true);
|
||||
$this->em->flush();
|
||||
|
||||
$this->confirmation()->onConfirmed($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
self::assertCount(0, $this->sessionsFor($appointment), 'نوبت رزروِ روز-محور ساعت ندارد؛ مراجعه نمیسازد');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Repository\ClinicDoctorPermissionRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* پروندهٔ کلینیکی per-بیمار است (یکتایی clinic+user) و مدیر کلینیک همه را میبیند.
|
||||
* پزشکِ عضو هم باید پروندههای بیمارانِ خودش در همان کلینیک را ببیند — رابطه از
|
||||
* نوبتهای همان پزشک در همان کلینیک میآید، نه از ستونی روی پرونده.
|
||||
*
|
||||
* با غیرفعال شدن پزشک، دسترسیاش قطع میشود ولی پروندهها دستنخورده میمانند.
|
||||
*/
|
||||
class ClinicRecordAccessTest extends ApiTestCase
|
||||
{
|
||||
private function makeDoctor(string $name = 'دکتر تست'): Doctor
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($user, $name);
|
||||
$doctor->setMobileNumber($user->getMobileNumber());
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
/** @return array{0: User, 1: Clinic} */
|
||||
private function makeClinicWith(Doctor ...$doctors): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$clinic->setName('کلینیک تست');
|
||||
foreach ($doctors as $d) {
|
||||
$clinic->getDoctors()->add($d);
|
||||
}
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $clinic];
|
||||
}
|
||||
|
||||
private function activeContext(User $user, string $dbUuid): void
|
||||
{
|
||||
static::getContainer()->get(UserActiveContextRepository::class)->upsert($user, $dbUuid);
|
||||
}
|
||||
|
||||
/** پروندهٔ کلینیکی بیمار + نوبتی که او را به این پزشک وصل میکند. */
|
||||
private function makeClinicRecordFor(Clinic $clinic, Doctor $doctor, ?User $patient = null): PatientRecord
|
||||
{
|
||||
$patient ??= $this->createUser();
|
||||
|
||||
$record = new PatientRecord('clinic', $clinic->getId(), $patient, 'system', $clinic->getId());
|
||||
$this->em->persist($record);
|
||||
|
||||
$start = strtotime('+60 days') + random_int(0, 500_000) * 7;
|
||||
$appointment = new Appointment($doctor, $patient, $start, $start + 900);
|
||||
$appointment->setClinic($clinic);
|
||||
$this->em->persist($appointment);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function uuidsFromList(array $response): array
|
||||
{
|
||||
return array_map(fn(array $row) => $row['uuid'], $response['data'] ?? []);
|
||||
}
|
||||
|
||||
// ── پزشک عضو ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function testMemberDoctorSeesOwnPatientsClinicRecord(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[, $clinic] = $this->makeClinicWith($doctor);
|
||||
$record = $this->makeClinicRecordFor($clinic, $doctor);
|
||||
|
||||
$this->activeContext($doctor->getUser(), $clinic->getUuid());
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $doctor->getUser());
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertContains($record->getUuid(), $this->uuidsFromList($res));
|
||||
}
|
||||
|
||||
public function testMemberDoctorCannotSeeAnotherDoctorsClinicRecord(): void
|
||||
{
|
||||
$mine = $this->makeDoctor('پزشک من');
|
||||
$theirs = $this->makeDoctor('پزشک دیگر');
|
||||
[, $clinic] = $this->makeClinicWith($mine, $theirs);
|
||||
$foreign = $this->makeClinicRecordFor($clinic, $theirs);
|
||||
|
||||
$this->activeContext($mine->getUser(), $clinic->getUuid());
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $mine->getUser());
|
||||
self::assertNotContains($foreign->getUuid(), $this->uuidsFromList($res));
|
||||
|
||||
$this->authJson('GET', "/api/v1/patient/{$foreign->getUuid()}", $mine->getUser());
|
||||
self::assertSame(404, $this->responseCode(), 'پروندهٔ بیمارِ پزشک دیگر برای او وجود ندارد');
|
||||
}
|
||||
|
||||
public function testMemberDoctorCanOpenAndManageOwnPatientRecord(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[, $clinic] = $this->makeClinicWith($doctor);
|
||||
$record = $this->makeClinicRecordFor($clinic, $doctor);
|
||||
|
||||
$this->activeContext($doctor->getUser(), $clinic->getUuid());
|
||||
|
||||
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser());
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', "/api/v1/patient/{$record->getUuid()}/note", $doctor->getUser(), [
|
||||
'body' => 'یادداشت پزشک عضو',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode(), 'پزشک عضو فعال پرونده را مدیریت هم میکند');
|
||||
}
|
||||
|
||||
public function testDoctorInPersonalContextSeesOnlyOwnOfficeRecords(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[, $clinic] = $this->makeClinicWith($doctor);
|
||||
$clinicRecord = $this->makeClinicRecordFor($clinic, $doctor);
|
||||
|
||||
// بدون محیط فعالِ کلینیک ⇒ مطب شخصی.
|
||||
$this->activeContext($doctor->getUser(), $doctor->getUuid());
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $doctor->getUser());
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertNotContains($clinicRecord->getUuid(), $this->uuidsFromList($res));
|
||||
}
|
||||
|
||||
// ── پایان همکاری ─────────────────────────────────────────────────────────
|
||||
|
||||
public function testDeactivatedDoctorLosesClinicRecordsButOwnerKeepsThem(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
||||
$record = $this->makeClinicRecordFor($clinic, $doctor);
|
||||
|
||||
$this->activeContext($doctor->getUser(), $clinic->getUuid());
|
||||
|
||||
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser());
|
||||
self::assertSame(200, $this->responseCode(), 'پزشک فعال دسترسی دارد');
|
||||
|
||||
$permissions = static::getContainer()->get(ClinicDoctorPermissionRepository::class);
|
||||
$permissions->getOrCreate($clinic, $doctor)->setActive(false);
|
||||
$this->em->flush();
|
||||
|
||||
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser());
|
||||
self::assertSame(404, $this->responseCode(), 'بعد از پایان همکاری، دسترسی قطع میشود');
|
||||
|
||||
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $owner);
|
||||
self::assertSame(200, $this->responseCode(), 'مدیر کلینیک دسترسی کامل دارد');
|
||||
|
||||
$this->em->clear();
|
||||
self::assertNotNull(
|
||||
$this->em->getRepository(PatientRecord::class)->find($record->getId()),
|
||||
'پرونده حذف یا منتقل نمیشود',
|
||||
);
|
||||
}
|
||||
|
||||
// ── مدیر کلینیک ──────────────────────────────────────────────────────────
|
||||
|
||||
public function testClinicOwnerSeesEveryDoctorsRecords(): void
|
||||
{
|
||||
$first = $this->makeDoctor('پزشک اول');
|
||||
$second = $this->makeDoctor('پزشک دوم');
|
||||
[$owner, $clinic] = $this->makeClinicWith($first, $second);
|
||||
$firstRecord = $this->makeClinicRecordFor($clinic, $first);
|
||||
$secondRecord = $this->makeClinicRecordFor($clinic, $second);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $owner);
|
||||
$uuids = $this->uuidsFromList($res);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertContains($firstRecord->getUuid(), $uuids);
|
||||
self::assertContains($secondRecord->getUuid(), $uuids);
|
||||
}
|
||||
|
||||
// ── منشی ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testClinicSecretaryIsLimitedToAssignedDoctors(): void
|
||||
{
|
||||
$mine = $this->makeDoctor('پزشک من');
|
||||
$theirs = $this->makeDoctor('پزشک دیگر');
|
||||
[, $clinic] = $this->makeClinicWith($mine, $theirs);
|
||||
$ownRecord = $this->makeClinicRecordFor($clinic, $mine);
|
||||
$foreign = $this->makeClinicRecordFor($clinic, $theirs);
|
||||
|
||||
$secretaryUser = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
|
||||
$relation = new DoctorSecretary($mine, $secretaryUser, DoctorSecretary::OWNER_CLINIC, $clinic);
|
||||
$this->em->persist($relation);
|
||||
$this->em->flush();
|
||||
|
||||
$this->activeContext($secretaryUser, $clinic->getUuid());
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patients?limit=50', $secretaryUser);
|
||||
$uuids = $this->uuidsFromList($res);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertContains($ownRecord->getUuid(), $uuids);
|
||||
self::assertNotContains($foreign->getUuid(), $uuids);
|
||||
}
|
||||
|
||||
// ── قطعیکردن نوبت کلینیکی، دیدهشده توسط هر دو نقش ───────────────────────
|
||||
|
||||
public function testConfirmedClinicAppointmentRecordIsVisibleToBothRoles(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
[$owner, $clinic] = $this->makeClinicWith($doctor);
|
||||
$patient = $this->createUser();
|
||||
|
||||
$start = strtotime('+70 days') + random_int(0, 500_000) * 7;
|
||||
$appointment = new Appointment($doctor, $patient, $start, $start + 900);
|
||||
$appointment->setClinic($clinic);
|
||||
$appointment->setVisitPriceRials(3_000_000);
|
||||
$this->em->persist($appointment);
|
||||
$this->em->flush();
|
||||
|
||||
// محیط فعال را قبل از confirm ست کن: آن درخواست EntityManager را پاک میکند.
|
||||
$this->activeContext($doctor->getUser(), $clinic->getUuid());
|
||||
|
||||
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $owner, [
|
||||
'version' => $appointment->getVersion(),
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$record = $this->em->getRepository(PatientRecord::class)->findOneBy([
|
||||
'entityType' => 'clinic',
|
||||
'entityId' => $clinic->getId(),
|
||||
'user' => $patient,
|
||||
]);
|
||||
self::assertNotNull($record);
|
||||
|
||||
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $owner);
|
||||
self::assertSame(200, $this->responseCode(), 'مدیر کلینیک');
|
||||
|
||||
$this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser());
|
||||
self::assertSame(200, $this->responseCode(), 'پزشکِ همان نوبت');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* List Patient Appointments (تب نوبتها): shape (incl. version), ordering,
|
||||
* empty boundary, and ownership scoping.
|
||||
*/
|
||||
class PatientAppointmentsTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: Doctor, 2: PatientRecord} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر ژیلا فتحی');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor, $record];
|
||||
}
|
||||
|
||||
private function appointment(Doctor $doctor, PatientRecord $record, int $slotStart): Appointment
|
||||
{
|
||||
$appt = new Appointment($doctor, $record->getUser(), $slotStart, $slotStart + 1800);
|
||||
$this->em->persist($appt);
|
||||
$this->em->flush();
|
||||
|
||||
return $appt;
|
||||
}
|
||||
|
||||
public function testListReturnsShapeWithVersion(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->recordFor();
|
||||
$this->appointment($doctor, $record, 1_754_000_000);
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(1, $list['data']);
|
||||
|
||||
$row = $list['data'][0];
|
||||
foreach (['uuid', 'starts_at', 'ends_at', 'status', 'version', 'doctor_name'] as $key) {
|
||||
self::assertArrayHasKey($key, $row);
|
||||
}
|
||||
self::assertSame(1, $row['version']);
|
||||
self::assertSame('pending', $row['status']);
|
||||
self::assertSame('دکتر ژیلا فتحی', $row['doctor_name']);
|
||||
self::assertSame(1_754_000_000, $row['starts_at']);
|
||||
}
|
||||
|
||||
public function testSortedByStartDescending(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->recordFor();
|
||||
$this->appointment($doctor, $record, 1_754_000_000);
|
||||
$this->appointment($doctor, $record, 1_755_000_000);
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $owner);
|
||||
self::assertCount(2, $list['data']);
|
||||
self::assertSame(1_755_000_000, $list['data'][0]['starts_at']);
|
||||
self::assertSame(1_754_000_000, $list['data'][1]['starts_at']);
|
||||
}
|
||||
|
||||
public function testEmptyWhenNoAppointments(): void
|
||||
{
|
||||
[$owner, , $record] = $this->recordFor();
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(0, $list['data']);
|
||||
}
|
||||
|
||||
public function testNotFoundForUnknownRecord(): void
|
||||
{
|
||||
[$owner] = $this->recordFor();
|
||||
$this->authJson('GET', '/api/v1/patient/00000000-0000-0000-0000-000000000000/appointments', $owner);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testOwnershipScoped(): void
|
||||
{
|
||||
[, $doctor, $record] = $this->recordFor();
|
||||
$this->appointment($doctor, $record, 1_754_000_000);
|
||||
|
||||
[$other] = $this->recordFor();
|
||||
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/appointments', $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientAttachment;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Patient attachments: list, delete, and tenant ownership scoping.
|
||||
*/
|
||||
class PatientAttachmentTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $record];
|
||||
}
|
||||
|
||||
public function testListAndDelete(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$att = new PatientAttachment($record, 'آزمایش.pdf', '/uploads/patients/attachments/x.pdf', 'application/pdf', 1234);
|
||||
$this->em->persist($att);
|
||||
$this->em->flush();
|
||||
$attUuid = $att->getUuid();
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/attachments', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(1, $list['data']);
|
||||
self::assertSame('آزمایش.pdf', $list['data'][0]['name']);
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/patient/attachment/' . $attUuid, $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$after = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/attachments', $owner);
|
||||
self::assertCount(0, $after['data']);
|
||||
}
|
||||
|
||||
public function testCannotDeleteAnotherTenantsAttachment(): void
|
||||
{
|
||||
[, $record] = $this->recordFor();
|
||||
$att = new PatientAttachment($record, 'x.pdf', '/uploads/x.pdf');
|
||||
$this->em->persist($att);
|
||||
$this->em->flush();
|
||||
|
||||
[$otherOwner] = $this->recordFor();
|
||||
$this->authJson('DELETE', '/api/v1/patient/attachment/' . $att->getUuid(), $otherOwner);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Patient call log (کال سنتر): create, list, outcome filter, delete + ownership scoping.
|
||||
*/
|
||||
class PatientCallTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $record];
|
||||
}
|
||||
|
||||
public function testCreateListDelete(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/call', $owner, [
|
||||
'subject' => 'پیگیری نوبت', 'summary' => 'جابهجایی تاریخ', 'outcome' => 'success', 'personnel' => 'مریم امینی',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('پیگیری نوبت', $created['data']['subject']);
|
||||
self::assertSame('success', $created['data']['outcome']);
|
||||
self::assertSame('مریم امینی', $created['data']['personnel']);
|
||||
$cUuid = $created['data']['uuid'];
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/calls', $owner);
|
||||
self::assertCount(1, $list['data']);
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/patient/call/' . $cUuid, $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$after = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/calls', $owner);
|
||||
self::assertCount(0, $after['data']);
|
||||
}
|
||||
|
||||
public function testRequiresSubject(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/call', $owner, ['subject' => '']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testOutcomeFilter(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/call', $owner, ['subject' => 'a', 'outcome' => 'success']);
|
||||
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/call', $owner, ['subject' => 'b', 'outcome' => 'missed']);
|
||||
|
||||
$missed = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/calls?outcome=missed', $owner);
|
||||
self::assertCount(1, $missed['data']);
|
||||
self::assertSame('missed', $missed['data'][0]['outcome']);
|
||||
}
|
||||
|
||||
public function testInvalidOutcomeFallsBackToSuccess(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/call', $owner, [
|
||||
'subject' => 'x', 'outcome' => 'garbage',
|
||||
]);
|
||||
self::assertSame('success', $created['data']['outcome']);
|
||||
}
|
||||
|
||||
public function testOwnershipScoped(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/call', $owner, ['subject' => 'x']);
|
||||
$cUuid = $created['data']['uuid'];
|
||||
|
||||
[$other] = $this->recordFor();
|
||||
$this->authJson('DELETE', '/api/v1/patient/call/' . $cUuid, $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/calls', $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Settlement\Entity\WalletTransaction;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Record-owner-gated reads of the patient's finances:
|
||||
* payments list, wallet balance, and wallet-transaction ledger.
|
||||
*/
|
||||
class PatientFinancialsTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord, 2: \App\Auth\Entity\User} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $record, $patient];
|
||||
}
|
||||
|
||||
public function testListsPatientPayments(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
|
||||
$payment = new Payment($patient, 250000, 'mellat', 'appointment');
|
||||
$payment->setStatus(Payment::STATUS_SUCCESS);
|
||||
$this->em->persist($payment);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/payments', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(1, $res['data']);
|
||||
self::assertSame(250000, $res['data'][0]['amount_rials']);
|
||||
self::assertSame(1, $res['meta']['totalRecords']);
|
||||
}
|
||||
|
||||
public function testWalletBalanceReflectsCreditMinusDebit(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
|
||||
$this->em->persist(new WalletTransaction($patient, 500000, 'credit', 500000));
|
||||
$this->em->persist(new WalletTransaction($patient, 200000, 'debit', 300000));
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(300000, $res['data']['balance_rials']);
|
||||
self::assertCount(2, $res['data']['recent_transactions']);
|
||||
}
|
||||
|
||||
public function testListsWalletTransactionsPaginated(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
|
||||
$this->em->persist(new WalletTransaction($patient, 100000, 'credit', 100000));
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet/transactions', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertCount(1, $res['data']);
|
||||
self::assertSame('credit', $res['data'][0]['type']);
|
||||
self::assertSame(1, $res['meta']['totalRecords']);
|
||||
}
|
||||
|
||||
public function testChargeWalletCreatesCreditAndReturnsBalance(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
|
||||
$this->em->persist(new WalletTransaction($patient, 200000, 'credit', 200000));
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/charge', $owner, [
|
||||
'amount_rials' => 300000, 'description' => 'بیعانه نوبت',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame(500000, $res['data']['balance_rials']);
|
||||
self::assertSame('credit', $res['data']['transaction']['type']);
|
||||
self::assertSame('بیعانه نوبت', $res['data']['transaction']['description']);
|
||||
|
||||
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
|
||||
self::assertSame(500000, $wallet['data']['balance_rials']);
|
||||
}
|
||||
|
||||
public function testChargeWalletRejectsNonPositiveAmount(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/charge', $owner, ['amount_rials' => 0]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testWithdrawWalletCreatesDebitAndReducesBalance(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
|
||||
$this->em->persist(new WalletTransaction($patient, 500000, 'credit', 500000));
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $owner, [
|
||||
'amount_rials' => 200000, 'description' => 'عودت وجه',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame(300000, $res['data']['balance_rials']);
|
||||
self::assertSame('debit', $res['data']['transaction']['type']);
|
||||
self::assertSame('عودت وجه', $res['data']['transaction']['description']);
|
||||
|
||||
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
|
||||
self::assertSame(300000, $wallet['data']['balance_rials']);
|
||||
}
|
||||
|
||||
public function testWithdrawWalletDefaultsDescription(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
|
||||
$this->em->persist(new WalletTransaction($patient, 400000, 'credit', 400000));
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $owner, [
|
||||
'amount_rials' => 400000,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame(0, $res['data']['balance_rials']);
|
||||
self::assertSame('برداشت از کیف پول', $res['data']['transaction']['description']);
|
||||
}
|
||||
|
||||
public function testWithdrawWalletRejectsAmountAboveBalance(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
|
||||
$this->em->persist(new WalletTransaction($patient, 100000, 'credit', 100000));
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $owner, [
|
||||
'amount_rials' => 150000,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_WALLET_INSUFFICIENT', $res['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testWithdrawWalletRejectsNonPositiveAmount(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $owner, ['amount_rials' => 0]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testChargeRecordsActingUserPaymentMethodAndStatus(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/charge', $owner, [
|
||||
'amount_rials' => 300000, 'payment_method' => 'card',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
$txn = $res['data']['transaction'];
|
||||
self::assertSame('card', $txn['payment_method']);
|
||||
self::assertSame('confirmed', $txn['status']);
|
||||
// بدون پروفایل → نامِ ثبتکننده = شماره موبایلِ کاربرِ عامل
|
||||
self::assertSame($owner->getMobileNumber(), $txn['created_by_name']);
|
||||
}
|
||||
|
||||
public function testChargeIgnoresUnknownPaymentMethod(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/charge', $owner, [
|
||||
'amount_rials' => 100000, 'payment_method' => 'bitcoin',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertNull($res['data']['transaction']['payment_method']);
|
||||
}
|
||||
|
||||
public function testFinancialsAreOwnershipScoped(): void
|
||||
{
|
||||
[, $record] = $this->recordFor();
|
||||
[$other] = $this->recordFor();
|
||||
|
||||
// A different owner cannot read this record's finances (or charge them).
|
||||
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/payments', $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/charge', $other, ['amount_rials' => 1000]);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/wallet/withdraw', $other, ['amount_rials' => 1000]);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
$this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet/transactions', $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Tag\Entity\TenantTag;
|
||||
use App\Tests\ApiTestCase;
|
||||
use App\UserProfile\Entity\UserProfile;
|
||||
|
||||
/**
|
||||
* GET /api/v1/patients advanced filters: tags, gender, insurance, admission
|
||||
* date range, service status (pending/completed) and has-debt.
|
||||
*/
|
||||
class PatientListFilterTest extends ApiTestCase
|
||||
{
|
||||
private function doctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{gender?:string,insurance?:int,tag?:TenantTag,pending?:bool,paid?:bool,createdAt?:int} $opts
|
||||
*/
|
||||
private function record(Doctor $doctor, array $opts = []): PatientRecord
|
||||
{
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
if (isset($opts['createdAt'])) {
|
||||
$ref = new \ReflectionProperty($record, 'createdAt');
|
||||
$ref->setAccessible(true);
|
||||
$ref->setValue($record, $opts['createdAt']);
|
||||
}
|
||||
$this->em->persist($record);
|
||||
|
||||
if (isset($opts['gender']) || isset($opts['insurance'])) {
|
||||
$profile = new UserProfile($patient);
|
||||
if (isset($opts['gender'])) $profile->setGender($opts['gender']);
|
||||
if (isset($opts['insurance'])) $profile->setBasicInsuranceId($opts['insurance']);
|
||||
$this->em->persist($profile);
|
||||
}
|
||||
if (isset($opts['tag'])) {
|
||||
$record->getTags()->add($opts['tag']);
|
||||
}
|
||||
if (!empty($opts['pending']) || !empty($opts['paid'])) {
|
||||
$session = new PatientSession($record);
|
||||
$session->setPaymentMethod(!empty($opts['pending']) ? 'pending' : 'cash');
|
||||
$this->em->persist($session);
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function tag(Doctor $doctor, string $name): TenantTag
|
||||
{
|
||||
$tag = new TenantTag('doctor', $doctor->getId(), $name, '#5559CE');
|
||||
$this->em->persist($tag);
|
||||
$this->em->flush();
|
||||
return $tag;
|
||||
}
|
||||
|
||||
private function uuids(array $res): array
|
||||
{
|
||||
return array_map(static fn(array $r) => $r['uuid'], $res['data']);
|
||||
}
|
||||
|
||||
public function testFilterByTag(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$tag = $this->tag($doctor, 'خوشحساب');
|
||||
$tagged = $this->record($doctor, ['tag' => $tag]);
|
||||
$this->record($doctor); // untagged
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patients?tags=' . $tag->getUuid(), $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(1, $res['meta']['totalRecords']);
|
||||
self::assertSame($tagged->getUuid(), $res['data'][0]['uuid']);
|
||||
}
|
||||
|
||||
public function testFilterByGenderAndInsurance(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$male = $this->record($doctor, ['gender' => 'male', 'insurance' => 7]);
|
||||
$this->record($doctor, ['gender' => 'female', 'insurance' => 9]);
|
||||
|
||||
$byGender = $this->authJson('GET', '/api/v1/patients?gender=male', $owner);
|
||||
self::assertSame(1, $byGender['meta']['totalRecords']);
|
||||
self::assertSame($male->getUuid(), $byGender['data'][0]['uuid']);
|
||||
|
||||
$byIns = $this->authJson('GET', '/api/v1/patients?insurance_id=7', $owner);
|
||||
self::assertSame(1, $byIns['meta']['totalRecords']);
|
||||
self::assertSame($male->getUuid(), $byIns['data'][0]['uuid']);
|
||||
}
|
||||
|
||||
public function testFilterByAdmissionDateRange(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$old = $this->record($doctor, ['createdAt' => 1000]);
|
||||
$new = $this->record($doctor, ['createdAt' => 5000]);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patients?admitted_from=4000&admitted_to=6000', $owner);
|
||||
self::assertSame(1, $res['meta']['totalRecords']);
|
||||
self::assertSame($new->getUuid(), $res['data'][0]['uuid']);
|
||||
self::assertNotContains($old->getUuid(), $this->uuids($res));
|
||||
}
|
||||
|
||||
public function testFilterByServiceStatusAndDebt(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$pending = $this->record($doctor, ['pending' => true]);
|
||||
$completed = $this->record($doctor, ['paid' => true]);
|
||||
|
||||
$onlyPending = $this->authJson('GET', '/api/v1/patients?service_status=pending', $owner);
|
||||
self::assertSame(1, $onlyPending['meta']['totalRecords']);
|
||||
self::assertSame($pending->getUuid(), $onlyPending['data'][0]['uuid']);
|
||||
|
||||
$onlyCompleted = $this->authJson('GET', '/api/v1/patients?service_status=completed', $owner);
|
||||
self::assertSame(1, $onlyCompleted['meta']['totalRecords']);
|
||||
self::assertSame($completed->getUuid(), $onlyCompleted['data'][0]['uuid']);
|
||||
|
||||
$withDebt = $this->authJson('GET', '/api/v1/patients?has_debt=1', $owner);
|
||||
self::assertSame(1, $withDebt['meta']['totalRecords']);
|
||||
self::assertSame($pending->getUuid(), $withDebt['data'][0]['uuid']);
|
||||
}
|
||||
|
||||
public function testNoFiltersReturnsAll(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$this->record($doctor);
|
||||
$this->record($doctor);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patients', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame(2, $res['meta']['totalRecords']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
use App\UserProfile\Entity\UserProfile;
|
||||
|
||||
/**
|
||||
* کد ملی روی جدول profiles ذخیره میشود نه users؛ لیست بیماران باید آن را
|
||||
* در فیلد user_national_code برگرداند تا فرم ثبت نوبت بتواند از بیمار موجود استفاده کند.
|
||||
*/
|
||||
class PatientListNationalCodeTest extends ApiTestCase
|
||||
{
|
||||
private function doctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
public function testNationalCodeFromProfileIsReturned(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
|
||||
// db_test ریست نمیشود؛ کد ملیِ یکتا رندوم تا با اجراهای قبلی تصادم نکند.
|
||||
$nc = '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT);
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
// کد ملی فقط روی پروفایل، نه روی خود کاربر
|
||||
$profile = new UserProfile($patient);
|
||||
$profile->setNationalCode($nc);
|
||||
$this->em->persist($profile);
|
||||
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patients', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($record->getUuid(), $res['data'][0]['uuid']);
|
||||
self::assertSame($nc, $res['data'][0]['user_national_code']);
|
||||
}
|
||||
|
||||
public function testNullWhenNoNationalCodeAnywhere(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/patients', $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertNull($res['data'][0]['user_national_code']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Patient medical records: CRUD + tenant ownership scoping.
|
||||
*/
|
||||
class PatientMedicalRecordTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $record];
|
||||
}
|
||||
|
||||
public function testCrud(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/medical-record', $owner, [
|
||||
'title' => 'معاینه اولیه', 'body' => 'فشار خون طبیعی', 'recorded_at' => 1_700_000_000,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('معاینه اولیه', $created['data']['title']);
|
||||
$mUuid = $created['data']['uuid'];
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/medical-records', $owner);
|
||||
self::assertCount(1, $list['data']);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/patient/medical-record/' . $mUuid, $owner, ['title' => 'معاینه دوم']);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/patient/medical-record/' . $mUuid, $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$after = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/medical-records', $owner);
|
||||
self::assertCount(0, $after['data']);
|
||||
}
|
||||
|
||||
public function testRequiresTitle(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/medical-record', $owner, ['title' => '']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testOwnershipScoped(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/medical-record', $owner, ['title' => 'x']);
|
||||
$mUuid = $created['data']['uuid'];
|
||||
|
||||
[$other] = $this->recordFor();
|
||||
$this->authJson('DELETE', '/api/v1/patient/medical-record/' . $mUuid, $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Patient messages: create, list, delete + tenant ownership scoping.
|
||||
*/
|
||||
class PatientMessageTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $record];
|
||||
}
|
||||
|
||||
public function testCreateListDelete(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/message', $owner, [
|
||||
'body' => 'یادآوری نوبت فردا', 'channel' => 'sms',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('یادآوری نوبت فردا', $created['data']['body']);
|
||||
self::assertSame('sms', $created['data']['channel']);
|
||||
$mUuid = $created['data']['uuid'];
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/messages', $owner);
|
||||
self::assertCount(1, $list['data']);
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/patient/message/' . $mUuid, $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$after = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/messages', $owner);
|
||||
self::assertCount(0, $after['data']);
|
||||
}
|
||||
|
||||
public function testRequiresBody(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/message', $owner, ['body' => '']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testOwnershipScoped(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/message', $owner, ['body' => 'x']);
|
||||
$mUuid = $created['data']['uuid'];
|
||||
|
||||
[$other] = $this->recordFor();
|
||||
$this->authJson('DELETE', '/api/v1/patient/message/' . $mUuid, $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Patient notes: create, list (pinned-first order), edit, pin toggle, delete,
|
||||
* validation and tenant ownership scoping.
|
||||
*/
|
||||
class PatientNoteTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$owner->setRealName('دکتر احمدی');
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $record];
|
||||
}
|
||||
|
||||
public function testCreateCapturesAuthorAndDefaults(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, [
|
||||
'body' => 'بیمار به دارو حساسیت دارد',
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('بیمار به دارو حساسیت دارد', $created['data']['body']);
|
||||
self::assertFalse($created['data']['pinned']);
|
||||
self::assertSame('دکتر احمدی', $created['data']['author']);
|
||||
self::assertNull($created['data']['updated_at']);
|
||||
}
|
||||
|
||||
public function testListReturnsPinnedFirst(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$base = '/api/v1/patient/' . $record->getUuid();
|
||||
|
||||
// Three notes; the oldest is pinned so it must float above the two newer ones.
|
||||
$pinned = $this->authJson('POST', $base . '/note', $owner, ['body' => 'قدیمی پینشده', 'pinned' => true]);
|
||||
$this->authJson('POST', $base . '/note', $owner, ['body' => 'دوم']);
|
||||
$this->authJson('POST', $base . '/note', $owner, ['body' => 'سوم (جدیدترین)']);
|
||||
|
||||
$list = $this->authJson('GET', $base . '/notes', $owner);
|
||||
self::assertCount(3, $list['data']);
|
||||
self::assertSame($pinned['data']['uuid'], $list['data'][0]['uuid']);
|
||||
self::assertTrue($list['data'][0]['pinned']);
|
||||
// remaining two are newest-first among the unpinned
|
||||
self::assertSame('سوم (جدیدترین)', $list['data'][1]['body']);
|
||||
self::assertSame('دوم', $list['data'][2]['body']);
|
||||
}
|
||||
|
||||
public function testEditBodyAndTogglePin(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => 'اولیه']);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
$edited = $this->authJson('PATCH', '/api/v1/patient/note/' . $uuid, $owner, [
|
||||
'body' => 'ویرایش شد', 'pinned' => true,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('ویرایش شد', $edited['data']['body']);
|
||||
self::assertTrue($edited['data']['pinned']);
|
||||
self::assertNotNull($edited['data']['updated_at']);
|
||||
|
||||
// pin-only toggle keeps body intact
|
||||
$unpinned = $this->authJson('PATCH', '/api/v1/patient/note/' . $uuid, $owner, ['pinned' => false]);
|
||||
self::assertFalse($unpinned['data']['pinned']);
|
||||
self::assertSame('ویرایش شد', $unpinned['data']['body']);
|
||||
}
|
||||
|
||||
public function testDelete(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => 'برای حذف']);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/patient/note/' . $uuid, $owner);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$after = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/notes', $owner);
|
||||
self::assertCount(0, $after['data']);
|
||||
}
|
||||
|
||||
public function testRequiresBodyOnCreate(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => ' ']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testRejectsEmptyBodyOnEdit(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => 'اولیه']);
|
||||
$this->authJson('PATCH', '/api/v1/patient/note/' . $created['data']['uuid'], $owner, ['body' => '']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testOwnershipScoped(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/note', $owner, ['body' => 'x']);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
[$other] = $this->recordFor();
|
||||
$this->authJson('PATCH', '/api/v1/patient/note/' . $uuid, $other, ['pinned' => true]);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
$this->authJson('DELETE', '/api/v1/patient/note/' . $uuid, $other);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Tag\Entity\TenantTag;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* PatientRecord carries clinic-scoped case-file fields (record_number, gender,
|
||||
* birth_date, referral_source, description) and a set of TenantTag labels.
|
||||
*/
|
||||
class PatientRecordFieldsTest extends ApiTestCase
|
||||
{
|
||||
public function testFieldsAndTagsRoundTrip(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$t1 = new TenantTag('doctor', $doctor->getId(), 'فوری', '#FF0000');
|
||||
$t2 = new TenantTag('doctor', $doctor->getId(), 'پیگیری', '#00AA00');
|
||||
$this->em->persist($t1);
|
||||
$this->em->persist($t2);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']); // random unique mobile — db_test is never reset
|
||||
$patient->setRealName('بیمار نمونه');
|
||||
$this->em->flush();
|
||||
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$record->setRecordNumber('P-1001')
|
||||
->setTags([$t1, $t2]);
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
$uuid = $record->getUuid();
|
||||
$this->em->clear();
|
||||
|
||||
/** @var PatientRecordRepository $repo */
|
||||
$repo = static::getContainer()->get(PatientRecordRepository::class);
|
||||
$reloaded = $repo->findByUuid($uuid);
|
||||
self::assertNotNull($reloaded);
|
||||
self::assertCount(2, $reloaded->getTags());
|
||||
|
||||
$arr = $reloaded->toArray();
|
||||
self::assertSame('P-1001', $arr['record_number']);
|
||||
self::assertCount(2, $arr['tags']);
|
||||
self::assertSame('فوری', $arr['tags'][0]['name']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tag\Entity\TenantTag;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* POST/PATCH /patient accept `record_number` and tenant-scoped `tags` (uuids).
|
||||
*/
|
||||
class PatientRecordTagsApiTest extends ApiTestCase
|
||||
{
|
||||
private function doctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
private function tag(int $doctorId, string $name): TenantTag
|
||||
{
|
||||
$t = new TenantTag('doctor', $doctorId, $name, '#FF0000');
|
||||
$this->em->persist($t);
|
||||
$this->em->flush();
|
||||
return $t;
|
||||
}
|
||||
|
||||
public function testCreateAndUpdateWithRecordNumberAndTags(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$t1 = $this->tag($doctor->getId(), 'فوری');
|
||||
$t2 = $this->tag($doctor->getId(), 'پیگیری');
|
||||
|
||||
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
$created = $this->authJson('POST', '/api/v1/patient', $owner, [
|
||||
'mobile' => $mobile,
|
||||
'name' => 'بیمار نمونه',
|
||||
'record_number' => 'P-1001',
|
||||
'tags' => [$t1->getUuid(), $t2->getUuid()],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('P-1001', $created['data']['record_number']);
|
||||
self::assertCount(2, $created['data']['tags']);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
// update: keep one tag, change record number
|
||||
$updated = $this->authJson('PATCH', '/api/v1/patient/' . $uuid, $owner, [
|
||||
'record_number' => 'P-2002',
|
||||
'tags' => [$t1->getUuid()],
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('P-2002', $updated['data']['record_number']);
|
||||
self::assertCount(1, $updated['data']['tags']);
|
||||
}
|
||||
|
||||
public function testRejectsForeignTag(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->doctor();
|
||||
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
$created = $this->authJson('POST', '/api/v1/patient', $owner, ['mobile' => $mobile, 'name' => 'ب']);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
[, $doctorB] = $this->doctor();
|
||||
$foreign = $this->tag($doctorB->getId(), 'خارجی');
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/patient/' . $uuid, $owner, ['tags' => [$foreign->getUuid()]]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Integration coverage for PATCH /api/v1/patient/{uuid}:
|
||||
* the extended demographic fields persist, and mobile edits validate,
|
||||
* enforce uniqueness, and rewrite the patient's login identifier.
|
||||
*
|
||||
* Gate: a doctor caller falls back to the seeded `free` plan, which grants
|
||||
* `patient_records` in db_test — so no explicit subscription is needed.
|
||||
*/
|
||||
class PatientUpdateProfileTest extends ApiTestCase
|
||||
{
|
||||
private User $doctorUser;
|
||||
private int $doctorId;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->doctorUser = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($this->doctorUser, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
$this->doctorId = $doctor->getId();
|
||||
}
|
||||
|
||||
/** Create a patient User + PatientRecord owned by the test doctor; return the record uuid. */
|
||||
private function makeRecord(?string $mobile = null): array
|
||||
{
|
||||
$patient = $this->createUser(['ROLE_USER'], $mobile);
|
||||
$patient->setRealName('ساغر صابری نژاد');
|
||||
$this->em->persist($patient);
|
||||
|
||||
$record = new PatientRecord('doctor', $this->doctorId, $patient, 'doctor', $this->doctorId);
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$record->getUuid(), $patient];
|
||||
}
|
||||
|
||||
public function testUpdatePersistsExtendedDemographicFields(): void
|
||||
{
|
||||
[$uuid] = $this->makeRecord();
|
||||
|
||||
$res = $this->authJson('PATCH', "/api/v1/patient/{$uuid}", $this->doctorUser, [
|
||||
'fathers_name' => 'رضا',
|
||||
'education' => 'کارشناسی',
|
||||
'field_of_study' => 'نرمافزار',
|
||||
'job' => 'مهندس',
|
||||
'province_id' => 8,
|
||||
'city_id' => 42,
|
||||
'postal_code' => '8913746351',
|
||||
'referral_source' => 'اینستاگرام',
|
||||
'description' => 'یادداشت آزمایشی',
|
||||
]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
$profile = $res['data']['profile'] ?? [];
|
||||
self::assertSame('رضا', $profile['fathers_name']);
|
||||
self::assertSame('کارشناسی', $profile['education']);
|
||||
self::assertSame('نرمافزار', $profile['field_of_study']);
|
||||
self::assertSame('مهندس', $profile['job']);
|
||||
self::assertSame(8, $profile['province_id']);
|
||||
self::assertSame(42, $profile['city_id']);
|
||||
self::assertSame('8913746351', $profile['postal_code']);
|
||||
self::assertSame('اینستاگرام', $profile['referral_source']);
|
||||
self::assertSame('یادداشت آزمایشی', $profile['description']);
|
||||
}
|
||||
|
||||
public function testEmptyStringClearsFieldToNull(): void
|
||||
{
|
||||
[$uuid] = $this->makeRecord();
|
||||
|
||||
$this->authJson('PATCH', "/api/v1/patient/{$uuid}", $this->doctorUser, ['job' => 'مهندس']);
|
||||
$res = $this->authJson('PATCH', "/api/v1/patient/{$uuid}", $this->doctorUser, ['job' => '']);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertNull($res['data']['profile']['job']);
|
||||
}
|
||||
|
||||
public function testMobileEditRewritesLoginIdentifier(): void
|
||||
{
|
||||
[$uuid, $patient] = $this->makeRecord();
|
||||
$newMobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
|
||||
$res = $this->authJson('PATCH', "/api/v1/patient/{$uuid}", $this->doctorUser, ['mobile' => $newMobile]);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame($newMobile, $res['data']['profile']['mobile']);
|
||||
|
||||
$this->em->refresh($patient);
|
||||
self::assertSame($newMobile, $patient->getMobileNumber());
|
||||
self::assertSame($newMobile, $patient->getUserIdentifier());
|
||||
}
|
||||
|
||||
public function testMobileTakenByAnotherUserReturns409(): void
|
||||
{
|
||||
$taken = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
$this->createUser(['ROLE_USER'], $taken); // occupy the number
|
||||
|
||||
[$uuid] = $this->makeRecord();
|
||||
$res = $this->authJson('PATCH', "/api/v1/patient/{$uuid}", $this->doctorUser, ['mobile' => $taken]);
|
||||
|
||||
self::assertSame(409, $this->responseCode());
|
||||
self::assertSame('ERR_PROFILE_002', $res['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testInvalidMobileReturns422(): void
|
||||
{
|
||||
[$uuid] = $this->makeRecord();
|
||||
$res = $this->authJson('PATCH', "/api/v1/patient/{$uuid}", $this->doctorUser, ['mobile' => '12345']);
|
||||
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_VALIDATION_001', $res['errors'][0]['code']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Settlement\Entity\WalletTransaction;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* تسویهٔ یک مراجعه (PatientSession) از کیف پول: PATCH /session/{uuid} با
|
||||
* payment_method=wallet مبلغِ نهایی را بهصورت debit از موجودی کسر میکند و در
|
||||
* صورتِ ناکافی بودن موجودی رد میشود.
|
||||
*/
|
||||
class PatientWalletSessionSettleTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord, 2: \App\Auth\Entity\User} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $record, $patient];
|
||||
}
|
||||
|
||||
private function sessionFor(PatientRecord $record, int $finalPriceRials): PatientSession
|
||||
{
|
||||
$session = new PatientSession($record);
|
||||
$session->setFinalPriceRials($finalPriceRials);
|
||||
$this->em->persist($session);
|
||||
$this->em->flush();
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
public function testSettleSessionFromWalletDebitsBalanceAndMarksPaid(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
$this->em->persist(new WalletTransaction($patient, 1_000_000, 'credit', 1_000_000));
|
||||
$this->em->flush();
|
||||
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'payment_method' => 'wallet',
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('wallet', $res['data']['payment_method']);
|
||||
self::assertTrue($res['data']['is_paid']);
|
||||
|
||||
// موجودی از ۱٬۰۰۰٬۰۰۰ به ۶۰۰٬۰۰۰ رسید و یک تراکنشِ debitِ گرهخورده به مراجعه ثبت شد.
|
||||
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
|
||||
self::assertSame(600_000, $wallet['data']['balance_rials']);
|
||||
$debit = $wallet['data']['recent_transactions'][0];
|
||||
self::assertSame('debit', $debit['type']);
|
||||
self::assertSame('wallet', $debit['payment_method']);
|
||||
self::assertSame('session:' . $session->getUuid(), $debit['reference']);
|
||||
self::assertSame($owner->getMobileNumber(), $debit['created_by_name']);
|
||||
}
|
||||
|
||||
public function testSettleSessionFromWalletRejectedWhenInsufficient(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
$this->em->persist(new WalletTransaction($patient, 100_000, 'credit', 100_000));
|
||||
$this->em->flush();
|
||||
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'payment_method' => 'wallet',
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_WALLET_INSUFFICIENT', $res['errors'][0]['code']);
|
||||
|
||||
// مراجعه تسویه نشد و موجودی دستنخورده ماند.
|
||||
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
|
||||
self::assertSame(100_000, $wallet['data']['balance_rials']);
|
||||
}
|
||||
|
||||
/**
|
||||
* رگرسیون: تسویهٔ کیف پول باید SessionPayment ثبت کند تا مراجعه واقعاً is_paid شود.
|
||||
* پیشتر فقط موجودی کسر میشد و مراجعه پرداختنشده میماند، پس PATCH دوم دوباره
|
||||
* از کیف پول کم میکرد (کسر مضاعف از پول واقعی بیمار).
|
||||
*/
|
||||
public function testSettlingTwiceDoesNotChargeWalletAgain(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
$this->em->persist(new WalletTransaction($patient, 1_000_000, 'credit', 1_000_000));
|
||||
$this->em->flush();
|
||||
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
$url = '/api/v1/session/' . $session->getUuid();
|
||||
|
||||
$this->authJson('PATCH', $url, $owner, ['payment_method' => 'wallet']);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
// تسویهٔ دوباره روی همان مراجعه نباید چیزی کسر کند.
|
||||
$res = $this->authJson('PATCH', $url, $owner, ['payment_method' => 'wallet']);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($res['data']['is_paid']);
|
||||
|
||||
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
|
||||
self::assertSame(600_000, $wallet['data']['balance_rials']);
|
||||
}
|
||||
|
||||
/**
|
||||
* رگرسیون: مبلغِ کسرشده باید مانده (پس از تخفیف) باشد نه قیمتِ نهاییِ خام.
|
||||
*/
|
||||
public function testWalletSettleChargesPayableAfterDiscount(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
$this->em->persist(new WalletTransaction($patient, 1_000_000, 'credit', 1_000_000));
|
||||
$this->em->flush();
|
||||
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
$session->setDiscount('fixed', 100_000, 100_000);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'payment_method' => 'wallet',
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($res['data']['is_paid']);
|
||||
|
||||
// ۴۰۰٬۰۰۰ − ۱۰۰٬۰۰۰ تخفیف = ۳۰۰٬۰۰۰ کسر شود، نه ۴۰۰٬۰۰۰.
|
||||
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
|
||||
self::assertSame(700_000, $wallet['data']['balance_rials']);
|
||||
}
|
||||
|
||||
public function testSettleSessionWithCashDoesNotTouchWallet(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
$this->em->persist(new WalletTransaction($patient, 500_000, 'credit', 500_000));
|
||||
$this->em->flush();
|
||||
|
||||
$session = $this->sessionFor($record, 300_000);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, ['payment_method' => 'cash']);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
|
||||
self::assertSame(500_000, $wallet['data']['balance_rials']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Inventory\Entity\InventoryItem;
|
||||
use App\Inventory\Entity\InventoryPackage;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* ثبت مراجعه با کالای مصرفی/پکیج/زمان پذیرش (ویزارد ثبت مراجعه):
|
||||
* POST /patient/{uuid}/session با consumables (سهم بیمار کامل، بدون بیمه)،
|
||||
* inventory_package_uuid (مرجع) و session_at. آیتمهای tenant دیگر بیصدا رد میشوند.
|
||||
*/
|
||||
class SessionConsumableTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: Doctor, 2: PatientRecord} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor, $record];
|
||||
}
|
||||
|
||||
private function itemFor(Doctor $doctor, string $name, int $price): InventoryItem
|
||||
{
|
||||
$item = new InventoryItem('doctor', $doctor->getId(), $name);
|
||||
$item->setPrice($price)->setStock(100);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
// ── موفق ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testCreateSessionWithConsumablesPackageAndSessionAt(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->recordFor();
|
||||
$glasses = $this->itemFor($doctor, 'عینک', 1_200_000);
|
||||
$pencil = $this->itemFor($doctor, 'مداد سفید', 300_000);
|
||||
|
||||
$package = new InventoryPackage('doctor', $doctor->getId(), 'پکیج زیبایی');
|
||||
$this->em->persist($package);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 500_000,
|
||||
'session_at' => 1_760_000_000,
|
||||
'inventory_package_uuid' => $package->getUuid(),
|
||||
'consumables' => [
|
||||
['inventory_item_uuid' => $glasses->getUuid(), 'quantity' => 2],
|
||||
['inventory_item_uuid' => $pencil->getUuid()],
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame(1_760_000_000, $res['data']['session_at']);
|
||||
self::assertSame($package->getUuid(), $res['data']['inventory_package_uuid']);
|
||||
self::assertSame('پکیج زیبایی', $res['data']['inventory_package_title']);
|
||||
|
||||
self::assertCount(2, $res['data']['consumables']);
|
||||
self::assertSame(2_700_000, $res['data']['consumables_total_rials']); // 2×1٬200٬000 + 300٬000
|
||||
|
||||
$byName = array_column($res['data']['consumables'], null, 'item_name');
|
||||
self::assertSame(2, $byName['عینک']['quantity']);
|
||||
self::assertSame(2_400_000, $byName['عینک']['line_total_rials']);
|
||||
self::assertSame(1, $byName['مداد سفید']['quantity']);
|
||||
|
||||
// کالاها بدون پوشش بیمه → کامل روی سهم بیمار
|
||||
self::assertSame(3_200_000, $res['data']['final_price_rials']); // ویزیت 500٬000 + کالاها 2٬700٬000
|
||||
}
|
||||
|
||||
public function testConsumablesAppearInSessionsList(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->recordFor();
|
||||
$item = $this->itemFor($doctor, 'سرنگ', 50_000);
|
||||
|
||||
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 0,
|
||||
'consumables' => [['inventory_item_uuid' => $item->getUuid(), 'quantity' => 3]],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$sessions = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/sessions', $owner);
|
||||
self::assertCount(1, $sessions['data'][0]['consumables']);
|
||||
self::assertSame(150_000, $sessions['data'][0]['consumables_total_rials']);
|
||||
self::assertSame(150_000, $sessions['data'][0]['patient_debt_rials']);
|
||||
}
|
||||
|
||||
// ── خطا ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testForeignTenantConsumableAndPackageSilentlySkipped(): void
|
||||
{
|
||||
[$owner, , $record] = $this->recordFor();
|
||||
|
||||
// tenant دیگر
|
||||
$otherOwner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$otherDoctor = new Doctor($otherOwner, 'دکتر دیگر');
|
||||
$this->em->persist($otherDoctor);
|
||||
$this->em->flush();
|
||||
$foreignItem = $this->itemFor($otherDoctor, 'آمپول', 900_000);
|
||||
$foreignPackage = new InventoryPackage('doctor', $otherDoctor->getId(), 'پکیج دیگری');
|
||||
$this->em->persist($foreignPackage);
|
||||
$this->em->flush();
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 400_000,
|
||||
'inventory_package_uuid' => $foreignPackage->getUuid(),
|
||||
'consumables' => [['inventory_item_uuid' => $foreignItem->getUuid(), 'quantity' => 5]],
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertCount(0, $res['data']['consumables']);
|
||||
self::assertSame(0, $res['data']['consumables_total_rials']);
|
||||
self::assertNull($res['data']['inventory_package_uuid']);
|
||||
self::assertSame(400_000, $res['data']['final_price_rials']);
|
||||
}
|
||||
|
||||
public function testUnknownConsumableUuidSkipped(): void
|
||||
{
|
||||
[$owner, , $record] = $this->recordFor();
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 200_000,
|
||||
'consumables' => [['inventory_item_uuid' => '00000000-0000-0000-0000-000000000000']],
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertCount(0, $res['data']['consumables']);
|
||||
self::assertSame(200_000, $res['data']['final_price_rials']);
|
||||
}
|
||||
|
||||
// ── مرزی ────────────────────────────────────────────────────────────────
|
||||
|
||||
public function testEmptyConsumablesAndNoSessionAtDefaults(): void
|
||||
{
|
||||
[$owner, , $record] = $this->recordFor();
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 100_000,
|
||||
'consumables' => [],
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame([], $res['data']['consumables']);
|
||||
self::assertSame(0, $res['data']['consumables_total_rials']);
|
||||
self::assertNull($res['data']['session_at']);
|
||||
self::assertNull($res['data']['inventory_package_uuid']);
|
||||
}
|
||||
|
||||
public function testZeroQuantityCoercedToOne(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->recordFor();
|
||||
$item = $this->itemFor($doctor, 'گاز استریل', 80_000);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 0,
|
||||
'consumables' => [['inventory_item_uuid' => $item->getUuid(), 'quantity' => 0]],
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame(1, $res['data']['consumables'][0]['quantity']);
|
||||
self::assertSame(80_000, $res['data']['consumables_total_rials']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Insurance\Entity\Insurance;
|
||||
use App\Insurance\Entity\TenantInsurance;
|
||||
use App\Insurance\Entity\TenantServiceCoverage;
|
||||
use App\Insurance\Enum\InsuranceType;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* POST /api/v1/patient/{uuid}/session — the insurance share breakdown persisted on
|
||||
* the session. The payment page, the invoice modal and the claims dashboard all read
|
||||
* these fields, so they must come out of BillingCalculator and never be re-derived.
|
||||
*/
|
||||
class SessionInsuranceShareTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Doctor, 2: PatientRecord} */
|
||||
private function tenant(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor, $record];
|
||||
}
|
||||
|
||||
private function serviceItem(Doctor $doctor, int $priceRials, bool $insuranceCovered): ServiceItem
|
||||
{
|
||||
$section = new ServiceSection('doctor', $doctor->getId(), 'جراحی');
|
||||
$this->em->persist($section);
|
||||
|
||||
$item = new ServiceItem($section, 'جراحی بینی', $priceRials);
|
||||
$item->setInsuranceCovered($insuranceCovered);
|
||||
$this->em->persist($item);
|
||||
$this->em->flush();
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
/** Active contract covering $coveragePercent of every covered service. */
|
||||
private function contract(Doctor $doctor, float $coveragePercent, ?ServiceItem $item = null): Insurance
|
||||
{
|
||||
$insurance = new Insurance('تامین اجتماعی', InsuranceType::Basic);
|
||||
$this->em->persist($insurance);
|
||||
$this->em->flush();
|
||||
|
||||
$contract = new TenantInsurance('doctor', $doctor->getId(), $insurance->getId());
|
||||
$contract->setCoveragePercent($coveragePercent)->setActive(true);
|
||||
$this->em->persist($contract);
|
||||
$this->em->flush();
|
||||
|
||||
if ($item !== null) {
|
||||
$coverage = new TenantServiceCoverage($contract->getId(), $item->getId());
|
||||
$coverage->setCovered(true)->setCoveragePercent($coveragePercent);
|
||||
$this->em->persist($coverage);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
return $insurance;
|
||||
}
|
||||
|
||||
public function testSessionStoresInsuranceAndPatientSharesForACoveredService(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->tenant();
|
||||
$item = $this->serviceItem($doctor, 40_000_000, true);
|
||||
$insurance = $this->contract($doctor, 70.0, $item);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 3_000_000,
|
||||
'insurance_base_id' => $insurance->getId(),
|
||||
'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 1]],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$session = $res['data'];
|
||||
// 70% of both the visit and the service is carried by the insurer.
|
||||
self::assertSame(43_000_000, $session['gross_total_rials']);
|
||||
self::assertSame(30_100_000, $session['base_insurance_rials']);
|
||||
self::assertSame(0, $session['supplementary_insurance_rials']);
|
||||
self::assertSame(12_900_000, $session['patient_share_rials']);
|
||||
// The payable amount is the patient share, not the gross total.
|
||||
self::assertSame(12_900_000, $session['final_price_rials']);
|
||||
self::assertSame(12_900_000, $session['remaining_rials']);
|
||||
}
|
||||
|
||||
public function testTheBreakdownAlwaysSumsBackToTheGrossTotal(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->tenant();
|
||||
$item = $this->serviceItem($doctor, 1_234_567, true);
|
||||
$insurance = $this->contract($doctor, 33.33, $item);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 987_654,
|
||||
'insurance_base_id' => $insurance->getId(),
|
||||
'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 3]],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$s = $res['data'];
|
||||
self::assertSame(
|
||||
$s['gross_total_rials'],
|
||||
$s['base_insurance_rials'] + $s['supplementary_insurance_rials'] + $s['patient_share_rials'],
|
||||
);
|
||||
}
|
||||
|
||||
public function testAServiceWithoutCoverageLeavesTheWholeAmountToThePatient(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->tenant();
|
||||
$item = $this->serviceItem($doctor, 40_000_000, false);
|
||||
$insurance = $this->contract($doctor, 70.0);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 0,
|
||||
'insurance_base_id' => $insurance->getId(),
|
||||
'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 1]],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$s = $res['data'];
|
||||
self::assertSame(0, $s['base_insurance_rials']);
|
||||
self::assertSame(40_000_000, $s['patient_share_rials']);
|
||||
self::assertSame(40_000_000, $s['final_price_rials']);
|
||||
}
|
||||
|
||||
public function testSessionWithoutInsuranceKeepsTheFullAmountAsThePatientShare(): void
|
||||
{
|
||||
[$owner, $doctor, $record] = $this->tenant();
|
||||
$item = $this->serviceItem($doctor, 2_400_000, true);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/session', $owner, [
|
||||
'visit_price_rials' => 500_000,
|
||||
'services' => [['service_item_uuid' => $item->getUuid(), 'quantity' => 1]],
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$s = $res['data'];
|
||||
self::assertSame(2_900_000, $s['gross_total_rials']);
|
||||
self::assertSame(0, $s['base_insurance_rials']);
|
||||
self::assertSame(2_900_000, $s['patient_share_rials']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Settlement\Entity\WalletTransaction;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* تسویهی چندتکهی مراجعه: POST /session/{uuid}/payments پرداخت جزئی ثبت میکند
|
||||
* (روش wallet از کیف پول کسر میشود) و PATCH /session/{uuid} تخفیف تسویه
|
||||
* (percent/fixed/حذف) را اعمال میکند. صفر شدن مانده → is_paid و paid_at.
|
||||
*/
|
||||
class SessionPaymentTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord, 2: \App\Auth\Entity\User} */
|
||||
private function recordFor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId());
|
||||
$this->em->persist($record);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $record, $patient];
|
||||
}
|
||||
|
||||
private function sessionFor(PatientRecord $record, int $finalPriceRials): PatientSession
|
||||
{
|
||||
$session = new PatientSession($record);
|
||||
$session->setFinalPriceRials($finalPriceRials);
|
||||
$this->em->persist($session);
|
||||
$this->em->flush();
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
// ── پرداخت جزئی ─────────────────────────────────────────────────────────
|
||||
|
||||
public function testPartialPaymentReducesDebtButNotPaid(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 500_000);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'cash', 'amount_rials' => 200_000,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertFalse($res['data']['is_paid']);
|
||||
self::assertSame(200_000, $res['data']['paid_total_rials']);
|
||||
self::assertSame(300_000, $res['data']['patient_debt_rials']);
|
||||
self::assertCount(1, $res['data']['payments']);
|
||||
self::assertSame('cash', $res['data']['payments'][0]['method']);
|
||||
self::assertSame($owner->getMobileNumber(), $res['data']['payments'][0]['created_by_name']);
|
||||
}
|
||||
|
||||
public function testFullSettlementViaMultiplePaymentsMarksPaid(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 500_000);
|
||||
|
||||
$this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'cash', 'amount_rials' => 200_000,
|
||||
]);
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'card', 'amount_rials' => 300_000, 'paid_at' => 1_800_000_000,
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertTrue($res['data']['is_paid']);
|
||||
self::assertSame('card', $res['data']['payment_method']);
|
||||
self::assertSame(1_800_000_000, $res['data']['paid_at']);
|
||||
self::assertSame(0, $res['data']['patient_debt_rials']);
|
||||
self::assertCount(2, $res['data']['payments']);
|
||||
}
|
||||
|
||||
public function testWalletPartialPaymentDebitsWallet(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
$this->em->persist(new WalletTransaction($patient, 1_000_000, 'credit', 1_000_000));
|
||||
$this->em->flush();
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'wallet', 'amount_rials' => 150_000,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame(250_000, $res['data']['patient_debt_rials']);
|
||||
|
||||
$wallet = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/wallet', $owner);
|
||||
self::assertSame(850_000, $wallet['data']['balance_rials']);
|
||||
$debit = $wallet['data']['recent_transactions'][0];
|
||||
self::assertSame('debit', $debit['type']);
|
||||
self::assertSame('session:' . $session->getUuid(), $debit['reference']);
|
||||
}
|
||||
|
||||
public function testWalletPaymentRejectedWhenInsufficient(): void
|
||||
{
|
||||
[$owner, $record, $patient] = $this->recordFor();
|
||||
$this->em->persist(new WalletTransaction($patient, 100_000, 'credit', 100_000));
|
||||
$this->em->flush();
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'wallet', 'amount_rials' => 200_000,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_WALLET_INSUFFICIENT', $res['errors'][0]['code']);
|
||||
|
||||
// هیچ پرداختی ثبت نشد
|
||||
$sessions = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/sessions', $owner);
|
||||
self::assertSame(400_000, $sessions['data'][0]['patient_debt_rials']);
|
||||
self::assertCount(0, $sessions['data'][0]['payments']);
|
||||
}
|
||||
|
||||
public function testPaymentExceedingDebtRejected(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 300_000);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'cash', 'amount_rials' => 400_000,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_SESSION_PAYMENT_EXCEEDS', $res['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testInvalidMethodRejected(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 300_000);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'bitcoin', 'amount_rials' => 100_000,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_SESSION_PAYMENT_INVALID', $res['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testZeroAmountRejected(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 300_000);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'cash', 'amount_rials' => 0,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_SESSION_PAYMENT_INVALID', $res['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testSessionNotFoundReturns404(): void
|
||||
{
|
||||
[$owner] = $this->recordFor();
|
||||
|
||||
$this->authJson('POST', '/api/v1/session/00000000-0000-0000-0000-000000000000/payments', $owner, [
|
||||
'method' => 'cash', 'amount_rials' => 100_000,
|
||||
]);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
|
||||
// ── تخفیف تسویه ─────────────────────────────────────────────────────────
|
||||
|
||||
public function testPercentDiscountReducesDebt(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'discount_type' => 'percent', 'discount_value' => 25,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('percent', $res['data']['discount_type']);
|
||||
self::assertSame(100_000, $res['data']['discount_rials']);
|
||||
|
||||
$sessions = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/sessions', $owner);
|
||||
self::assertSame(300_000, $sessions['data'][0]['patient_debt_rials']);
|
||||
}
|
||||
|
||||
public function testFixedDiscountThenRemove(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'discount_type' => 'fixed', 'discount_value' => 150_000,
|
||||
]);
|
||||
self::assertSame(150_000, $res['data']['discount_rials']);
|
||||
|
||||
// حذف تخفیف
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'discount_type' => null,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertNull($res['data']['discount_type']);
|
||||
self::assertSame(0, $res['data']['discount_rials']);
|
||||
}
|
||||
|
||||
public function testDiscountOverLimitsRejected(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'discount_type' => 'percent', 'discount_value' => 150,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_SESSION_DISCOUNT_INVALID', $res['errors'][0]['code']);
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'discount_type' => 'fixed', 'discount_value' => 500_000,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_SESSION_DISCOUNT_INVALID', $res['errors'][0]['code']);
|
||||
}
|
||||
|
||||
public function testFullPercentDiscountMarksPaid(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'discount_type' => 'percent', 'discount_value' => 100,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($res['data']['is_paid']);
|
||||
self::assertNotNull($res['data']['paid_at']);
|
||||
}
|
||||
|
||||
public function testDiscountPlusPaymentSettles(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 400_000);
|
||||
|
||||
$this->authJson('PATCH', '/api/v1/session/' . $session->getUuid(), $owner, [
|
||||
'discount_type' => 'fixed', 'discount_value' => 100_000,
|
||||
]);
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'pos', 'amount_rials' => 300_000,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertTrue($res['data']['is_paid']);
|
||||
self::assertSame('pos', $res['data']['payment_method']);
|
||||
self::assertSame(0, $res['data']['patient_debt_rials']);
|
||||
}
|
||||
|
||||
public function testPaymentOnSettledSessionRejected(): void
|
||||
{
|
||||
[$owner, $record] = $this->recordFor();
|
||||
$session = $this->sessionFor($record, 200_000);
|
||||
|
||||
$this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'cash', 'amount_rials' => 200_000,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
// مراجعه تسویه شده — هر پرداخت بعدی از مانده (صفر) بیشتر است
|
||||
$res = $this->authJson('POST', '/api/v1/session/' . $session->getUuid() . '/payments', $owner, [
|
||||
'method' => 'cash', 'amount_rials' => 1,
|
||||
]);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
self::assertSame('ERR_SESSION_PAYMENT_EXCEEDS', $res['errors'][0]['code']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Patient;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\UserProfile\Entity\UserProfile;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Unit coverage for the patient demographic columns added to UserProfile
|
||||
* (field_of_study, province_id, city_id, postal_code, referral_source):
|
||||
* setters round-trip through the getters and toArray() exposes the API keys.
|
||||
*/
|
||||
class UserProfileDemographicsTest extends TestCase
|
||||
{
|
||||
private function profile(): UserProfile
|
||||
{
|
||||
return new UserProfile(new User('09121234567'));
|
||||
}
|
||||
|
||||
public function testNewDemographicSettersRoundTrip(): void
|
||||
{
|
||||
$p = $this->profile();
|
||||
$p->setFieldOfStudy('نرمافزار')
|
||||
->setProvinceId(8)
|
||||
->setCityId(42)
|
||||
->setPostalCode('8913746351')
|
||||
->setReferralSource('اینستاگرام');
|
||||
|
||||
self::assertSame('نرمافزار', $p->getFieldOfStudy());
|
||||
self::assertSame(8, $p->getProvinceId());
|
||||
self::assertSame(42, $p->getCityId());
|
||||
self::assertSame('8913746351', $p->getPostalCode());
|
||||
self::assertSame('اینستاگرام', $p->getReferralSource());
|
||||
}
|
||||
|
||||
public function testNewFieldsDefaultToNull(): void
|
||||
{
|
||||
$p = $this->profile();
|
||||
|
||||
self::assertNull($p->getFieldOfStudy());
|
||||
self::assertNull($p->getProvinceId());
|
||||
self::assertNull($p->getCityId());
|
||||
self::assertNull($p->getPostalCode());
|
||||
self::assertNull($p->getReferralSource());
|
||||
}
|
||||
|
||||
public function testToArrayExposesNewKeys(): void
|
||||
{
|
||||
$arr = $this->profile()
|
||||
->setFieldOfStudy('پزشکی')
|
||||
->setProvinceId(1)
|
||||
->setCityId(2)
|
||||
->setPostalCode('1234567890')
|
||||
->setReferralSource('معرفی دوستان')
|
||||
->toArray();
|
||||
|
||||
self::assertSame('پزشکی', $arr['field_of_study']);
|
||||
self::assertSame(1, $arr['province_id']);
|
||||
self::assertSame(2, $arr['city_id']);
|
||||
self::assertSame('1234567890', $arr['postal_code']);
|
||||
self::assertSame('معرفی دوستان', $arr['referral_source']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Payment;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Config\Entity\SiteConfig;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Patient\Entity\PatientSession;
|
||||
use App\Payment\Entity\Payment;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Paying for an online booking must file the case file, same as any other way
|
||||
* of confirming.
|
||||
*
|
||||
* This is the path every Nobat724 booking takes, and it was the one path that
|
||||
* never created a record: the payment callback confirmed the appointment
|
||||
* without running the confirmation side-effects.
|
||||
*/
|
||||
class AppointmentPaidConfirmFilesSessionTest extends ApiTestCase
|
||||
{
|
||||
private function enableTestMode(): void
|
||||
{
|
||||
$cfg = $this->em->getRepository(SiteConfig::class)->findOneBy(['configKey' => 'payment_test_mode']);
|
||||
if ($cfg === null) {
|
||||
$this->em->persist(new SiteConfig('payment_test_mode', '1'));
|
||||
} else {
|
||||
$cfg->setValue('1');
|
||||
}
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
/** @return array{0: Payment, 1: Appointment} */
|
||||
private function pendingPaidBooking(?callable $withClinic = null): array
|
||||
{
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر پرداخت');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$appointment = new Appointment($doctor, $this->createUser(['ROLE_USER']), 1_790_200_000, 1_790_201_800);
|
||||
if ($withClinic !== null) {
|
||||
$appointment->setClinic($withClinic($doctor));
|
||||
}
|
||||
$this->em->persist($appointment);
|
||||
|
||||
$payment = new Payment($appointment->getUser(), 50_000, 'mock', Payment::TYPE_APPOINTMENT);
|
||||
$payment->setAppointment($appointment);
|
||||
$this->em->persist($payment);
|
||||
$this->em->flush();
|
||||
|
||||
return [$payment, $appointment];
|
||||
}
|
||||
|
||||
private function fireCallback(Payment $payment): void
|
||||
{
|
||||
$this->client->request('POST', '/api/v1/payment/callback/mock?' . http_build_query([
|
||||
'order_id' => $payment->getOrderId(),
|
||||
'mock' => '1',
|
||||
'ResCode' => '0',
|
||||
'mock_amount' => '50000',
|
||||
]));
|
||||
}
|
||||
|
||||
public function testPaidPersonalBookingIsConfirmedAndFiled(): void
|
||||
{
|
||||
$this->enableTestMode();
|
||||
[$payment, $appointment] = $this->pendingPaidBooking();
|
||||
|
||||
$this->fireCallback($payment);
|
||||
$this->em->clear();
|
||||
|
||||
$reloaded = $this->em->getRepository(Appointment::class)->find($appointment->getId());
|
||||
self::assertSame(Appointment::STATUS_CONFIRMED, $reloaded->getStatus());
|
||||
|
||||
$sessions = $this->em->getRepository(PatientSession::class)->findBy(['appointment' => $reloaded]);
|
||||
self::assertCount(1, $sessions, 'پرداخت آنلاین هم باید پرونده بسازد');
|
||||
|
||||
$record = $sessions[0]->getRecord();
|
||||
self::assertSame('doctor', $record->getEntityType());
|
||||
}
|
||||
|
||||
public function testPaidClinicBookingIsFiledUnderTheClinic(): void
|
||||
{
|
||||
$this->enableTestMode();
|
||||
[$payment, $appointment] = $this->pendingPaidBooking(function (Doctor $doctor): Clinic {
|
||||
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
|
||||
$clinic->setName('کلینیک پرداخت');
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return $clinic;
|
||||
});
|
||||
|
||||
$this->fireCallback($payment);
|
||||
$this->em->clear();
|
||||
|
||||
$reloaded = $this->em->getRepository(Appointment::class)->find($appointment->getId());
|
||||
$records = $this->em->getRepository(PatientRecord::class)->findBy(['user' => $reloaded->getUser()]);
|
||||
|
||||
self::assertCount(1, $records, 'یک نوبت، یک پرونده — نه یکی برای پزشک و یکی برای کلینیک');
|
||||
self::assertSame('clinic', $records[0]->getEntityType());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\PaymentMethod;
|
||||
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Functional coverage for the per-clinic payment methods API
|
||||
* (bank accounts + POS devices). Success, error and boundary cases.
|
||||
*/
|
||||
class PaymentMethodTest extends ApiTestCase
|
||||
{
|
||||
// ---- Bank accounts -----------------------------------------------------
|
||||
|
||||
public function testEmptyBankAccountListForNewClinic(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_CLINIC']);
|
||||
|
||||
$res = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertTrue($res['success']);
|
||||
$this->assertSame([], $res['data']);
|
||||
}
|
||||
|
||||
public function testCreateAndListBankAccount(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_CLINIC']);
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
|
||||
'bank_name' => 'ملی',
|
||||
'card_number' => '6037991234567890',
|
||||
'account_number' => '0101234567890',
|
||||
'shaba_number' => 'IR820540102680020817909002',
|
||||
]);
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$this->assertSame('ملی', $created['data']['bank_name']);
|
||||
$this->assertTrue($created['data']['is_active']);
|
||||
$this->assertNotEmpty($created['data']['uuid']);
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user);
|
||||
$this->assertCount(1, $list['data']);
|
||||
$this->assertSame('0101234567890', $list['data'][0]['account_number']);
|
||||
}
|
||||
|
||||
public function testCreateBankAccountValidationErrorWhenBankNameMissing(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_CLINIC']);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
|
||||
'account_number' => '0101234567890',
|
||||
]);
|
||||
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
$this->assertFalse($res['success']);
|
||||
$this->assertSame('bank_name', $res['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testUpdateBankAccount(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_CLINIC']);
|
||||
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
|
||||
'bank_name' => 'ملی',
|
||||
'account_number' => '0101234567890',
|
||||
]);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
$updated = $this->authJson('PUT', "/api/v1/my/payment-methods/bank-accounts/$uuid", $user, [
|
||||
'bank_name' => 'ملت',
|
||||
'account_number' => '0209876543210',
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertSame('ملت', $updated['data']['bank_name']);
|
||||
$this->assertSame('0209876543210', $updated['data']['account_number']);
|
||||
}
|
||||
|
||||
public function testToggleBankAccountStatus(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_CLINIC']);
|
||||
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $user, [
|
||||
'bank_name' => 'ملی',
|
||||
'account_number' => '0101234567890',
|
||||
]);
|
||||
$uuid = $created['data']['uuid'];
|
||||
$this->assertTrue($created['data']['is_active']);
|
||||
|
||||
$toggled = $this->authJson('PATCH', "/api/v1/my/payment-methods/bank-accounts/$uuid/status", $user);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertFalse($toggled['data']['is_active']);
|
||||
}
|
||||
|
||||
public function testToggleUnknownBankAccountReturns404(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_CLINIC']);
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/my/payment-methods/bank-accounts/does-not-exist/status', $user);
|
||||
|
||||
$this->assertSame(404, $this->responseCode());
|
||||
$this->assertFalse($res['success']);
|
||||
}
|
||||
|
||||
public function testCannotTouchAnotherClinicsBankAccount(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_CLINIC']);
|
||||
$other = $this->createUser(['ROLE_CLINIC']);
|
||||
$created = $this->authJson('POST', '/api/v1/my/payment-methods/bank-accounts', $owner, [
|
||||
'bank_name' => 'ملی',
|
||||
'account_number' => '0101234567890',
|
||||
]);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
$res = $this->authJson('PATCH', "/api/v1/my/payment-methods/bank-accounts/$uuid/status", $other);
|
||||
|
||||
$this->assertSame(404, $this->responseCode());
|
||||
$this->assertFalse($res['success']);
|
||||
}
|
||||
|
||||
public function testBankAccountForbiddenForPlainUser(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER']);
|
||||
|
||||
$this->authJson('GET', '/api/v1/my/payment-methods/bank-accounts', $user);
|
||||
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
// ---- POS devices -------------------------------------------------------
|
||||
|
||||
public function testCreateAndListPos(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_DOCTOR']);
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [
|
||||
'bank_name' => 'ملت',
|
||||
'serial_number' => 'SN-98765',
|
||||
'terminal_number' => '123456',
|
||||
]);
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$this->assertSame('ملت', $created['data']['bank_name']);
|
||||
$this->assertSame('123456', $created['data']['terminal_number']);
|
||||
$this->assertTrue($created['data']['is_active']);
|
||||
|
||||
$list = $this->authJson('GET', '/api/v1/my/payment-methods/pos', $user);
|
||||
$this->assertCount(1, $list['data']);
|
||||
}
|
||||
|
||||
public function testCreatePosValidationErrorWhenTerminalMissing(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_DOCTOR']);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [
|
||||
'bank_name' => 'ملت',
|
||||
]);
|
||||
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
$this->assertSame('terminal_number', $res['errors'][0]['field']);
|
||||
}
|
||||
|
||||
public function testTogglePosStatus(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_SECRETARY']);
|
||||
$created = $this->authJson('POST', '/api/v1/my/payment-methods/pos', $user, [
|
||||
'bank_name' => 'تجارت',
|
||||
'terminal_number' => '345678',
|
||||
]);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
$toggled = $this->authJson('PATCH', "/api/v1/my/payment-methods/pos/$uuid/status", $user);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertFalse($toggled['data']['is_active']);
|
||||
}
|
||||
|
||||
public function testPosListIsolatedPerUser(): void
|
||||
{
|
||||
$a = $this->createUser(['ROLE_CLINIC']);
|
||||
$b = $this->createUser(['ROLE_CLINIC']);
|
||||
$this->authJson('POST', '/api/v1/my/payment-methods/pos', $a, [
|
||||
'bank_name' => 'صادرات',
|
||||
'terminal_number' => '901234',
|
||||
]);
|
||||
|
||||
$listB = $this->authJson('GET', '/api/v1/my/payment-methods/pos', $b);
|
||||
|
||||
$this->assertSame([], $listB['data']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Secretary;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* A clinic manager can assign one secretary to several of the clinic's doctors
|
||||
* at once (many DoctorSecretary rows for one secretary User) and later re-sync
|
||||
* that set. Doctors outside the clinic are rejected; foreign owners are 403.
|
||||
*/
|
||||
class ClinicSharedSecretaryTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: Clinic, 2: Doctor[]} */
|
||||
private function makeClinicWithDoctors(int $count): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$this->em->persist($clinic);
|
||||
|
||||
$doctors = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), "دکتر $i");
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$doctors[] = $doctor;
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $clinic, $doctors];
|
||||
}
|
||||
|
||||
private function mobile(): string
|
||||
{
|
||||
return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
public function testClinicOwnerAssignsSecretaryToMultipleDoctors(): void
|
||||
{
|
||||
[$owner, , $doctors] = $this->makeClinicWithDoctors(3);
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/secretary', $owner, [
|
||||
'mobile_number' => $this->mobile(),
|
||||
'name' => 'منشی مشترک',
|
||||
'doctor_uuids' => [$doctors[0]->getUuid(), $doctors[1]->getUuid()],
|
||||
]);
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$this->assertCount(2, $body['data']['created']);
|
||||
$this->assertSame([], $body['data']['skipped_not_in_clinic']);
|
||||
$this->assertNotEmpty($body['data']['secretary_uuid']);
|
||||
}
|
||||
|
||||
public function testSkipsDuplicatesOnReassign(): void
|
||||
{
|
||||
[$owner, , $doctors] = $this->makeClinicWithDoctors(2);
|
||||
$mobile = $this->mobile();
|
||||
$uuids = [$doctors[0]->getUuid(), $doctors[1]->getUuid()];
|
||||
|
||||
$this->authJson('POST', '/api/v1/secretary', $owner, ['mobile_number' => $mobile, 'doctor_uuids' => $uuids]);
|
||||
$body = $this->authJson('POST', '/api/v1/secretary', $owner, ['mobile_number' => $mobile, 'doctor_uuids' => $uuids]);
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$this->assertCount(0, $body['data']['created']);
|
||||
$this->assertCount(2, $body['data']['skipped_duplicate']);
|
||||
}
|
||||
|
||||
public function testRejectsDoctorOutsideClinic(): void
|
||||
{
|
||||
[$owner, , $doctors] = $this->makeClinicWithDoctors(1);
|
||||
$foreignDoctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'خارج از کلینیک');
|
||||
$this->em->persist($foreignDoctor);
|
||||
$this->em->flush();
|
||||
|
||||
$body = $this->authJson('POST', '/api/v1/secretary', $owner, [
|
||||
'mobile_number' => $this->mobile(),
|
||||
'doctor_uuids' => [$doctors[0]->getUuid(), $foreignDoctor->getUuid()],
|
||||
]);
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$this->assertCount(1, $body['data']['created']);
|
||||
$this->assertContains($foreignDoctor->getUuid(), $body['data']['skipped_not_in_clinic']);
|
||||
}
|
||||
|
||||
public function testSyncAddsAndRemovesDoctors(): void
|
||||
{
|
||||
[$owner, $clinic, $doctors] = $this->makeClinicWithDoctors(3);
|
||||
$mobile = $this->mobile();
|
||||
|
||||
$assigned = $this->authJson('POST', '/api/v1/secretary', $owner, [
|
||||
'mobile_number' => $mobile,
|
||||
'doctor_uuids' => [$doctors[0]->getUuid(), $doctors[1]->getUuid()],
|
||||
]);
|
||||
$secretaryUuid = $assigned['data']['secretary_uuid'];
|
||||
|
||||
// sync to {doctor1, doctor2} → drop doctor0, add doctor2
|
||||
$body = $this->authJson('PUT', '/api/v1/secretaries/clinic/' . $clinic->getUuid() . '/doctors', $owner, [
|
||||
'secretary_uuid' => $secretaryUuid,
|
||||
'doctor_uuids' => [$doctors[1]->getUuid(), $doctors[2]->getUuid()],
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$this->assertSame(1, $body['data']['added']); // doctor2
|
||||
$this->assertSame(1, $body['data']['removed']); // doctor0
|
||||
}
|
||||
|
||||
public function testSyncCopiesProfileAndPermissionsToNewLinks(): void
|
||||
{
|
||||
[$owner, $clinic, $doctors] = $this->makeClinicWithDoctors(2);
|
||||
$mobile = $this->mobile();
|
||||
|
||||
$assigned = $this->authJson('POST', '/api/v1/secretary', $owner, [
|
||||
'mobile_number' => $mobile,
|
||||
'doctor_uuids' => [$doctors[0]->getUuid()],
|
||||
'national_code' => '1212121212',
|
||||
'address' => 'یزد، خیابان تست',
|
||||
'permissions' => ['version' => 1, 'resources' => ['patients' => ['view' => true]]],
|
||||
]);
|
||||
$secretaryUuid = $assigned['data']['secretary_uuid'];
|
||||
|
||||
// adding a second doctor must clone the person's profile onto the new link
|
||||
$this->authJson('PUT', '/api/v1/secretaries/clinic/' . $clinic->getUuid() . '/doctors', $owner, [
|
||||
'secretary_uuid' => $secretaryUuid,
|
||||
'doctor_uuids' => [$doctors[0]->getUuid(), $doctors[1]->getUuid()],
|
||||
]);
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
|
||||
$rows = $this->authJson('GET', '/api/v1/secretaries/clinic/' . $clinic->getUuid(), $owner);
|
||||
$this->assertCount(2, $rows['data']);
|
||||
foreach ($rows['data'] as $row) {
|
||||
$this->assertSame('1212121212', $row['national_code']);
|
||||
$this->assertSame('یزد، خیابان تست', $row['address']);
|
||||
$this->assertTrue($row['permissions']['patients']['view']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testForeignOwnerCannotSync(): void
|
||||
{
|
||||
[, $clinic, ] = $this->makeClinicWithDoctors(1);
|
||||
$intruder = $this->createUser(['ROLE_CLINIC']);
|
||||
|
||||
$this->authJson('PUT', '/api/v1/secretaries/clinic/' . $clinic->getUuid() . '/doctors', $intruder, [
|
||||
'secretary_uuid' => 'whatever',
|
||||
'doctor_uuids' => [],
|
||||
]);
|
||||
|
||||
$this->assertSame(403, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Secretary;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\UserActiveContext;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* A clinic-owned secretary must only see the appointments of the doctors they
|
||||
* are actually assigned to — not every doctor in the clinic.
|
||||
*/
|
||||
class SecretaryAppointmentScopeTest extends ApiTestCase
|
||||
{
|
||||
public function testSecretarySeesOnlyAssignedDoctorsAppointments(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$this->em->persist($clinic);
|
||||
|
||||
$doctorA = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر A');
|
||||
$doctorB = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر B');
|
||||
$this->em->persist($doctorA);
|
||||
$this->em->persist($doctorB);
|
||||
$clinic->getDoctors()->add($doctorA);
|
||||
$clinic->getDoctors()->add($doctorB);
|
||||
|
||||
// منشی فقط به دکتر A تخصیص داده شده
|
||||
$secretaryUser = $this->createUser(['ROLE_SECRETARY']);
|
||||
$rel = new DoctorSecretary($doctorA, $secretaryUser, DoctorSecretary::OWNER_CLINIC, $clinic);
|
||||
$this->em->persist($rel);
|
||||
|
||||
// scope فعالِ منشی = این کلینیک
|
||||
$this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid()));
|
||||
|
||||
// یک نوبت برای هر پزشک
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$start = time() + 3600;
|
||||
$this->em->persist(new Appointment($doctorA, $patient, $start, $start + 900));
|
||||
$this->em->persist(new Appointment($doctorB, $patient, $start + 1800, $start + 2700));
|
||||
$this->em->flush();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
// فقط نوبت دکتر A دیده میشود، نه دکتر B
|
||||
$this->assertSame(1, $body['meta']['totalRecords']);
|
||||
}
|
||||
|
||||
public function testSecretaryWithNoAssignmentSeesNothing(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_CLINIC']);
|
||||
$clinic = new Clinic($owner);
|
||||
$this->em->persist($clinic);
|
||||
|
||||
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تنها');
|
||||
$this->em->persist($doctor);
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
|
||||
// منشی context کلینیک دارد ولی رابطهی فعال ندارد
|
||||
$secretaryUser = $this->createUser(['ROLE_SECRETARY']);
|
||||
$this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid()));
|
||||
|
||||
$patient = $this->createUser(['ROLE_USER']);
|
||||
$start = time() + 3600;
|
||||
$this->em->persist(new Appointment($doctor, $patient, $start, $start + 900));
|
||||
$this->em->flush();
|
||||
|
||||
$body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser);
|
||||
|
||||
// بدون رابطهی فعال → resolveSecretaryFilter=null → لیست خالی
|
||||
$this->assertSame(0, $body['meta']['totalRecords']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Secretary;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Covers the national_code / address fields and the extended permission
|
||||
* taxonomy (patients, payments) on the secretary create/update endpoints.
|
||||
*/
|
||||
class SecretaryFieldsTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: \App\Auth\Entity\User, 1: Doctor} */
|
||||
private function makeDoctor(): array
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return [$owner, $doctor];
|
||||
}
|
||||
|
||||
public function testCreatePersistsNationalCodeAddressAndExtendedPermissions(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->makeDoctor();
|
||||
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/secretary', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'mobile_number' => $mobile,
|
||||
'name' => 'سارا احمدی',
|
||||
'national_code' => '1234567890',
|
||||
'address' => 'یزد، خیابان تست',
|
||||
'permissions' => [
|
||||
'version' => 1,
|
||||
'resources' => [
|
||||
'patients' => ['view' => true, 'create' => true],
|
||||
'payments' => ['view' => true],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$data = $res['data']['data'] ?? $res['data'];
|
||||
$this->assertSame('1234567890', $data['national_code']);
|
||||
$this->assertSame('یزد، خیابان تست', $data['address']);
|
||||
$this->assertTrue($data['permissions']['patients']['view']);
|
||||
$this->assertTrue($data['permissions']['patients']['create']);
|
||||
$this->assertTrue($data['permissions']['payments']['view']);
|
||||
}
|
||||
|
||||
public function testCreateWithoutOptionalFieldsPersistsNulls(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->makeDoctor();
|
||||
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
|
||||
$res = $this->authJson('POST', '/api/v1/secretary', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'mobile_number' => $mobile,
|
||||
'name' => 'بدون کدملی',
|
||||
]);
|
||||
|
||||
$this->assertSame(201, $this->responseCode());
|
||||
$data = $res['data']['data'] ?? $res['data'];
|
||||
$this->assertNull($data['national_code']);
|
||||
$this->assertNull($data['address']);
|
||||
}
|
||||
|
||||
public function testUpdateChangesNameNationalCodeAddressAndPermissions(): void
|
||||
{
|
||||
[$owner, $doctor] = $this->makeDoctor();
|
||||
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
|
||||
$created = $this->authJson('POST', '/api/v1/secretary', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'mobile_number' => $mobile,
|
||||
'name' => 'نام اولیه',
|
||||
]);
|
||||
$uuid = ($created['data']['data'] ?? $created['data'])['uuid'];
|
||||
|
||||
$res = $this->authJson('PATCH', '/api/v1/secretary/' . $uuid, $owner, [
|
||||
'name' => 'نام جدید',
|
||||
'national_code' => '9999999999',
|
||||
'address' => 'آدرس جدید',
|
||||
'permissions' => [
|
||||
'version' => 1,
|
||||
'resources' => ['appointments' => ['create' => false]],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $this->responseCode());
|
||||
$data = $res['data']['data'] ?? $res['data'];
|
||||
$this->assertSame('نام جدید', $data['user_name']);
|
||||
$this->assertSame('9999999999', $data['national_code']);
|
||||
$this->assertSame('آدرس جدید', $data['address']);
|
||||
$this->assertFalse($data['permissions']['appointments']['create']);
|
||||
}
|
||||
|
||||
public function testCreateRequiresDoctorUuidAndMobile(): void
|
||||
{
|
||||
[$owner] = $this->makeDoctor();
|
||||
|
||||
$this->authJson('POST', '/api/v1/secretary', $owner, ['name' => 'ناقص']);
|
||||
|
||||
$this->assertSame(422, $this->responseCode());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Shared;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Shared\Util\PersianText;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Persian/Arabic digits sent by any client must never reach the database.
|
||||
* The admin SPA normalizes at the input, but nobat724_front and clinic-pro-tauri
|
||||
* hit the same endpoints, so the request layer is the real guarantee.
|
||||
*/
|
||||
class NumericFieldNormalizerTest extends ApiTestCase
|
||||
{
|
||||
public function testDigitsHelperTranslatesWithoutStripping(): void
|
||||
{
|
||||
self::assertSame('09123456789', PersianText::digits('۰۹۱۲۳۴۵۶۷۸۹'));
|
||||
self::assertSame('0912', PersianText::digits('٠٩١٢'));
|
||||
self::assertSame('IR12-34', PersianText::digits('IR۱۲-۳۴'), 'letters and separators survive');
|
||||
self::assertSame('', PersianText::digits(''));
|
||||
}
|
||||
|
||||
public function testSecretaryCreatedWithPersianDigitsIsStoredLatin(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$persianMobile = '۰۹' . str_pad((string) random_int(0, 999_999_999), 9, '۰', STR_PAD_LEFT);
|
||||
$latinMobile = PersianText::digits($persianMobile);
|
||||
|
||||
$this->authJson('POST', '/api/v1/secretary', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'mobile_number' => $persianMobile,
|
||||
'name' => 'منشی تست',
|
||||
'national_code' => '۰۰۱۲۳۴۵۶۷۸',
|
||||
]);
|
||||
|
||||
self::assertSame(201, $this->responseCode(), 'Persian digits must not break validation');
|
||||
|
||||
$this->em->clear();
|
||||
$created = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $latinMobile]);
|
||||
self::assertNotNull($created, 'user is stored under the latin mobile');
|
||||
|
||||
$rel = $this->em->getRepository(DoctorSecretary::class)->findOneBy(['secretary' => $created]);
|
||||
self::assertSame('0012345678', $rel->getNationalCode());
|
||||
}
|
||||
|
||||
public function testNestedArraysAreNormalized(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
|
||||
$this->authJson('PUT', '/api/v1/insurance-pricing', $user, [
|
||||
'free_visit_price_rials' => '۵۰۰۰۰۰',
|
||||
'insurances' => [
|
||||
['insurance_id' => 1, 'patient_share_rials' => '۱۲۳۴۵'],
|
||||
],
|
||||
]);
|
||||
|
||||
// پروفایل پزشک ممکن است بیمهای نداشته باشد؛ مهم این است که ارقام فارسی
|
||||
// باعث خطای اعتبارسنجی یا NaN نشوند.
|
||||
self::assertNotSame(500, $this->responseCode(), 'nested persian digits must not blow up');
|
||||
}
|
||||
|
||||
public function testNonNumericKeysKeepPersianDigits(): void
|
||||
{
|
||||
$owner = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($owner, 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
$mobile = '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
|
||||
$this->authJson('POST', '/api/v1/secretary', $owner, [
|
||||
'doctor_uuid' => $doctor->getUuid(),
|
||||
'mobile_number' => $mobile,
|
||||
'name' => 'منشی شماره ۲',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
|
||||
$this->em->clear();
|
||||
$created = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
|
||||
self::assertStringContainsString('۲', $created->getRealName(), 'name is not a numeric field');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Shared;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* تایمزون سراسری اپلیکیشن باید ایران باشد (اجباری، مستقل از php.ini سرور).
|
||||
* توسط config/bootstrap_tz.php از طریق composer autoload.files تضمین میشود.
|
||||
*/
|
||||
class TimezoneTest extends TestCase
|
||||
{
|
||||
public function testDefaultTimezoneIsTehran(): void
|
||||
{
|
||||
$this->assertSame('Asia/Tehran', date_default_timezone_get());
|
||||
}
|
||||
|
||||
public function testDateFunctionsUseTehran(): void
|
||||
{
|
||||
// یک لحظهٔ مشخص UTC → ساعت ایران (+03:30).
|
||||
$ts = 1750000000; // 2025-06-15 15:06:40 UTC
|
||||
$this->assertSame('+0330', date('O', $ts));
|
||||
$this->assertSame('18:36', date('H:i', $ts));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Subscription;
|
||||
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Subscription\Entity\SubscriptionPlan;
|
||||
use App\Subscription\Repository\ClinicSubscriptionRepository;
|
||||
use App\Subscription\Repository\SubscriptionPeriodRepository;
|
||||
use App\Subscription\Repository\SubscriptionPlanRepository;
|
||||
use App\Subscription\Service\SubscriptionService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* A `basic` plan whose trial period has been deactivated is a normal admin
|
||||
* configuration. It used to surface as ERR_NOT_FOUND_001/500; it must now be
|
||||
* the same ERR_TRIAL_DISABLED/422 the trial_enabled flag produces.
|
||||
*/
|
||||
class ActivateTrialTest extends TestCase
|
||||
{
|
||||
private function service(
|
||||
bool $usedTrial,
|
||||
?string $trialEnabled,
|
||||
?SubscriptionPlan $basicPlan,
|
||||
mixed $trialPeriod,
|
||||
): SubscriptionService {
|
||||
$subscriptionRepo = $this->createMock(ClinicSubscriptionRepository::class);
|
||||
$subscriptionRepo->method('hasUsedTrial')->willReturn($usedTrial);
|
||||
|
||||
$planRepo = $this->createMock(SubscriptionPlanRepository::class);
|
||||
$planRepo->method('findByName')->willReturn($basicPlan);
|
||||
|
||||
$periodRepo = $this->createMock(SubscriptionPeriodRepository::class);
|
||||
$periodRepo->method('findTrialPeriodForPlan')->willReturn($trialPeriod);
|
||||
|
||||
$configRepo = $this->createMock(SiteConfigRepository::class);
|
||||
$configRepo->method('get')->willReturn($trialEnabled);
|
||||
|
||||
return new SubscriptionService($subscriptionRepo, $planRepo, $periodRepo, $configRepo);
|
||||
}
|
||||
|
||||
public function testNoActiveTrialPeriodIsReportedAsTrialDisabled(): void
|
||||
{
|
||||
$service = $this->service(
|
||||
usedTrial: false,
|
||||
trialEnabled: '1',
|
||||
basicPlan: $this->createMock(SubscriptionPlan::class),
|
||||
trialPeriod: null,
|
||||
);
|
||||
|
||||
try {
|
||||
$service->activateTrial('doctor', 1);
|
||||
$this->fail('expected AppException');
|
||||
} catch (AppException $e) {
|
||||
$this->assertSame(ErrorCodes::ERR_TRIAL_DISABLED, $e->getErrorCode());
|
||||
$this->assertSame(422, $e->getHttpStatus());
|
||||
}
|
||||
}
|
||||
|
||||
public function testMissingBasicPlanStaysAServerError(): void
|
||||
{
|
||||
$service = $this->service(
|
||||
usedTrial: false,
|
||||
trialEnabled: '1',
|
||||
basicPlan: null,
|
||||
trialPeriod: null,
|
||||
);
|
||||
|
||||
try {
|
||||
$service->activateTrial('doctor', 1);
|
||||
$this->fail('expected AppException');
|
||||
} catch (AppException $e) {
|
||||
$this->assertSame(ErrorCodes::ERR_NOT_FOUND_001, $e->getErrorCode());
|
||||
$this->assertSame(500, $e->getHttpStatus());
|
||||
}
|
||||
}
|
||||
|
||||
public function testAlreadyUsedTrialTakesPrecedence(): void
|
||||
{
|
||||
$service = $this->service(
|
||||
usedTrial: true,
|
||||
trialEnabled: '1',
|
||||
basicPlan: null,
|
||||
trialPeriod: null,
|
||||
);
|
||||
|
||||
try {
|
||||
$service->activateTrial('doctor', 1);
|
||||
$this->fail('expected AppException');
|
||||
} catch (AppException $e) {
|
||||
$this->assertSame(ErrorCodes::ERR_TRIAL_ALREADY_USED, $e->getErrorCode());
|
||||
$this->assertSame(422, $e->getHttpStatus());
|
||||
}
|
||||
}
|
||||
|
||||
public function testDisabledFlagTakesPrecedenceOverMissingPlan(): void
|
||||
{
|
||||
$service = $this->service(
|
||||
usedTrial: false,
|
||||
trialEnabled: '0',
|
||||
basicPlan: null,
|
||||
trialPeriod: null,
|
||||
);
|
||||
|
||||
try {
|
||||
$service->activateTrial('doctor', 1);
|
||||
$this->fail('expected AppException');
|
||||
} catch (AppException $e) {
|
||||
$this->assertSame(ErrorCodes::ERR_TRIAL_DISABLED, $e->getErrorCode());
|
||||
$this->assertSame(422, $e->getHttpStatus());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Tag;
|
||||
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* Per-tenant tag CRUD, scoped to the caller's doctor/clinic entity.
|
||||
*/
|
||||
class TenantTagTest extends ApiTestCase
|
||||
{
|
||||
private function doctorUser(): array
|
||||
{
|
||||
$user = $this->createUser(['ROLE_DOCTOR']);
|
||||
$doctor = new Doctor($user, 'دکتر');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
return [$user, $doctor];
|
||||
}
|
||||
|
||||
public function testCreateListUpdateDelete(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
|
||||
// create
|
||||
$created = $this->authJson('POST', '/api/v1/tenant-tag', $user, [
|
||||
'name' => 'فوری', 'color' => '#FF0000',
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertSame('فوری', $created['data']['name']);
|
||||
self::assertSame('#FF0000', $created['data']['color']);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
// list
|
||||
$list = $this->authJson('GET', '/api/v1/tenant-tags', $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertSame('فوری', $list['data'][0]['name']);
|
||||
|
||||
// update
|
||||
$this->authJson('PATCH', '/api/v1/tenant-tag/' . $uuid, $user, [
|
||||
'name' => 'مهم', 'color' => '#00AA00', 'active' => false,
|
||||
]);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
// delete
|
||||
$this->authJson('DELETE', '/api/v1/tenant-tag/' . $uuid, $user);
|
||||
self::assertSame(200, $this->responseCode());
|
||||
|
||||
$after = $this->authJson('GET', '/api/v1/tenant-tags', $user);
|
||||
self::assertCount(0, $after['data']);
|
||||
}
|
||||
|
||||
public function testCreateHonorsActiveFlag(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
|
||||
// explicit active:false is persisted, not forced to the default true
|
||||
$created = $this->authJson('POST', '/api/v1/tenant-tag', $user, [
|
||||
'name' => 'بایگانی', 'color' => '#123456', 'active' => false,
|
||||
]);
|
||||
self::assertSame(201, $this->responseCode());
|
||||
self::assertFalse($created['data']['active']);
|
||||
|
||||
// omitting active still defaults to true
|
||||
$default = $this->authJson('POST', '/api/v1/tenant-tag', $user, [
|
||||
'name' => 'پیشفرض', 'color' => '#654321',
|
||||
]);
|
||||
self::assertTrue($default['data']['active']);
|
||||
}
|
||||
|
||||
public function testRejectsInvalidNameAndColor(): void
|
||||
{
|
||||
[$user] = $this->doctorUser();
|
||||
|
||||
$this->authJson('POST', '/api/v1/tenant-tag', $user, ['name' => '', 'color' => '#FF0000']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
|
||||
$this->authJson('POST', '/api/v1/tenant-tag', $user, ['name' => 'ok', 'color' => 'red']);
|
||||
self::assertSame(422, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testCannotTouchAnotherTenantsTag(): void
|
||||
{
|
||||
[$ownerA] = $this->doctorUser();
|
||||
$created = $this->authJson('POST', '/api/v1/tenant-tag', $ownerA, ['name' => 'مال A', 'color' => '#123456']);
|
||||
$uuid = $created['data']['uuid'];
|
||||
|
||||
[$ownerB] = $this->doctorUser();
|
||||
$this->authJson('PATCH', '/api/v1/tenant-tag/' . $uuid, $ownerB, ['name' => 'دزدی']);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
|
||||
$this->authJson('DELETE', '/api/v1/tenant-tag/' . $uuid, $ownerB);
|
||||
self::assertSame(404, $this->responseCode());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user