diff --git a/CHANGELOG.md b/CHANGELOG.md index cddbbaa5..21f90c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,16 @@ The format is based on Keep a Changelog. ## [3.1.0] - TBD ### Added +- Added boot stage lifecycle events: stages may declare `BEFORE`/`AFTER` event names and `BootPipeline` dispatches them with the `AppContext` as a named payload. The environment, config, error handler, HTTP, and modules stages declare `boot..before` and `boot..after` (#536) - Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533) ### Changed +- **BREAKING:** Replaced the `Quantum\Hook` package with `Quantum\Event` (#536): + - `hook()` is now `event()`; `on()` and `fire()` are now `listen()` and `dispatch()`, and no aliases remain. Listeners receive one named payload array, which is empty when none is given. + - Event names no longer need to be registered, so `shared/config/hooks.php` is no longer read; dispatching a name without listeners does nothing and an empty name throws `EventException`. + - Listeners stay registered after `dispatch()` instead of being removed after the first call. + - Listeners can be registered from app helper files, which load before config and before any boot event. + - Removed the unused `hooks_dir()` helper and renamed the debugger tab constant `Debugger::HOOKS` to `Debugger::EVENTS`; the debugbar tab is now `events` and lists only events that have listeners. - **BREAKING:** Removed `Quantum\Loader` and moved its responsibilities to their owning packages (#535): - Moved `Setup` to `Quantum\Config\Setup`; required config failures now use `ConfigException`, and optional imports use `Config::importIfExists()`. - Config paths resolve from the application base directory, independent of the process working directory. diff --git a/src/App/BootPipeline.php b/src/App/BootPipeline.php index 89cfa493..486a9aac 100644 --- a/src/App/BootPipeline.php +++ b/src/App/BootPipeline.php @@ -43,7 +43,26 @@ public function __construct(array $stages = []) public function run(AppContext $context): void { foreach ($this->stages as $stage) { + $this->dispatchStageEvent($stage, 'BEFORE', $context); + $stage->process($context); + + $this->dispatchStageEvent($stage, 'AFTER', $context); + } + } + + /** + * Dispatches the event a stage declares for the given point, if any. + * Skipped while the event() helper is not loaded yet, since nothing can listen then. + */ + private function dispatchStageEvent(BootStageInterface $stage, string $point, AppContext $context): void + { + $constant = $stage::class . '::' . $point; + + if (!defined($constant) || !function_exists('event')) { + return; } + + event()->dispatch((string) constant($constant), ['context' => $context]); } } diff --git a/src/App/Helpers/dir.php b/src/App/Helpers/dir.php index cdea4218..d3f713fa 100644 --- a/src/App/Helpers/dir.php +++ b/src/App/Helpers/dir.php @@ -64,14 +64,6 @@ function assets_dir(): string return public_dir() . DS . 'assets'; } -/** - * Gets hooks directory - */ -function hooks_dir(): string -{ - return App::getBaseDir() . DS . 'hooks'; -} - /** * Recursively deletes folder */ diff --git a/src/App/Stages/InitHttpStage.php b/src/App/Stages/InitHttpStage.php index 0c77901f..ff893653 100644 --- a/src/App/Stages/InitHttpStage.php +++ b/src/App/Stages/InitHttpStage.php @@ -11,11 +11,9 @@ namespace Quantum\App\Stages; use Quantum\App\Contracts\BootStageInterface; -use Quantum\Di\Exceptions\DiException; use Quantum\App\AppContext; use Quantum\Http\Response; use Quantum\Http\Request; -use ReflectionException; use Quantum\Di\Di; /** @@ -24,9 +22,10 @@ */ class InitHttpStage implements BootStageInterface { - /** - * @throws DiException|ReflectionException - */ + public const BEFORE = 'boot.http.before'; + + public const AFTER = 'boot.http.after'; + public function process(AppContext $context): void { if (!Di::isRegistered(Request::class)) { diff --git a/src/App/Stages/LoadAppConfigStage.php b/src/App/Stages/LoadAppConfigStage.php index 156b3731..f2944bdf 100644 --- a/src/App/Stages/LoadAppConfigStage.php +++ b/src/App/Stages/LoadAppConfigStage.php @@ -23,6 +23,10 @@ */ class LoadAppConfigStage implements BootStageInterface { + public const BEFORE = 'boot.config.before'; + + public const AFTER = 'boot.config.after'; + /** * @throws ConfigException|DiException|ReflectionException */ diff --git a/src/App/Stages/LoadEnvironmentStage.php b/src/App/Stages/LoadEnvironmentStage.php index 6fd49c31..16f935cc 100644 --- a/src/App/Stages/LoadEnvironmentStage.php +++ b/src/App/Stages/LoadEnvironmentStage.php @@ -24,6 +24,10 @@ */ class LoadEnvironmentStage implements BootStageInterface { + public const BEFORE = 'boot.environment.before'; + + public const AFTER = 'boot.environment.after'; + /** * @throws EnvException|DiException|BaseException */ diff --git a/src/App/Stages/LoadModulesStage.php b/src/App/Stages/LoadModulesStage.php index 2baa9eb0..c75ff5c7 100644 --- a/src/App/Stages/LoadModulesStage.php +++ b/src/App/Stages/LoadModulesStage.php @@ -27,6 +27,10 @@ */ class LoadModulesStage implements BootStageInterface { + public const BEFORE = 'boot.modules.before'; + + public const AFTER = 'boot.modules.after'; + /** * @throws ModuleException|RouteException|DiException|ReflectionException */ diff --git a/src/App/Stages/SetupErrorHandlerStage.php b/src/App/Stages/SetupErrorHandlerStage.php index cf565ee5..88097abb 100644 --- a/src/App/Stages/SetupErrorHandlerStage.php +++ b/src/App/Stages/SetupErrorHandlerStage.php @@ -26,6 +26,10 @@ */ class SetupErrorHandlerStage implements BootStageInterface { + public const BEFORE = 'boot.error_handler.before'; + + public const AFTER = 'boot.error_handler.after'; + /** * @throws ConfigException|DiException|BaseException|ReflectionException */ diff --git a/src/App/Traits/WebAppTrait.php b/src/App/Traits/WebAppTrait.php index e2ee5cd9..91cb8866 100644 --- a/src/App/Traits/WebAppTrait.php +++ b/src/App/Traits/WebAppTrait.php @@ -68,7 +68,7 @@ private function logDebugInfo(): void $debugbar = debugbar(); if ($debugbar->isEnabled()) { - $debugbar->addToStoreCell(Debugger::HOOKS, 'info', hook()->getRegistered()); + $debugbar->addToStoreCell(Debugger::EVENTS, 'info', event()->getRegistered()); } } diff --git a/src/Debugger/Debugger.php b/src/Debugger/Debugger.php index a42db167..558063b6 100644 --- a/src/Debugger/Debugger.php +++ b/src/Debugger/Debugger.php @@ -44,9 +44,9 @@ class Debugger public const ROUTES = 'routes'; /** - * Hooks tab + * Events tab */ - public const HOOKS = 'hooks'; + public const EVENTS = 'events'; /** * Mails tab @@ -104,7 +104,7 @@ public function initStore(): void Debugger::MESSAGES, Debugger::QUERIES, Debugger::ROUTES, - Debugger::HOOKS, + Debugger::EVENTS, Debugger::MAILS, ]); } @@ -151,7 +151,7 @@ public function render(): string return ''; } - foreach ([self::MESSAGES, self::QUERIES, self::ROUTES, self::HOOKS, self::MAILS] as $tab) { + foreach ([self::MESSAGES, self::QUERIES, self::ROUTES, self::EVENTS, self::MAILS] as $tab) { $this->createTab($tab); } diff --git a/src/Hook/Enums/ExceptionMessages.php b/src/Event/Enums/ExceptionMessages.php similarity index 59% rename from src/Hook/Enums/ExceptionMessages.php rename to src/Event/Enums/ExceptionMessages.php index f7b3d674..1639d077 100644 --- a/src/Hook/Enums/ExceptionMessages.php +++ b/src/Event/Enums/ExceptionMessages.php @@ -8,17 +8,15 @@ * @link https://quantumphp.io */ -namespace Quantum\Hook\Enums; +namespace Quantum\Event\Enums; use Quantum\App\Enums\ExceptionMessages as BaseExceptionMessages; /** * Class ExceptionMessages - * @package Quantum\Hook + * @package Quantum\Event */ final class ExceptionMessages extends BaseExceptionMessages { - public const DUPLICATE_HOOK_NAME = 'The Hook `{%1}` already registered.'; - - public const UNREGISTERED_HOOK_NAME = 'The Hook `{%1}` was not registered.'; + public const EMPTY_EVENT_NAME = 'The event name must not be empty.'; } diff --git a/src/Event/EventManager.php b/src/Event/EventManager.php new file mode 100644 index 00000000..de0bd619 --- /dev/null +++ b/src/Event/EventManager.php @@ -0,0 +1,70 @@ +> + */ + private array $listeners = []; + + /** + * Adds a listener for a given event + * @throws EventException + */ + public function listen(string $name, callable $listener): void + { + $this->assertName($name); + + $this->listeners[$name][] = $listener; + } + + /** + * Dispatches the event to its listeners in registration order + * @param array $payload + * @throws EventException + */ + public function dispatch(string $name, array $payload = []): void + { + $this->assertName($name); + + foreach ($this->listeners[$name] ?? [] as $listener) { + $listener($payload); + } + } + + /** + * Gets all registered listeners by event name + * @return array> + */ + public function getRegistered(): array + { + return $this->listeners; + } + + /** + * @throws EventException + */ + private function assertName(string $name): void + { + if ($name === '') { + throw EventException::emptyEventName(); + } + } +} diff --git a/src/Event/Exceptions/EventException.php b/src/Event/Exceptions/EventException.php new file mode 100644 index 00000000..3ce95a33 --- /dev/null +++ b/src/Event/Exceptions/EventException.php @@ -0,0 +1,29 @@ +> - */ - private array $store = []; - - /** - * @throws HookException|ConfigException|DiException|ReflectionException - */ - public function __construct() - { - if (!config()->has('hooks')) { - config()->import(new Setup('config', 'hooks')); - } - - $registeredHooks = array_merge(self::CORE_HOOKS, config()->get('hooks') ?: []); - - foreach ($registeredHooks as $hookName) { - $this->register($hookName); - } - } - - /** - * Adds a new listener for a given hook - * @throws HookException - */ - public function on(string $name, callable $function): void - { - if (!$this->exists($name)) { - throw HookException::unregisteredHookName($name); - } - - $this->store[$name][] = $function; - } - - /** - * Fires the hook - * @param array|null $args - * @throws HookException - */ - public function fire(string $name, ?array $args = null): void - { - if (!$this->exists($name)) { - throw HookException::unregisteredHookName($name); - } - - foreach ($this->store[$name] as $index => $fn) { - unset($this->store[$name][$index]); - $fn($args); - } - } - - /** - * Gets all registered hooks - * @return array> - */ - public function getRegistered(): array - { - return $this->store; - } - - /** - * Registers new hook - * @throws HookException - */ - protected function register(string $name): void - { - if ($this->exists($name)) { - throw HookException::hookDuplicateName($name); - } - - $this->store[$name] = []; - } - - /** - * Checks if hooks registered - */ - protected function exists(string $name): bool - { - return array_key_exists($name, $this->store); - } -} diff --git a/tests/Unit/App/Adapters/WebAppAdapterTest.php b/tests/Unit/App/Adapters/WebAppAdapterTest.php index 3e512ae3..c651d122 100644 --- a/tests/Unit/App/Adapters/WebAppAdapterTest.php +++ b/tests/Unit/App/Adapters/WebAppAdapterTest.php @@ -5,7 +5,9 @@ use Quantum\Http\Exceptions\HttpException; use Quantum\App\Adapters\WebAppAdapter; use Quantum\Tests\Unit\AppTestCase; +use Quantum\Debugger\Debugger; use Quantum\Router\Route; +use Quantum\Di\Di; use Throwable; class WebAppAdapterTest extends AppTestCase @@ -19,6 +21,10 @@ public function setUp(): void public function tearDown(): void { + if (Di::isRegistered(Debugger::class)) { + Di::get(Debugger::class)->resetStore(); + } + config()->flush(); $this->clearAppContext(); } @@ -95,4 +101,113 @@ public function send(): void $this->assertSame('1', response()->getHeader('X-Test')); $this->assertSame(200, response()->getStatusCode()); } + + public function testWebAppAdapterLogsRegisteredEventsToDebugger(): void + { + config()->set('app.debug', true); + + $listener = function (): void { + }; + + event()->listen('test.event', $listener); + + $this->invokePrivateMethod($this->webAppAdapter, 'logDebugInfo'); + + $cell = debugbar()->getStoreCell(Debugger::EVENTS); + + $this->assertCount(1, $cell); + $this->assertSame(['test.event' => [$listener]], $cell[0]['info']); + } + + public function testWebAppAdapterLogsNothingToDebuggerWithoutEventListeners(): void + { + config()->set('app.debug', true); + + $this->assertSame([], event()->getRegistered()); + + $this->invokePrivateMethod($this->webAppAdapter, 'logDebugInfo'); + + $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); + } + + public function testWebAppAdapterSkipsDebuggerEventLogWhenDebugDisabled(): void + { + config()->set('app.debug', false); + + event()->listen('test.event', function (): void { + }); + + $this->invokePrivateMethod($this->webAppAdapter, 'logDebugInfo'); + + $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); + } + + public function testWebAppAdapterBootFiresAppHelperListenerAtModulesBeforeEvent(): void + { + $this->withTemporaryAppHelper(function (): void { + $context = $this->createContext(); + + new WebAppAdapter($context); + + $this->assertCount(1, $GLOBALS['bootEventPayloads']); + $this->assertSame($context, $GLOBALS['bootEventPayloads'][0]['context']); + }); + } + + public function testWebAppAdapterBootRegistersAppHelperListenerBeforeAppConfigIsLoaded(): void + { + $this->withTemporaryAppHelper(function (): void { + new WebAppAdapter($this->createContext()); + + $this->assertFalse($GLOBALS['bootEventConfigLoadedAtRegistration']); + $this->assertTrue(config()->has('app')); + }); + } + + public function testWebAppAdapterSecondBootDoesNotRegisterAppHelperListenerAgain(): void + { + $this->withTemporaryAppHelper(function (): void { + new WebAppAdapter($this->createContext()); + $this->assertCount(1, $GLOBALS['bootEventPayloads']); + + // Helper files load with require_once, so a new container does not get the listener again. + new WebAppAdapter($this->createContext()); + $this->assertCount(1, $GLOBALS['bootEventPayloads']); + }); + } + + private function withTemporaryAppHelper(callable $test): void + { + $dir = PROJECT_ROOT . DS . 'helpers'; + $createdDir = !is_dir($dir); + $file = $dir . DS . uniqid('event_listener_helper_') . '.php'; + + $helper = <<<'PHP' +has('app'); + +event()->listen('boot.modules.before', function (array $payload): void { + $GLOBALS['bootEventPayloads'][] = $payload; +}); +PHP; + + try { + if ($createdDir) { + $this->assertTrue(mkdir($dir)); + } + + $this->assertNotFalse(file_put_contents($file, $helper)); + + $test(); + } finally { + if (is_file($file)) { + unlink($file); + } + if ($createdDir && is_dir($dir)) { + rmdir($dir); + } + unset($GLOBALS['bootEventPayloads'], $GLOBALS['bootEventConfigLoadedAtRegistration']); + } + } } diff --git a/tests/Unit/App/BootPipelineTest.php b/tests/Unit/App/BootPipelineTest.php index f425fdae..03685fca 100644 --- a/tests/Unit/App/BootPipelineTest.php +++ b/tests/Unit/App/BootPipelineTest.php @@ -2,16 +2,33 @@ namespace Quantum\Tests\Unit\App; +use Quantum\App\Stages\SetupErrorHandlerStage; use Quantum\App\Contracts\BootStageInterface; +use Quantum\App\Stages\LoadEnvironmentStage; +use Quantum\App\Stages\LoadAppConfigStage; +use Quantum\App\Stages\InitDebuggerStage; +use Quantum\App\Stages\LoadModulesStage; +use Quantum\App\Stages\LoadHelpersStage; +use Quantum\App\Stages\InitHttpStage; +use Quantum\Tests\Unit\AppTestCase; +use InvalidArgumentException; use Quantum\App\BootPipeline; -use Quantum\App\AppContext; use Quantum\Di\DiContainer; -use PHPUnit\Framework\TestCase; -use InvalidArgumentException; +use Quantum\App\AppContext; use RuntimeException; -class BootPipelineTest extends TestCase +class BootPipelineTest extends AppTestCase { + public function setUp(): void + { + $this->context = $this->createContext(); + } + + public function tearDown(): void + { + $this->clearAppContext(); + } + public function testPipelineRunsStagesInOrder(): void { $log = []; @@ -69,6 +86,170 @@ public function testPipelinePropagatesException(): void $pipeline->run(new AppContext('', new DiContainer())); } + public function testPipelineDispatchesEventsAroundStage(): void + { + $this->loadHelpers(); + + $log = []; + + event()->listen('test.first.before', function () use (&$log): void { + $log[] = 'before'; + }); + + event()->listen('test.first.after', function () use (&$log): void { + $log[] = 'after'; + }); + + $pipeline = new BootPipeline([$this->createFirstEventStage(function () use (&$log): void { + $log[] = 'process'; + })]); + + $pipeline->run($this->context); + + $this->assertSame(['before', 'process', 'after'], $log); + } + + public function testPipelineDispatchesEventsInStageOrder(): void + { + $this->loadHelpers(); + + $log = []; + + foreach (['test.first.before', 'test.first.after', 'test.second.before', 'test.second.after'] as $name) { + event()->listen($name, function () use (&$log, $name): void { + $log[] = $name; + }); + } + + $pipeline = new BootPipeline([ + $this->createFirstEventStage(function () use (&$log): void { + $log[] = 'process first'; + }), + $this->createSecondEventStage(function () use (&$log): void { + $log[] = 'process second'; + }), + ]); + + $pipeline->run($this->context); + + $this->assertSame([ + 'test.first.before', + 'process first', + 'test.first.after', + 'test.second.before', + 'process second', + 'test.second.after', + ], $log); + } + + public function testPipelinePassesContextInEventPayload(): void + { + $this->loadHelpers(); + + $payloads = []; + + event()->listen('test.first.before', function (array $payload) use (&$payloads): void { + $payloads[] = $payload; + }); + + event()->listen('test.first.after', function (array $payload) use (&$payloads): void { + $payloads[] = $payload; + }); + + $pipeline = new BootPipeline([$this->createFirstEventStage(function (): void { + })]); + + $pipeline->run($this->context); + + $this->assertCount(2, $payloads); + $this->assertSame(['context'], array_keys($payloads[0])); + $this->assertSame($this->context, $payloads[0]['context']); + $this->assertSame($this->context, $payloads[1]['context']); + } + + public function testPipelineDispatchesOnlyAfterEventWhenStageDeclaresOnlyAfter(): void + { + $this->loadHelpers(); + + $log = []; + + event()->listen('test.after-only.after', function () use (&$log): void { + $log[] = 'after'; + }); + + $pipeline = new BootPipeline([$this->createAfterOnlyEventStage(function () use (&$log): void { + $log[] = 'process'; + })]); + + $pipeline->run($this->context); + + $this->assertSame(['process', 'after'], $log); + } + + public function testPipelineKeepsListenersAfterStageEvents(): void + { + $this->loadHelpers(); + + $count = 0; + + event()->listen('test.first.before', function () use (&$count): void { + $count++; + }); + + $pipeline = new BootPipeline([$this->createFirstEventStage(function (): void { + })]); + + $pipeline->run($this->context); + $pipeline->run($this->context); + + $this->assertSame(2, $count); + } + + public function testPipelineStagesDeclareBootEventNames(): void + { + $this->assertSame('boot.environment.before', LoadEnvironmentStage::BEFORE); + $this->assertSame('boot.environment.after', LoadEnvironmentStage::AFTER); + + $this->assertSame('boot.config.before', LoadAppConfigStage::BEFORE); + $this->assertSame('boot.config.after', LoadAppConfigStage::AFTER); + + $this->assertSame('boot.error_handler.before', SetupErrorHandlerStage::BEFORE); + $this->assertSame('boot.error_handler.after', SetupErrorHandlerStage::AFTER); + + $this->assertSame('boot.http.before', InitHttpStage::BEFORE); + $this->assertSame('boot.http.after', InitHttpStage::AFTER); + + $this->assertSame('boot.modules.before', LoadModulesStage::BEFORE); + $this->assertSame('boot.modules.after', LoadModulesStage::AFTER); + } + + public function testLoadHelpersAndInitDebuggerStagesDeclareNoEvents(): void + { + foreach ([LoadHelpersStage::class, InitDebuggerStage::class] as $stage) { + $this->assertFalse(defined($stage . '::BEFORE'), $stage . ' must not declare BEFORE'); + $this->assertFalse(defined($stage . '::AFTER'), $stage . ' must not declare AFTER'); + } + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testPipelineSkipsStageEventsWhileEventHelperIsNotLoaded(): void + { + $this->assertFalse(function_exists('event')); + + $processed = false; + + $pipeline = new BootPipeline([$this->createFirstEventStage(function () use (&$processed): void { + $processed = true; + })]); + + $pipeline->run($this->context); + + $this->assertTrue($processed); + } + public function testPipelineRejectsInvalidStage(): void { $this->expectException(InvalidArgumentException::class); @@ -77,6 +258,11 @@ public function testPipelineRejectsInvalidStage(): void new BootPipeline([new \stdClass()]); } + private function loadHelpers(): void + { + (new LoadHelpersStage())->process($this->context); + } + private function createStage(callable $callback): BootStageInterface { return new class ($callback) implements BootStageInterface { @@ -93,4 +279,63 @@ public function process(AppContext $context): void } }; } + + private function createFirstEventStage(callable $callback): BootStageInterface + { + return new class ($callback) implements BootStageInterface { + public const BEFORE = 'test.first.before'; + public const AFTER = 'test.first.after'; + + private $callback; + + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function process(AppContext $context): void + { + ($this->callback)($context); + } + }; + } + + private function createSecondEventStage(callable $callback): BootStageInterface + { + return new class ($callback) implements BootStageInterface { + public const BEFORE = 'test.second.before'; + public const AFTER = 'test.second.after'; + + private $callback; + + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function process(AppContext $context): void + { + ($this->callback)($context); + } + }; + } + + private function createAfterOnlyEventStage(callable $callback): BootStageInterface + { + return new class ($callback) implements BootStageInterface { + public const AFTER = 'test.after-only.after'; + + private $callback; + + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function process(AppContext $context): void + { + ($this->callback)($context); + } + }; + } } diff --git a/tests/Unit/App/Helpers/DirHelperTest.php b/tests/Unit/App/Helpers/DirHelperTest.php index 24d818bb..c647b553 100644 --- a/tests/Unit/App/Helpers/DirHelperTest.php +++ b/tests/Unit/App/Helpers/DirHelperTest.php @@ -52,10 +52,4 @@ public function testAssetsDir(): void $expected = dirname(__DIR__, 3) . DS . '_root' . DS . 'public' . DS . 'assets'; $this->assertEquals($expected, assets_dir()); } - - public function testHooksDir(): void - { - $expected = dirname(__DIR__, 3) . DS . '_root' . DS . 'hooks'; - $this->assertEquals($expected, hooks_dir()); - } } diff --git a/tests/Unit/Debugger/DebuggerTest.php b/tests/Unit/Debugger/DebuggerTest.php index b73bcffe..4e963ba0 100644 --- a/tests/Unit/Debugger/DebuggerTest.php +++ b/tests/Unit/Debugger/DebuggerTest.php @@ -59,7 +59,7 @@ public function testDebuggerInitStore(): void $this->assertTrue($this->debuggerStore->has(Debugger::MESSAGES)); $this->assertTrue($this->debuggerStore->has(Debugger::QUERIES)); $this->assertTrue($this->debuggerStore->has(Debugger::ROUTES)); - $this->assertTrue($this->debuggerStore->has(Debugger::HOOKS)); + $this->assertTrue($this->debuggerStore->has(Debugger::EVENTS)); $this->assertTrue($this->debuggerStore->has(Debugger::MAILS)); } diff --git a/tests/Unit/Event/EventManagerTest.php b/tests/Unit/Event/EventManagerTest.php new file mode 100644 index 00000000..e1dd7267 --- /dev/null +++ b/tests/Unit/Event/EventManagerTest.php @@ -0,0 +1,185 @@ +events = new EventManager(); + } + + public function testEventManagerDispatchesToListener(): void + { + $output = ''; + + $this->events->listen('SAVE', function () use (&$output): void { + $output .= 'Saved!'; + }); + + $this->events->dispatch('SAVE'); + + $this->assertSame('Saved!', $output); + } + + public function testEventManagerPassesPayloadToListener(): void + { + $received = null; + + $this->events->listen('NOTIFY', function (array $payload) use (&$received): void { + $received = $payload; + }); + + $this->events->dispatch('NOTIFY', ['user' => 'John']); + + $this->assertSame(['user' => 'John'], $received); + } + + public function testEventManagerPassesEmptyPayloadWhenNoneGiven(): void + { + $received = null; + + $this->events->listen('NOTIFY', function (array $payload) use (&$received): void { + $received = $payload; + }); + + $this->events->dispatch('NOTIFY'); + + $this->assertSame([], $received); + } + + public function testEventManagerRunsListenersInRegistrationOrder(): void + { + $output = ''; + + $this->events->listen('SAVE', function () use (&$output): void { + $output .= 'A'; + }); + + $this->events->listen('SAVE', function () use (&$output): void { + $output .= 'B'; + }); + + $this->events->dispatch('SAVE'); + + $this->assertSame('AB', $output); + } + + public function testEventManagerKeepsListenersAfterDispatch(): void + { + $count = 0; + + $this->events->listen('SAVE', function () use (&$count): void { + $count++; + }); + + $this->events->dispatch('SAVE'); + $this->events->dispatch('SAVE'); + + $this->assertSame(2, $count); + } + + public function testEventManagerScopesListenersByEventName(): void + { + $output = ''; + + $this->events->listen('SAVE', function () use (&$output): void { + $output .= 'save'; + }); + + $this->events->listen('DELETE', function () use (&$output): void { + $output .= 'delete'; + }); + + $this->events->dispatch('DELETE'); + + $this->assertSame('delete', $output); + } + + public function testEventManagerIgnoresDispatchWithoutListeners(): void + { + $this->events->dispatch('NEVER_LISTENED'); + + $this->assertSame([], $this->events->getRegistered()); + } + + public function testEventManagerPropagatesListenerExceptionAndStopsLaterListeners(): void + { + $called = false; + + $this->events->listen('SAVE', function (): void { + throw new RuntimeException('Listener failed'); + }); + + $this->events->listen('SAVE', function () use (&$called): void { + $called = true; + }); + + try { + $this->events->dispatch('SAVE'); + $this->fail('The listener exception was not propagated.'); + } catch (RuntimeException $e) { + $this->assertSame('Listener failed', $e->getMessage()); + } + + $this->assertFalse($called); + } + + public function testEventManagerThrowsWhenListeningWithEmptyName(): void + { + $this->expectException(EventException::class); + $this->expectExceptionMessage('The event name must not be empty.'); + + $this->events->listen('', function (): void { + }); + } + + public function testEventManagerThrowsWhenDispatchingWithEmptyName(): void + { + $this->expectException(EventException::class); + $this->expectExceptionMessage('The event name must not be empty.'); + + $this->events->dispatch(''); + } + + public function testEventManagerGetRegisteredReturnsListenersByName(): void + { + $this->events->listen('SAVE', function (): void { + }); + + $this->events->listen('SAVE', function (): void { + }); + + $registered = $this->events->getRegistered(); + + $this->assertArrayHasKey('SAVE', $registered); + $this->assertCount(2, $registered['SAVE']); + } + + public function testEventManagerWorksWithoutLoadedConfig(): void + { + config()->flush(); + + $events = new EventManager(); + $output = ''; + + $events->listen('BOOT', function () use (&$output): void { + $output .= 'ok'; + }); + + $events->dispatch('BOOT'); + + $this->assertSame('ok', $output); + $this->assertFalse(config()->has('hooks')); + $this->assertFalse(config()->has('events')); + } +} diff --git a/tests/Unit/Event/Exceptions/EventExceptionTest.php b/tests/Unit/Event/Exceptions/EventExceptionTest.php new file mode 100644 index 00000000..0d4b046e --- /dev/null +++ b/tests/Unit/Event/Exceptions/EventExceptionTest.php @@ -0,0 +1,18 @@ +assertInstanceOf(EventException::class, $exception); + $this->assertSame('The event name must not be empty.', $exception->getMessage()); + $this->assertSame(E_ERROR, $exception->getCode()); + } +} diff --git a/tests/Unit/Event/Helpers/EventHelperTest.php b/tests/Unit/Event/Helpers/EventHelperTest.php new file mode 100644 index 00000000..eff37484 --- /dev/null +++ b/tests/Unit/Event/Helpers/EventHelperTest.php @@ -0,0 +1,40 @@ +assertInstanceOf(EventManager::class, event()); + } + + public function testEventHelperRegistersManagerInContainer(): void + { + event(); + + $this->assertTrue(Di::isRegistered(EventManager::class)); + } + + public function testEventHelperReturnsSameInstance(): void + { + $this->assertSame(event(), event()); + } + + public function testEventHelperListenAndDispatch(): void + { + $output = ''; + + event()->listen('SAVE', function (array $payload) use (&$output): void { + $output .= 'The file ' . $payload['filename'] . ' was saved'; + }); + + event()->dispatch('SAVE', ['filename' => 'doc.pdf']); + + $this->assertSame('The file doc.pdf was saved', $output); + } +} diff --git a/tests/Unit/Hook/Exceptions/HookExceptionTest.php b/tests/Unit/Hook/Exceptions/HookExceptionTest.php deleted file mode 100644 index 2df1e632..00000000 --- a/tests/Unit/Hook/Exceptions/HookExceptionTest.php +++ /dev/null @@ -1,27 +0,0 @@ -assertInstanceOf(HookException::class, $exception); - $this->assertSame('The Hook `boot` already registered.', $exception->getMessage()); - $this->assertSame(E_ERROR, $exception->getCode()); - } - - public function testUnregisteredHookName(): void - { - $exception = HookException::unregisteredHookName('shutdown'); - - $this->assertInstanceOf(HookException::class, $exception); - $this->assertSame('The Hook `shutdown` was not registered.', $exception->getMessage()); - $this->assertSame(E_WARNING, $exception->getCode()); - } -} diff --git a/tests/Unit/Hook/Helpers/HookHelperTest.php b/tests/Unit/Hook/Helpers/HookHelperTest.php deleted file mode 100644 index 3eacea22..00000000 --- a/tests/Unit/Hook/Helpers/HookHelperTest.php +++ /dev/null @@ -1,80 +0,0 @@ -assertInstanceOf(HookManager::class, hook()); - } - - public function testHookOnAndFire(): void - { - $output = ''; - hook()->on('SAVE', function () use (&$output): void { - $output .= 'Data successfully saved'; - }); - - hook()->fire('SAVE'); - - $this->assertSame('Data successfully saved', $output); - } - - public function testHookFireWithArgument(): void - { - $output = ''; - hook()->on('SAVE', function (array $data) use (&$output): void { - $output .= 'The file ' . $data['filename'] . ' was successfully saved'; - }); - - hook()->fire('SAVE', ['filename' => 'doc.pdf']); - - $this->assertSame('The file doc.pdf was successfully saved', $output); - } - - public function testHookMultipleListeners(): void - { - $output = ''; - hook()->on('SAVE', function (array $data) use (&$output): void { - $output .= 'The file ' . $data['filename'] . ' was successfully saved' . PHP_EOL; - }); - - hook()->on('SAVE', function () use (&$output): void { - $output .= 'The email was successfully sent'; - }); - - hook()->fire('SAVE', ['filename' => 'doc.pdf']); - - $this->assertSame('The file doc.pdf was successfully saved' . PHP_EOL . 'The email was successfully sent', $output); - } - - public function testUnregisteredHookAtOn(): void - { - $this->expectException(HookException::class); - - $this->expectExceptionMessage('The Hook `SOME_EVENT` was not registered.'); - - hook()->on('SOME_EVENT', function (): void { - // No output needed - }); - } - - public function testUnregisteredHookAtFire(): void - { - $this->expectException(HookException::class); - - $this->expectExceptionMessage('The Hook `SOME_EVENT` was not registered.'); - - hook()->fire('SOME_EVENT'); - } -} diff --git a/tests/Unit/Hook/HookManagerTest.php b/tests/Unit/Hook/HookManagerTest.php deleted file mode 100644 index e9c9b948..00000000 --- a/tests/Unit/Hook/HookManagerTest.php +++ /dev/null @@ -1,98 +0,0 @@ -assertInstanceOf(HookManager::class, hook()); - } - - public function testOnAndFireOutputsCorrectly(): void - { - $output = ''; - hook()->on('SAVE', function () use (&$output): void { - $output .= 'Saved!'; - }); - - hook()->fire('SAVE'); - - $this->assertSame('Saved!', $output); - } - - public function testFireWithArguments(): void - { - config()->set('hooks', ['NOTIFY']); - - $output = ''; - hook()->on('NOTIFY', function (array $args) use (&$output): void { - $output .= 'Notified ' . $args['user']; - }); - - hook()->fire('NOTIFY', ['user' => 'John']); - - $this->assertSame('Notified John', $output); - } - - public function testMultipleListeners(): void - { - $output = ''; - hook()->on('SAVE', function () use (&$output): void { - $output .= 'A'; - }); - - hook()->on('SAVE', function () use (&$output): void { - $output .= 'B'; - }); - - hook()->fire('SAVE'); - - $this->assertSame('AB', $output); - } - - public function testHookIsFiredOnlyOncePerListener(): void - { - $output = ''; - hook()->on('SAVE', function () use (&$output): void { - $output .= 'Once'; - }); - - hook()->fire('SAVE'); - hook()->fire('SAVE'); - - $this->assertSame('Once', $output); - } - - public function testUnregisteredHookThrowsOnOn(): void - { - $this->expectException(HookException::class); - $this->expectExceptionMessage('The Hook `INVALID` was not registered.'); - - hook()->on('INVALID', function (): void { - }); - } - - public function testUnregisteredHookThrowsOnFire(): void - { - $this->expectException(HookException::class); - $this->expectExceptionMessage('The Hook `INVALID` was not registered.'); - - hook()->fire('INVALID'); - } - - public function testGetRegisteredReturnsHookStore(): void - { - hook()->on('SAVE', function (): void { - }); - - $store = hook()->getRegistered(); - - $this->assertArrayHasKey('SAVE', $store); - $this->assertCount(1, $store['SAVE']); - } -} diff --git a/tests/_root/shared/config/hooks.php b/tests/_root/shared/config/hooks.php deleted file mode 100644 index 293a6551..00000000 --- a/tests/_root/shared/config/hooks.php +++ /dev/null @@ -1,5 +0,0 @@ -