From 2baa2ce7cadbc763bf0ab5619363e08989b87208 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sat, 1 Aug 2026 15:16:26 +0330 Subject: [PATCH] test: cover the paths that were reasoned about but never executed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifteen rows across five tasks said the mechanism was there and the test was not. Each of these is a case where being wrong would be silent. - the price rows must add up to the final amount. The chain test checks every number individually, which stays green if a new row is added and left out of the total; this checks the relationship itself. - a fixed deposit beats a percentage one, and neither can exceed the final amount — charging a deposit larger than the bill puts the patient in debt before the visit. - an appointment booked without a service still gets an invoice. Slot mode has no service, and without this the financial report is short a row with nothing to say which. - the four accuracy thresholds, each tested on its own boundary. One step off and either everything is red (so nobody looks) or nothing is (so the report is pointless). Includes a short-running service, since the deviation is measured on its absolute value. - all six policy templates build a policy that survives the normal validation, simulation and activation path. A template is a shortcut, not a second road: if one of them produced something the validator rejects, a user could create a rule in one click that never works. - simulation leaves nothing pending for a later flush in the same request. That is what the finally-rollback-clear is for, and the failure would surface in the next operation rather than in the sandbox. The course controller was reading $this->credits without it being injected — phpstan caught it; the package-shortfall path had no test yet and would have 500'd on the first course that had a package. Co-Authored-By: Claude Opus 5 (1M context) --- .../Controller/TreatmentCourseController.php | 2 + tests/Policy/PolicySimulationTest.php | 103 ++++++++++++++ tests/Pricing/PricingTest.php | 129 ++++++++++++++++++ tests/Report/ReportTest.php | 48 +++++++ 4 files changed, 282 insertions(+) diff --git a/src/Course/Controller/TreatmentCourseController.php b/src/Course/Controller/TreatmentCourseController.php index 16231840..786c0a0d 100644 --- a/src/Course/Controller/TreatmentCourseController.php +++ b/src/Course/Controller/TreatmentCourseController.php @@ -15,6 +15,7 @@ use App\Course\Service\CourseStarter; use App\Doctor\Repository\DoctorRepository; use App\Package\Repository\PatientPackageRepository; use App\Patient\Entity\PatientRecord; +use App\Package\Service\CreditLedgerService; use App\Patient\Repository\PatientRecordRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; @@ -43,6 +44,7 @@ class TreatmentCourseController extends BaseController private readonly CourseProgressCalculator $progress, private readonly BranchResolver $branches, private readonly TenantOwnershipChecker $ownership, + private readonly CreditLedgerService $credits, ) {} #[Route('/api/v1/treatment-course', name: 'treatment_course_create', methods: ['POST'])] diff --git a/tests/Policy/PolicySimulationTest.php b/tests/Policy/PolicySimulationTest.php index 8539f1f6..1469348c 100644 --- a/tests/Policy/PolicySimulationTest.php +++ b/tests/Policy/PolicySimulationTest.php @@ -140,6 +140,109 @@ class PolicySimulationTest extends ApiTestCase self::assertSame([['type' => 'discount_percent', 'value' => 15]], $policy['effects']); } + /** + * ⭐ هر شش الگو باید قانونِ **معتبر** بسازند. + * + * الگو میان‌بُر است، نه مسیر دوم: اگر خروجی یکی از آن‌ها از اعتبارسنجی عادی رد + * نشود، کاربر با یک کلیک قانونی می‌سازد که هیچ‌وقت کار نمی‌کند. + * + * @param array $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(); diff --git a/tests/Pricing/PricingTest.php b/tests/Pricing/PricingTest.php index 51464ce2..ef1290ef 100644 --- a/tests/Pricing/PricingTest.php +++ b/tests/Pricing/PricingTest.php @@ -239,6 +239,61 @@ class PricingTest extends ApiTestCase self::assertSame(7_500_000, $body['data']['final_rials']); } + /** + * ⭐ جمعِ ردیف‌ها **باید** برابر مبلغ نهایی باشد. + * + * تست زنجیره عددها را تک‌تک می‌سنجد؛ این یکی خودِ رابطه را می‌سنجد. اگر روزی ردیف + * تازه‌ای اضافه شود و در جمع نهایی حساب نشود، آن تست سبز می‌ماند و این قرمز. + */ + public function testTheRowsAlwaysAddUpToTheFinalAmount(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'لیزر', 9_500_000); + $extra = $this->service($c['section'], 'ناحیهٔ اضافه', 3_300_000); + + $d = $this->quote($c['user'], $service, $c['address'], [ + 'item_uuids' => [$extra->getUuid()], + 'policy' => [ + 'discount_percent' => 7, + 'insurance_base_percent' => 15, + 'insurance_supplementary_percent' => 25, + 'tax_percent' => 9, + ], + ])['data']; + + $sum = $d['base_rials'] + + $d['items_rials'] + - $d['discount_rials'] + - $d['insurance_base_rials'] + - $d['insurance_supplementary_rials'] + + $d['tax_rials']; + + self::assertSame($d['final_rials'], $sum, json_encode($d, JSON_UNESCAPED_UNICODE)); + } + + /** بیعانهٔ مبلغی بر درصدی مقدم است و هرگز از مبلغ نهایی بیشتر نمی‌شود. */ + public function testAFixedDepositOverridesThePercentageAndIsCapped(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'ویزیت', 2_000_000); + + $fixed = $this->quote($c['user'], $service, $c['address'], [ + 'policy' => ['deposit_percent' => 50, 'deposit_rials' => 300_000], + ])['data']; + + self::assertSame(300_000, $fixed['deposit_rials'], 'مبلغی برنده است، نه ۵۰٪'); + + $capped = $this->quote($c['user'], $service, $c['address'], [ + 'policy' => ['deposit_rials' => 9_000_000], + ])['data']; + + self::assertSame( + $capped['final_rials'], + $capped['deposit_rials'], + 'بیعانهٔ بیشتر از مبلغ نهایی یعنی بدهکار کردن بیمار پیش از ویزیت', + ); + } + public function testForeignServiceIsNotFound(): void { $c = $this->clinic(); @@ -349,6 +404,80 @@ class PricingTest extends ApiTestCase self::assertSame(4_000_000, $snapshot['data']['base_rials']); } + /** + * ⭐ نوبت بدون سرویس هم فاکتور می‌گیرد. + * + * حالت اسلاتی سرویس ندارد؛ اگر فاکتورش ساخته نمی‌شد، گزارش مالی یک ردیف کم داشت و + * هیچ‌جا هم معلوم نمی‌شد کدام ردیف. + */ + public function testAnAppointmentWithoutAServiceStillGetsAnInvoice(): void + { + $c = $this->clinic(); + $service = $this->service($c['section'], 'ویزیت', 1_500_000); + + $room = new \App\Resource\Entity\ResourceType( + $c['address']->tenantEntityType(), + $c['address']->tenantEntityId(), + 'room', + 'اتاق', + ); + $this->em->persist($room); + $this->em->flush(); + + $resource = $this->authJson('POST', '/api/v1/resource', $c['user'], [ + 'address_uuid' => $c['address']->getUuid(), + 'type_uuid' => $room->getUuid(), + 'name' => 'اتاق ویزیت', + ]); + self::assertSame(201, $this->responseCode()); + + $this->authJson('PUT', "/api/v1/resource/{$resource['data']['uuid']}/calendar", $c['user'], [ + 'days' => array_fill_keys(range(0, 6), [['start_minute' => 0, 'end_minute' => 1440]]), + ]); + + $this->authJson('PUT', "/api/v1/service-item/{$service->getUuid()}/segments", $c['user'], [ + 'segments' => [[ + 'sequence' => 1, 'name' => 'ویزیت', 'duration_minutes' => 15, + 'requirements' => [['type_uuid' => $room->getUuid()]], + ]], + ]); + self::assertSame(200, $this->responseCode()); + + $doctorUser = $this->createUser(['ROLE_USER', 'ROLE_DOCTOR']); + $doctor = new \App\Doctor\Entity\Doctor($doctorUser, 'دکتر ویزیت'); + $this->em->persist($doctor); + $this->em->flush(); + + $start = (new \DateTimeImmutable('next saturday', new \DateTimeZone('Asia/Tehran'))) + ->setTime(11, 0) + ->getTimestamp(); + + $hold = $this->authJson('POST', '/api/v1/appointment-hold', $c['user'], [ + 'service_uuid' => $service->getUuid(), + 'branch_uuid' => $c['address']->getUuid(), + 'start' => $start, + 'assignment' => ['room' => [$resource['data']['uuid']]], + ]); + self::assertSame(201, $this->responseCode(), json_encode($hold, JSON_UNESCAPED_UNICODE)); + + // ثبت **بدون** `service_uuid`: مسیر فاکتور تخت. + $confirmed = $this->authJson('POST', '/api/v1/appointment-confirm', $c['user'], [ + 'hold_uuid' => $hold['data']['hold_uuid'], + 'doctor_uuid' => $doctor->getUuid(), + ]); + self::assertSame(200, $this->responseCode(), json_encode($confirmed, JSON_UNESCAPED_UNICODE)); + + $snapshot = $this->authJson( + 'GET', + "/api/v1/appointment/{$confirmed['data']['appointment_uuid']}/price-snapshot", + $c['user'], + ); + + self::assertSame(200, $this->responseCode(), 'فاکتور باید وجود داشته باشد'); + self::assertSame(0, $snapshot['data']['items_rials']); + self::assertSame($snapshot['data']['base_rials'], $snapshot['data']['final_rials']); + } + public function testDraftListHasNoEffectUntilActivated(): void { $c = $this->clinic(); diff --git a/tests/Report/ReportTest.php b/tests/Report/ReportTest.php index 477a8a9e..dfd804bf 100644 --- a/tests/Report/ReportTest.php +++ b/tests/Report/ReportTest.php @@ -186,6 +186,54 @@ class ReportTest extends ApiTestCase // ── بهره‌وری منابع ────────────────────────────────────────────────────── /** منبعی بدون تقویم «۰٪ بهره‌وری» ندارد — بهره‌وری‌اش تعریف‌نشده است. */ + /** + * ⭐ چهار آستانه، هر کدام روی مرز خودش. + * + * آستانه‌ای که یک درجه اشتباه بیفتد، یا همه‌چیز را قرمز می‌کند (و کسی دیگر نگاه + * نمی‌کند) یا هیچ‌چیز را (و گزارش بی‌فایده است). + * + * @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 <= 3; $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();