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>
484 lines
20 KiB
PHP
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());
|
|
}
|
|
}
|