Merge branch 'dev' into main

# Conflicts:
#	docs/api/doctor.md
This commit is contained in:
hamed
2026-07-19 16:15:30 +03:30
1026 changed files with 190049 additions and 15130 deletions
@@ -0,0 +1,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']);
}
}
+135
View File
@@ -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));
}
}
+6 -5
View File
@@ -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']);
}
}
+84
View File
@@ -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());
}
}
+105
View File
@@ -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:0017: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());
}
}
+35
View File
@@ -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 -5
View File
@@ -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']);
+71
View File
@@ -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());
}
}
+186
View File
@@ -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());
}
}
+171
View File
@@ -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']);
}
}
+271
View File
@@ -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']);
}
}
+191
View File
@@ -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']);
}
}
+179
View File
@@ -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());
}
}
+184
View File
@@ -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']);
}
}
+190
View File
@@ -0,0 +1,190 @@
<?php
namespace App\Tests\Doctor;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Doctor\Repository\DoctorRepository;
use App\Location\Entity\City;
use App\Location\Entity\Province;
use App\Tests\ApiTestCase;
/**
* The public doctor list must expose each doctor's city/state so multi-domain
* consumers (nobat724_front sitemap) can tell which city domain owns a doctor.
*
* Location resolution mirrors the city_id/state_id filter in
* DoctorRepository::findWithFilters — own address first, clinic address as
* fallback — otherwise a doctor could match the filter but report no city.
*/
class DoctorListLocationTest extends ApiTestCase
{
private function makeCity(string $cityName, string $provinceName): City
{
$province = new Province($provinceName);
$this->em->persist($province);
$city = new City($cityName, $province);
$this->em->persist($city);
return $city;
}
private function makeDoctor(string $name): Doctor
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), $name);
$this->em->persist($doctor);
return $doctor;
}
private function giveOwnAddress(Doctor $doctor, City $city): void
{
$address = DoctorAddress::forDoctor($doctor);
$address->setCity($city)->setProvince($city->getProvince());
$this->em->persist($address);
}
/** @return array<int, array> map of doctor name => list payload */
private function fetchListByName(string $name): array
{
$this->client->request('GET', '/api/v1/doctors?limit=50&name=' . urlencode($name));
$this->assertSame(200, $this->responseCode());
$payload = json_decode($this->client->getResponse()->getContent(), true);
$byName = [];
foreach ($payload['data'] as $row) {
$byName[$row['name']] = $row;
}
return $byName;
}
public function testDoctorWithOwnAddressReportsItsCity(): void
{
$city = $this->makeCity('یاسوج', 'کهگیلویه و بویراحمد');
$marker = 'loc-own-' . bin2hex(random_bytes(4));
$doctor = $this->makeDoctor($marker);
$this->giveOwnAddress($doctor, $city);
$this->em->flush();
$row = $this->fetchListByName($marker)[$marker] ?? null;
$this->assertNotNull($row, 'doctor missing from list');
$this->assertCount(1, $row['city']);
$this->assertSame('یاسوج', $row['city'][0]['name']);
$this->assertSame((string) $city->getId(), $row['city'][0]['id']);
$this->assertSame((string) $city->getProvince()->getId(), $row['city'][0]['parent']);
$this->assertCount(1, $row['state']);
$this->assertSame('کهگیلویه و بویراحمد', $row['state'][0]['name']);
}
public function testDoctorWithoutOwnAddressFallsBackToClinicCity(): void
{
$city = $this->makeCity('تبریز', 'آذربایجان شرقی');
$marker = 'loc-clinic-' . bin2hex(random_bytes(4));
$doctor = $this->makeDoctor($marker);
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$this->em->persist($clinic);
$clinic->getDoctors()->add($doctor);
$this->em->flush();
// آدرس کلینیک ردیفی از DoctorAddress با doctor NULL و clinicId پرشده است.
$clinicAddress = DoctorAddress::forClinic($clinic->getId());
$clinicAddress->setCity($city)->setProvince($city->getProvince());
$this->em->persist($clinicAddress);
$this->em->flush();
$row = $this->fetchListByName($marker)[$marker] ?? null;
$this->assertNotNull($row, 'doctor missing from list');
$this->assertCount(1, $row['city'], 'clinic-located doctor must still report a city');
$this->assertSame('تبریز', $row['city'][0]['name']);
}
public function testDoctorWithNoLocationReportsEmptyArrays(): void
{
$marker = 'loc-none-' . bin2hex(random_bytes(4));
$this->makeDoctor($marker);
$this->em->flush();
$row = $this->fetchListByName($marker)[$marker] ?? null;
$this->assertNotNull($row, 'doctor missing from list');
$this->assertSame([], $row['city']);
$this->assertSame([], $row['state']);
}
public function testReportedCityMatchesCityIdFilter(): void
{
$city = $this->makeCity('یزد', 'یزد');
$marker = 'loc-filter-' . bin2hex(random_bytes(4));
$doctor = $this->makeDoctor($marker);
$this->giveOwnAddress($doctor, $city);
$this->em->flush();
$this->client->request('GET', '/api/v1/doctors?limit=50&city_id=' . $city->getId());
$this->assertSame(200, $this->responseCode());
$payload = json_decode($this->client->getResponse()->getContent(), true);
$this->assertNotEmpty($payload['data']);
foreach ($payload['data'] as $row) {
$this->assertNotEmpty(
$row['city'],
"doctor {$row['name']} matched city_id filter but reports no city"
);
$this->assertSame((string) $city->getId(), $row['city'][0]['id']);
}
}
/**
* سهمِ خودِ حل‌مکان اندازه‌گیری می‌شود، نه کل اندپوینت: پاسخ لیست lazy-loadهای
* قدیمی (specialties) هم دارد که با تعداد پزشک رشد می‌کنند و ربطی به این تغییر
* ندارند. پس مستقیم ریپازیتوری تست می‌شود — باید حداکثر ۲ کوئری باشد، ثابت.
*/
public function testLocationResolutionCostIsConstant(): void
{
$city = $this->makeCity('اصفهان', 'اصفهان');
$repo = static::getContainer()->get(DoctorRepository::class);
$makeDoctors = function (int $count) use ($city): array {
$doctors = [];
for ($i = 0; $i < $count; $i++) {
$doctor = $this->makeDoctor('loc-cost-' . bin2hex(random_bytes(4)));
$this->giveOwnAddress($doctor, $city);
$doctors[] = $doctor;
}
$this->em->flush();
return $doctors;
};
$few = $makeDoctors(2);
$many = $makeDoctors(12);
$qFew = $this->countQueries(fn () => $repo->findLocationsByDoctors($few));
$qMany = $this->countQueries(fn () => $repo->findLocationsByDoctors($many));
$this->assertSame($qFew, $qMany, "location resolution scales with doctor count: $qFew -> $qMany");
$this->assertLessThanOrEqual(2, $qMany, 'location resolution must cost at most 2 queries');
// و همچنان درست کار کند
$resolved = $repo->findLocationsByDoctors($many);
$this->assertCount(12, $resolved);
foreach ($many as $doctor) {
$this->assertSame('اصفهان', $resolved[$doctor->getId()]['city']['name']);
}
}
public function testPaginationMetaExposesAppliedLimit(): void
{
$this->client->request('GET', '/api/v1/doctors?limit=500');
$this->assertSame(200, $this->responseCode());
$payload = json_decode($this->client->getResponse()->getContent(), true);
// سقف ریپازیتوری ۵۰ است؛ meta باید مقدار واقعاً اعمال‌شده را بگوید نه ۵۰۰.
$this->assertSame(50, $payload['meta']['limit']);
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
namespace App\Tests\Doctor;
use App\Doctor\Entity\Doctor;
use App\Shared\Util\PersianText;
use App\Tests\ApiTestCase;
/**
* A doctor's stored name never carries the «دکتر» title — the display layer
* decides how to present it. DoctorImportService and DoctorClaimService already
* enforced this; the ordinary registration paths did not, so a name typed as
* «دکتر حامد حسینی» was stored verbatim and then rendered «دکتر دکتر حامد حسینی».
*/
class DoctorNameTitleTest extends ApiTestCase
{
/** db_test is never reset, so a fixed mobile collides across runs. */
private function freshMobile(): string
{
return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
}
private function createDoctorAs(string $name): ?Doctor
{
$admin = $this->createUser(['ROLE_ADMIN']);
$mobile = $this->freshMobile();
$this->authJson('POST', '/api/v1/admin/doctors', $admin, [
'mobile' => $mobile,
'name' => $name,
]);
$this->assertSame(201, $this->responseCode());
return $this->em->getRepository(Doctor::class)
->createQueryBuilder('d')
->join('d.user', 'u')->where('u.mobileNumber = :m')->setParameter('m', $mobile)
->getQuery()->getOneOrNullResult();
}
public function testAdminCreateStripsTheTitle(): void
{
$doctor = $this->createDoctorAs('دکتر حامد حسینی');
$this->assertNotNull($doctor);
$this->assertSame('حامد حسینی', $doctor->getName());
}
public function testPlainNameIsLeftAlone(): void
{
$doctor = $this->createDoctorAs('حامد حسینی');
$this->assertNotNull($doctor);
$this->assertSame('حامد حسینی', $doctor->getName());
}
#[\PHPUnit\Framework\Attributes\DataProvider('titleVariants')]
public function testStripDoctorTitle(string $input, string $expected): void
{
$this->assertSame($expected, PersianText::stripDoctorTitle($input));
}
public static function titleVariants(): array
{
return [
'plain' => ['حامد حسینی', 'حامد حسینی'],
'single title' => ['دکتر حامد حسینی', 'حامد حسینی'],
'repeated title' => ['دکتر دکتر حامد حسینی', 'حامد حسینی'],
'extra whitespace' => [' دکتر حامد حسینی ', 'حامد حسینی'],
'title inside' => ['حامد دکتر حسینی', 'حامد دکتر حسینی'],
];
}
}
+115
View File
@@ -0,0 +1,115 @@
<?php
namespace App\Tests\Doctor;
use App\Clinic\Entity\Clinic;
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
use App\Doctor\Entity\Doctor;
use App\Shared\Exception\AppException;
use App\Tests\ApiTestCase;
/**
* A doctor/clinic name reaches the public site's <title> and search results, so a
* phone number or "test" must never be storable. The guard lives on the entity
* because eight different call sites construct a Doctor.
*/
class PollutedNameRejectionTest extends ApiTestCase
{
public function testDoctorCannotBeCreatedWithPhoneNumberAsName(): void
{
$this->expectException(AppException::class);
new Doctor($this->createUser(['ROLE_DOCTOR']), '09390039833');
}
public function testDoctorCannotBeRenamedToPlaceholder(): void
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر علی احمدی');
$this->expectException(AppException::class);
$doctor->setName('test');
}
public function testClinicCannotBeNamedAfterPhoneNumber(): void
{
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$this->expectException(AppException::class);
$clinic->setName('09398631203');
}
public function testClinicNameMayStayNullBeforeItIsSet(): void
{
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$clinic->setName(null);
$this->assertNull($clinic->getName());
}
public function testAdminCreatingDoctorWithPhoneNameGets422(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$this->em->flush();
$this->authJson('POST', '/api/v1/admin/doctors', $admin, [
'name' => '09390039833',
'mobile_number' => '0912' . random_int(1000000, 9999999),
]);
$this->assertSame(422, $this->responseCode());
}
/**
* ریشهٔ آلودگی تولیدی: دعوت پزشک فقط با موبایل، شماره را به‌عنوان نام می‌نشاند.
*/
public function testInvitingDoctorByMobileDoesNotUseMobileAsName(): void
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$this->em->flush();
$mobile = '0912' . random_int(1000000, 9999999);
$this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
'mobile' => $mobile,
]);
$this->assertSame(201, $this->responseCode());
$invitation = $this->em->getRepository(ClinicDoctorInvitation::class)
->findOneBy(['mobile' => $mobile]);
$this->assertNotNull($invitation, 'invitation was not created');
$this->client->request('POST', "/api/v1/clinic-invitation/{$invitation->getToken()}/accept");
$this->assertSame(200, $this->responseCode());
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['mobileNumber' => $mobile]);
$this->assertNotNull($doctor, 'invited doctor was not created');
$this->assertNotSame($mobile, $doctor->getName(), 'mobile number leaked into the doctor name');
$this->assertSame('پزشک دعوت‌شده', $doctor->getName());
}
public function testInvitedNameIsUsedWhenTheClinicProvidesOne(): void
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$this->em->flush();
$mobile = '0912' . random_int(1000000, 9999999);
$this->authJson('POST', "/api/v1/admin/clinic/{$clinic->getUuid()}/invite-doctor", $owner, [
'mobile' => $mobile,
'name' => 'دکتر مریم رضایی',
]);
$this->assertSame(201, $this->responseCode());
$invitation = $this->em->getRepository(ClinicDoctorInvitation::class)
->findOneBy(['mobile' => $mobile]);
$this->client->request('POST', "/api/v1/clinic-invitation/{$invitation->getToken()}/accept");
$this->assertSame(200, $this->responseCode());
$this->em->clear();
$doctor = $this->em->getRepository(Doctor::class)->findOneBy(['mobileNumber' => $mobile]);
$this->assertSame('دکتر مریم رضایی', $doctor->getName());
}
}
+259
View File
@@ -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']);
}
}
+240
View File
@@ -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), 'نوبت رزروِ روز-محور ساعت ندارد؛ مراجعه نمی‌سازد');
}
}
+251
View File
@@ -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(), 'پزشکِ همان نوبت');
}
}
+97
View File
@@ -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());
}
}
+62
View File
@@ -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());
}
}
+92
View File
@@ -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());
}
}
+200
View File
@@ -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());
}
}
+143
View File
@@ -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());
}
}
+69
View File
@@ -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());
}
}
+126
View File
@@ -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());
}
}
+53
View File
@@ -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());
}
}
+123
View File
@@ -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']);
}
}
+175
View File
@@ -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']);
}
}
+153
View File
@@ -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']);
}
}
+270
View File
@@ -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());
}
}
+191
View File
@@ -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']);
}
}
+109
View File
@@ -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());
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Tests\Shared;
use App\Shared\Exception\AppException;
use App\Shared\Util\DisplayName;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* These rules must stay in sync with nobat724_front/lib/entityQuality.js — the
* public site noindexes exactly the records this class refuses to create.
*/
class DisplayNameTest extends TestCase
{
/** @return array<string, array{string}> */
public static function pollutedNames(): array
{
return [
'mobile' => ['09390039833'],
'mobile without leading' => ['9390039833'],
'mobile with spaces' => ['0939 003 9833'],
'mobile with dashes' => ['0939-003-9833'],
'test latin' => ['test'],
'test latin uppercase' => ['TEST'],
'test persian' => ['تست'],
'dash' => ['-'],
'empty' => [''],
'whitespace only' => [' '],
'single char' => ['a'],
'literal null' => ['null'],
];
}
#[DataProvider('pollutedNames')]
public function testPollutedNamesAreRejected(string $name): void
{
// «{$name}» — بدون آکولاد، PHP کاراکتر » را جزو نام متغیر می‌گیرد
$this->assertTrue(DisplayName::isPlaceholder($name), "«{$name}» باید نامعتبر باشد");
$this->expectException(AppException::class);
DisplayName::assertReal($name);
}
/** @return array<string, array{string}> */
public static function realNames(): array
{
return [
'persian full name' => ['سیده مهدیه کشاورز'],
'with title' => ['دکتر علی احمدی'],
'clinic name' => ['کلینیک تخصصی امید تبریز'],
'short persian' => ['رضا'],
'latin name' => ['John Smith'],
// شماره‌ای که الگوی موبایل ایران نیست، نام عجیبی است ولی سانسور نمی‌شود
'landline' => ['02191550875'],
];
}
#[DataProvider('realNames')]
public function testRealNamesPass(string $name): void
{
$this->assertFalse(DisplayName::isPlaceholder($name), "«{$name}» باید معتبر باشد");
DisplayName::assertReal($name);
$this->addToAssertionCount(1);
}
public function testNullIsTreatedAsPlaceholder(): void
{
$this->assertTrue(DisplayName::isPlaceholder(null));
}
public function testRejectionCarries422(): void
{
try {
DisplayName::assertReal('09390039833');
$this->fail('expected AppException');
} catch (AppException $e) {
$this->assertSame(422, $e->getHttpStatus());
$this->assertStringContainsString('نام معتبر نیست', $e->getMessage());
}
}
}
@@ -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');
}
}
+25
View File
@@ -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));
}
}
+114
View File
@@ -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());
}
}
}
+96
View File
@@ -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());
}
}