Remove ReportTest and WaitlistTest files as part of codebase cleanup

This commit is contained in:
hamed
2026-08-01 20:16:41 +03:30
parent 4711ba0af7
commit 65d5831c64
102 changed files with 0 additions and 13506 deletions
-548
View File
@@ -1,548 +0,0 @@
<?php
namespace App\Tests\Cancellation;
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\Doctor\Entity\DoctorAddress;
use App\Patient\Entity\PatientRecord;
use App\Payment\Entity\Payment;
use App\Settlement\Service\WalletService;
use App\Tag\Entity\TenantTag;
use App\Tests\ApiTestCase;
/**
* سیاست لغو، جریمه و عدم حضور — تسک ۱۳.
*
* دو قاعده که شکستنشان گران است: لغو توسط کلینیک هرگز جریمه ندارد، و جریمه هرگز از
* مبلغ پرداختی بیشتر نمی‌شود.
*/
class CancellationTest extends ApiTestCase
{
private int $slotCursor = 0;
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
private function clinicWithPatient(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک لغو');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($doctorUser, 'دکتر لغو');
$this->em->persist($doctor);
$patientUser = $this->createUser(['ROLE_USER']);
$patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
$this->em->persist($patient);
$this->em->flush();
return [$user, $section, $address, $doctor, $patient];
}
private function service(ServiceSection $section, string $name = 'لیزر'): ServiceItem
{
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes(30);
$item->setPriceRials(4_000_000);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** @param array<string, mixed> $body */
private function savePolicy(User $user, array $body): array
{
$saved = $this->authJson('PUT', '/api/v1/cancellation-policy', $user, $body);
self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE));
return $saved['data'];
}
/** نوبتی در آینده، با مبلغ ثبت‌شده و در صورت نیاز پرداخت موفق. */
private function appointment(
Doctor $doctor,
PatientRecord $patient,
ServiceItem $service,
int $clinicId,
int $hoursAhead,
int $price = 4_000_000,
int $paid = 0,
): Appointment {
$em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);
$start = time() + $hoursAhead * 3600 + (++$this->slotCursor) * 60;
$appointment = new Appointment(
$em->getRepository(Doctor::class)->find($doctor->getId()),
$em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(),
$start,
$start + 1800,
);
$appointment->assignTenantPair('clinic', $clinicId);
$appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId()));
$appointment->setVisitPriceRials($price);
$appointment->setPatientName('بیمار لغو');
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$em->persist($appointment);
$em->flush();
if ($paid > 0) {
$payment = new Payment($appointment->getUser(), $paid, 'zarinpal', Payment::TYPE_APPOINTMENT);
$payment->assignTenantPair('clinic', $clinicId);
$payment->setAppointment($appointment);
$payment->setStatus(Payment::STATUS_SUCCESS);
$em->persist($payment);
$em->flush();
}
return $appointment;
}
private function wallet(): WalletService
{
return static::getContainer()->get(WalletService::class);
}
// ── پیش‌نمایش ───────────────────────────────────────────────────────────
public function testInsideTheFreeWindowThereIsNoPenalty(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$this->savePolicy($user, [
'free_window_hours' => 24,
'penalty_mode' => 'percent',
'penalty_value' => 50,
'deposit_refundable' => false,
]);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 48, paid: 4_000_000);
$preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user);
self::assertSame(200, $this->responseCode(), json_encode($preview, JSON_UNESCAPED_UNICODE));
self::assertSame(0, $preview['data']['penalty_rials']);
self::assertTrue($preview['data']['deposit_refundable']);
self::assertTrue($preview['data']['within_free_window']);
}
public function testOutsideTheFreeWindowThePercentagePenaltyApplies(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$this->savePolicy($user, [
'free_window_hours' => 24,
'penalty_mode' => 'percent',
'penalty_value' => 50,
'deposit_refundable' => false,
]);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 6, paid: 4_000_000);
$preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data'];
self::assertSame(2_000_000, $preview['penalty_rials']);
self::assertFalse($preview['deposit_refundable']);
self::assertFalse($preview['within_free_window']);
}
/** ⭐ لغو توسط کلینیک هرگز جریمه ندارد، حتی یک ساعت مانده به نوبت. */
public function testTheClinicCancellingItsOwnAppointmentIsAlwaysFree(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 100]);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 1, paid: 4_000_000);
$preview = $this->authJson(
'GET',
"/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview?by=doctor",
$user,
)['data'];
self::assertSame(0, $preview['penalty_rials']);
self::assertTrue($preview['deposit_refundable']);
}
/** ⭐ جریمهٔ بیشتر از پرداختی یعنی بدهی، و بدهی مسئلهٔ لغو نیست. */
public function testThePenaltyNeverExceedsWhatWasPaid(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$this->savePolicy($user, [
'free_window_hours' => 24,
'penalty_mode' => 'fixed',
'penalty_value' => 9_000_000,
]);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2, paid: 1_000_000);
$preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data'];
self::assertSame(1_000_000, $preview['penalty_rials']);
self::assertNotEmpty($preview['notes']);
}
/** نوبت نقدی: جریمه صفر می‌شود و پاسخ توضیحش را می‌دهد. */
public function testAnUnpaidAppointmentIsNotCharged(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 50]);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2);
$preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data'];
self::assertSame(0, $preview['penalty_rials']);
self::assertStringContainsString('پرداختی نداشته', implode(' ', $preview['notes']));
}
public function testWithoutAPolicyNothingIsCharged(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 1, paid: 4_000_000);
$preview = $this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $user)['data'];
self::assertSame(0, $preview['penalty_rials']);
}
// ── لغو واقعی ───────────────────────────────────────────────────────────
public function testCancellingChargesTheWalletAndReleasesTheSlot(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 25]);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 3, paid: 4_000_000);
// کیف پول باید موجودی داشته باشد وگرنه جریمه کسر نمی‌شود.
$em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);
$this->wallet()->charge($em->getRepository(\App\Auth\Entity\User::class)->find($appointment->getUser()->getId()), 5_000_000);
$body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame(1_000_000, $body['data']['penalty_rials']);
self::assertTrue($body['data']['penalty_charged']);
self::assertSame('cancelled_by_user', $body['data']['status']);
$patientUser = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class)
->getRepository(\App\Auth\Entity\User::class)
->find($appointment->getUser()->getId());
self::assertSame(4_000_000, $this->wallet()->balance($patientUser), 'جریمه باید از کیف پول کسر شود');
}
/** موجودی ناکافی نباید لغو را شکست بدهد؛ نوبت باید آزاد شود. */
public function testAnEmptyWalletDoesNotBlockTheCancellation(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$this->savePolicy($user, ['free_window_hours' => 24, 'penalty_mode' => 'percent', 'penalty_value' => 50]);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 3, paid: 4_000_000);
$body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);
self::assertSame(200, $this->responseCode());
self::assertSame(2_000_000, $body['data']['penalty_rials']);
self::assertFalse($body['data']['penalty_charged'], 'موجودی نبود، پس کسر نشد — ولی نوبت لغو شد');
self::assertSame('cancelled_by_user', $body['data']['status']);
}
public function testCancellingTwiceIsRejected(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 30);
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);
self::assertSame(200, $this->responseCode());
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);
self::assertSame(409, $this->responseCode());
}
/** برای گذشته `no_show` یا `completed` معنا دارد، نه لغو. */
public function testAPastAppointmentCannotBeCancelled(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 5);
$this->em->getConnection()->executeStatement(
'UPDATE appointments SET slot_start = ?, slot_end = ? WHERE uuid = ?',
[time() - 7200, time() - 5400, $appointment->getUuid()],
);
$this->em->clear();
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);
self::assertSame(422, $this->responseCode());
}
// ── عدم حضور ────────────────────────────────────────────────────────────
/** ⭐ سومین عدم حضور برچسب پرریسک می‌گذارد — ولی بیمار را مسدود نمی‌کند. */
public function testTheThirdNoShowTagsThePatient(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$tag = new TenantTag('clinic', (int) $address->getClinicId(), 'پرریسک', '#dc2626');
$this->em->persist($tag);
$this->em->flush();
$this->savePolicy($user, ['no_show_threshold' => 3, 'risk_tag_uuid' => $tag->getUuid()]);
$last = null;
for ($i = 1; $i <= 3; $i++) {
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2);
$last = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user);
self::assertSame(200, $this->responseCode(), json_encode($last, JSON_UNESCAPED_UNICODE));
}
self::assertSame(3, $last['data']['count']);
self::assertTrue($last['data']['tagged']);
$reloaded = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class)
->getRepository(PatientRecord::class)
->find($patient->getId());
$tagNames = array_map(static fn (TenantTag $t): string => $t->getName(), $reloaded->getTags()->toArray());
self::assertContains('پرریسک', $tagNames);
}
/** ثبت دوباره روی همان نوبت، عدم حضور دوم نمی‌سازد. */
public function testRecordingTheSameNoShowTwiceCountsOnce(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 2);
$first = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user);
$second = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user);
self::assertTrue($first['data']['recorded']);
self::assertFalse($second['data']['recorded']);
self::assertSame(1, $second['data']['count']);
}
// ── جداسازی محیط ────────────────────────────────────────────────────────
/**
* ⭐ سیاست سرویس بر سیاست محیط مقدم است — بدون ترکیب.
*
* ترکیب («پنجرهٔ رایگانِ محیط با درصدِ سرویس») یعنی هیچ‌کس نتواند بگوید عدد نهایی از
* کجا آمد. سرویس اگر سیاست دارد، **همه‌اش** مال اوست.
*/
public function testTheServicePolicyWinsOverTheTenantPolicy(): void
{
[$user, $section, , $doctor, $patient] = $this->clinicWithPatient();
$clinicId = (int) $patient->getEntityId();
$service = $this->service($section);
// پنجرهٔ محیط یک ساعت است: با ۲۴ ساعت مانده، لغو رایگان می‌شد.
$this->savePolicy($user, [
'free_window_hours' => 1,
'penalty_mode' => 'percent',
'penalty_value' => 10,
]);
// پنجرهٔ سرویس ۴۸ ساعت است: همان لغو، جریمه دارد.
$saved = $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/cancellation-policy", $user, [
'free_window_hours' => 48,
'penalty_mode' => 'percent',
'penalty_value' => 50,
]);
self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE));
// عددِ نهایی می‌گوید کدام سیاست حاکم بوده: ۰ یعنی محیط، ۵۰٪ یعنی سرویس.
$appointment = $this->appointment($doctor, $patient, $service, $clinicId, 24, 4_000_000, 4_000_000);
$preview = $this->authJson(
'GET',
"/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview?by=user",
$user,
);
self::assertSame(2_000_000, $preview['data']['penalty_rials'], 'سیاست سرویس حاکم است، نه سیاست محیط');
self::assertFalse($preview['data']['within_free_window']);
}
/**
* ⭐ برچسب پرریسک **مسدود نمی‌کند**.
*
* مسدودسازی یک قانون `eligibility` جداست؛ کلینیکی که می‌خواهد بیمار پرریسک را ببیند
* ولی بیعانه بگیرد، نباید مجبور شود برچسب را خاموش کند.
*/
public function testATaggedPatientCanStillBook(): void
{
[$user, $section, , $doctor, $patient] = $this->clinicWithPatient();
$clinicId = (int) $patient->getEntityId();
$service = $this->service($section);
$this->savePolicy($user, ['no_show_threshold' => 2]);
foreach ([1, 2, 3] as $i) {
$appointment = $this->appointment($doctor, $patient, $service, $clinicId, -$i * 24);
$this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/no-show", $user);
self::assertSame(200, $this->responseCode());
}
$summary = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/no-shows", $user);
self::assertTrue($summary['data']['at_risk']);
self::assertSame(3, $summary['data']['count']);
// و با همین وضعیت، نوبت تازه ثبت می‌شود.
$fresh = $this->appointment($doctor, $patient, $service, $clinicId, 48);
self::assertSame(Appointment::STATUS_CONFIRMED, $fresh->getStatus());
}
public function testAnotherClinicCannotSeeTheNoShowSummary(): void
{
[$user, , , , $patient] = $this->clinicWithPatient();
[$other] = $this->clinicWithPatient();
$this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/no-shows", $other);
self::assertSame(404, $this->responseCode());
$this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/no-shows", $user);
self::assertSame(200, $this->responseCode());
}
/**
* ⭐ شکست اطلاع‌رسانی نباید لغو را برگرداند.
*
* حالا که همهٔ نوشتن‌ها در یک تراکنش‌اند، این سؤال جدی است: اگر پیامک **داخل** آن
* بلوک بود، یک خطای سرویس پیامک ظرفیت آزادشده را پس می‌گرفت و بیمار هم نوبت
* نداشت هم وقتش را. اطلاع‌رسانی عمداً بعد از commit است و این تست همان را پین
* می‌کند: با یک notifier که همیشه می‌ترکد، لغو باز هم کامل انجام می‌شود.
*/
public function testAFailingNotifierDoesNotUndoTheCancellation(): void
{
[$user, $section, , $doctor, $patient] = $this->clinicWithPatient();
$clinicId = (int) $patient->getEntityId();
$service = $this->service($section);
$appointment = $this->appointment($doctor, $patient, $service, $clinicId, 48, 4_000_000, 4_000_000);
$uuid = $appointment->getUuid();
// برای اینکه اطلاع‌رسانی واقعاً به بیمار برسد، باید کسی در لیست انتظار باشد.
$waiting = $this->createUser(['ROLE_USER']);
$record = new PatientRecord('clinic', $clinicId, $waiting, 'clinic', $clinicId);
$this->em->persist($record);
$this->em->flush();
$entry = new \App\Waitlist\Entity\WaitlistEntry(
$record,
$this->em->getRepository(ServiceItem::class)->find($service->getId()),
$appointment->getSlotStart() - 86400,
$appointment->getSlotStart() + 86400,
);
$this->em->persist($entry);
$this->em->flush();
// سرویس پیامکی که همیشه می‌ترکد — همان چیزی که در تولید یک قطعی است.
static::getContainer()->set(
\App\Sms\Service\SmsService::class,
new class extends \App\Sms\Service\SmsService {
public function __construct() {}
public function dispatchAsync(
string $mobile,
string $message,
string $provider = 'kavenegar',
?string $templateUuid = null,
array $templateVars = [],
?string $templateCode = null,
string $tag = \App\Sms\Entity\SmsLog::TAG_GLOBAL,
): void {
throw new \RuntimeException('sms provider down');
}
},
);
$threw = false;
try {
static::getContainer()->get(\App\Cancellation\Service\CancellationService::class)
->cancel($appointment, Appointment::STATUS_CANCELLED_BY_USER, $user);
} catch (\RuntimeException) {
$threw = true;
}
self::assertTrue($threw, 'خطای اطلاع‌رسانی بالا می‌آید — پنهانش نمی‌کنیم');
// ولی خودِ لغو commit شده است.
$this->em->clear();
$reloaded = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $uuid]);
self::assertSame(
Appointment::STATUS_CANCELLED_BY_USER,
$reloaded->getStatus(),
'لغو نباید گروگان سرویس پیامک بماند',
);
}
public function testAnotherClinicCannotPreviewTheCancellation(): void
{
[$owner, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
[$other] = $this->clinicWithPatient();
$service = $this->service($section);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId(), 5);
$this->authJson('GET', "/api/v1/appointment/{$appointment->getUuid()}/cancellation-preview", $other);
self::assertSame(404, $this->responseCode());
}
public function testAPercentageAboveOneHundredIsRejected(): void
{
[$user] = $this->clinicWithPatient();
$this->authJson('PUT', '/api/v1/cancellation-policy', $user, [
'penalty_mode' => 'percent',
'penalty_value' => 150,
]);
self::assertSame(422, $this->responseCode());
}
}
-34
View File
@@ -1,34 +0,0 @@
<?php
namespace App\Tests\Course;
use App\Clinic\Entity\Clinic; use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress; use App\Patient\Entity\PatientRecord; use App\Tests\ApiTestCase;
use PHPUnit\Framework\Attributes\Group;
#[Group('docs')]
class CourseDocsCaptureTest extends ApiTestCase {
public function testCapture(): void {
if (getenv('COURSE_DOCS') !== '1') { self::markTestSkipped('برای تولید خروجی مستندات: COURSE_DOCS=1'); }
$user = $this->createUser(['ROLE_USER','ROLE_CLINIC']);
$clinic = new Clinic($user); $clinic->setName('کلینیک نمونه'); $this->em->persist($clinic); $this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); $this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId()); $address->setName('شعبهٔ مرکزی'); $this->em->persist($address);
$pu = $this->createUser(['ROLE_USER']);
$patient = new PatientRecord('clinic', (int)$clinic->getId(), $pu, 'clinic', (int)$clinic->getId());
$this->em->persist($patient); $this->em->flush();
$item = new ServiceItem($section, 'لیزر فول‌بادی'); $item->setSoloDurationMinutes(30); $item->setPriceRials(5000000);
$this->em->persist($item); $this->em->flush();
$d = function(string $l, mixed $b): void { fwrite(STDERR, sprintf("\n===%s %d===\n%s\n", $l, $this->responseCode(), json_encode($b, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT))); };
$p = $this->authJson('POST','/api/v1/course-protocols',$user,[
'service_uuid'=>$item->getUuid(),'session_count'=>8,'min_days'=>21,'ideal_days'=>28,'max_days'=>45,
'steps'=>[['session_number'=>1,'params'=>['energy'=>12]],['session_number'=>2,'params'=>['energy'=>14]]],
]);
$d('PROTOCOL_CREATE', $p);
$c = $this->authJson('POST','/api/v1/treatment-course',$user,['patient_uuid'=>$patient->getUuid(),'protocol_uuid'=>$p['data']['uuid']]);
$d('COURSE_CREATE', $c);
$d('COURSE_SHOW', $this->authJson('GET',"/api/v1/treatment-course/{$c['data']['uuid']}",$user));
$d('NEXT_SLOT', $this->authJson('GET',"/api/v1/treatment-course/{$c['data']['uuid']}/next-slot-suggestion?branch_uuid={$address->getUuid()}",$user));
$d('PATIENT_COURSES', $this->authJson('GET',"/api/v1/patient/{$patient->getUuid()}/courses",$user));
$d('DUPLICATE', $this->authJson('POST','/api/v1/treatment-course',$user,['patient_uuid'=>$patient->getUuid(),'protocol_uuid'=>$p['data']['uuid']]));
$d('ABANDON', $this->authJson('POST',"/api/v1/treatment-course/{$c['data']['uuid']}/abandon",$user,['reason'=>'انصراف بیمار']));
self::assertTrue(true);
}
}
-669
View File
@@ -1,669 +0,0 @@
<?php
namespace App\Tests\Course;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Course\Entity\CourseSession;
use App\Course\Entity\TreatmentCourse;
use App\Course\Repository\CourseSessionRepository;
use App\Course\Repository\TreatmentCourseRepository;
use App\Course\Service\CourseSessionLinker;
use App\Doctor\Entity\Doctor;
use App\Doctor\Entity\DoctorAddress;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
/**
* دورهٔ درمان — تسک ۱۲.
*
* «لیزر معمولاً شش تا هشت جلسه است»؛ طراحی قبلی فقط نوبت تکی می‌شناخت. تست‌ها روی سه
* چیز تمرکز دارند: snapshot پروتکل، لنگر متحرک فاصله‌ها، و اینکه لغو یک جلسه بقیهٔ
* دوره را خراب نکند.
*/
class TreatmentCourseTest extends ApiTestCase
{
private int $slotCursor = 0;
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
private function clinicWithPatient(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک دوره');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($doctorUser, 'دکتر دوره');
$this->em->persist($doctor);
$patientUser = $this->createUser(['ROLE_USER']);
$patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
$this->em->persist($patient);
$this->em->flush();
return [$user, $section, $address, $doctor, $patient];
}
private function service(ServiceSection $section, string $name = 'لیزر فول‌بادی'): ServiceItem
{
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes(30);
$item->setPriceRials(5_000_000);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** @param array<string, mixed> $extra */
private function protocol(User $user, ServiceItem $service, array $extra = []): array
{
$body = $this->authJson('POST', '/api/v1/course-protocols', $user, $extra + [
'service_uuid' => $service->getUuid(),
'session_count' => 8,
'min_days' => 21,
'ideal_days' => 28,
'max_days' => 45,
'steps' => [
['session_number' => 1, 'params' => ['energy' => 12]],
['session_number' => 2, 'params' => ['energy' => 14]],
['session_number' => 3, 'params' => ['energy' => 16]],
['session_number' => 4, 'params' => ['energy' => 18]],
],
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['data'];
}
private function startCourse(User $user, PatientRecord $patient, string $protocolUuid): array
{
$body = $this->authJson('POST', '/api/v1/treatment-course', $user, [
'patient_uuid' => $patient->getUuid(),
'protocol_uuid' => $protocolUuid,
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['data'];
}
private function courseEntity(string $uuid): TreatmentCourse
{
return static::getContainer()->get(TreatmentCourseRepository::class)->findByUuid($uuid);
}
private function linker(): CourseSessionLinker
{
return static::getContainer()->get(CourseSessionLinker::class);
}
// ── پروتکل ──────────────────────────────────────────────────────────────
public function testProtocolKeepsItsStepsAndSpacing(): void
{
[$user, $section] = $this->clinicWithPatient();
$protocol = $this->protocol($user, $this->service($section));
self::assertSame(8, $protocol['session_count']);
self::assertSame([21, 28, 45], [$protocol['min_days'], $protocol['ideal_days'], $protocol['max_days']]);
self::assertSame([1, 2, 3, 4], array_column($protocol['steps'], 'session_number'));
self::assertSame(16, $protocol['steps'][2]['params']['energy']);
}
/** ترتیب فاصله‌ها معنا دارد؛ حداکثر کوچک‌تر از حداقل یعنی پروتکل غیرقابل اجرا. */
public function testSpacingMustBeOrdered(): void
{
[$user, $section] = $this->clinicWithPatient();
$this->authJson('POST', '/api/v1/course-protocols', $user, [
'service_uuid' => $this->service($section)->getUuid(),
'session_count' => 6,
'min_days' => 30,
'ideal_days' => 20,
'max_days' => 45,
]);
self::assertSame(422, $this->responseCode());
}
/** دورهٔ یک‌جلسه‌ای همان نوبت تکی است. */
public function testASingleSessionCourseIsRejected(): void
{
[$user, $section] = $this->clinicWithPatient();
$this->authJson('POST', '/api/v1/course-protocols', $user, [
'service_uuid' => $this->service($section)->getUuid(),
'session_count' => 1,
'min_days' => 7,
'ideal_days' => 7,
'max_days' => 14,
]);
self::assertSame(422, $this->responseCode());
}
public function testOneProtocolPerService(): void
{
[$user, $section] = $this->clinicWithPatient();
$service = $this->service($section);
$this->protocol($user, $service);
$this->authJson('POST', '/api/v1/course-protocols', $user, [
'service_uuid' => $service->getUuid(),
'session_count' => 4,
'min_days' => 7,
'ideal_days' => 14,
'max_days' => 21,
]);
self::assertSame(422, $this->responseCode());
}
// ── شروع دوره ───────────────────────────────────────────────────────────
public function testStartingACourseCreatesEverySessionWithItsParams(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$protocol = $this->protocol($user, $this->service($section));
$course = $this->startCourse($user, $patient, $protocol['uuid']);
self::assertCount(8, $course['sessions']);
self::assertSame(array_fill(0, 8, 'planned'), array_column($course['sessions'], 'status'));
self::assertSame(12, $course['sessions'][0]['params']['energy']);
self::assertSame(18, $course['sessions'][3]['params']['energy']);
// جلسات ۵ تا ۸ پارامتری در پروتکل ندارند — آرایهٔ خالی، نه خطا.
self::assertSame([], (array) $course['sessions'][7]['params']);
self::assertSame(
['completed' => 0, 'booked' => 0, 'planned' => 8, 'skipped' => 0, 'total' => 8],
array_intersect_key($course['progress'], array_flip(['completed', 'booked', 'planned', 'skipped', 'total'])),
);
self::assertSame(1, $course['progress']['next_session_number']);
}
/** ⭐ تغییر پروتکل نباید دورهٔ در جریان را عوض کند. */
public function testChangingTheProtocolLeavesRunningCoursesAlone(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$protocol = $this->protocol($user, $this->service($section));
$course = $this->startCourse($user, $patient, $protocol['uuid']);
$this->authJson('PATCH', "/api/v1/course-protocol/{$protocol['uuid']}", $user, [
'session_count' => 12,
'min_days' => 30,
'ideal_days' => 40,
'max_days' => 60,
]);
self::assertSame(200, $this->responseCode());
$after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data'];
self::assertSame(8, $after['session_count']);
self::assertSame([21, 28, 45], [$after['min_days'], $after['ideal_days'], $after['max_days']]);
self::assertCount(8, $after['sessions']);
}
public function testASecondActiveCourseForTheSameServiceIsRejected(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$protocol = $this->protocol($user, $this->service($section));
$first = $this->startCourse($user, $patient, $protocol['uuid']);
$body = $this->authJson('POST', '/api/v1/treatment-course', $user, [
'patient_uuid' => $patient->getUuid(),
'protocol_uuid' => $protocol['uuid'],
]);
self::assertSame(422, $this->responseCode());
// پیام باید شناسهٔ دورهٔ موجود را بدهد تا اپراتور بتواند برود سراغش.
self::assertStringContainsString($first['uuid'], $body['errors'][0]['message']);
}
/** رهاکردن دوره جا را برای دورهٔ تازه باز می‌کند. */
public function testAbandoningACourseFreesTheSlotForANewOne(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$protocol = $this->protocol($user, $this->service($section));
$course = $this->startCourse($user, $patient, $protocol['uuid']);
$this->authJson('POST', "/api/v1/treatment-course/{$course['uuid']}/abandon", $user, ['reason' => '']);
self::assertSame(422, $this->responseCode(), 'رهاکردن بدون دلیل نباید پذیرفته شود');
$abandoned = $this->authJson('POST', "/api/v1/treatment-course/{$course['uuid']}/abandon", $user, [
'reason' => 'انصراف بیمار',
]);
self::assertSame(200, $this->responseCode());
self::assertSame('abandoned', $abandoned['data']['status']);
$this->startCourse($user, $patient, $protocol['uuid']);
}
// ── پیشرفت و لنگر متحرک ─────────────────────────────────────────────────
public function testProgressCountsCompletedSessionsAndPointsAtTheNextOne(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$protocol = $this->protocol($user, $service);
$course = $this->startCourse($user, $patient, $protocol['uuid']);
$this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 3);
$after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data'];
self::assertSame(3, $after['progress']['completed']);
self::assertSame(8, $after['progress']['total']);
self::assertSame(4, $after['progress']['next_session_number']);
self::assertSame(18, $after['progress']['next_params']['energy']);
}
/** ⭐ فاصله از آخرین جلسهٔ **انجام‌شده** حساب می‌شود، نه از شروع دوره. */
public function testTheSuggestionAnchorsOnTheLastCompletedSession(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$protocol = $this->protocol($user, $service);
$course = $this->startCourse($user, $patient, $protocol['uuid']);
$completedAt = time() - 10 * 86400;
$this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 3, $completedAt);
$body = $this->authJson(
'GET',
"/api/v1/treatment-course/{$course['uuid']}/next-slot-suggestion?branch_uuid={$address->getUuid()}",
$user,
);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
$data = $body['data'];
self::assertSame(4, $data['session_number']);
self::assertSame(18, $data['params']['energy']);
self::assertSame($completedAt + 21 * 86400, $data['range']['min']);
self::assertSame($completedAt + 45 * 86400, $data['range']['max']);
self::assertSame($completedAt + 28 * 86400, $data['ideal_at']);
self::assertNull($data['warning']);
}
public function testPassingTheMaximumGapProducesAWarning(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$protocol = $this->protocol($user, $service);
$course = $this->startCourse($user, $patient, $protocol['uuid']);
$this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1, time() - 50 * 86400);
$data = $this->authJson(
'GET',
"/api/v1/treatment-course/{$course['uuid']}/next-slot-suggestion?branch_uuid={$address->getUuid()}",
$user,
)['data'];
self::assertNotNull($data['warning']);
self::assertStringContainsString('45', $data['warning'], 'پیام باید حداکثر فاصلهٔ همان دوره را بگوید');
}
// ── لغو یک جلسهٔ وسط دوره ───────────────────────────────────────────────
/** ⭐ لغو یک جلسه فقط همان جلسه را برمی‌گرداند؛ بقیهٔ دوره دست‌نخورده. */
public function testCancellingOneSessionOnlyResetsThatSession(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$protocol = $this->protocol($user, $service);
$course = $this->startCourse($user, $patient, $protocol['uuid']);
$entity = $this->courseEntity($course['uuid']);
$sessions = $entity->getSessions()->toArray();
usort($sessions, static fn (CourseSession $a, CourseSession $b): int => $a->getSessionNumber() <=> $b->getSessionNumber());
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());
$this->linker()->link($sessions[0], $appointment);
$second = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());
$this->linker()->link($this->reloadSession($sessions[1]->getUuid()), $second);
$before = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data'];
self::assertSame(['booked', 'booked'], array_slice(array_column($before['sessions'], 'status'), 0, 2));
self::assertTrue($this->linker()->unlink($this->reloadAppointment($appointment->getUuid())));
$after = $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data'];
self::assertSame('planned', $after['sessions'][0]['status']);
self::assertSame('booked', $after['sessions'][1]['status'], 'بقیهٔ جلسات نباید دست بخورند');
}
public function testTheCourseCompletesOnlyWhenEverySessionIsDone(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
// پروتکل کوتاه تا کل دوره در تست تمام شود.
$protocol = $this->authJson('POST', '/api/v1/course-protocols', $user, [
'service_uuid' => $service->getUuid(),
'session_count' => 2,
'min_days' => 7,
'ideal_days' => 14,
'max_days' => 21,
])['data'];
$course = $this->startCourse($user, $patient, $protocol['uuid']);
$this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1);
self::assertSame('active', $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']['status']);
$this->completeSessions($course['uuid'], $doctor, $patient, $service, (int) $address->getClinicId(), 1);
self::assertSame('completed', $this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $user)['data']['status']);
}
// ── جداسازی محیط ────────────────────────────────────────────────────────
/**
* ⭐ «سخت‌گیرانه‌تر برنده»: قانون `spacing` کلینیک با پروتکل دوره نمی‌جنگد.
*
* پروتکل ۷ روز می‌گوید و قانون ۲۱ روز؛ فاصلهٔ مؤثر باید ۲۱ باشد. اگر پروتکل برنده
* می‌شد، قانونِ ایمنی کلینیک با تعریف یک پروتکل کوتاه دور زده می‌شد.
*/
public function testTheStricterOfProtocolAndSpacingPolicyWins(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$protocol = $this->protocol($user, $service, ['min_days' => 7, 'ideal_days' => 10, 'max_days' => 20]);
$started = $this->startCourse($user, $patient, $protocol['uuid']);
$course = $this->courseEntity($started['uuid']);
$scheduler = static::getContainer()->get(\App\Course\Service\CourseScheduler::class);
self::assertSame(7, $scheduler->effectiveMinDays($course), 'بدون قانون، پروتکل حاکم است');
$policy = new \App\Policy\Entity\Policy(
$course->getEntityType(),
$course->getEntityId(),
\App\Policy\Entity\Policy::CATEGORY_SPACING,
'حداقل ۲۱ روز بین جلسات لیزر',
);
$policy->setCondition(['match' => 'all', 'conditions' => []]);
$policy->setEffects([['type' => 'min_days_between', 'value' => 21]]);
$policy->setActive(true);
$this->em->persist($policy);
$this->em->flush();
$this->em->clear();
self::assertSame(
21,
$scheduler->effectiveMinDays($this->courseEntity($started['uuid'])),
'قانون سخت‌گیرتر برنده است',
);
}
/**
* ⭐ سقف افق: جلسه‌ای که حتی حداقلِ فاصله‌اش بیرون ۹۰ روز می‌افتد **رد** می‌شود، نه
* اینکه `book-all` را بشکند. جلسات بیرون بازه `planned` می‌مانند تا بعداً رزرو شوند.
*/
public function testSessionsBeyondTheHorizonAreSkippedNotFailed(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
// فاصلهٔ ۶۰ روزه با ۸ جلسه: جلسهٔ سوم به بعد بیرون افق ۹۰ روزه است.
$protocol = $this->protocol($user, $service, ['min_days' => 60, 'ideal_days' => 60, 'max_days' => 70]);
$started = $this->startCourse($user, $patient, $protocol['uuid']);
$course = $this->courseEntity($started['uuid']);
$scheduler = static::getContainer()->get(\App\Course\Service\CourseScheduler::class);
$now = time();
$minDays = $scheduler->effectiveMinDays($course, $now);
$horizon = $now + \App\Course\Service\CourseScheduler::SEARCH_HORIZON_DAYS * 86400;
// لنگر دوم = لنگر اول + ۶۰ روز؛ سومی از افق می‌گذرد.
$third = $now + 3 * $minDays * 86400;
self::assertGreaterThan($horizon, $third, 'جلسهٔ سوم باید بیرون افق باشد');
self::assertSame(60, $minDays);
// خودِ دوره دست‌نخورده می‌ماند: هیچ جلسه‌ای حذف نمی‌شود.
self::assertCount(8, $course->getSessions()->toArray());
}
/**
* ⭐ `book-all` همه یا هیچ است.
*
* تقویم فقط یک روزِ هفته باز است و فاصلهٔ پروتکل ۱ تا ۲ روز؛ پس جلسهٔ اول وقت پیدا
* می‌کند و جلسهٔ دوم نه. اگر تراکنش کار نکند، بیمار با یک نوبتِ تنها از یک دورهٔ
* هشت‌جلسه‌ای می‌ماند و هیچ‌کس نمی‌فهمد کجا قطع شد.
*/
public function testAFailedBookAllLeavesEverySessionPlanned(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$type = $this->authJson('POST', '/api/v1/resource-types', $user, [
'address_uuid' => $address->getUuid(),
'code' => 'room',
'name' => 'اتاق',
]);
self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE));
$resource = $this->authJson('POST', '/api/v1/resource', $user, [
'address_uuid' => $address->getUuid(),
'type_uuid' => $type['data']['uuid'],
'name' => 'اتاق ۱',
]);
self::assertSame(201, $this->responseCode());
// فقط شنبه‌ها باز است.
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $user, [
'days' => [6 => [['start_minute' => 540, 'end_minute' => 1020]]],
]);
self::assertSame(200, $this->responseCode());
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [
'segments' => [
['sequence' => 1, 'name' => 'جلسه', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $type['data']['uuid']]]],
],
]);
self::assertSame(200, $this->responseCode());
// بازهٔ ۱ تا ۲ روز: جلسهٔ دوم حتماً بیرون تنها روزِ باز می‌افتد.
$protocol = $this->protocol($user, $service, ['min_days' => 1, 'ideal_days' => 1, 'max_days' => 2]);
$started = $this->startCourse($user, $patient, $protocol['uuid']);
$body = $this->authJson('POST', "/api/v1/treatment-course/{$started['uuid']}/book-all", $user, [
'branch_uuid' => $address->getUuid(),
'doctor_uuid' => $doctor->getUuid(),
]);
self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
$this->em->clear();
$course = $this->courseEntity($started['uuid']);
foreach ($course->getSessions() as $session) {
self::assertSame(
CourseSession::STATUS_PLANNED,
$session->getStatus(),
sprintf('جلسهٔ %d نباید رزرو مانده باشد', $session->getSessionNumber()),
);
self::assertNull($session->getAppointment());
}
}
/**
* ⭐ مسیر **موفق** `book-all`: لنگر بعد از هر رزرو جلو می‌رود.
*
* تست شکست از قبل بود؛ این یکی همان چیزی را می‌سنجد که کار می‌کند. لنگر ثابت یعنی
* هر هشت جلسه دور همان تاریخ جمع می‌شوند و پروتکل عملاً بی‌اثر است.
*/
public function testBookAllMovesTheAnchorForwardBetweenSessions(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$type = $this->authJson('POST', '/api/v1/resource-types', $user, [
'address_uuid' => $address->getUuid(),
'code' => 'room',
'name' => 'اتاق',
]);
self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE));
$resource = $this->authJson('POST', '/api/v1/resource', $user, [
'address_uuid' => $address->getUuid(),
'type_uuid' => $type['data']['uuid'],
'name' => 'اتاق دوره',
]);
self::assertSame(201, $this->responseCode());
// هر روز باز — تا جلسات فقط با فاصلهٔ پروتکل جدا شوند، نه با تعطیلی.
$this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $user, [
'days' => array_fill_keys(range(0, 6), [['start_minute' => 480, 'end_minute' => 1200]]),
]);
self::assertSame(200, $this->responseCode());
$this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $user, [
'segments' => [
['sequence' => 1, 'name' => 'جلسه', 'duration_minutes' => 30, 'requirements' => [['type_uuid' => $type['data']['uuid']]]],
],
]);
self::assertSame(200, $this->responseCode());
$protocol = $this->protocol($user, $service, [
'session_count' => 3,
'min_days' => 7,
'ideal_days' => 7,
'max_days' => 14,
'steps' => [
['session_number' => 1, 'params' => ['energy' => 12]],
['session_number' => 2, 'params' => ['energy' => 14]],
['session_number' => 3, 'params' => ['energy' => 16]],
],
]);
$started = $this->startCourse($user, $patient, $protocol['uuid']);
$body = $this->authJson('POST', "/api/v1/treatment-course/{$started['uuid']}/book-all", $user, [
'branch_uuid' => $address->getUuid(),
'doctor_uuid' => $doctor->getUuid(),
]);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame(3, $body['data']['booked']);
$this->em->clear();
$sessions = $this->courseEntity($started['uuid'])->getSessions()->toArray();
usort($sessions, static fn (CourseSession $a, CourseSession $b): int
=> $a->getSessionNumber() <=> $b->getSessionNumber());
$starts = array_map(
static fn (CourseSession $s): ?int => $s->getAppointment()?->getSlotStart(),
$sessions,
);
self::assertNotContains(null, $starts, 'هر سه جلسه باید نوبت گرفته باشند');
// لنگر متحرک: هر جلسه دست‌کم هفت روز بعد از جلسهٔ قبلی است.
for ($i = 1; $i < count($starts); $i++) {
$gapDays = (int) floor(($starts[$i] - $starts[$i - 1]) / 86400);
self::assertGreaterThanOrEqual(7, $gapDays, sprintf(
'فاصلهٔ جلسهٔ %d با قبلی %d روز شد',
$i + 1,
$gapDays,
));
}
}
public function testAnotherClinicCannotSeeTheCourse(): void
{
[$owner, $section, , , $patient] = $this->clinicWithPatient();
[$other] = $this->clinicWithPatient();
$protocol = $this->protocol($owner, $this->service($section));
$course = $this->startCourse($owner, $patient, $protocol['uuid']);
$this->authJson('GET', "/api/v1/treatment-course/{$course['uuid']}", $other);
self::assertSame(404, $this->responseCode());
}
// ── کمکی ────────────────────────────────────────────────────────────────
private function reloadSession(string $uuid): CourseSession
{
return static::getContainer()->get(CourseSessionRepository::class)->findByUuid($uuid);
}
private function reloadAppointment(string $uuid): \App\Appointment\Entity\Appointment
{
return static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class)
->getRepository(\App\Appointment\Entity\Appointment::class)
->findOneBy(['uuid' => $uuid]);
}
private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId): \App\Appointment\Entity\Appointment
{
$em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);
$start = time() + 86400 + (++$this->slotCursor) * 3600;
$appointment = new \App\Appointment\Entity\Appointment(
$em->getRepository(Doctor::class)->find($doctor->getId()),
$em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(),
$start,
$start + 1800,
);
$appointment->assignTenantPair('clinic', $clinicId);
$appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId()));
$appointment->setPatientName('بیمار دوره');
$em->persist($appointment);
$em->flush();
return $appointment;
}
/** n جلسهٔ بعدی را رزرو و انجام‌شده می‌کند. */
private function completeSessions(
string $courseUuid,
Doctor $doctor,
PatientRecord $patient,
ServiceItem $service,
int $clinicId,
int $count,
?int $completedAt = null,
): void {
for ($i = 0; $i < $count; $i++) {
$course = $this->courseEntity($courseUuid);
$sessions = $course->plannedSessions();
usort($sessions, static fn (CourseSession $a, CourseSession $b): int
=> $a->getSessionNumber() <=> $b->getSessionNumber());
$appointment = $this->appointment($doctor, $patient, $service, $clinicId);
$this->linker()->link($this->reloadSession($sessions[0]->getUuid()), $appointment);
$this->linker()->complete($this->reloadAppointment($appointment->getUuid()), $completedAt);
}
}
}
-32
View File
@@ -1,32 +0,0 @@
<?php
namespace App\Tests\Package;
use App\Clinic\Entity\Clinic; use App\ClinicService\Entity\ServiceItem; use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress; use App\Patient\Entity\PatientRecord; use App\Tests\ApiTestCase;
use PHPUnit\Framework\Attributes\Group;
#[Group('docs')]
class PackageDocsCaptureTest extends ApiTestCase {
public function testCapture(): void {
if (getenv('PKG_DOCS') !== '1') { self::markTestSkipped('برای تولید خروجی مستندات: PKG_DOCS=1'); }
$user = $this->createUser(['ROLE_USER','ROLE_CLINIC']);
$clinic = new Clinic($user); $clinic->setName('کلینیک نمونه'); $this->em->persist($clinic); $this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر'); $this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId()); $address->setName('شعبهٔ مرکزی'); $this->em->persist($address);
$pu = $this->createUser(['ROLE_USER']);
$patient = new PatientRecord('clinic', (int)$clinic->getId(), $pu, 'clinic', (int)$clinic->getId());
$this->em->persist($patient); $this->em->flush();
$item = new ServiceItem($section, 'لیزر فول‌بادی'); $item->setSoloDurationMinutes(20); $item->setPriceRials(5000000);
$this->em->persist($item); $this->em->flush();
$d = function(string $l, mixed $b): void { fwrite(STDERR, sprintf("\n===%s %d===\n%s\n", $l, $this->responseCode(), json_encode($b, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT))); };
$c = $this->authJson('POST','/api/v1/packages',$user,['name'=>'۶ جلسه لیزر فول‌بادی','session_count'=>6,'price_rials'=>25000000,'validity_days'=>365,'service_uuids'=>[$item->getUuid()]]);
$d('CREATE', $c);
$d('INDEX', $this->authJson('GET','/api/v1/packages',$user));
$s = $this->authJson('POST',"/api/v1/patient/{$patient->getUuid()}/package",$user,['package_uuid'=>$c['data']['uuid']]);
$d('SELL', $s);
$d('PATIENT_PACKAGES', $this->authJson('GET',"/api/v1/patient/{$patient->getUuid()}/packages",$user));
$this->authJson('POST',"/api/v1/patient-package/{$s['data']['uuid']}/adjust",$user,['delta'=>1,'reason'=>'جبران جلسهٔ لغوشده']);
$d('LEDGER', $this->authJson('GET',"/api/v1/patient-package/{$s['data']['uuid']}/ledger",$user));
$d('ADJUST_NO_REASON', $this->authJson('POST',"/api/v1/patient-package/{$s['data']['uuid']}/adjust",$user,['delta'=>1]));
$d('QUOTE', $this->authJson('POST','/api/v1/pricing/quote',$user,['service_uuid'=>$item->getUuid(),'branch_uuid'=>$address->getUuid(),'patient_uuid'=>$patient->getUuid()]));
self::assertTrue(true);
}
}
-560
View File
@@ -1,560 +0,0 @@
<?php
namespace App\Tests\Package;
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\Doctor\Entity\DoctorAddress;
use App\Package\Entity\SessionCreditLedger;
use App\Package\Repository\PatientPackageRepository;
use App\Package\Service\CreditLedgerService;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
/**
* پکیج و دفتر اعتبار جلسات — تسک ۱۱.
*
* محور همهٔ تست‌ها یک جمله از مستند است: «اعتبار را به صورت دفتر حساب نگه می‌داریم،
* نه یک عدد شمارنده.» پس مانده هیچ‌جا ذخیره نمی‌شود و هر تغییر یک ردیف است.
*/
class PackageLedgerTest extends ApiTestCase
{
private int $slotCursor = 0;
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
private function clinicWithPatient(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک پکیج');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($doctorUser, 'دکتر پکیج');
$this->em->persist($doctor);
$patientUser = $this->createUser(['ROLE_USER']);
$patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
$this->em->persist($patient);
$this->em->flush();
return [$user, $section, $address, $doctor, $patient];
}
private function service(ServiceSection $section, string $name, int $price = 5_000_000): ServiceItem
{
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes(20);
$item->setPriceRials($price);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** @param array<string, mixed> $extra */
private function definePackage(User $user, ServiceItem $service, int $sessions = 6, array $extra = []): array
{
$body = $this->authJson('POST', '/api/v1/packages', $user, $extra + [
'name' => '۶ جلسه لیزر فول‌بادی',
'session_count' => $sessions,
'price_rials' => 25_000_000,
'service_uuids' => [$service->getUuid()],
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['data'];
}
private function sell(User $user, PatientRecord $patient, string $packageUuid): array
{
$body = $this->authJson('POST', "/api/v1/patient/{$patient->getUuid()}/package", $user, [
'package_uuid' => $packageUuid,
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['data'];
}
/**
* هر درخواست HTTP کرنل را ری‌بوت می‌کند و EntityManager تازه می‌شود، پس entity های
* قبلی detached اند و باید دوباره خوانده شوند.
*/
private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, int $clinicId): Appointment
{
// یک اسلات یکتا per فراخوانی: پزشک کلید یکتای (doctor, slot_start) دارد.
$start = time() + 86400 + (++$this->slotCursor) * 3600;
$doctor = $this->em->getRepository(Doctor::class)->find($doctor->getId());
$patient = $this->em->getRepository(PatientRecord::class)->find($patient->getId());
$service = $this->em->getRepository(ServiceItem::class)->find($service->getId());
$appointment = new Appointment($doctor, $patient->getUser(), $start, $start + 1200);
$appointment->assignTenantPair('clinic', $clinicId);
$appointment->setServiceItem($service);
$appointment->setPatientName('بیمار پکیج');
$this->em->persist($appointment);
$this->em->flush();
return $appointment;
}
private function ledgerService(): CreditLedgerService
{
return static::getContainer()->get(CreditLedgerService::class);
}
/**
* سرویس‌های کانتینر با EntityManager خودشان کار می‌کنند؛ entity ساخته‌شده در تست
* باید از همان EM دوباره خوانده شود وگرنه «موجودیت جدیدِ persist نشده» می‌شود.
*/
private function reload(Appointment $appointment): Appointment
{
return static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class)
->getRepository(Appointment::class)
->find($appointment->getId());
}
private function consumption(): \App\Package\Service\PackageConsumptionService
{
return static::getContainer()->get(\App\Package\Service\PackageConsumptionService::class);
}
// ── تعریف و فروش ────────────────────────────────────────────────────────
public function testSellingAPackageOpensTheLedgerWithItsSessionCount(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر فول‌بادی');
$package = $this->definePackage($user, $service);
$sold = $this->sell($user, $patient, $package['uuid']);
self::assertSame(6, $sold['balance']);
$list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user);
self::assertSame(6, $list['data'][0]['balance']);
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
self::assertCount(1, $ledger['data']['rows']);
self::assertSame('purchase', $ledger['data']['rows'][0]['kind']);
self::assertSame(6, $ledger['data']['rows'][0]['delta']);
self::assertSame(6, $ledger['data']['rows'][0]['running_balance']);
}
/** پکیجی که هیچ سرویسی را پوشش نمی‌دهد هرگز قابل مصرف نیست. */
public function testAPackageWithoutServicesIsRejected(): void
{
[$user] = $this->clinicWithPatient();
$this->authJson('POST', '/api/v1/packages', $user, [
'name' => 'پکیج بی‌سرویس',
'session_count' => 3,
'price_rials' => 1_000_000,
'service_uuids' => [],
]);
self::assertSame(422, $this->responseCode());
}
/** ⭐ مانده باید محاسبه شود، نه ذخیره — همین جلوی «بهینه‌سازی» شش ماه بعد را می‌گیرد. */
public function testNoStoredBalanceColumnExists(): void
{
$columns = $this->em->getConnection()
->createSchemaManager()
->listTableColumns('patient_packages');
$names = array_map(static fn ($c): string => strtolower($c->getName()), $columns);
foreach (['remaining', 'remaining_sessions', 'used_count', 'balance'] as $forbidden) {
self::assertNotContains($forbidden, $names, 'مانده باید از دفتر محاسبه شود، نه ذخیره');
}
}
// ── مصرف و بازگشت ───────────────────────────────────────────────────────
public function testConsumingLeavesARowAndCancellingAddsAnotherOne(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$package = $this->definePackage($user, $service);
$sold = $this->sell($user, $patient, $package['uuid']);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());
$appointment = $this->reload($appointment);
self::assertTrue($this->consumption()->consumeFor($appointment));
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::assertSame(5, $this->ledgerService()->balance($entity));
self::assertTrue($this->ledgerService()->refund($appointment));
self::assertSame(6, $this->ledgerService()->balance($entity));
// ردیف `consume` **حذف نمی‌شود** — دفتر append-only است.
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
$kinds = array_column($ledger['data']['rows'], 'kind');
self::assertSame(['purchase', 'consume', 'refund'], $kinds);
self::assertSame([6, 5, 6], array_column($ledger['data']['rows'], 'running_balance'));
}
/**
* ⭐ `credit_refundable: false` اعتبار برگشته را پس می‌گیرد — **بدون** حذف ردیف.
*
* دفتر append-only است، پس «پس گرفتن» یک ردیف `adjustment` منفی است نه پاک کردن
* `refund`. تاریخچه باید نشان بدهد اعتبار برگشت و بعد طبق سیاست پس گرفته شد.
*/
public function testAPolicyThatDoesNotRefundCreditTakesItBackWithAnAdjustment(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
// نوبت حدود یک روز دیگر است؛ پنجرهٔ ۹۶ ساعته یعنی این لغو **بیرون** بازهٔ رایگان
// نیست بلکه درونِ محدودهٔ جریمه می‌افتد — تنها حالتی که سیاست اعتبار اثر دارد.
$saved = $this->authJson('PUT', '/api/v1/cancellation-policy', $user, [
'free_window_hours' => 96,
'penalty_mode' => 'none',
'credit_refundable' => false,
]);
self::assertSame(200, $this->responseCode(), json_encode($saved, JSON_UNESCAPED_UNICODE));
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$appointment = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
self::assertTrue($this->consumption()->consumeFor($appointment));
$body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user, ['by' => 'user']);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertFalse($body['data']['credit_refundable']);
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
$kinds = array_column($ledger['data']['rows'], 'kind');
self::assertSame(['purchase', 'consume', 'refund', 'adjustment'], $kinds, 'هیچ ردیفی حذف نمی‌شود');
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::assertSame(5, $this->ledgerService()->balance($entity), 'جلسه پس گرفته شد');
}
/**
* ⭐ رقابت واقعی: ردیف `consume` از یک اتصال دیگر درج می‌شود و بعد سرویس تلاش
* می‌کند همان را بنویسد.
*
* بررسی پیش از درج این پنجره را نمی‌بندد؛ فقط کلید یکتا می‌بندد. و چون Doctrine روی
* نقض کلید `EntityManager` را می‌بندد، بدون بازنشانیِ رجیستری این حالت به یک ۵۰۰
* بی‌ربط تبدیل می‌شد — نه یک «قبلاً مصرف شده».
*/
public function testAConcurrentConsumeIsAbsorbedWithoutBurningTheRequest(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$appointment = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
$package = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
// اتصال جدا = «درخواست دیگر». ردیف مصرف را پشت سرِ سرویس درج می‌کند.
$other = \Doctrine\DBAL\DriverManager::getConnection($this->em->getConnection()->getParams());
try {
$other->insert('session_credit_ledger', [
'patient_package_id' => $package->getId(),
'appointment_id' => $appointment->getId(),
'kind' => 'consume',
'delta' => -1,
'created_at' => time(),
'entity_type' => $package->getEntityType(),
'entity_id' => $package->getEntityId(),
'uuid' => \Symfony\Component\Uid\Uuid::v4()->toRfc4122(),
]);
} finally {
$other->close();
}
// سرویس همان مصرف را دوباره تلاش می‌کند: باید `true` بدهد، نه خطا.
self::assertTrue($this->consumption()->consumeFor($this->reload($appointment)));
// و مهم‌تر: مدیر هنوز زنده است و کارِ بعدی همین request انجام می‌شود.
$em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);
self::assertTrue($em->isOpen(), 'EntityManager نباید بعد از نقض کلید بسته بماند');
$fresh = $em->getRepository(\App\Package\Entity\PatientPackage::class)->findOneBy(['uuid' => $sold['uuid']]);
self::assertSame(5, $this->ledgerService()->balance($fresh), 'فقط یک جلسه خورده شود');
}
/** `confirm` idempotent است؛ اجرای دومش نباید جلسهٔ دوم بخورد. */
public function testConsumingTwiceForTheSameAppointmentTakesOnlyOneSession(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$appointment = $this->appointment($doctor, $patient, $service, (int) $address->getClinicId());
$appointment = $this->reload($appointment);
$this->consumption()->consumeFor($appointment);
$this->consumption()->consumeFor($appointment);
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::assertSame(5, $this->ledgerService()->balance($entity));
}
/** ماندهٔ صفر خطا نیست: بیمار نقدی می‌پردازد. */
public function testAnEmptyPackageIsSimplyNotApplied(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service, 1)['uuid']);
$first = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
$second = $this->reload($this->appointment($doctor, $patient, $service, (int) $address->getClinicId()));
self::assertTrue($this->consumption()->consumeFor($first));
self::assertFalse($this->consumption()->consumeFor($second), 'ماندهٔ صفر باید بی‌سروصدا رد شود');
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::assertSame(0, $this->ledgerService()->balance($entity), 'مانده هرگز منفی نمی‌شود');
}
// ── قیمت ────────────────────────────────────────────────────────────────
public function testQuoteAnnouncesThePackageWithoutConsumingIt(): void
{
[$user, $section, $address, , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر', 5_000_000);
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
'patient_uuid' => $patient->getUuid(),
]);
self::assertSame(200, $this->responseCode(), json_encode($quote, JSON_UNESCAPED_UNICODE));
self::assertTrue($quote['data']['package_will_be_consumed']);
self::assertSame(0, $quote['data']['final_rials']);
// پیش‌نمایش هرگز مصرف نمی‌کند؛ وگرنه هر رفرش یک جلسه می‌خورد.
$this->authJson('POST', '/api/v1/pricing/quote', $user, [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
'patient_uuid' => $patient->getUuid(),
]);
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::assertSame(6, $this->ledgerService()->balance($entity));
}
public function testQuoteWithoutAPatientChargesTheFullPrice(): void
{
[$user, $section, $address, , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر', 5_000_000);
$this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
]);
self::assertFalse($quote['data']['package_will_be_consumed']);
self::assertSame(5_000_000, $quote['data']['final_rials']);
}
// ── FIFO و انقضا ────────────────────────────────────────────────────────
/** قدیمی‌ترین اول، چون به انقضا نزدیک‌تر است. */
public function testTheOldestUnexpiredPackageIsUsedFirst(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$definition = $this->definePackage($user, $service);
$older = $this->sell($user, $patient, $definition['uuid']);
$newer = $this->sell($user, $patient, $definition['uuid']);
$repo = static::getContainer()->get(PatientPackageRepository::class);
$olderE = $repo->findByUuid($older['uuid']);
// خرید دوم را عمداً تازه‌تر می‌کنیم تا ترتیب قطعی باشد.
$this->em->getConnection()->executeStatement(
'UPDATE patient_packages SET purchased_at = purchased_at + 100 WHERE uuid = ?',
[$newer['uuid']],
);
$this->em->clear();
$chosen = $this->consumption()->firstUsable(
$this->em->getRepository(PatientRecord::class)->find($patient->getId()),
$this->em->getRepository(ServiceItem::class)->find($service->getId()),
);
self::assertSame($olderE->getUuid(), $chosen?->getUuid());
}
public function testAnExpiredPackageShowsZeroBalanceButKeepsItsLedger(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->em->getConnection()->executeStatement(
'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?',
[time() - 86400, $sold['uuid']],
);
$this->em->clear();
$list = $this->authJson('GET', "/api/v1/patient/{$patient->getUuid()}/packages", $user);
self::assertTrue($list['data'][0]['expired']);
self::assertSame(0, $list['data'][0]['balance']);
// دفتر دست‌نخورده است: «۶ جلسه‌ام چه شد؟» هنوز جواب دارد.
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
self::assertSame(6, $ledger['data']['rows'][0]['running_balance']);
}
public function testExpiryCommandWritesTheClosingRow(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->em->getConnection()->executeStatement(
'UPDATE patient_packages SET valid_to = ? WHERE uuid = ?',
[time() - 86400, $sold['uuid']],
);
$this->em->clear();
$command = static::getContainer()->get(\App\Package\Command\ExpirePackagesCommand::class);
$tester = new \Symfony\Component\Console\Tester\CommandTester($command);
$tester->execute([]);
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
$kinds = array_column($ledger['data']['rows'], 'kind');
self::assertSame(['purchase', 'expiry'], $kinds);
self::assertSame(-6, $ledger['data']['rows'][1]['delta']);
self::assertSame(0, $ledger['data']['rows'][1]['running_balance']);
}
// ── اصلاح دستی و جداسازی محیط ───────────────────────────────────────────
public function testAdjustmentNeedsAReasonAndIsRecorded(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, ['delta' => 1]);
self::assertSame(422, $this->responseCode(), 'اصلاح بدون دلیل نباید پذیرفته شود');
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
'delta' => 2,
'reason' => 'جبران جلسهٔ لغوشده توسط کلینیک',
]);
self::assertSame(201, $this->responseCode());
$ledger = $this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $user);
$row = $ledger['data']['rows'][1];
self::assertSame('adjustment', $row['kind']);
self::assertSame(2, $row['delta']);
self::assertSame('جبران جلسهٔ لغوشده توسط کلینیک', $row['reason']);
self::assertNotNull($row['created_by']);
self::assertSame(8, $row['running_balance']);
}
public function testAdjustmentCannotDriveTheBalanceNegative(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
'delta' => -10,
'reason' => 'اشتباه اپراتور',
]);
self::assertSame(422, $this->responseCode());
}
public function testAnotherClinicCannotSeeOrTouchThePackage(): void
{
[$owner, $section, , , $patient] = $this->clinicWithPatient();
[$other] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']);
$this->authJson('GET', "/api/v1/patient-package/{$sold['uuid']}/ledger", $other);
self::assertSame(404, $this->responseCode());
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $other, [
'delta' => 5,
'reason' => 'تلاش از محیط دیگر',
]);
self::assertSame(404, $this->responseCode());
}
/** منشی نباید بتواند اعتبار را دستی عوض کند. */
public function testASecretaryCannotAdjustTheLedger(): void
{
[$owner, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($owner, $patient, $this->definePackage($owner, $service)['uuid']);
$secretary = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']);
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $secretary, [
'delta' => 5,
'reason' => 'تلاش منشی',
]);
self::assertContains($this->responseCode(), [403, 404]);
}
public function testLedgerRowsNeverHaveAZeroDelta(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section, 'لیزر');
$sold = $this->sell($user, $patient, $this->definePackage($user, $service)['uuid']);
$this->authJson('POST', "/api/v1/patient-package/{$sold['uuid']}/adjust", $user, [
'delta' => 0,
'reason' => 'بی‌اثر',
]);
self::assertSame(422, $this->responseCode());
$entity = static::getContainer()->get(PatientPackageRepository::class)->findByUuid($sold['uuid']);
self::expectException(\InvalidArgumentException::class);
new SessionCreditLedger($entity, SessionCreditLedger::KIND_ADJUSTMENT, 0);
}
}
-97
View File
@@ -1,97 +0,0 @@
<?php
namespace App\Tests\Policy;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
use PHPUnit\Framework\Attributes\Group;
/**
* خروجی واقعیِ اندپوینت‌های قانون برای `docs/api/policy.md`.
*
* جدا از تست‌های رفتاری است و در گروه `docs` می‌ماند تا در اجرای عادی نویز نسازد.
*/
#[Group('docs')]
class DocsCaptureTest extends ApiTestCase
{
public function testCapture(): void
{
// به‌صورت پیش‌فرض رد می‌شود: کارش تولید خروجی برای مستندات است، نه ادعای رفتار.
if (getenv('POLICY_DOCS') !== '1') {
self::markTestSkipped('برای تولید خروجی مستندات: POLICY_DOCS=1');
}
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک نمونه');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$this->em->flush();
$service = new ServiceItem($section, 'لیزر صورت');
$service->setSoloDurationMinutes(20);
$service->setPriceRials(1_000_000);
$this->em->persist($service);
$this->em->flush();
$dump = function (string $label, mixed $body): void {
fwrite(
STDERR,
sprintf("\n===%s %d===\n%s\n", $label, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)),
);
};
$dump('SCHEMA', $this->authJson('GET', '/api/v1/policy-schema', $user));
$created = $this->authJson('POST', '/api/v1/policy', $user, [
'category' => 'timing',
'name' => 'حداقل یک ساعت برای لیزر',
'priority' => 10,
'condition' => ['match' => 'all', 'conditions' => [
['field' => 'item_count', 'operator' => 'greater_than', 'value' => 1],
]],
'effects' => [['type' => 'min_duration_minutes', 'value' => 60]],
]);
$dump('CREATE', $created);
$uuid = $created['data']['uuid'];
$dump('ACTIVATE', $this->authJson('POST', "/api/v1/policy/$uuid/activate", $user));
$dump('VERSION', $this->authJson('POST', "/api/v1/policy/$uuid/version", $user, [
'effects' => [['type' => 'min_duration_minutes', 'value' => 90]],
]));
$dump('SHOW', $this->authJson('GET', "/api/v1/policy/$uuid", $user));
$dump('INDEX', $this->authJson('GET', '/api/v1/policies?category=timing', $user));
$dump('BAD_FIELD', $this->authJson('POST', '/api/v1/policy', $user, [
'category' => 'timing',
'name' => 'قانون نامعتبر',
'condition' => ['match' => 'all', 'conditions' => [
['field' => 'subtotal_rials', 'operator' => 'greater_than', 'value' => 10],
]],
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
]));
$dump('BAD_EFFECT', $this->authJson('POST', '/api/v1/policy', $user, [
'category' => 'timing',
'name' => 'اثر نامعتبر',
'effects' => [['type' => 'discount_percent', 'value' => 10]],
]));
$dump('DEACTIVATE', $this->authJson('POST', "/api/v1/policy/$uuid/deactivate", $user));
self::assertTrue(true);
}
}
-72
View File
@@ -1,72 +0,0 @@
<?php
namespace App\Tests\Policy;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* بدون هیچ قانونی، خروجی‌ها باید **دقیقاً** همان تسک ۰۸ باشند.
*
* موتور قوانین یک لایهٔ افزودنی است نه بازنویسی: کلینیکی که هیچ قانونی ننوشته نباید
* هیچ تفاوتی حس کند. این تست همان تضمین را می‌گیرد — و چون همهٔ قلاب‌ها در مسیر داغ
* نشسته‌اند، شکستنش یعنی یک اثرِ پیش‌فرضِ ناخواسته وارد محاسبه شده.
*/
class NoPolicyRegressionTest extends ApiTestCase
{
public function testWithoutAnyPolicyThePlanAndQuoteAreUnchanged(): void
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک بی‌قانون');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$this->em->flush();
$service = new ServiceItem($section, 'لیزر');
$service->setSoloDurationMinutes(20);
$service->setPriceRials(1_000_000);
$this->em->persist($service);
$this->em->flush();
$plan = $this->authJson('POST', '/api/v1/appointment-plan/preview', $user, [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
]);
self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE));
self::assertSame(20, $plan['data']['total_minutes']);
self::assertSame([0], array_column($plan['data']['segments'], 'offset_minutes'));
$quote = $this->authJson('POST', '/api/v1/pricing/quote', $user, [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
]);
self::assertSame(1_000_000, $quote['data']['base_rials']);
self::assertSame(0, $quote['data']['discount_rials']);
self::assertSame(1_000_000, $quote['data']['final_rials']);
// نبودِ کلید مهم‌تر از صفر بودن مقدار است: کلیدِ خالی هم یعنی موتور چیزی
// اعمال کرده که نباید می‌کرد.
self::assertArrayNotHasKey('applied_policies', $quote['data']['breakdown']['sources']);
$selection = $this->authJson('POST', '/api/v1/service-selection/validate', $user, [
'item_uuids' => [$service->getUuid()],
'branch_uuid' => $address->getUuid(),
]);
self::assertTrue($selection['data']['valid']);
self::assertSame([], $selection['data']['errors']);
}
}
-77
View File
@@ -1,77 +0,0 @@
<?php
namespace App\Tests\Policy;
use App\Policy\Service\OperatorRegistry;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* یازده عملگر، هر کدام روی هر دو نتیجه.
*
* عملگری که غلط بسنجد قانونی می‌سازد که یا همیشه می‌گیرد یا هرگز — و هیچ‌کدام خطا
* نمی‌دهند. تست واحد است نه یکپارچه، چون خودِ مقایسه تابعی خالص است.
*/
class OperatorRegistryTest extends TestCase
{
private const NOW = 1_800_000_000;
#[DataProvider('cases')]
public function testOperatorDecides(string $op, mixed $actual, mixed $expected, bool $result): void
{
self::assertSame(
$result,
(new OperatorRegistry())->evaluate($op, $actual, $expected, self::NOW),
sprintf('%s(%s, %s)', $op, json_encode($actual), json_encode($expected)),
);
}
public static function cases(): array
{
$day = 86400;
return [
'equals' => ['equals', 5, 5, true],
'equals — رشتهٔ عددی' => ['equals', '5', 5, true],
'equals — نه' => ['equals', 5, 6, false],
'not_equals' => ['not_equals', 5, 6, true],
'greater_than' => ['greater_than', 6, 5, true],
'greater_than — مرز' => ['greater_than', 5, 5, false],
'greater_or_equal' => ['greater_or_equal', 5, 5, true],
'less_than' => ['less_than', 4, 5, true],
'less_or_equal' => ['less_or_equal', 5, 5, true],
'in' => ['in', 2, [1, 2, 3], true],
'in — نه' => ['in', 9, [1, 2, 3], false],
'not_in' => ['not_in', 9, [1, 2, 3], true],
'between — داخل' => ['between', 30, [18, 65], true],
'between — مرز پایین' => ['between', 18, [18, 65], true],
'between — مرز بالا' => ['between', 65, [18, 65], true],
'between — بیرون' => ['between', 66, [18, 65], false],
'contains' => ['contains', ['vip', 'new'], 'vip', true],
'contains — نه' => ['contains', ['new'], 'vip', false],
'days_since — گذشته' => ['days_since', self::NOW - 40 * $day, 30, true],
'days_since — تازه' => ['days_since', self::NOW - 10 * $day, 30, false],
'days_since — هرگز' => ['days_since', 0, 30, false],
];
}
/** عملگر ناشناخته `false` می‌دهد، نه خطا — ولی ذخیره‌اش از قبل جلوگیری شده. */
public function testAnUnknownOperatorIsFalseAndNotRegistered(): void
{
$registry = new OperatorRegistry();
self::assertFalse($registry->has('regex'));
self::assertFalse($registry->evaluate('regex', 'a', 'a'));
}
/** فرم فقط عملگرهای معنادار همان نوع را نشان می‌دهد. */
public function testOperatorsAreFilteredByFieldType(): void
{
$registry = new OperatorRegistry();
self::assertSame(['contains'], $registry->forType('list'));
self::assertSame(['equals'], $registry->forType('bool'));
self::assertContains('days_since', $registry->forType('timestamp'));
self::assertNotContains('between', $registry->forType('uuid'));
}
}
-617
View File
@@ -1,617 +0,0 @@
<?php
namespace App\Tests\Policy;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* موتور قوانین شش‌دسته‌ای — بند ۸ مستند.
*
* تأکید تست‌ها روی سه چیز است که خرابیشان بی‌صداست: ترکیب اثرها (max/sum/veto)،
* ترتیب حل تناقض (اولویت ← اختصاصی‌بودن ← قدمت)، و نسخه‌پذیری (قانون ویرایش
* نمی‌شود، نسخهٔ تازه می‌گیرد).
*/
class PolicyEngineTest extends ApiTestCase
{
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress} */
private function clinicWithBranch(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک قوانین');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$this->em->flush();
return [$user, $section, $address];
}
private function service(ServiceSection $section, string $name, int $solo = 20, int $price = 1_000_000): ServiceItem
{
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes($solo);
$item->setPriceRials($price);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/**
* قانون تازه **پیش‌نویس** است؛ تا فعال نشود اجرا نمی‌شود.
*
* @param array<string, mixed> $body
*/
private function policy(User $user, array $body, bool $activate = true): array
{
$created = $this->authJson('POST', '/api/v1/policy', $user, $body);
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
if (!$activate) {
return $created['data'];
}
// فعال‌سازی از تسک ۱۰ به بعد یک اجرای آزمایشی از **همین نسخه** می‌خواهد.
$this->authJson('POST', "/api/v1/policy/{$created['data']['uuid']}/simulate", $user);
self::assertSame(201, $this->responseCode());
$active = $this->authJson('POST', "/api/v1/policy/{$created['data']['uuid']}/activate", $user);
self::assertSame(200, $this->responseCode(), json_encode($active, JSON_UNESCAPED_UNICODE));
return $active['data'];
}
/** پیش‌نویس ماندنِ قانون تازه عمدی است: نوشتن قانون نباید یعنی اجرای آن. */
public function testANewPolicyIsADraftUntilActivated(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'خدمت پیش‌نویس', 20);
$draft = $this->policy($user, [
'category' => 'timing',
'name' => 'قانون پیش‌نویس',
'effects' => [['type' => 'add_duration_minutes', 'value' => 30]],
], activate: false);
self::assertFalse($draft['active']);
self::assertSame(20, $this->preview($user, $service, $address)['data']['total_minutes']);
}
/** @param array<string, mixed> $extra */
private function preview(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array
{
return $this->authJson('POST', '/api/v1/appointment-plan/preview', $user, $extra + [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
]);
}
/** @param array<string, mixed> $extra */
private function quote(User $user, ServiceItem $service, DoctorAddress $address, array $extra = []): array
{
return $this->authJson('POST', '/api/v1/pricing/quote', $user, $extra + [
'service_uuid' => $service->getUuid(),
'branch_uuid' => $address->getUuid(),
]);
}
// ── شِما ────────────────────────────────────────────────────────────────
public function testSchemaIsAClosedListPerCategory(): void
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$body = $this->authJson('GET', '/api/v1/policy-schema', $user);
self::assertSame(200, $this->responseCode());
$schema = $body['data'];
self::assertArrayHasKey('timing', $schema);
self::assertContains('equals', array_column($schema['timing']['operators'], 'value'));
self::assertSame(
['min_duration_minutes', 'add_duration_minutes'],
array_column($schema['timing']['effects'], 'type'),
);
self::assertSame(
['max', 'sum'],
array_column($schema['timing']['effects'], 'combination'),
);
// فیلد قیمتی در دستهٔ زمان جایی ندارد — همین بسته‌بودن نکتهٔ اصلی شِماست.
self::assertNotContains('subtotal_rials', $schema['timing']['fields']);
// فرم باید عملگرها را per فیلد فیلتر کند، وگرنه کاربر «برچسب > ۵» می‌سازد و
// ۴۲۲ می‌گیرد بی‌آنکه بفهمد چرا.
$meta = array_column($schema['eligibility']['field_meta'], null, 'key');
self::assertSame('int', $meta['patient_age']['type']);
// عدد یازده عملگر ندارد؛ فقط آن‌هایی که روی عدد معنا دارند.
self::assertSame(
['equals', 'not_equals', 'greater_than', 'greater_or_equal', 'less_than', 'less_or_equal', 'between', 'in', 'not_in'],
$meta['patient_age']['operators'],
);
self::assertSame(['contains'], $meta['patient_tags']['operators']);
self::assertSame('سن بیمار', $meta['patient_age']['label']);
}
public function testFieldOutsideTheCategoryIsRejectedAtCreateTime(): void
{
[$user] = $this->clinicWithBranch();
$body = $this->authJson('POST', '/api/v1/policy', $user, [
'category' => 'timing',
'name' => 'قانون بی‌ربط',
'condition' => ['match' => 'all', 'conditions' => [['field' => 'subtotal_rials', 'operator' => 'greater_than', 'value' => 10]]],
]);
self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
}
public function testEffectOutsideTheCategoryIsRejectedAtCreateTime(): void
{
[$user] = $this->clinicWithBranch();
$this->authJson('POST', '/api/v1/policy', $user, [
'category' => 'timing',
'name' => 'تخفیف در دستهٔ زمان',
'effects' => [['type' => 'discount_percent', 'value' => 10]],
]);
self::assertSame(422, $this->responseCode());
}
// ── ترکیب اثرها ─────────────────────────────────────────────────────────
/** «حداقل مدت» با max ترکیب می‌شود: سخت‌گیرترین قانون برنده است. */
public function testMinDurationTakesTheLargestNotTheLast(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر', 20);
$this->policy($user, [
'category' => 'timing',
'name' => 'حداقل ۴۵ دقیقه',
'effects' => [['type' => 'min_duration_minutes', 'value' => 45]],
]);
$this->policy($user, [
'category' => 'timing',
'name' => 'حداقل ۶۰ دقیقه',
'effects' => [['type' => 'min_duration_minutes', 'value' => 60]],
]);
$plan = $this->preview($user, $service, $address);
self::assertSame(200, $this->responseCode(), json_encode($plan, JSON_UNESCAPED_UNICODE));
self::assertSame(60, $plan['data']['total_minutes']);
}
/** «افزودن مدت» با sum ترکیب می‌شود — دو قانون ۱۰ دقیقه‌ای یعنی ۲۰ دقیقه. */
public function testAddDurationSumsAcrossPolicies(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'پاکسازی', 20);
foreach (['ضدعفونی اضافه', 'آماده‌سازی اضافه'] as $name) {
$this->policy($user, [
'category' => 'timing',
'name' => $name,
'effects' => [['type' => 'add_duration_minutes', 'value' => 10]],
]);
}
$plan = $this->preview($user, $service, $address);
self::assertSame(40, $plan['data']['total_minutes']);
}
/** یک ممنوعیت کافی است؛ ممنوعیت رأی اکثریت نیست. */
public function testOneForbidVetoesTheSelection(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'بوتاکس', 20);
$this->policy($user, [
'category' => 'selection',
'name' => 'این خدمت فعلاً ارائه نمی‌شود',
'service_uuid' => $service->getUuid(),
'effects' => [['type' => 'forbid', 'reason' => 'این خدمت موقتاً متوقف است']],
]);
$body = $this->authJson('POST', '/api/v1/service-selection/validate', $user, [
'item_uuids' => [$service->getUuid()],
'branch_uuid' => $address->getUuid(),
]);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertFalse($body['data']['valid']);
self::assertSame('policy_forbidden', $body['data']['errors'][0]['code']);
self::assertSame('این خدمت موقتاً متوقف است', $body['data']['errors'][0]['message']);
}
// ── شرط‌ها ──────────────────────────────────────────────────────────────
public function testConditionThatDoesNotMatchLeavesThePlanAlone(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'مشاوره', 20);
$this->policy($user, [
'category' => 'timing',
'name' => 'فقط برای انتخاب‌های پرتعداد',
'condition' => ['match' => 'all', 'conditions' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => 3]]],
'effects' => [['type' => 'add_duration_minutes', 'value' => 30]],
]);
$plan = $this->preview($user, $service, $address);
self::assertSame(20, $plan['data']['total_minutes']);
}
/** حقیقتِ غایب یعنی شرط **برقرار نیست** — نه اینکه بی‌صدا رد شود. */
public function testMissingFactFailsTheClauseInsteadOfPassingIt(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر بدن', 20);
$this->policy($user, [
'category' => 'timing',
'name' => 'وابسته به سن',
'condition' => ['match' => 'all', 'conditions' => [['field' => 'patient_age', 'operator' => 'less_than', 'value' => 18]]],
'effects' => [['type' => 'add_duration_minutes', 'value' => 15]],
]);
// پیش‌نمایش برنامه سن بیمار را نمی‌فرستد.
$plan = $this->preview($user, $service, $address);
self::assertSame(20, $plan['data']['total_minutes']);
}
public function testExpiredPolicyIsIgnored(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'میکرونیدلینگ', 20);
$this->policy($user, [
'category' => 'timing',
'name' => 'کمپین نوروز',
'valid_from' => time() - 86400 * 30,
'valid_to' => time() - 86400,
'effects' => [['type' => 'add_duration_minutes', 'value' => 25]],
]);
$plan = $this->preview($user, $service, $address);
self::assertSame(20, $plan['data']['total_minutes']);
}
public function testDeactivatedPolicyIsIgnored(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'هیدرافیشیال', 20);
$policy = $this->policy($user, [
'category' => 'timing',
'name' => 'قانون خاموش‌شدنی',
'effects' => [['type' => 'add_duration_minutes', 'value' => 20]],
]);
self::assertSame(40, $this->preview($user, $service, $address)['data']['total_minutes']);
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/deactivate", $user);
self::assertSame(200, $this->responseCode());
self::assertSame(20, $this->preview($user, $service, $address)['data']['total_minutes']);
}
// ── ترتیب و اختصاصی‌بودن ────────────────────────────────────────────────
/**
* در تساوی اولویت، قانونِ اختصاصی‌تر اول می‌نشیند — همان که برچسبش روی فاکتور
* می‌رود.
*/
public function testMoreSpecificPolicyIsRankedFirstOnEqualPriority(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'فیلر', 20, 2_000_000);
$this->policy($user, [
'category' => 'pricing',
'name' => 'تخفیف عمومی محیط',
'effects' => [['type' => 'discount_percent', 'value' => 5]],
]);
$this->policy($user, [
'category' => 'pricing',
'name' => 'تخفیف همین سرویس',
'service_uuid' => $service->getUuid(),
'effects' => [['type' => 'discount_percent', 'value' => 10]],
]);
$quote = $this->quote($user, $service, $address);
self::assertSame(200, $this->responseCode(), json_encode($quote, JSON_UNESCAPED_UNICODE));
$applied = $quote['data']['breakdown']['sources']['applied_policies'];
self::assertSame('تخفیف همین سرویس', $applied[0]['name']);
// درصدها جمع می‌شوند: ۵٪ + ۱۰٪ روی ۲٬۰۰۰٬۰۰۰
self::assertSame(300_000, $quote['data']['discount_rials']);
}
public function testHigherPriorityBeatsSpecificity(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'مزوتراپی', 20, 1_000_000);
$this->policy($user, [
'category' => 'pricing',
'name' => 'قانون محیطی با اولویت بالا',
'priority' => 100,
'effects' => [['type' => 'discount_percent', 'value' => 5]],
]);
$this->policy($user, [
'category' => 'pricing',
'name' => 'قانون سرویسی با اولویت پایین',
'service_uuid' => $service->getUuid(),
'priority' => 1,
'effects' => [['type' => 'discount_percent', 'value' => 5]],
]);
$applied = $this->quote($user, $service, $address)['data']['breakdown']['sources']['applied_policies'];
self::assertSame('قانون محیطی با اولویت بالا', $applied[0]['name']);
}
// ── نسخه ────────────────────────────────────────────────────────────────
/**
* قانون **ویرایش نمی‌شود**: تغییر یعنی نسخهٔ تازه، و شمارهٔ نسخه در فاکتور ثبت
* می‌شود تا سه ماه بعد بشود گفت کدام متن اعمال شده بود (قانون پنجم مستند).
*/
public function testEditingAPolicyCreatesANewVersionAndTheQuoteRecordsIt(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر صورت', 20, 1_000_000);
$policy = $this->policy($user, [
'category' => 'pricing',
'name' => 'تخفیف پاییز',
'effects' => [['type' => 'discount_percent', 'value' => 10]],
]);
self::assertSame(1, $policy['version']);
$first = $this->quote($user, $service, $address);
self::assertSame(100_000, $first['data']['discount_rials']);
self::assertSame(1, $first['data']['breakdown']['sources']['applied_policies'][0]['version']);
$updated = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
'effects' => [['type' => 'discount_percent', 'value' => 20]],
]);
self::assertSame(200, $this->responseCode(), json_encode($updated, JSON_UNESCAPED_UNICODE));
self::assertSame(2, $updated['data']['version']);
// نسخهٔ تازه فعال می‌ماند؛ آزمایش دوباره لازم نیست چون قانون از قبل فعال بود.
$second = $this->quote($user, $service, $address);
self::assertSame(200_000, $second['data']['discount_rials']);
self::assertSame(2, $second['data']['breakdown']['sources']['applied_policies'][0]['version']);
// هر دو نسخه در تاریخچه می‌مانند.
$show = $this->authJson('GET', "/api/v1/policy/{$policy['uuid']}", $user);
self::assertSame([1, 2], array_column($show['data']['versions'], 'version'));
}
/**
* ⭐ نسخهٔ تازه نمی‌تواند ادعا کند از دیروز برقرار بوده.
*
* نوبت‌های دیروز با متن قبلی حساب شده‌اند؛ اعتبار عقب‌رونده یعنی ردپای قیمت‌ها با
* قانونی توضیح داده شود که آن روز وجود نداشت.
*/
public function testANewVersionCannotStartInThePast(): void
{
[$user, , ] = $this->clinicWithBranch();
$policy = $this->policy($user, [
'category' => 'pricing',
'name' => 'تخفیف پاییز',
'effects' => [['type' => 'discount_percent', 'value' => 10]],
], false);
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
'valid_from' => time() - 7 * 86400,
]);
self::assertSame(422, $this->responseCode());
// آینده مجاز است.
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
'valid_from' => time() + 86400,
]);
self::assertSame(200, $this->responseCode());
}
/**
* ⭐ شش عملگر، هر کدام جدا. عملگری که غلط بسنجد، قانونی می‌سازد که یا همیشه
* می‌گیرد یا هرگز — و هیچ‌کدام خطا نمی‌دهند.
*
*/
#[\PHPUnit\Framework\Attributes\DataProvider('operatorCases')]
public function testEachOperatorDecidesOnItsOwn(array $clause, bool $expected): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'خدمت عملگر', 20, 1_000_000);
$this->policy($user, [
'category' => 'pricing',
'name' => 'آزمون عملگر',
'condition' => ['match' => 'all', 'conditions' => [$clause]],
'effects' => [['type' => 'discount_percent', 'value' => 50]],
]);
$quote = $this->quote($user, $service, $address);
self::assertSame(
$expected ? 500_000 : 0,
$quote['data']['discount_rials'],
json_encode($clause, JSON_UNESCAPED_UNICODE),
);
}
/** بدون `item_uuids` هیچ آیتم اضافه‌ای انتخاب نشده، پس `item_count` صفر است. */
public static function operatorCases(): array
{
return [
'equals می‌گیرد' => [['field' => 'item_count', 'operator' => 'equals', 'value' => 0], true],
'equals نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'equals', 'value' => 9], false],
'not_equals می‌گیرد' => [['field' => 'item_count', 'operator' => 'not_equals', 'value' => 9], true],
'not_equals نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'not_equals', 'value' => 0], false],
'greater_than می‌گیرد' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => -1], true],
'greater_than نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'greater_than', 'value' => 5], false],
'less_than می‌گیرد' => [['field' => 'item_count', 'operator' => 'less_than', 'value' => 5], true],
'less_than نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'less_than', 'value' => 0], false],
'in می‌گیرد' => [['field' => 'item_count', 'operator' => 'in', 'value' => [0, 2]], true],
'in نمی‌گیرد' => [['field' => 'item_count', 'operator' => 'in', 'value' => [7, 8]], false],
'contains نمی‌گیرد' => [['field' => 'patient_tags', 'operator' => 'contains', 'value' => 'vip'], false],
];
}
/**
* ⭐ قانون `spacing` در لحظهٔ **رزرو موقت** اجرا می‌شود، نه هنگام تولید کاندید.
*
* هزینه‌اش یک اسلات است که نمایش داده می‌شود و بعد رد می‌شود؛ سودش این است که
* جستجوی وقت به‌ازای هر کاندید یک کوئری تاریخچهٔ بیمار نمی‌زند. این تست همان مرز را
* پین می‌کند: نوبت نزدیک رد می‌شود، نوبت دور می‌گذرد.
*/
public function testSpacingRejectsABookingTooCloseToTheLastOne(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر', 20, 1_000_000);
$this->policy($user, [
'category' => 'spacing',
'name' => 'حداقل ۲۱ روز بین جلسات',
'effects' => [['type' => 'min_days_between', 'value' => 21]],
]);
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر فاصله');
$this->em->persist($doctor);
$this->em->flush();
$patient = $this->createUser(['ROLE_USER']);
$last = time() - 5 * 86400;
$previous = new \App\Appointment\Entity\Appointment($doctor, $patient, $last, $last + 1200);
$previous->assignTenantPair($address->tenantEntityType(), $address->tenantEntityId());
$previous->setServiceItem($this->em->getRepository(ServiceItem::class)->find($service->getId()));
$previous->setAddressId($address->getId());
$previous->setPatientName('بیمار فاصله');
$previous->transitionTo(\App\Appointment\Entity\Appointment::STATUS_CONFIRMED);
$this->em->persist($previous);
$this->em->flush();
$guard = static::getContainer()->get(\App\Policy\Service\BookingPolicyGuard::class);
// پنج روز بعد از جلسهٔ قبلی → رد.
$rejected = false;
try {
$guard->assertSpacing($patient, $service, $address, $last + 5 * 86400);
} catch (\App\Shared\Exception\AppException $e) {
$rejected = true;
self::assertStringContainsString('۲۱', str_replace(
['0','1','2','3','4','5','6','7','8','9'],
['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'],
$e->getMessage(),
));
}
self::assertTrue($rejected, 'فاصلهٔ کمتر از قانون باید رد شود');
// سی روز بعد → می‌گذرد.
$guard->assertSpacing($patient, $service, $address, $last + 30 * 86400);
self::assertTrue(true);
}
/**
* ⭐ `specificity` هنگام **ذخیره** حساب می‌شود و در تساوی اولویت تصمیم می‌گیرد.
*
* محاسبه‌اش در زمان اجرا یعنی کاری که یک بار در عمر قانون کافی بود، در هر رزرو
* تکرار شود؛ و ذخیره‌شدنش یعنی می‌شود روزی مرتب‌سازی را به SQL برد.
*/
public function testSpecificityIsStoredAndDecidesTiesAtEqualPriority(): void
{
[$user, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر', 20, 1_000_000);
// قانون عام: بدون دامنه، بدون شرط.
$broad = $this->policy($user, [
'category' => 'pricing',
'name' => 'تخفیف عمومی',
'priority' => 5,
'effects' => [['type' => 'discount_percent', 'value' => 10]],
]);
// قانون خاص: همان اولویت، ولی سرویس و یک شرط دارد.
$narrow = $this->policy($user, [
'category' => 'pricing',
'name' => 'تخفیف همین سرویس',
'priority' => 5,
'service_uuid' => $service->getUuid(),
'condition' => ['match' => 'all', 'conditions' => [
['field' => 'item_count', 'operator' => 'greater_or_equal', 'value' => 0],
]],
'effects' => [['type' => 'discount_percent', 'value' => 40]],
]);
self::assertSame(0, $broad['specificity'], 'قانون بی‌دامنه و بی‌شرط');
self::assertSame(5, $narrow['specificity'], 'سرویس ۴ + یک شرط ۱');
// هر دو اعمال می‌شوند (تخفیف درصدی جمع می‌شود)، ولی **ترتیب** مال specificity است:
// اختصاصی‌تر اول می‌آید، و همان ترتیبی است که اثرهای «اولی برنده» را تعیین می‌کند.
$quote = $this->quote($user, $service, $address);
$names = array_column($quote['data']['breakdown']['sources']['applied_policies'], 'name');
self::assertSame(['تخفیف همین سرویس', 'تخفیف عمومی'], $names, 'اختصاصی‌تر باید اول باشد');
}
// ── جداسازی محیط ────────────────────────────────────────────────────────
public function testPolicyOfAnotherClinicIsNeitherVisibleNorApplied(): void
{
[$owner, , ] = $this->clinicWithBranch();
[$other, $section, $address] = $this->clinicWithBranch();
$service = $this->service($section, 'خدمت کلینیک دوم', 20, 1_000_000);
$foreign = $this->policy($owner, [
'category' => 'pricing',
'name' => 'تخفیف کلینیک اول',
'effects' => [['type' => 'discount_percent', 'value' => 50]],
]);
$this->authJson('GET', "/api/v1/policy/{$foreign['uuid']}", $other);
self::assertSame(404, $this->responseCode());
$quote = $this->quote($other, $service, $address);
self::assertSame(0, $quote['data']['discount_rials']);
self::assertArrayNotHasKey('applied_policies', $quote['data']['breakdown']['sources']);
}
}
-78
View File
@@ -1,78 +0,0 @@
<?php
namespace App\Tests\Policy;
use App\Policy\Entity\Policy;
use App\Policy\Service\FieldRegistry;
use App\Policy\Service\OperatorRegistry;
use PHPUnit\Framework\TestCase;
/**
* هر فیلدی که schema تبلیغ می‌کند، باید جایی در کد **واقعاً پر شود**.
*
* خطر مشخص است و بی‌صداست: قانونی که روی فیلدی شرط بگذارد که هیچ فراخوانی آن را
* نمی‌فرستد، هرگز مطابقت نمی‌کند و هیچ خطایی هم نمی‌دهد. اپراتور قانون را می‌سازد،
* فعالش می‌کند، و تا ابد فکر می‌کند دارد کار می‌کند.
*
* این تست عمداً ساختاری است نه رفتاری: پیمایشِ همهٔ مسیرهای واقعی برای هر فیلد،
* دستگاه تستی می‌خواست بزرگ‌تر از خودِ موتور.
*/
class PolicyFieldCoverageTest extends TestCase
{
public function testEveryAdvertisedFieldIsSuppliedSomewhereInTheCode(): void
{
$registry = new FieldRegistry(new OperatorRegistry());
$fields = [];
foreach (Policy::CATEGORIES as $category) {
foreach ($registry->forCategory($category) as $field) {
$fields[$field][] = $category;
}
}
$sources = $this->sourceFiles(dirname(__DIR__, 2) . '/src');
$missing = [];
foreach ($fields as $field => $categories) {
$found = false;
foreach ($sources as $file => $code) {
// خودِ schema فقط نام را اعلام می‌کند؛ پر کردنش جای دیگری است.
if (str_ends_with($file, 'PolicySchema.php') || str_ends_with($file, 'FieldRegistry.php')) {
continue;
}
if (str_contains($code, sprintf("'%s'", $field)) && str_contains($code, '=>')) {
$found = true;
break;
}
}
if (!$found) {
$missing[$field] = $categories;
}
}
self::assertSame(
[],
$missing,
'این فیلدها در schema هستند ولی هیچ‌جا در context پر نمی‌شوند: ' .
json_encode($missing, JSON_UNESCAPED_UNICODE),
);
}
/** @return array<string, string> مسیر => محتوا */
private function sourceFiles(string $root): array
{
$files = [];
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root));
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$files[$file->getPathname()] = (string) file_get_contents($file->getPathname());
}
}
return $files;
}
}
-483
View File
@@ -1,483 +0,0 @@
<?php
namespace App\Tests\Policy;
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\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* آزمایشگاه قانون — تسک ۱۰.
*
* مهم‌ترین تستِ این فایل `testSimulationWritesNothingButItsOwnRun` است: هر بار که کسی
* `PolicySimulator` را عوض کند، همان تست جلوی نوشتنِ ناخواسته را می‌گیرد.
*/
class PolicySimulationTest extends ApiTestCase
{
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor} */
private function clinicWithBranch(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک آزمایشگاه');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'زیبایی');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($doctorUser, 'دکتر آزمون');
$this->em->persist($doctor);
$this->em->flush();
return [$user, $section, $address, $doctor];
}
private function service(ServiceSection $section, string $name, int $price = 1_000_000): ServiceItem
{
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes(20);
$item->setPriceRials($price);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** نوبت گذشتهٔ ثبت‌شده — نمونهٔ آزمایش از همین‌ها ساخته می‌شود. */
private function pastAppointment(
Doctor $doctor,
User $patient,
ServiceItem $service,
Clinic|int $clinicId,
int $daysAgo,
int $price = 1_000_000,
): Appointment {
$start = time() - $daysAgo * 86400;
$appointment = new Appointment($doctor, $patient, $start, $start + 1200);
$appointment->assignTenantPair('clinic', is_int($clinicId) ? $clinicId : (int) $clinicId->getId());
$appointment->setServiceItem($service);
$appointment->setVisitPriceRials($price);
$appointment->setPatientName('بیمار نمونه');
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$appointment->transitionTo(Appointment::STATUS_COMPLETED);
$this->em->persist($appointment);
$this->em->flush();
return $appointment;
}
/** @param array<string, mixed> $body */
private function draft(User $user, array $body): array
{
$created = $this->authJson('POST', '/api/v1/policy', $user, $body);
self::assertSame(201, $this->responseCode(), json_encode($created, JSON_UNESCAPED_UNICODE));
return $created['data'];
}
/** @param string[] $tables */
private function countRows(array $tables): array
{
$connection = $this->em->getConnection();
$counts = [];
foreach ($tables as $table) {
$counts[$table] = (int) $connection->fetchOne("SELECT COUNT(*) FROM $table");
}
return $counts;
}
// ── الگوها ──────────────────────────────────────────────────────────────
public function testTemplatesAreListedWithTheirInputs(): void
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$body = $this->authJson('GET', '/api/v1/policy-templates', $user);
self::assertSame(200, $this->responseCode());
$keys = array_column($body['data'], 'key');
self::assertContains('min_days_between_sessions', $keys);
self::assertContains('vip_discount', $keys);
$vip = current(array_filter($body['data'], static fn (array $t): bool => $t['key'] === 'vip_discount'));
self::assertSame('pricing', $vip['category']);
self::assertSame(['visit_count', 'percent'], array_column($vip['inputs'], 'key'));
}
/** الگو باید همان قانونی را بسازد که کاربر دستی می‌ساخت — نه چیز دیگری. */
public function testTemplateBuildsAValidPolicy(): void
{
[$user] = $this->clinicWithBranch();
$policy = $this->draft($user, [
'name' => 'تخفیف مشتری وفادار',
'template' => 'vip_discount',
'values' => ['visit_count' => 3, 'percent' => 15],
]);
self::assertSame('pricing', $policy['category']);
self::assertSame(
[['field' => 'visit_count', 'operator' => 'greater_than', 'value' => 3]],
$policy['condition']['conditions'],
);
self::assertSame([['type' => 'discount_percent', 'value' => 15]], $policy['effects']);
}
/**
* ⭐ هر شش الگو باید قانونِ **معتبر** بسازند.
*
* الگو میان‌بُر است، نه مسیر دوم: اگر خروجی یکی از آن‌ها از اعتبارسنجی عادی رد
* نشود، کاربر با یک کلیک قانونی می‌سازد که هیچ‌وقت کار نمی‌کند.
*
* @param array<string, mixed> $values
*/
#[\PHPUnit\Framework\Attributes\DataProvider('templateCases')]
public function testEveryTemplateBuildsAValidPolicy(string $key, array $values, string $category): void
{
[$user, , $address] = $this->clinicWithBranch();
// الگوی نقش‌محور به یک نوع منبع واقعی نیاز دارد.
if (isset($values['role'])) {
$type = $this->authJson('POST', '/api/v1/resource-types', $user, [
'address_uuid' => $address->getUuid(),
'code' => 'surgeon',
'name' => 'جراح',
]);
self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE));
$values['role'] = 'surgeon';
}
$policy = $this->draft($user, [
'name' => sprintf('الگوی %s', $key),
'template' => $key,
'values' => $values,
]);
self::assertSame($category, $policy['category']);
self::assertNotSame([], $policy['effects'], 'قانونی بدون اثر، قانون نیست');
// و باید از مسیر عادیِ آزمایش و فعال‌سازی رد شود.
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
self::assertSame(201, $this->responseCode());
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user);
self::assertSame(200, $this->responseCode());
}
public static function templateCases(): array
{
return [
'فاصلهٔ جلسات' => ['min_days_between_sessions', ['days' => 21], 'spacing'],
'حداقل مدت' => ['complex_min_duration', ['minutes' => 60], 'timing'],
'زمان اضافه' => ['extra_time_for_many_items', ['item_count' => 2, 'minutes' => 15], 'timing'],
'نقش لازم' => ['surgery_needs_surgeon', ['role' => 'surgeon'], 'resource'],
'رضایت والدین' => ['minor_needs_consent', ['age' => 18], 'eligibility'],
'تخفیف وفادار' => ['vip_discount', ['visit_count' => 3, 'percent' => 15], 'pricing'],
];
}
/**
* ⭐ آزمایش نباید هیچ نیمه‌حالتی جا بگذارد که **flushِ بعدیِ همین درخواست** ثبتش کند.
*
* این دقیقاً همان باگی است که `finally { rollback(); clear(); }` جلویش را می‌گیرد و
* پیدا کردنش روزها می‌برد: خطا در صفحهٔ آزمایش ظاهر نمی‌شود، در عملیاتِ بعدی ظاهر
* می‌شود.
*/
public function testSimulationLeavesNoPendingStateForALaterFlush(): void
{
[$user, , $address] = $this->clinicWithBranch();
$policy = $this->draft($user, [
'category' => 'pricing',
'name' => 'قانون آزمایشی',
'effects' => [['type' => 'discount_percent', 'value' => 10]],
]);
$entity = static::getContainer()
->get(\App\Policy\Repository\PolicyRepository::class)
->findOneBy(['uuid' => $policy['uuid']]);
// یک entity در انتظار flush — دقیقاً وضعیتی که سناریوی خطرناک با آن شروع می‌شود.
$pending = new \App\Resource\Entity\ResourceType(
$address->tenantEntityType(),
$address->tenantEntityId(),
'pending_type',
'نوع در انتظار',
);
$this->em->persist($pending);
static::getContainer()->get(\App\Policy\Simulation\PolicySimulator::class)->simulate($entity, 5);
self::assertSame(
0,
$this->em->getConnection()->getTransactionNestingLevel(),
'تراکنش آزمایش باید بسته شده باشد',
);
// flushِ بعدی نباید چیزی از قبل از آزمایش را ثبت کند.
$this->em->flush();
$written = (int) $this->em->getConnection()->fetchOne(
'SELECT COUNT(*) FROM resource_types WHERE code = ?',
['pending_type'],
);
self::assertSame(0, $written, 'آزمایش نباید حالتِ در انتظار را به ثبت برساند');
}
public function testTemplateWithAMissingValueIsRejected(): void
{
[$user] = $this->clinicWithBranch();
$this->authJson('POST', '/api/v1/policy', $user, [
'name' => 'بدون مقدار',
'template' => 'vip_discount',
'values' => ['visit_count' => 3],
]);
self::assertSame(422, $this->responseCode());
}
// ── شبیه‌سازی ───────────────────────────────────────────────────────────
/** ⭐ ارزشمندترین تست این تسک. */
public function testSimulationWritesNothingButItsOwnRun(): void
{
[$user, $section, $address, $doctor] = $this->clinicWithBranch();
$service = $this->service($section, 'لیزر');
$clinic = $address->getClinicId();
for ($i = 1; $i <= 3; $i++) {
$this->pastAppointment($doctor, $user, $service, (int) $clinic, $i * 10);
}
$policy = $this->draft($user, [
'category' => 'pricing',
'name' => 'تخفیف ۱۰٪',
'effects' => [['type' => 'discount_percent', 'value' => 10]],
]);
$tables = ['appointments', 'price_snapshots', 'resource_occupancy', 'policies', 'policy_version_logs'];
$before = $this->countRows($tables);
$runsBefore = $this->countRows(['policy_simulation_runs'])['policy_simulation_runs'];
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame($before, $this->countRows($tables), 'شبیه‌سازی نباید هیچ ردیفی بنویسد');
// دیتابیس تست هرگز ریست نمی‌شود، پس تفاوت شمرده می‌شود نه مقدار مطلق.
self::assertSame(
$runsBefore + 1,
$this->countRows(['policy_simulation_runs'])['policy_simulation_runs'],
'تنها ردیفی که باید نوشته شود، خودِ نتیجهٔ آزمایش است',
);
}
public function testPricingSimulationShowsThePerAppointmentDifference(): void
{
[$user, $section, $address, $doctor] = $this->clinicWithBranch();
$service = $this->service($section, 'فیلر');
$this->pastAppointment($doctor, $user, $service, (int) $address->getClinicId(), 5, 2_000_000);
$policy = $this->draft($user, [
'category' => 'pricing',
'name' => 'تخفیف ۲۵٪',
'effects' => [['type' => 'discount_percent', 'value' => 25]],
]);
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
self::assertSame(1, $body['data']['sample_size']);
self::assertSame(1, $body['data']['affected_count']);
self::assertSame(100, $body['data']['affected_percent']);
self::assertSame('high', $body['data']['severity']);
$row = $body['data']['rows'][0];
self::assertSame('2,000,000 ریال', $row['before']);
self::assertSame('1,500,000 ریال', $row['after']);
}
/** کلینیک تازه نوبتی ندارد؛ اگر این حالت خطا بود، هرگز قانونی فعال نمی‌کرد. */
public function testEmptySampleSucceedsWithAWarning(): void
{
[$user] = $this->clinicWithBranch();
$policy = $this->draft($user, [
'category' => 'timing',
'name' => 'حداقل ۳۰ دقیقه',
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
]);
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame(0, $body['data']['sample_size']);
self::assertSame('none', $body['data']['severity']);
self::assertSame('داده‌ای برای آزمایش نیست', $body['data']['warning']);
}
/** قانونی که همهٔ نمونه را رد می‌کند تقریباً همیشه اشتباه نوشته شده. */
public function testAPolicyThatRejectsEverythingIsFlaggedHigh(): void
{
[$user, $section, $address, $doctor] = $this->clinicWithBranch();
$service = $this->service($section, 'بوتاکس');
for ($i = 1; $i <= 3; $i++) {
$this->pastAppointment($doctor, $user, $service, (int) $address->getClinicId(), $i);
}
$policy = $this->draft($user, [
'category' => 'selection',
'name' => 'توقف کامل خدمت',
'effects' => [['type' => 'forbid', 'reason' => 'این خدمت متوقف است']],
]);
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
self::assertSame(3, $body['data']['affected_count']);
self::assertSame('high', $body['data']['severity']);
self::assertSame('رد می‌شد', $body['data']['rows'][0]['after']);
}
/** شرطی که هرگز برقرار نمی‌شود هم هشدار است، نه موفقیت. */
public function testAPolicyThatMatchesNothingIsFlaggedNone(): void
{
[$user, $section, $address, $doctor] = $this->clinicWithBranch();
$service = $this->service($section, 'مشاوره');
$this->pastAppointment($doctor, $user, $service, (int) $address->getClinicId(), 2);
$policy = $this->draft($user, [
'category' => 'timing',
'name' => 'فقط برای سبد بزرگ',
'condition' => ['match' => 'all', 'conditions' => [
['field' => 'item_count', 'operator' => 'greater_than', 'value' => 50],
]],
'effects' => [['type' => 'add_duration_minutes', 'value' => 15]],
]);
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
self::assertSame(1, $body['data']['sample_size']);
self::assertSame(0, $body['data']['affected_count']);
self::assertSame('none', $body['data']['severity']);
}
// ── دروازهٔ فعال‌سازی ────────────────────────────────────────────────────
public function testActivateWithoutSimulationIsRejected(): void
{
[$user] = $this->clinicWithBranch();
$policy = $this->draft($user, [
'category' => 'timing',
'name' => 'قانون آزمایش‌نشده',
'effects' => [['type' => 'min_duration_minutes', 'value' => 45]],
]);
$body = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user);
self::assertSame(422, $this->responseCode());
self::assertSame('ابتدا قانون را آزمایش کنید و نتیجه را ببینید', $body['errors'][0]['message']);
}
public function testSimulationOfTheOldVersionDoesNotUnlockTheNewOne(): void
{
[$user] = $this->clinicWithBranch();
$policy = $this->draft($user, [
'category' => 'timing',
'name' => 'قانون نسخه‌دار',
'effects' => [['type' => 'min_duration_minutes', 'value' => 45]],
]);
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
self::assertSame(201, $this->responseCode());
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/version", $user, [
'effects' => [['type' => 'min_duration_minutes', 'value' => 90]],
]);
self::assertSame(200, $this->responseCode());
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user);
self::assertSame(422, $this->responseCode(), 'آزمایش نسخهٔ ۱ نباید نسخهٔ ۲ را باز کند');
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
$activated = $this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/activate", $user);
self::assertSame(200, $this->responseCode());
self::assertTrue($activated['data']['active']);
}
public function testSimulationHistoryIsListedNewestFirst(): void
{
[$user] = $this->clinicWithBranch();
$policy = $this->draft($user, [
'category' => 'timing',
'name' => 'قانون با تاریخچه',
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
]);
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user);
$body = $this->authJson('GET', "/api/v1/policy/{$policy['uuid']}/simulations", $user);
self::assertSame(200, $this->responseCode());
self::assertCount(2, $body['data']);
}
public function testSampleSizeAboveTheCapIsRejected(): void
{
[$user] = $this->clinicWithBranch();
$policy = $this->draft($user, [
'category' => 'timing',
'name' => 'قانون با نمونهٔ بزرگ',
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
]);
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $user, ['sample_size' => 500]);
// سقف بی‌صدا اعمال نمی‌شود: کاربری که ۵۰۰ خواسته باید بداند نگرفته.
self::assertSame(422, $this->responseCode());
}
public function testSimulatingAnotherClinicsPolicyIsNotFound(): void
{
[$owner] = $this->clinicWithBranch();
[$other] = $this->clinicWithBranch();
$policy = $this->draft($owner, [
'category' => 'timing',
'name' => 'قانون کلینیک اول',
'effects' => [['type' => 'min_duration_minutes', 'value' => 30]],
]);
$this->authJson('POST', "/api/v1/policy/{$policy['uuid']}/simulate", $other);
self::assertSame(404, $this->responseCode());
}
}
@@ -1,87 +0,0 @@
<?php
namespace App\Tests\Report;
use App\Appointment\Entity\Appointment;
use App\Doctor\Entity\Doctor;
use App\Shared\Event\DomainEvents;
use App\Shared\Event\Entity\DomainEventLog;
use App\Tests\ApiTestCase;
/**
* دو رویدادی که مسیرشان از تسک‌های قدیمی‌تر می‌آید: «نوبت انجام شد» و «نوبت جابه‌جا شد».
*
* هر دو از مسیرِ وضعیتِ موجود عبور می‌کنند، پس چیزی که این تست‌ها نگه می‌دارند این است
* که رویداد **بعد از ذخیرهٔ موفق** ثبت شود — نه هنگام درخواستِ تغییر وضعیت.
*/
class AppointmentLifecycleEventTest extends ApiTestCase
{
private function makeDoctor(): Doctor
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر رویداد');
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
private function latest(string $name): ?DomainEventLog
{
return $this->em->getRepository(DomainEventLog::class)
->findOneBy(['name' => $name], ['id' => 'DESC']);
}
public function testCompletingAnAppointmentRecordsTheEvent(): void
{
$doctor = $this->makeDoctor();
$start = time() + 86_400;
$appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800);
$this->em->persist($appointment);
$this->em->flush();
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $doctor->getUser(), [
'status' => Appointment::STATUS_CONFIRMED,
'version' => $appointment->getVersion(),
]);
self::assertSame(200, $this->responseCode());
$this->em->clear();
$reloaded = $this->em->getRepository(Appointment::class)->findOneBy(['uuid' => $appointment->getUuid()]);
$this->authJson('PATCH', "/api/v1/appointment/{$reloaded->getUuid()}/status", $doctor->getUser(), [
'status' => Appointment::STATUS_COMPLETED,
'version' => $reloaded->getVersion(),
]);
self::assertSame(200, $this->responseCode());
$event = $this->latest(DomainEvents::APPOINTMENT_COMPLETED);
self::assertNotNull($event);
self::assertSame($appointment->getUuid(), $event->getPayload()['appointment_uuid']);
self::assertSame($start, $event->getPayload()['slot_start']);
}
/**
* انتقال ردشده نباید رویداد بگذارد؛ وگرنه گزارش «انجام‌شده»ها از خودِ نوبت‌ها جلو می‌زند.
*/
public function testARejectedTransitionRecordsNothing(): void
{
$doctor = $this->makeDoctor();
$start = time() + 86_400;
$appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800);
$this->em->persist($appointment);
$this->em->flush();
$before = $this->latest(DomainEvents::APPOINTMENT_COMPLETED);
// `pending → completed` در جدول انتقال‌ها نیست.
$this->authJson('PATCH', "/api/v1/appointment/{$appointment->getUuid()}/status", $doctor->getUser(), [
'status' => Appointment::STATUS_COMPLETED,
'version' => $appointment->getVersion(),
]);
self::assertSame(422, $this->responseCode());
$after = $this->latest(DomainEvents::APPOINTMENT_COMPLETED);
self::assertSame($before?->getId(), $after?->getId());
}
}
-249
View File
@@ -1,249 +0,0 @@
<?php
namespace App\Tests\Report;
use App\Auth\Entity\User;
use App\Clinic\Entity\Clinic;
use App\ClinicService\Entity\ServiceItem;
use App\ClinicService\Entity\ServiceSection;
use App\Doctor\Entity\DoctorAddress;
use App\Patient\Entity\PatientRecord;
use App\Shared\Event\DomainEventPublisher;
use App\Shared\Event\DomainEvents;
use App\Shared\Event\Entity\DomainEventLog;
use App\Shared\Event\Repository\DomainEventLogRepository;
use App\Tests\ApiTestCase;
use Doctrine\ORM\EntityManagerInterface;
/**
* رویدادهای دامنه و صندوق خروجی — تسک ۱۴.
*
* دو تضمین که کل الگو برایشان وجود دارد: رویداد **بعد از** commit منتشر می‌شود، و
* هیچ رویدادی گم نمی‌شود.
*/
class DomainEventTest extends ApiTestCase
{
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: PatientRecord} */
private function clinic(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک رویداد');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$patientUser = $this->createUser(['ROLE_USER']);
$patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
$this->em->persist($patient);
$this->em->flush();
return [$user, $section, $address, $patient];
}
private function service(ServiceSection $section): ServiceItem
{
$item = new ServiceItem($section, 'لیزر فول‌بادی');
$item->setSoloDurationMinutes(30);
$item->setPriceRials(4_000_000);
$this->em->persist($item);
$this->em->flush();
return $item;
}
private function publisher(): DomainEventPublisher
{
return static::getContainer()->get(DomainEventPublisher::class);
}
private function repo(): DomainEventLogRepository
{
return static::getContainer()->get(DomainEventLogRepository::class);
}
private function containerEm(): EntityManagerInterface
{
return static::getContainer()->get(EntityManagerInterface::class);
}
// ── قرارداد ─────────────────────────────────────────────────────────────
/** نام رویداد قرارداد عمومی است؛ تایپو باید همان‌جا بترکد نه در سکوت. */
public function testAnUnknownEventNameIsRejected(): void
{
[$user] = $this->clinic();
self::expectException(\InvalidArgumentException::class);
$this->publisher()->record('clinic', 1, 'AppointmentBookd', ['appointment_uuid' => 'x']);
}
/** ⭐ payload فقط اسکالر و uuid — هیچ entity ای در رویداد نیست. */
public function testNonScalarPayloadValuesAreDropped(): void
{
$event = new DomainEventLog('clinic', 1, DomainEvents::APPOINTMENT_BOOKED, [
'appointment_uuid' => 'abc',
'count' => 3,
'nested' => ['a' => 1],
'object' => new \stdClass(),
]);
self::assertSame(['appointment_uuid' => 'abc', 'count' => 3], $event->getPayload());
}
// ── انتشار بعد از commit ────────────────────────────────────────────────
/** ⭐⭐ تراکنشی که برمی‌گردد، هیچ رویدادی جا نمی‌گذارد. */
public function testARolledBackTransactionLeavesNoEvent(): void
{
$this->clinic();
$before = $this->repo()->count([]);
$em = $this->containerEm();
$em->beginTransaction();
try {
$this->publisher()->record('clinic', 999, DomainEvents::APPOINTMENT_BOOKED, ['appointment_uuid' => 'ghost']);
$em->flush();
} finally {
$em->rollback();
$em->clear();
}
self::assertSame($before, $this->repo()->count([]), 'رویداد نباید از تراکنشِ برگشته جا بماند');
}
// ── صندوق خروجی ────────────────────────────────────────────────────────
public function testPendingEventsArePublishedAndMarked(): void
{
[$user, $section, $address, $patient] = $this->clinic();
$event = $this->publisher()->recordAndFlush(
'clinic',
(int) $address->getClinicId(),
DomainEvents::PACKAGE_PURCHASED,
['patient_package_uuid' => 'pkg-1'],
);
self::assertNull($event->getPublishedAt());
self::assertContains($event->getUuid(), array_map(
static fn (DomainEventLog $e): string => $e->getUuid(),
$this->repo()->findPending(500),
));
$command = static::getContainer()->get(\App\Shared\Event\Command\PublishDomainEventsCommand::class);
$tester = new \Symfony\Component\Console\Tester\CommandTester($command);
$tester->execute(['--limit' => '500']);
$this->containerEm()->clear();
$reloaded = $this->repo()->findOneBy(['uuid' => $event->getUuid()]);
self::assertNotNull($reloaded->getPublishedAt(), 'رویداد باید منتشر و علامت‌گذاری شود');
self::assertSame(0, $reloaded->getAttempts());
}
/** ردیفی که سقف تلاش را رد کرده دیگر برداشته نمی‌شود، ولی حذف هم نمی‌شود. */
public function testAnExhaustedEventIsNoLongerPickedUpButStays(): void
{
[$user, , $address] = $this->clinic();
$event = $this->publisher()->recordAndFlush(
'clinic',
(int) $address->getClinicId(),
DomainEvents::CREDIT_CONSUMED,
['patient_package_uuid' => 'pkg-2'],
);
for ($i = 0; $i < DomainEventLog::MAX_ATTEMPTS; $i++) {
$event->markFailed('اتصال Redis برقرار نشد');
}
$this->containerEm()->flush();
$pendingUuids = array_map(
static fn (DomainEventLog $e): string => $e->getUuid(),
$this->repo()->findPending(500),
);
self::assertNotContains($event->getUuid(), $pendingUuids);
self::assertNotNull($this->repo()->findOneBy(['uuid' => $event->getUuid()]), 'ردیف مرده باید بماند تا دیده شود');
self::assertSame('اتصال Redis برقرار نشد', $event->getLastError());
}
// ── رویدادهای واقعی ────────────────────────────────────────────────────
public function testSellingAPackageRecordsItsEvent(): void
{
[$user, $section, , $patient] = $this->clinic();
$service = $this->service($section);
$package = $this->authJson('POST', '/api/v1/packages', $user, [
'name' => '۶ جلسه',
'session_count' => 6,
'price_rials' => 10_000_000,
'service_uuids' => [$service->getUuid()],
])['data'];
$this->authJson('POST', "/api/v1/patient/{$patient->getUuid()}/package", $user, [
'package_uuid' => $package['uuid'],
]);
self::assertSame(201, $this->responseCode());
$names = array_map(
static fn (DomainEventLog $e): string => $e->getName(),
$this->repo()->search(DomainEvents::PACKAGE_PURCHASED, null, null, 10),
);
self::assertContains(DomainEvents::PACKAGE_PURCHASED, $names);
}
public function testStartingACourseRecordsItsEvent(): void
{
[$user, $section, , $patient] = $this->clinic();
$service = $this->service($section);
$protocol = $this->authJson('POST', '/api/v1/course-protocols', $user, [
'service_uuid' => $service->getUuid(),
'session_count' => 4,
'min_days' => 7,
'ideal_days' => 14,
'max_days' => 21,
])['data'];
$this->authJson('POST', '/api/v1/treatment-course', $user, [
'patient_uuid' => $patient->getUuid(),
'protocol_uuid' => $protocol['uuid'],
]);
self::assertSame(201, $this->responseCode());
$events = $this->repo()->search(DomainEvents::COURSE_STARTED, null, null, 10);
self::assertNotEmpty($events);
self::assertArrayHasKey('course_uuid', $events[0]->getPayload());
}
// ── دسترسی ──────────────────────────────────────────────────────────────
public function testOnlyAdminsCanReadTheEventLog(): void
{
[$user] = $this->clinic();
$this->authJson('GET', '/api/v1/domain-events', $user);
self::assertSame(403, $this->responseCode());
$admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']);
$this->authJson('GET', '/api/v1/domain-events', $admin);
self::assertSame(200, $this->responseCode());
}
}
-512
View File
@@ -1,512 +0,0 @@
<?php
namespace App\Tests\Report;
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\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* گزارش بهره‌وری منابع و دقت برنامه — تسک ۱۴.
*
* گزارش دقت برنامه تنها بازخوردی است که به کلینیک می‌گوید تعریف بخش‌هایش درست است یا
* نه؛ بدون آن، ابزار قدرتمند تسک ۰۵ کور کار می‌کند.
*/
class ReportTest extends ApiTestCase
{
private int $slotCursor = 0;
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor} */
private function clinic(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک گزارش');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($doctorUser, 'دکتر گزارش');
$this->em->persist($doctor);
$this->em->flush();
return [$user, $section, $address, $doctor];
}
private function service(ServiceSection $section, string $name, int $solo): ServiceItem
{
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes($solo);
$item->setPriceRials(1_000_000);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** نوبت انجام‌شده با مدت پیش‌بینی و مدت واقعی مشخص. */
private function completed(
Doctor $doctor,
User $patient,
ServiceItem $service,
int $clinicId,
int $plannedMinutes,
int $actualMinutes,
int $daysAgo,
): Appointment {
$em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);
$start = time() - $daysAgo * 86400 + (++$this->slotCursor) * 60;
$appointment = new Appointment(
$em->getRepository(Doctor::class)->find($doctor->getId()),
$em->getRepository(User::class)->find($patient->getId()),
$start,
$start + $actualMinutes * 60,
);
$appointment->assignTenantPair('clinic', $clinicId);
$appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId()));
$appointment->setPatientName('بیمار گزارش');
$appointment->setServiceDuration($plannedMinutes, 0);
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$appointment->transitionTo(Appointment::STATUS_COMPLETED);
$em->persist($appointment);
$em->flush();
return $appointment;
}
// ── دقت برنامه ──────────────────────────────────────────────────────────
/** ⭐ سرویسی که ۶۰ دقیقه پیش‌بینی شده ولی ۹۰ دقیقه طول می‌کشد. */
public function testAServiceThatRunsLongIsFlaggedHigh(): void
{
[$user, $section, $address, $doctor] = $this->clinic();
$service = $this->service($section, 'لیزر فول‌بادی', 60);
$patient = $this->createUser(['ROLE_USER']);
for ($i = 1; $i <= 10; $i++) {
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 90, $i);
}
$body = $this->authJson(
'GET',
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()),
$user,
);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
$row = $body['data']['rows'][0];
self::assertSame($service->getUuid(), $row['service_uuid']);
self::assertSame(60, $row['planned_minutes']);
self::assertSame(90, $row['actual_minutes']);
self::assertSame(50, $row['deviation_percent']);
self::assertSame('high', $row['severity']);
}
/** انحراف منفی هم غلط است: ظرفیتی که می‌شد فروخت، خالی مانده. */
public function testAServiceThatRunsShortIsAlsoFlagged(): void
{
[$user, $section, $address, $doctor] = $this->clinic();
$service = $this->service($section, 'مشاوره', 60);
$patient = $this->createUser(['ROLE_USER']);
for ($i = 1; $i <= 10; $i++) {
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 30, $i);
}
$rows = $this->authJson(
'GET',
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()),
$user,
)['data']['rows'];
self::assertSame(-50, $rows[0]['deviation_percent']);
self::assertSame('high', $rows[0]['severity']);
}
/** زیر سه نمونه، میانگین معنا ندارد. */
/**
* ⭐ زیر آستانه **حذف نمی‌شود، بی‌شدت برمی‌گردد**.
*
* میانگین دو نمونه معنا ندارد و نباید کسی رویش تصمیم بگیرد؛ ولی حذف کاملش یعنی
* کلینیک کوچک گزارشی خالی می‌بیند و فکر می‌کند همه‌چیز درست است.
*/
public function testASmallSampleIsShownWithoutASeverity(): void
{
[$user, $section, $address, $doctor] = $this->clinic();
$service = $this->service($section, 'خدمت کم‌تکرار', 60);
$patient = $this->createUser(['ROLE_USER']);
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 120, 1);
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 120, 2);
$rows = $this->authJson(
'GET',
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()),
$user,
)['data']['rows'];
$mine = array_values(array_filter(
$rows,
static fn (array $r): bool => $r['service_uuid'] === $service->getUuid(),
));
self::assertCount(1, $mine);
self::assertNull($mine[0]['severity'], 'با دو نمونه نباید شدتی ادعا شود');
self::assertTrue($mine[0]['below_min_sample']);
self::assertSame(2, $mine[0]['sample_size']);
}
public function testAnAccurateServiceHasNoSeverity(): void
{
[$user, $section, $address, $doctor] = $this->clinic();
$service = $this->service($section, 'خدمت دقیق', 60);
$patient = $this->createUser(['ROLE_USER']);
for ($i = 1; $i <= 10; $i++) {
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 60, $i);
}
$rows = $this->authJson(
'GET',
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()),
$user,
)['data']['rows'];
$row = current(array_filter($rows, static fn (array $r): bool => $r['service_uuid'] === $service->getUuid()));
self::assertSame(0, $row['deviation_percent']);
self::assertSame('none', $row['severity']);
}
// ── بهره‌وری منابع ──────────────────────────────────────────────────────
/** منبعی بدون تقویم «۰٪ بهره‌وری» ندارد — بهره‌وری‌اش تعریف‌نشده است. */
/**
* ⭐ چهار آستانه، هر کدام روی مرز خودش.
*
* آستانه‌ای که یک درجه اشتباه بیفتد، یا همه‌چیز را قرمز می‌کند (و کسی دیگر نگاه
* نمی‌کند) یا هیچ‌چیز را (و گزارش بی‌فایده است).
*
* @param int $planned مدت برنامه
* @param int $actual مدت واقعی
*/
#[\PHPUnit\Framework\Attributes\DataProvider('severityCases')]
public function testEachSeverityThresholdIsHitExactly(int $planned, int $actual, string $expected): void
{
[$user, $section, $address, $doctor] = $this->clinic();
$service = $this->service($section, 'خدمت آستانه', $planned);
$patient = $this->createUser(['ROLE_USER']);
for ($i = 1; $i <= 10; $i++) {
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), $planned, $actual, $i);
}
$body = $this->authJson(
'GET',
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()),
$user,
);
$row = $body['data']['rows'][0];
self::assertSame($expected, $row['severity'], sprintf(
'انحراف %d%%',
$row['deviation_percent'],
));
}
/** مرزها: ۳۰ · ۱۵ · ۵ درصد، روی قدر مطلق. */
public static function severityCases(): array
{
return [
'دقیقاً روی مرز high' => [100, 130, 'high'],
'یک قدم زیر high' => [100, 129, 'medium'],
'دقیقاً روی مرز medium' => [100, 115, 'medium'],
'یک قدم زیر medium' => [100, 114, 'low'],
'دقیقاً روی مرز low' => [100, 105, 'low'],
'یک قدم زیر low' => [100, 104, 'none'],
'کوتاه‌تر هم شمرده می‌شود' => [100, 70, 'high'],
];
}
public function testAResourceWithoutACalendarHasNullUtilization(): void
{
[$user, , $address] = $this->clinic();
$type = $this->authJson('POST', '/api/v1/resource-types', $user, [
'address_uuid' => $address->getUuid(),
'code' => 'device',
'name' => 'دستگاه',
]);
self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE));
$this->authJson('POST', '/api/v1/resource', $user, [
'address_uuid' => $address->getUuid(),
'type_uuid' => $type['data']['uuid'],
'name' => 'لیزر ۱',
]);
self::assertSame(201, $this->responseCode());
$body = $this->authJson(
'GET',
sprintf(
'/api/v1/reports/resource-utilization?branch_uuid=%s&from=%d&to=%d',
$address->getUuid(),
time() - 7 * 86400,
time(),
),
$user,
);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
$row = $body['data']['rows'][0];
self::assertSame('لیزر ۱', $row['resource_name']);
self::assertSame(0, $row['available_minutes']);
self::assertNull($row['utilization'], 'تقسیم بر صفر معنای متفاوتی دارد');
self::assertNull($row['active_ratio']);
self::assertFalse($row['wasted_capacity']);
}
/**
* ⭐ سنجه‌های واقعی: اشغال شامل انتظار است، «کار مفید» نه.
*
* فاصلهٔ این دو همان چیزی است که تعریف غلط بخش‌ها را لو می‌دهد؛ اگر هر دو یکی
* برگردند، گزارش بی‌فایده است و کسی متوجه نمی‌شود.
*/
public function testOccupiedIncludesTheWaitingSegmentButActiveDoesNot(): void
{
[$user, , $address] = $this->clinic();
$type = $this->authJson('POST', '/api/v1/resource-types', $user, [
'address_uuid' => $address->getUuid(),
'code' => 'room',
'name' => 'اتاق',
]);
self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE));
$created = $this->authJson('POST', '/api/v1/resource', $user, [
'address_uuid' => $address->getUuid(),
'type_uuid' => $type['data']['uuid'],
'name' => 'اتاق ۱',
]);
self::assertSame(201, $this->responseCode());
$resource = $this->em->getRepository(\App\Resource\Entity\ClinicResource::class)
->findOneBy(['uuid' => $created['data']['uuid']]);
$from = time() - 2 * 86400;
$start = $from + 3600;
$appointment = $this->bookedAppointment($address, $start, 60);
// یک ساعت اشغال؛ ولی بیمار فقط ۲۰ دقیقهٔ اولش حاضر است.
$this->occupy($resource, $appointment, $start, $start + 3600);
$this->segment($appointment, 1, 'ویزیت', $start, $start + 1200, true);
$this->segment($appointment, 2, 'انتظار', $start + 1200, $start + 3600, false);
$body = $this->authJson(
'GET',
sprintf(
'/api/v1/reports/resource-utilization?branch_uuid=%s&from=%d&to=%d',
$address->getUuid(),
$from,
time(),
),
$user,
);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
$row = $body['data']['rows'][0];
self::assertSame(60, $row['occupied_minutes'], 'انتظار هم اشغال است');
self::assertSame(20, $row['active_minutes'], 'ولی کار مفید نیست');
}
private function bookedAppointment(\App\Doctor\Entity\DoctorAddress $address, int $start, int $minutes): \App\Appointment\Entity\Appointment
{
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر گزارش');
$this->em->persist($doctor);
$appointment = new \App\Appointment\Entity\Appointment(
$doctor,
$this->createUser(['ROLE_USER']),
$start,
$start + $minutes * 60,
);
$appointment->assignTenantPair('clinic', (int) $address->getClinicId());
$appointment->setAddressId($address->getId());
$appointment->setPatientName('بیمار گزارش');
$appointment->transitionTo(\App\Appointment\Entity\Appointment::STATUS_CONFIRMED);
$this->em->persist($appointment);
$this->em->flush();
return $appointment;
}
private function occupy(
\App\Resource\Entity\ClinicResource $resource,
\App\Appointment\Entity\Appointment $appointment,
int $from,
int $to,
): void {
$row = new \App\Appointment\Availability\Entity\ResourceOccupancy(
$resource,
$from,
$to,
\App\Appointment\Availability\Entity\ResourceOccupancy::STATUS_BOOKED,
);
$row->setAppointmentId($appointment->getId());
$this->em->persist($row);
$this->em->flush();
}
private function segment(
\App\Appointment\Entity\Appointment $appointment,
int $sequence,
string $name,
int $from,
int $to,
bool $present,
): void {
$this->em->persist(new \App\Appointment\Booking\Entity\AppointmentSegment(
$appointment,
$sequence,
$name,
$from,
$to,
$present,
));
$this->em->flush();
}
/**
* ⭐ اشغال و کار مفید هرکدام **یک** کوئری‌اند، مستقل از تعداد منبع.
*
* پیمایش per منبع روی کلینیکی با ۴۰ منبع یعنی ۸۰ کوئری برای یک گزارش. تعداد
* دقیقش مهم نیست؛ چیزی که این تست نگه می‌دارد این است که با سه برابر شدن منابع،
* تعداد کوئری‌ها سه برابر **نشود**.
*/
public function testQueryCountDoesNotGrowWithTheNumberOfResources(): void
{
[$user, , $address] = $this->clinic();
$type = $this->authJson('POST', '/api/v1/resource-types', $user, [
'address_uuid' => $address->getUuid(),
'code' => 'room',
'name' => 'اتاق',
]);
self::assertSame(201, $this->responseCode(), json_encode($type, JSON_UNESCAPED_UNICODE));
$reporter = static::getContainer()->get(\App\Report\Service\ResourceUtilizationReporter::class);
$count = function (int $resources) use ($user, $address, $type, $reporter): int {
for ($i = 0; $i < $resources; $i++) {
$this->authJson('POST', '/api/v1/resource', $user, [
'address_uuid' => $address->getUuid(),
'type_uuid' => $type['data']['uuid'],
'name' => sprintf('اتاق %d', $i + 1),
]);
self::assertSame(201, $this->responseCode());
}
$all = $this->em->getRepository(\App\Resource\Entity\ClinicResource::class)
->findBy(['address' => $address]);
$connection = $this->em->getConnection();
$before = $this->queryCount($connection);
$reporter->report($all, $address, time() - 7 * 86400, time());
return $this->queryCount($connection) - $before;
};
$withOne = $count(1);
$withMany = $count(5);
self::assertLessThan(
$withOne * 3,
$withMany,
sprintf('یک منبع %d کوئری، شش منبع %d کوئری — رشد خطی است', $withOne, $withMany),
);
}
/** شمار کوئری از خودِ سرور — `SHOW SESSION STATUS` روی همان اتصال. */
private function queryCount(\Doctrine\DBAL\Connection $connection): int
{
return (int) ($connection->fetchAssociative("SHOW SESSION STATUS LIKE 'Questions'")['Value'] ?? 0);
}
// ── محدودیت بازه و دسترسی ───────────────────────────────────────────────
public function testARangeLongerThanNinetyDaysIsRejected(): void
{
[$user] = $this->clinic();
$this->authJson(
'GET',
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 200 * 86400, time()),
$user,
);
self::assertSame(422, $this->responseCode());
}
public function testAnInvertedRangeIsRejected(): void
{
[$user] = $this->clinic();
$this->authJson(
'GET',
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time(), time() - 86400),
$user,
);
self::assertSame(422, $this->responseCode());
}
public function testAnotherClinicSeesItsOwnNumbersOnly(): void
{
[$owner, $section, $address, $doctor] = $this->clinic();
[$other] = $this->clinic();
$service = $this->service($section, 'لیزر', 60);
$patient = $this->createUser(['ROLE_USER']);
for ($i = 1; $i <= 10; $i++) {
$this->completed($doctor, $patient, $service, (int) $address->getClinicId(), 60, 90, $i);
}
$rows = $this->authJson(
'GET',
sprintf('/api/v1/reports/plan-accuracy?from=%d&to=%d', time() - 10 * 86400, time()),
$other,
)['data']['rows'];
self::assertSame([], array_values(array_filter(
$rows,
static fn (array $r): bool => $r['service_uuid'] === $service->getUuid(),
)));
}
}
-463
View File
@@ -1,463 +0,0 @@
<?php
namespace App\Tests\Waitlist;
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\Doctor\Entity\DoctorAddress;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
use App\Waitlist\Entity\WaitlistEntry;
use App\Waitlist\Repository\WaitlistEntryRepository;
use App\Waitlist\Service\WaitlistNotifier;
/**
* لیست انتظار — تسک ۱۳.
*
* تصمیم معماری: ظرفیت آزادشده **به همه** خبر داده می‌شود و اولین رزروکننده می‌برد.
* صف انحصاری یعنی وقتی که کسی جوابش را نمی‌دهد نیم ساعت قفل بماند، و ظرفیتِ دو ساعت
* مانده به نوبت آن نیم ساعت را ندارد.
*/
class WaitlistTest extends ApiTestCase
{
private int $slotCursor = 0;
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: Doctor, 4: PatientRecord} */
private function clinicWithPatient(): array
{
$user = $this->createUser(['ROLE_USER', 'ROLE_CLINIC']);
$clinic = new Clinic($user);
$clinic->setName('کلینیک انتظار');
$this->em->persist($clinic);
$this->em->flush();
$section = new ServiceSection('clinic', $clinic->getId(), 'لیزر');
$this->em->persist($section);
$address = DoctorAddress::forClinic($clinic->getId());
$address->setName('شعبهٔ مرکزی');
$this->em->persist($address);
$doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']);
$doctor = new Doctor($doctorUser, 'دکتر انتظار');
$this->em->persist($doctor);
$patientUser = $this->createUser(['ROLE_USER']);
$patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId());
$this->em->persist($patient);
$this->em->flush();
return [$user, $section, $address, $doctor, $patient];
}
private function extraPatient(int $clinicId): PatientRecord
{
$user = $this->createUser(['ROLE_USER']);
$patient = new PatientRecord('clinic', $clinicId, $user, 'clinic', $clinicId);
$this->em->persist($patient);
$this->em->flush();
return $patient;
}
private function service(ServiceSection $section, string $name = 'لیزر'): ServiceItem
{
$item = new ServiceItem($section, $name);
$item->setSoloDurationMinutes(30);
$item->setPriceRials(4_000_000);
$this->em->persist($item);
$this->em->flush();
return $item;
}
/** @param array<string, mixed> $extra */
private function join(User $user, PatientRecord $patient, ServiceItem $service, int $from, int $to, array $extra = []): array
{
$body = $this->authJson('POST', '/api/v1/waitlist', $user, $extra + [
'patient_uuid' => $patient->getUuid(),
'service_uuid' => $service->getUuid(),
'desired_from' => $from,
'desired_to' => $to,
]);
self::assertSame(201, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
return $body['data'];
}
private function appointment(Doctor $doctor, PatientRecord $patient, ServiceItem $service, DoctorAddress $address, int $start): Appointment
{
$em = static::getContainer()->get(\Doctrine\ORM\EntityManagerInterface::class);
$start += (++$this->slotCursor) * 60;
$appointment = new Appointment(
$em->getRepository(Doctor::class)->find($doctor->getId()),
$em->getRepository(PatientRecord::class)->find($patient->getId())->getUser(),
$start,
$start + 1800,
);
$appointment->assignTenantPair('clinic', (int) $address->getClinicId());
$appointment->setServiceItem($em->getRepository(ServiceItem::class)->find($service->getId()));
$appointment->setAddressId($address->getId());
$appointment->setPatientName('بیمار نوبت');
$appointment->transitionTo(Appointment::STATUS_CONFIRMED);
$em->persist($appointment);
$em->flush();
return $appointment;
}
private function notifier(): WaitlistNotifier
{
return static::getContainer()->get(WaitlistNotifier::class);
}
// ── ثبت ─────────────────────────────────────────────────────────────────
public function testJoiningTheWaitlistStoresTheWindow(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$from = time() + 86400;
$to = $from + 3 * 86400;
$entry = $this->join($user, $patient, $service, $from, $to, ['preferred_day_parts' => ['evening']]);
self::assertSame('waiting', $entry['status']);
self::assertSame($from, $entry['desired_from']);
self::assertSame(['evening'], $entry['preferred_day_parts']);
self::assertSame(0, $entry['notify_count']);
$list = $this->authJson('GET', '/api/v1/waitlist', $user);
self::assertCount(1, $list['data']);
}
/** انتظار برای بازهٔ گذشته هرگز به نتیجه نمی‌رسد. */
public function testAPastWindowIsRejected(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$this->authJson('POST', '/api/v1/waitlist', $user, [
'patient_uuid' => $patient->getUuid(),
'service_uuid' => $service->getUuid(),
'desired_from' => time() - 5 * 86400,
'desired_to' => time() - 86400,
]);
self::assertSame(422, $this->responseCode());
}
public function testAnInvertedWindowIsRejected(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$this->authJson('POST', '/api/v1/waitlist', $user, [
'patient_uuid' => $patient->getUuid(),
'service_uuid' => $service->getUuid(),
'desired_from' => time() + 5 * 86400,
'desired_to' => time() + 86400,
]);
self::assertSame(422, $this->responseCode());
}
// ── تطبیق و اطلاع ───────────────────────────────────────────────────────
public function testMatchesFindsEveryoneWaitingForThatMoment(): void
{
[$user, $section, $address, , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$from = time() + 86400;
$to = $from + 3 * 86400;
$this->join($user, $patient, $service, $from, $to);
$this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $from, $to);
// کسی که بازه‌اش پوشش نمی‌دهد نباید بیاید.
$this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $to + 86400, $to + 5 * 86400);
$start = $from + 3600;
$matches = $this->authJson(
'GET',
sprintf('/api/v1/waitlist/matches?service_uuid=%s&start=%d', $service->getUuid(), $start),
$user,
);
self::assertSame(200, $this->responseCode(), json_encode($matches, JSON_UNESCAPED_UNICODE));
self::assertCount(2, $matches['data']);
}
/** ⭐ همه خبر می‌شوند — نه فقط نفر اول. */
public function testCancellingAnAppointmentNotifiesEveryMatchingEntry(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$slot = time() + 2 * 86400;
$from = $slot - 86400;
$to = $slot + 86400;
$first = $this->join($user, $patient, $service, $from, $to);
$second = $this->join($user, $this->extraPatient((int) $address->getClinicId()), $service, $from, $to);
$appointment = $this->appointment($doctor, $patient, $service, $address, $slot);
$body = $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/cancel", $user);
self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame(2, $body['data']['waitlist_notified']);
$repo = static::getContainer()->get(WaitlistEntryRepository::class);
foreach ([$first['uuid'], $second['uuid']] as $uuid) {
$entry = $repo->findByUuid($uuid);
self::assertSame(WaitlistEntry::STATUS_NOTIFIED, $entry->getStatus());
self::assertNotNull($entry->getNotifiedAt());
self::assertSame(1, $entry->getNotifyCount());
}
}
/** سقف اطلاع‌رسانی، یک بازهٔ پرلغو را به منبع اسپم تبدیل نمی‌کند. */
public function testNotificationsStopAtTheCap(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$slot = time() + 2 * 86400;
$entry = $this->join($user, $patient, $service, $slot - 86400, $slot + 86400);
$repo = static::getContainer()->get(WaitlistEntryRepository::class);
for ($i = 0; $i < WaitlistEntry::MAX_NOTIFICATIONS + 2; $i++) {
$appointment = $this->appointment($doctor, $patient, $service, $address, $slot);
$this->notifier()->notifyForFreedSlot($appointment);
}
self::assertSame(
WaitlistEntry::MAX_NOTIFICATIONS,
$repo->findByUuid($entry['uuid'])->getNotifyCount(),
);
}
/** درخواستی که شعبهٔ دیگری را خواسته، برای این ظرفیت خبر نمی‌شود. */
public function testAnEntryForAnotherBranchIsNotNotified(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$otherBranch = DoctorAddress::forClinic((int) $address->getClinicId());
$otherBranch->setName('شعبهٔ دوم');
$this->em->persist($otherBranch);
$this->em->flush();
$slot = time() + 2 * 86400;
$this->join($user, $patient, $service, $slot - 86400, $slot + 86400, [
'branch_uuid' => $otherBranch->getUuid(),
]);
$appointment = $this->appointment($doctor, $patient, $service, $address, $slot);
self::assertSame(0, $this->notifier()->notifyForFreedSlot($appointment));
}
public function testDeletingAnEntryRemovesItFromTheList(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$entry = $this->join($user, $patient, $service, time() + 86400, time() + 4 * 86400);
$this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $user);
self::assertSame(200, $this->responseCode());
self::assertCount(0, $this->authJson('GET', '/api/v1/waitlist', $user)['data']);
}
public function testAnotherClinicCannotSeeOrDeleteTheEntry(): void
{
[$owner, $section, , , $patient] = $this->clinicWithPatient();
[$other] = $this->clinicWithPatient();
$service = $this->service($section);
$entry = $this->join($owner, $patient, $service, time() + 86400, time() + 4 * 86400);
self::assertCount(0, $this->authJson('GET', '/api/v1/waitlist', $other)['data']);
$this->authJson('DELETE', "/api/v1/waitlist/{$entry['uuid']}", $other);
self::assertSame(404, $this->responseCode());
}
// ── بخش روز ─────────────────────────────────────────────────────────────
/**
* ⭐ ترجیح روز باید در **تطبیق** اعمال شود، نه فقط ذخیره.
*
* ذخیره‌کردنِ «عصر» و بعد خبر دادن برای ساعت ۹ صبح، بدتر از نپرسیدن است: بیمار
* فکر می‌کند سیستم حرفش را شنیده.
*/
public function testAnEntryIsNotNotifiedOutsideItsPreferredDayPart(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$morning = $this->localHour(9);
$this->join($user, $patient, $service, $morning - 86400, $morning + 86400, [
'preferred_day_parts' => ['evening'],
]);
$appointment = $this->appointment($doctor, $patient, $service, $address, $morning);
self::assertSame(0, $this->notifier()->notifyForFreedSlot($appointment));
}
public function testAnEntryIsNotifiedInsideItsPreferredDayPart(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$evening = $this->localHour(19);
$this->join($user, $patient, $service, $evening - 86400, $evening + 86400, [
'preferred_day_parts' => ['evening'],
]);
$appointment = $this->appointment($doctor, $patient, $service, $address, $evening);
self::assertSame(1, $this->notifier()->notifyForFreedSlot($appointment));
}
/** نداشتن ترجیح یعنی «هر ساعتی» — نه «هیچ ساعتی». */
public function testAnEntryWithoutAPreferenceMatchesAnyHour(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$dawn = $this->localHour(5);
$this->join($user, $patient, $service, $dawn - 86400, $dawn + 86400);
$appointment = $this->appointment($doctor, $patient, $service, $address, $dawn);
self::assertSame(1, $this->notifier()->notifyForFreedSlot($appointment));
}
public function testAnUnknownDayPartIsRejected(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$this->authJson('POST', '/api/v1/waitlist', $user, [
'patient_uuid' => $patient->getUuid(),
'service_uuid' => $service->getUuid(),
'desired_from' => time() + 86400,
'desired_to' => time() + 3 * 86400,
'preferred_day_parts' => ['midnight'],
]);
self::assertSame(422, $this->responseCode());
}
// ── مرزها و چرخهٔ عمر ───────────────────────────────────────────────────
public function testARangeLongerThanNinetyDaysIsRejected(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$from = time() + 86400;
$this->authJson('POST', '/api/v1/waitlist', $user, [
'patient_uuid' => $patient->getUuid(),
'service_uuid' => $service->getUuid(),
'desired_from' => $from,
'desired_to' => $from + 91 * 86400,
]);
self::assertSame(422, $this->responseCode());
}
/**
* ⭐ ردیفِ منقضی از قبل هم در تطبیق نمی‌آمد؛ این پاکسازیِ **نمایش** است تا اپراتور
* بفهمد کدام انتظار هنوز زنده است.
*/
public function testExpiringClosesPassedEntriesAndLeavesLiveOnes(): void
{
[$user, $section, , , $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$live = $this->join($user, $patient, $service, time() + 86400, time() + 5 * 86400);
$dead = $this->join($user, $this->extraPatient((int) $patient->getEntityId()), $service, time() + 86400, time() + 2 * 86400);
// بازهٔ ردیف دوم را به گذشته می‌بریم — API عمداً بازهٔ گذشته را نمی‌پذیرد.
$this->em->getConnection()->executeStatement(
'UPDATE waitlist_entries SET desired_from = ?, desired_to = ? WHERE uuid = ?',
[time() - 10 * 86400, time() - 86400, $dead['uuid']],
);
$expired = static::getContainer()->get(\App\Waitlist\Service\WaitlistExpirer::class)->expire();
self::assertSame(1, $expired);
// خواندن مستقیم از دیتابیس: `expire()` با SQL خام می‌نویسد، پس هر نقشهٔ هویتِ
// باز، نسخهٔ کهنه را برمی‌گرداند.
self::assertSame(WaitlistEntry::STATUS_EXPIRED, $this->statusOf($dead['uuid']));
self::assertSame(WaitlistEntry::STATUS_WAITING, $this->statusOf($live['uuid']));
}
/**
* ⭐ تبدیل باید **تنگ** باشد: ردیفِ خدمت دیگر نباید بسته شود، وگرنه بیمار برای
* چیزی که هنوز منتظرش است دیگر هرگز خبر نمی‌شود.
*/
public function testBookingConvertsOnlyTheMatchingEntry(): void
{
[$user, $section, $address, $doctor, $patient] = $this->clinicWithPatient();
$service = $this->service($section);
$other = $this->service($section, 'بوتاکس');
$start = time() + 2 * 86400;
$mine = $this->join($user, $patient, $service, $start - 86400, $start + 86400);
$unrelated = $this->join($user, $patient, $other, $start - 86400, $start + 86400);
$appointment = $this->appointment($doctor, $patient, $service, $address, $start);
$converter = static::getContainer()->get(\App\Waitlist\Service\WaitlistConverter::class);
self::assertSame(1, $converter->convertFor($appointment));
// اجرای دوباره چیزی را دوباره نمی‌بندد — تحویل دوبارهٔ پیام بی‌خطر است.
self::assertSame(0, $converter->convertFor($appointment));
$this->em->clear();
$repo = static::getContainer()->get(WaitlistEntryRepository::class);
self::assertSame(WaitlistEntry::STATUS_CONVERTED, $repo->findByUuid($mine['uuid'])->getStatus());
self::assertSame(WaitlistEntry::STATUS_WAITING, $repo->findByUuid($unrelated['uuid'])->getStatus());
}
private function statusOf(string $uuid): string
{
return (string) $this->em->getConnection()->fetchOne(
'SELECT status FROM waitlist_entries WHERE uuid = ?',
[$uuid],
);
}
/** ساعت محلیِ شعبه روی فردا — تست نباید به ساعت اجرا وابسته باشد. */
private function localHour(int $hour): int
{
return (new \DateTimeImmutable('tomorrow', new \DateTimeZone(DoctorAddress::DEFAULT_TIMEZONE)))
->setTime($hour, 0)
->getTimestamp();
}
}