feat(events): domain event outbox and the two reports that close the loop
Tasks 07 through 13 each changed something the rest of the system might want to know about, with no contract for saying so. And task 05 shipped a powerful segment editor with no feedback on whether a clinic defined its segments right. Events - A closed list of names, because a consumer branches on the string and a one-letter typo would produce an event nobody hears and no error either - Payloads carry uuids and scalars only; non-scalars are dropped, not serialised, so a consumer always fetches fresh rather than reading a stale detached entity - record() deliberately does not flush: the event row commits with the change it describes, so a rolled-back transaction leaves no event behind. A test pins exactly that - app:events:publish drains the outbox; five failed attempts park a row with its error rather than deleting it, because a silently dropped event is a loss with no trace. app:events:prune only ever removes published rows Reports - Resource utilisation separates available, occupied and active minutes. The gap between occupied and active is what exposes a bad segment definition, and available is multiplied by capacity so a three-chair room does not read as permanently over 100% - A resource with no calendar reports utilization: null, not zero — dividing by zero means something different from being idle - Plan accuracy compares planned against actual duration per service and flags both directions: running short wastes capacity that could have been sold. Its row links straight to editing that service's segments, because a report with no route to a fix does not get read Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Report;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\ClinicService\Entity\ServiceItem;
|
||||
use App\ClinicService\Entity\ServiceSection;
|
||||
use App\Doctor\Entity\DoctorAddress;
|
||||
use App\Patient\Entity\PatientRecord;
|
||||
use App\Shared\Event\DomainEventPublisher;
|
||||
use App\Shared\Event\DomainEvents;
|
||||
use App\Shared\Event\Entity\DomainEventLog;
|
||||
use App\Shared\Event\Repository\DomainEventLogRepository;
|
||||
use App\Tests\ApiTestCase;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* رویدادهای دامنه و صندوق خروجی — تسک ۱۴.
|
||||
*
|
||||
* دو تضمین که کل الگو برایشان وجود دارد: رویداد **بعد از** commit منتشر میشود، و
|
||||
* هیچ رویدادی گم نمیشود.
|
||||
*/
|
||||
class DomainEventTest extends ApiTestCase
|
||||
{
|
||||
/** @return array{0: User, 1: ServiceSection, 2: DoctorAddress, 3: PatientRecord} */
|
||||
private function clinic(): 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);
|
||||
|
||||
$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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user