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); $patientUser = $this->createUser(['ROLE_USER']); $patient = new PatientRecord('clinic', (int) $clinic->getId(), $patientUser, 'clinic', (int) $clinic->getId()); $this->em->persist($patient); $this->em->flush(); return [$user, $section, $address, $patient]; } private function service(ServiceSection $section): ServiceItem { $item = new ServiceItem($section, 'لیزر فول‌بادی'); $item->setSoloDurationMinutes(30); $item->setPriceRials(4_000_000); $this->em->persist($item); $this->em->flush(); return $item; } private function publisher(): DomainEventPublisher { return static::getContainer()->get(DomainEventPublisher::class); } private function repo(): DomainEventLogRepository { return static::getContainer()->get(DomainEventLogRepository::class); } private function containerEm(): EntityManagerInterface { return static::getContainer()->get(EntityManagerInterface::class); } // ── قرارداد ───────────────────────────────────────────────────────────── /** نام رویداد قرارداد عمومی است؛ تایپو باید همان‌جا بترکد نه در سکوت. */ public function testAnUnknownEventNameIsRejected(): void { [$user] = $this->clinic(); self::expectException(\InvalidArgumentException::class); $this->publisher()->record('clinic', 1, 'AppointmentBookd', ['appointment_uuid' => 'x']); } /** ⭐ payload فقط اسکالر و uuid — هیچ entity ای در رویداد نیست. */ public function testNonScalarPayloadValuesAreDropped(): void { $event = new DomainEventLog('clinic', 1, DomainEvents::APPOINTMENT_BOOKED, [ 'appointment_uuid' => 'abc', 'count' => 3, 'nested' => ['a' => 1], 'object' => new \stdClass(), ]); self::assertSame(['appointment_uuid' => 'abc', 'count' => 3], $event->getPayload()); } // ── انتشار بعد از commit ──────────────────────────────────────────────── /** ⭐⭐ تراکنشی که برمی‌گردد، هیچ رویدادی جا نمی‌گذارد. */ public function testARolledBackTransactionLeavesNoEvent(): void { $this->clinic(); $before = $this->repo()->count([]); $em = $this->containerEm(); $em->beginTransaction(); try { $this->publisher()->record('clinic', 999, DomainEvents::APPOINTMENT_BOOKED, ['appointment_uuid' => 'ghost']); $em->flush(); } finally { $em->rollback(); $em->clear(); } self::assertSame($before, $this->repo()->count([]), 'رویداد نباید از تراکنشِ برگشته جا بماند'); } // ── صندوق خروجی ──────────────────────────────────────────────────────── public function testPendingEventsArePublishedAndMarked(): void { [$user, $section, $address, $patient] = $this->clinic(); $event = $this->publisher()->recordAndFlush( 'clinic', (int) $address->getClinicId(), DomainEvents::PACKAGE_PURCHASED, ['patient_package_uuid' => 'pkg-1'], ); self::assertNull($event->getPublishedAt()); self::assertContains($event->getUuid(), array_map( static fn (DomainEventLog $e): string => $e->getUuid(), $this->repo()->findPending(500), )); $command = static::getContainer()->get(\App\Shared\Event\Command\PublishDomainEventsCommand::class); $tester = new \Symfony\Component\Console\Tester\CommandTester($command); $tester->execute(['--limit' => '500']); $this->containerEm()->clear(); $reloaded = $this->repo()->findOneBy(['uuid' => $event->getUuid()]); self::assertNotNull($reloaded->getPublishedAt(), 'رویداد باید منتشر و علامت‌گذاری شود'); self::assertSame(0, $reloaded->getAttempts()); } /** ردیفی که سقف تلاش را رد کرده دیگر برداشته نمی‌شود، ولی حذف هم نمی‌شود. */ public function testAnExhaustedEventIsNoLongerPickedUpButStays(): void { [$user, , $address] = $this->clinic(); $event = $this->publisher()->recordAndFlush( 'clinic', (int) $address->getClinicId(), DomainEvents::CREDIT_CONSUMED, ['patient_package_uuid' => 'pkg-2'], ); for ($i = 0; $i < DomainEventLog::MAX_ATTEMPTS; $i++) { $event->markFailed('اتصال Redis برقرار نشد'); } $this->containerEm()->flush(); $pendingUuids = array_map( static fn (DomainEventLog $e): string => $e->getUuid(), $this->repo()->findPending(500), ); self::assertNotContains($event->getUuid(), $pendingUuids); self::assertNotNull($this->repo()->findOneBy(['uuid' => $event->getUuid()]), 'ردیف مرده باید بماند تا دیده شود'); self::assertSame('اتصال Redis برقرار نشد', $event->getLastError()); } // ── رویدادهای واقعی ──────────────────────────────────────────────────── public function testSellingAPackageRecordsItsEvent(): void { [$user, $section, , $patient] = $this->clinic(); $service = $this->service($section); $package = $this->authJson('POST', '/api/v1/packages', $user, [ 'name' => '۶ جلسه', 'session_count' => 6, 'price_rials' => 10_000_000, 'service_uuids' => [$service->getUuid()], ])['data']; $this->authJson('POST', "/api/v1/patient/{$patient->getUuid()}/package", $user, [ 'package_uuid' => $package['uuid'], ]); self::assertSame(201, $this->responseCode()); $names = array_map( static fn (DomainEventLog $e): string => $e->getName(), $this->repo()->search(DomainEvents::PACKAGE_PURCHASED, null, null, 10), ); self::assertContains(DomainEvents::PACKAGE_PURCHASED, $names); } public function testStartingACourseRecordsItsEvent(): void { [$user, $section, , $patient] = $this->clinic(); $service = $this->service($section); $protocol = $this->authJson('POST', '/api/v1/course-protocols', $user, [ 'service_uuid' => $service->getUuid(), 'session_count' => 4, 'min_days' => 7, 'ideal_days' => 14, 'max_days' => 21, ])['data']; $this->authJson('POST', '/api/v1/treatment-course', $user, [ 'patient_uuid' => $patient->getUuid(), 'protocol_uuid' => $protocol['uuid'], ]); self::assertSame(201, $this->responseCode()); $events = $this->repo()->search(DomainEvents::COURSE_STARTED, null, null, 10); self::assertNotEmpty($events); self::assertArrayHasKey('course_uuid', $events[0]->getPayload()); } // ── دسترسی ────────────────────────────────────────────────────────────── public function testOnlyAdminsCanReadTheEventLog(): void { [$user] = $this->clinic(); $this->authJson('GET', '/api/v1/domain-events', $user); self::assertSame(403, $this->responseCode()); $admin = $this->createUser(['ROLE_USER', 'ROLE_ADMIN']); $this->authJson('GET', '/api/v1/domain-events', $admin); self::assertSame(200, $this->responseCode()); } }