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>
250 lines
9.5 KiB
PHP
250 lines
9.5 KiB
PHP
<?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());
|
|
}
|
|
}
|