called = true; $this->capturedEvent = $event; $this->capturedPayload = $payload; } }; $container = new AppContainer(); $container->set($listener::class, static fn () => $listener); $dispatcher = new ModuleEventDispatcher( ['user.deleted' => [['class' => $listener::class, 'method' => 'handle']]], $container ); $dispatcher->dispatch('user.deleted', ['user_id' => 42]); self::assertTrue($called); self::assertSame('user.deleted', $capturedEvent); self::assertSame(['user_id' => 42], $capturedPayload); } public function testDispatchIsNoOpForUnknownEvent(): void { $container = new AppContainer(); $dispatcher = new ModuleEventDispatcher([], $container); // Should not throw $dispatcher->dispatch('unknown.event', ['data' => 'test']); self::addToAssertionCount(1); } public function testDispatchContinuesAfterListenerException(): void { RequestContext::resetForTests(); $secondCalled = false; $failingListener = new class () implements EventListener { public function handle(string $event, array $payload): void { throw new \RuntimeException('Listener failure'); } }; $successListener = new class ($secondCalled) implements EventListener { public function __construct(/** @phpstan-ignore property.onlyWritten */ private bool &$called) { } public function handle(string $event, array $payload): void { $this->called = true; } }; $container = new AppContainer(); $container->set($failingListener::class, static fn () => $failingListener); $container->set($successListener::class, static fn () => $successListener); $dispatcher = new ModuleEventDispatcher( [ 'user.created' => [ ['class' => $failingListener::class, 'method' => 'handle'], ['class' => $successListener::class, 'method' => 'handle'], ], ], $container ); $dispatcher->dispatch('user.created', ['user_id' => 1]); self::assertTrue($secondCalled, 'Second listener should be called even after the first one throws'); } public function testDispatchAuditsListenerFailureWithoutPayloadLeakage(): void { RequestContext::resetForTests(); $failingListener = new class () implements EventListener { public function handle(string $event, array $payload): void { throw new \RuntimeException('Do not leak this message'); } }; $container = new AppContainer(); $container->set($failingListener::class, static fn () => $failingListener); $systemAuditService = $this->createMock(AuditRecorderInterface::class); $systemAuditService->expects($this->once()) ->method('record') ->with( 'module.listener.failed', 'failed', $this->callback(static function (array $context): bool { $metadata = is_array($context['metadata'] ?? null) ? $context['metadata'] : []; $keys = array_keys($metadata); sort($keys); if ($keys !== ['exception_class', 'listener_class', 'listener_method', 'module_event', 'request_id']) { return false; } if (($metadata['module_event'] ?? '') !== 'user.created') { return false; } if (($metadata['exception_class'] ?? '') !== \RuntimeException::class) { return false; } if (($metadata['listener_method'] ?? '') !== 'handle') { return false; } if (!is_string($metadata['request_id'] ?? null) || trim((string) $metadata['request_id']) === '') { return false; } return !array_key_exists('payload', $metadata) && !array_key_exists('exception_message', $metadata) && !array_key_exists('trace', $metadata); }) ); $dispatcher = new ModuleEventDispatcher( ['user.created' => [['class' => $failingListener::class, 'method' => 'handle']]], $container, $systemAuditService ); $dispatcher->dispatch('user.created', ['secret' => 'must-not-be-logged']); } }