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>
381 lines
15 KiB
PHP
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());
|
|
}
|
|
}
|