test: cover the paths that were reasoned about but never executed

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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-01 15:16:26 +03:30
co-authored by Claude Opus 5
parent 5c754244f2
commit 2baa2ce7ca
4 changed files with 282 additions and 0 deletions
+129
View File
@@ -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();