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
+103
View File
@@ -140,6 +140,109 @@ class PolicySimulationTest extends ApiTestCase
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();