5b3e8f6588
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
306 lines
9.1 KiB
PHP
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);
|
|
}
|
|
}
|