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