Files
server/tests/php/Unit/Event/EventDispatcherTest.php
T
2026-08-06 00:02:15 -04:00

306 lines
9.1 KiB
PHP

<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXF\Event\DeliveryMode;
use KTXF\Event\Event;
use KTXC\Event\EventDispatcher;
use KTXC\Event\EventListenerRegistry;
use KTXF\Event\FailurePolicy;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use Psr\Container\ContainerInterface;
use Psr\Log\NullLogger;
final class EventDispatcherTest extends TestCase
{
#[Test]
#[TestDox('Listeners are lazy and deferred events stay in their execution')]
public function dispatches(): void
{
$listener = new RecordingListener();
$container = new RecordingContainer([RecordingListener::class => $listener]);
$registry = new EventListenerRegistry();
$registry->listen('test', 'test.event', RecordingListener::class, 'immediate');
$registry->listen(
'test',
'test.event',
RecordingListener::class,
'deferred',
DeliveryMode::Deferred,
);
$registry->freeze();
self::assertSame(0, $container->resolutions);
$dispatcher = new EventDispatcher($registry, $container, new NullLogger());
$dispatcher->beginExecution('first');
$dispatcher->dispatch(new Event('test.event'));
self::assertSame(1, $listener->immediate);
self::assertSame(0, $listener->deferred);
$result = $dispatcher->processDeferred('first');
self::assertSame(1, $result->processed);
self::assertSame(1, $listener->deferred);
$dispatcher->beginExecution('second');
$second = $dispatcher->processDeferred('second');
self::assertSame(0, $second->processed);
self::assertSame(0, $second->remaining);
}
#[Test]
#[TestDox('Frozen registries reject new listeners')]
public function freezes(): void
{
$registry = new EventListenerRegistry();
$registry->freeze();
$this->expectException(\LogicException::class);
$registry->listen('test', 'test.event', RecordingListener::class, 'immediate');
}
#[Test]
#[TestDox('Duplicate listener registrations are rejected')]
public function rejectsDuplicates(): void
{
$registry = new EventListenerRegistry();
$registry->listen('test', 'test.event', RecordingListener::class, 'immediate');
$this->expectException(\LogicException::class);
$registry->listen('test', 'test.event', RecordingListener::class, 'immediate');
}
#[Test]
#[TestDox('Invalid listener methods are rejected during registration')]
public function validatesMethods(): void
{
$registry = new EventListenerRegistry();
$this->expectException(\InvalidArgumentException::class);
$registry->listen('test', 'test.event', RecordingListener::class, 'missing');
}
#[Test]
#[TestDox('Unresolvable listener services fail registry compilation')]
public function validatesServices(): void
{
$registry = new EventListenerRegistry();
$registry->listen('test', 'test.event', RecordingListener::class, 'immediate');
$this->expectException(\LogicException::class);
$registry->freeze(new RecordingContainer([]));
}
#[Test]
#[TestDox('Listener priority determines dispatch order')]
public function prioritizes(): void
{
$listener = new RecordingListener();
$registry = new EventListenerRegistry();
$registry->listen('test', 'test.event', RecordingListener::class, 'low', priority: 1);
$registry->listen('test', 'test.event', RecordingListener::class, 'high', priority: 100);
$registry->freeze();
$dispatcher = new EventDispatcher(
$registry,
new RecordingContainer([RecordingListener::class => $listener]),
new NullLogger(),
);
$dispatcher->beginExecution('test');
$dispatcher->dispatch(new Event('test.event'));
$dispatcher->processDeferred('test');
self::assertSame(['high', 'low'], $listener->order);
}
#[Test]
#[TestDox('Continue failures do not prevent later listeners')]
public function continues(): void
{
$listener = new RecordingListener();
$registry = new EventListenerRegistry();
$registry->listen('test', 'test.event', FailingListener::class, 'fail');
$registry->listen('test', 'test.event', RecordingListener::class, 'immediate');
$registry->freeze();
$dispatcher = new EventDispatcher(
$registry,
new RecordingContainer([
FailingListener::class => new FailingListener(),
RecordingListener::class => $listener,
]),
new NullLogger(),
);
$dispatcher->beginExecution('test');
$dispatcher->dispatch(new Event('test.event'));
self::assertSame(1, $listener->immediate);
}
#[Test]
#[TestDox('Propagate failures reach the publisher')]
public function propagates(): void
{
$registry = new EventListenerRegistry();
$registry->listen(
'test',
'test.event',
FailingListener::class,
'fail',
failurePolicy: FailurePolicy::Propagate,
);
$registry->freeze();
$dispatcher = new EventDispatcher(
$registry,
new RecordingContainer([FailingListener::class => new FailingListener()]),
new NullLogger(),
);
$dispatcher->beginExecution('test');
$this->expectException(\RuntimeException::class);
$dispatcher->dispatch(new Event('test.event'));
}
#[Test]
#[TestDox('Propagated deferred failures close their execution scope')]
public function recoversFromDeferredFailure(): void
{
$registry = new EventListenerRegistry();
$registry->listen(
'test',
'test.event',
FailingListener::class,
'fail',
DeliveryMode::Deferred,
failurePolicy: FailurePolicy::Propagate,
);
$registry->freeze();
$dispatcher = new EventDispatcher(
$registry,
new RecordingContainer([FailingListener::class => new FailingListener()]),
new NullLogger(),
);
$dispatcher->beginExecution('failed');
$dispatcher->dispatch(new Event('test.event'));
try {
$dispatcher->processDeferred('failed');
self::fail('Expected deferred listener failure to propagate.');
} catch (\RuntimeException $error) {
self::assertSame('Listener failed.', $error->getMessage());
}
$dispatcher->beginExecution('next');
$result = $dispatcher->processDeferred('next');
self::assertSame(0, $result->processed);
self::assertSame(0, $result->remaining);
}
#[Test]
#[TestDox('Deferred processing stops at its configured count limit')]
public function boundsDeferredWork(): void
{
$listener = new RecursiveListener();
$registry = new EventListenerRegistry();
$registry->listen(
'test',
'test.event',
RecursiveListener::class,
'deferred',
DeliveryMode::Deferred,
);
$registry->freeze();
$dispatcher = new EventDispatcher(
$registry,
new RecordingContainer([RecursiveListener::class => $listener]),
new NullLogger(),
);
$listener->dispatcher = $dispatcher;
$dispatcher->beginExecution('test');
$dispatcher->dispatch(new Event('test.event'));
$result = $dispatcher->processDeferred('test');
self::assertSame(1000, $result->processed);
self::assertSame(1, $result->remaining);
self::assertFalse($result->deadlineExceeded);
self::assertTrue($result->limitExceeded);
}
}
final class RecordingListener
{
public int $immediate = 0;
public int $deferred = 0;
public array $order = [];
public function immediate(Event $event): void
{
$this->immediate++;
}
public function deferred(Event $event): void
{
$this->deferred++;
}
public function high(Event $event): void
{
$this->order[] = 'high';
}
public function low(Event $event): void
{
$this->order[] = 'low';
}
}
final class FailingListener
{
public function fail(Event $event): void
{
throw new \RuntimeException('Listener failed.');
}
}
final class RecursiveListener
{
public EventDispatcher $dispatcher;
public function deferred(Event $event): void
{
$this->dispatcher->dispatch(new Event('test.event'));
}
}
final class RecordingContainer implements ContainerInterface
{
public int $resolutions = 0;
public function __construct(
private readonly array $services,
) {
}
public function get(string $id): mixed
{
$this->resolutions++;
return $this->services[$id]
?? throw new \RuntimeException("Service not found: {$id}");
}
public function has(string $id): bool
{
return array_key_exists($id, $this->services);
}
}