Files
clinicpro/tests/Policy/PolicySimulationTest.php
T
hamedandClaude Opus 5 bcfa87bfad feat(policy): rule builder and mandatory dry-run sandbox
Task 09 shipped a powerful API that a non-technical clinic owner could not
safely use. This closes that gap: activation now requires having seen what the
rule actually does.

- PolicySimulator runs a policy against real past appointments and writes
  nothing: evaluation works on facts (never entities), the whole run sits in a
  transaction rolled back and cleared in `finally`, and a test counts rows in
  five sensitive tables before and after
- activate() now demands a simulation of the *same version* — a report for
  version 1 does not unlock version 2
- PolicyTemplateRegistry: six ready-made rules, so the common case never
  touches a raw condition
- Severity from the affected ratio; 0% is a warning too, since a rule that
  changes nothing usually has a condition that never matches
- An empty clinic still succeeds with a warning, otherwise a new clinic could
  never activate anything

Admin: PoliciesPage, PolicyFormPage, PolicySimulationPage, and a
PolicyConditionBuilder built entirely from GET /policy-schema — a test proves a
field that exists only in the schema shows up with no frontend change, and that
operators are filtered per field type.

The schema response now carries per-field metadata (label, type, meaningful
operators) so the form has one source of truth instead of two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 10:44:28 +03:30

381 lines
15 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']);
}
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());
}
}