Files
clinicpro/tests/Policy/PolicySimulationTest.php
T
hamedandClaude Opus 5 2baa2ce7ca 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>
2026-08-01 15:16:26 +03:30

484 lines
20 KiB
PHP

<?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());
}
}