diff --git a/CHANGELOG.md b/CHANGELOG.md index 5393c452..31f5bfdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +# Release Notes for 0.7.x + +## [v0.7.0 (2025-09-26)](https://github.com/phenixphp/framework/compare/0.6.0...0.7.0) + +### Added + +- Event system. ([#67](https://github.com/phenixphp/framework/pull/67)) + # Release Notes for 0.6.x ## [v0.6.0 (2025-08-22)](https://github.com/phenixphp/framework/compare/0.5.2...0.6.0) diff --git a/src/App.php b/src/App.php index 780b049e..95ded4c7 100644 --- a/src/App.php +++ b/src/App.php @@ -12,6 +12,8 @@ use Amp\Socket; use League\Container\Container; use League\Uri\Uri; +use Mockery\LegacyMockInterface; +use Mockery\MockInterface; use Monolog\Logger; use Phenix\Console\Phenix; use Phenix\Contracts\App as AppContract; @@ -105,6 +107,11 @@ public static function make(string $key): object return self::$container->get($key); } + public static function fake(string $key, LegacyMockInterface|MockInterface $concrete): void + { + self::$container->extend($key)->setConcrete($concrete); + } + public static function path(): string { return self::$path; diff --git a/src/Console/Commands/MakeModel.php b/src/Console/Commands/MakeModel.php index 069377b6..3ff88f0d 100644 --- a/src/Console/Commands/MakeModel.php +++ b/src/Console/Commands/MakeModel.php @@ -102,7 +102,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int File::put($filePath, $stub); - $output->writeln(["{$this->commonName()} successfully generated!", self::EMPTY_LINE]); + $outputPath = str_replace(base_path(), '', $filePath); + + $output->writeln(["{$this->commonName()} [{$outputPath}] successfully generated!", self::EMPTY_LINE]); return parent::SUCCESS; } diff --git a/src/Console/Maker.php b/src/Console/Maker.php index 14a90479..b9d8b03e 100644 --- a/src/Console/Maker.php +++ b/src/Console/Maker.php @@ -48,7 +48,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int File::put($filePath, $stub); - $output->writeln(["{$this->commonName()} successfully generated!", self::EMPTY_LINE]); + $outputPath = str_replace(base_path(), '', $filePath); + + $output->writeln(["{$this->commonName()} [{$outputPath}] successfully generated!", self::EMPTY_LINE]); return Command::SUCCESS; } diff --git a/src/Database/Console/MakeSeeder.php b/src/Database/Console/MakeSeeder.php index 89b603a2..990e38f2 100644 --- a/src/Database/Console/MakeSeeder.php +++ b/src/Database/Console/MakeSeeder.php @@ -36,7 +36,7 @@ protected function configure(): void protected function outputDirectory(): string { - return 'database' . DIRECTORY_SEPARATOR . 'seeds'; + return 'database' . DIRECTORY_SEPARATOR . 'seeders'; } protected function stub(): string diff --git a/src/Events/AbstractEvent.php b/src/Events/AbstractEvent.php new file mode 100644 index 00000000..04f1fcf9 --- /dev/null +++ b/src/Events/AbstractEvent.php @@ -0,0 +1,34 @@ +payload; + } + + public function isPropagationStopped(): bool + { + return $this->propagationStopped; + } + + public function stopPropagation(): void + { + $this->propagationStopped = true; + } +} diff --git a/src/Events/AbstractListener.php b/src/Events/AbstractListener.php new file mode 100644 index 00000000..9314fbfe --- /dev/null +++ b/src/Events/AbstractListener.php @@ -0,0 +1,57 @@ +priority = $this->normalizePriority($priority); + + return $this; + } + + public function getPriority(): int + { + return $this->priority; + } + + public function shouldHandle(Event $event): bool + { + return true; + } + + public function isOnce(): bool + { + return $this->once; + } + + public function setOnce(bool $once = true): self + { + $this->once = $once; + + return $this; + } + + public function getHandler(): Closure|static|string + { + return $this; + } + + protected function normalizePriority(int $priority): int + { + return max(0, min($priority, 100)); + } +} diff --git a/src/Events/Console/MakeEvent.php b/src/Events/Console/MakeEvent.php new file mode 100644 index 00000000..a7bbf2d3 --- /dev/null +++ b/src/Events/Console/MakeEvent.php @@ -0,0 +1,39 @@ +addArgument('name', InputArgument::REQUIRED, 'The name of the event'); + + $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Force to create event'); + } + + protected function outputDirectory(): string + { + return 'app' . DIRECTORY_SEPARATOR . 'Events'; + } + + protected function commonName(): string + { + return 'Event'; + } + + protected function stub(): string + { + return 'event.stub'; + } +} diff --git a/src/Events/Console/MakeListener.php b/src/Events/Console/MakeListener.php new file mode 100644 index 00000000..fbdbc8c2 --- /dev/null +++ b/src/Events/Console/MakeListener.php @@ -0,0 +1,39 @@ +addArgument('name', InputArgument::REQUIRED, 'The name of the listener'); + + $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Force to create listener'); + } + + protected function outputDirectory(): string + { + return 'app' . DIRECTORY_SEPARATOR . 'Listeners'; + } + + protected function commonName(): string + { + return 'Event Listener'; + } + + protected function stub(): string + { + return 'listener.stub'; + } +} diff --git a/src/Events/Contracts/Event.php b/src/Events/Contracts/Event.php new file mode 100644 index 00000000..b8428e87 --- /dev/null +++ b/src/Events/Contracts/Event.php @@ -0,0 +1,16 @@ +payload = $payload; + $this->timestamp = microtime(true); + } + + public function getTimestamp(): float + { + return $this->timestamp; + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php new file mode 100644 index 00000000..0b383f43 --- /dev/null +++ b/src/Events/EventEmitter.php @@ -0,0 +1,310 @@ +> + */ + protected array $listeners = []; + + /** + * @var array + */ + protected array $listenerCounts = []; + + /** + * Maximum number of listeners per event. + */ + protected int $maxListeners = 10; + + /** + * Whether to emit warnings for too many listeners. + */ + protected bool $emitWarnings = true; + + public function on(string $event, Closure|EventListenerContract|string $listener, int $priority = 0): void + { + $eventListener = $this->createEventListener($listener, $priority); + + $this->listeners[$event][] = $eventListener; + $this->listenerCounts[$event] = ($this->listenerCounts[$event] ?? 0) + 1; + + $this->sortListenersByPriority($event); + $this->checkMaxListeners($event); + } + + public function once(string $event, Closure|EventListenerContract|string $listener, int $priority = 0): void + { + $eventListener = $this->createEventListener($listener, $priority); + $eventListener->setOnce(true); + + $this->listeners[$event][] = $eventListener; + $this->listenerCounts[$event] = ($this->listenerCounts[$event] ?? 0) + 1; + + $this->sortListenersByPriority($event); + $this->checkMaxListeners($event); + } + + public function off(string $event, Closure|EventListenerContract|string|null $listener = null): void + { + if (! isset($this->listeners[$event])) { + return; + } + + if ($listener === null) { + unset($this->listeners[$event]); + $this->listenerCounts[$event] = 0; + + return; + } + + $this->listeners[$event] = array_filter( + $this->listeners[$event], + fn (EventListenerContract $eventListener): bool => ! $this->isSameListener($eventListener, $listener) + ); + + $this->listenerCounts[$event] = count($this->listeners[$event]); + + if ($this->listenerCounts[$event] === 0) { + unset($this->listeners[$event]); + } + } + + public function emit(string|EventContract $event, mixed $payload = null): array + { + $eventObject = $this->createEvent($event, $payload); + $results = []; + + $listeners = $this->getListeners($eventObject->getName()); + + foreach ($listeners as $listener) { + if ($eventObject->isPropagationStopped()) { + break; + } + + if (! $listener->shouldHandle($eventObject)) { + continue; + } + + try { + $result = $listener->handle($eventObject); + $results[] = $result; + + // Remove one-time listeners after execution + if ($listener->isOnce()) { + $this->removeListener($eventObject->getName(), $listener); + } + } catch (Throwable $e) { + Log::error('Event listener error', [ + 'event' => $eventObject->getName(), + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + + if ($this->emitWarnings) { + throw new EventException( + "Error in event listener for '{$eventObject->getName()}': {$e->getMessage()}", + 0, + $e + ); + } + } + } + + return $results; + } + + public function emitAsync(string|EventContract $event, mixed $payload = null): Future + { + return async(function () use ($event, $payload): array { + $eventObject = $this->createEvent($event, $payload); + $listeners = $this->getListeners($eventObject->getName()); + $futures = []; + + foreach ($listeners as $listener) { + if (! $listener->shouldHandle($eventObject)) { + continue; + } + + $futures[] = $this->handleListenerAsync($listener, $eventObject); + } + + $results = []; + + foreach ($futures as $future) { + try { + $results[] = $future->await(); + } catch (Throwable $e) { + Log::error('Future await error', [ + 'event' => $eventObject->getName(), + 'error' => $e->getMessage(), + ]); + + $results[] = null; + } + } + + return $results; + }); + } + + protected function handleListenerAsync(EventListenerContract $listener, EventContract $eventObject): Future + { + return async(function () use ($listener, $eventObject): mixed { + try { + if ($eventObject->isPropagationStopped()) { + return null; + } + + $result = $listener->handle($eventObject); + + // Remove one-time listeners after execution + if ($listener->isOnce()) { + $this->removeListener($eventObject->getName(), $listener); + } + + return $result; + } catch (Throwable $e) { + Log::error('Async event listener error', [ + 'event' => $eventObject->getName(), + 'error' => $e->getMessage(), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + + if ($this->emitWarnings) { + throw new EventException( + "Error in async event listener for '{$eventObject->getName()}': {$e->getMessage()}", + 0, + $e + ); + } + + return null; + } + }); + } + + /** + * @return array + */ + public function getListeners(string $event): array + { + return $this->listeners[$event] ?? []; + } + + public function hasListeners(string $event): bool + { + return isset($this->listeners[$event]) && count($this->listeners[$event]) > 0; + } + + public function removeAllListeners(): void + { + $this->listeners = []; + $this->listenerCounts = []; + } + + public function setMaxListeners(int $maxListeners): void + { + $this->maxListeners = $maxListeners; + } + + public function getMaxListeners(): int + { + return $this->maxListeners; + } + + public function setEmitWarnings(bool $emitWarnings): void + { + $this->emitWarnings = $emitWarnings; + } + + public function getListenerCount(string $event): int + { + return $this->listenerCounts[$event] ?? 0; + } + + public function getEventNames(): array + { + return array_keys($this->listeners); + } + + protected function createEventListener(Closure|EventListenerContract|string $listener, int $priority): EventListenerContract + { + if ($listener instanceof EventListenerContract) { + return $listener; + } + + return new EventListener($listener, $priority); + } + + protected function createEvent(string|EventContract $event, mixed $payload): EventContract + { + if ($event instanceof EventContract) { + return $event; + } + + return new Event($event, $payload); + } + + protected function sortListenersByPriority(string $event): void + { + usort($this->listeners[$event], function (EventListenerContract $a, EventListenerContract $b): int { + return $b->getPriority() <=> $a->getPriority(); + }); + } + + protected function checkMaxListeners(string $event): void + { + if (! $this->emitWarnings) { + return; + } + + $count = $this->getListenerCount($event); + + if ($count > $this->maxListeners) { + Log::warning("Possible memory leak detected. Event '{$event}' has {$count} listeners. Maximum is {$this->maxListeners}."); + } + } + + protected function isSameListener(EventListenerContract $eventListener, Closure|EventListenerContract|string $listener): bool + { + $handler = $eventListener->getHandler(); + + if ($listener instanceof EventListenerContract) { + return $eventListener::class === $listener::class; + } + + return $handler === $listener; + } + + protected function removeListener(string $event, EventListenerContract $listener): void + { + $this->listeners[$event] = array_filter( + $this->listeners[$event], + fn (EventListenerContract $eventListener): bool => ! $this->isSameListener($eventListener, $listener) + ); + + $this->listenerCounts[$event] = count($this->listeners[$event]); + + if ($this->listenerCounts[$event] === 0) { + unset($this->listeners[$event]); + } + } +} diff --git a/src/Events/EventListener.php b/src/Events/EventListener.php new file mode 100644 index 00000000..8e30ed5a --- /dev/null +++ b/src/Events/EventListener.php @@ -0,0 +1,49 @@ +priority = $this->normalizePriority($priority); + } + + public function handle(Event $event): mixed + { + if ($this->handler instanceof Closure) { + return ($this->handler)($event); + } + + $listener = $this->resolveListener(); + + if (! $listener || ! (method_exists($listener, 'handle') || is_callable($listener))) { + return null; + } + + return method_exists($listener, 'handle') ? $listener->handle($event) : $listener($event); + } + + public function getHandler(): Closure|string + { + return $this->handler; + } + + private function resolveListener(): object|null + { + return class_exists($this->handler) + ? new $this->handler() + : null; + } +} diff --git a/src/Events/EventServiceProvider.php b/src/Events/EventServiceProvider.php new file mode 100644 index 00000000..07b72665 --- /dev/null +++ b/src/Events/EventServiceProvider.php @@ -0,0 +1,37 @@ +provides); + } + + public function register(): void + { + $this->getContainer()->addShared(EventEmitter::class, EventEmitter::class); + $this->getContainer()->add(EventEmitterContract::class, EventEmitter::class); + } + + public function boot(): void + { + $this->commands([ + MakeEvent::class, + MakeListener::class, + ]); + } +} diff --git a/src/Events/Exceptions/EventException.php b/src/Events/Exceptions/EventException.php new file mode 100644 index 00000000..47ef792c --- /dev/null +++ b/src/Events/Exceptions/EventException.php @@ -0,0 +1,12 @@ +shouldAllowMockingProtectedMethods()->makePartial(); + + App::fake(self::getKeyName(), $mock); + + return $mock->shouldReceive($method); + } } diff --git a/src/stubs/controller.stub b/src/stubs/controller.stub index fca5395a..2d455916 100644 --- a/src/stubs/controller.stub +++ b/src/stubs/controller.stub @@ -4,10 +4,10 @@ declare(strict_types=1); namespace {namespace}; +use Phenix\Http\Constants\HttpStatus; +use Phenix\Http\Controller; use Phenix\Http\Request; use Phenix\Http\Response; -use Amp\Http\Status; -use Phenix\Http\Controller; class {name} extends Controller { diff --git a/src/stubs/event.stub b/src/stubs/event.stub new file mode 100644 index 00000000..132a05d5 --- /dev/null +++ b/src/stubs/event.stub @@ -0,0 +1,12 @@ +phenix('make:collection', [ - 'name' => 'User', + 'name' => 'UserCollection', ]); $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Collection successfully generated!'); + expect($command->getDisplay())->toContain('Collection [app/Collections/UserCollection.php] successfully generated!'); }); it('does not create the collection because it already exists', function () { @@ -72,13 +72,13 @@ /** @var CommandTester $command */ $command = $this->phenix('make:collection', [ - 'name' => 'User', + 'name' => 'UserCollection', '--force' => true, ]); $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Collection successfully generated!'); + expect($command->getDisplay())->toContain('Collection [app/Collections/UserCollection.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -101,5 +101,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Collection successfully generated!'); + expect($command->getDisplay())->toContain('Collection [app/Collections/Admin/User.php] successfully generated!'); }); diff --git a/tests/Unit/Console/MakeControllerCommandTest.php b/tests/Unit/Console/MakeControllerCommandTest.php index ed86ae8c..e2ed621f 100644 --- a/tests/Unit/Console/MakeControllerCommandTest.php +++ b/tests/Unit/Console/MakeControllerCommandTest.php @@ -24,7 +24,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Controller successfully generated!'); + expect($command->getDisplay())->toContain('Controller [app/Http/Controllers/TestController.php] successfully generated!'); }); it('does not create the controller because it already exists', function () { @@ -75,7 +75,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Controller successfully generated!'); + expect($command->getDisplay())->toContain('Controller [app/Http/Controllers/TestController.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -98,7 +98,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Controller successfully generated!'); + expect($command->getDisplay())->toContain('Controller [app/Http/Controllers/Admin/UserController.php] successfully generated!'); }); it('creates controller successfully with api option', function () { @@ -124,6 +124,6 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Controller successfully generated!'); + expect($command->getDisplay())->toContain('Controller [app/Http/Controllers/TestController.php] successfully generated!'); expect(file_get_contents($tempPath))->toContain('Hello, world!'); }); diff --git a/tests/Unit/Console/MakeMiddlewareCommandTest.php b/tests/Unit/Console/MakeMiddlewareCommandTest.php index bed1025d..8e9345a8 100644 --- a/tests/Unit/Console/MakeMiddlewareCommandTest.php +++ b/tests/Unit/Console/MakeMiddlewareCommandTest.php @@ -28,7 +28,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Middleware successfully generated!'); + expect($command->getDisplay())->toContain('Middleware [app/Http/Middleware/AwesomeMiddleware.php] successfully generated!'); }); it('does not create the middleware because it already exists', function () { @@ -79,7 +79,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Middleware successfully generated!'); + expect($command->getDisplay())->toContain('Middleware [app/Http/Middleware/TestMiddleware.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -106,5 +106,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Middleware successfully generated!'); + expect($command->getDisplay())->toContain('Middleware [app/Http/Middleware/Admin/TestMiddleware.php] successfully generated!'); }); diff --git a/tests/Unit/Console/MakeModelCommandTest.php b/tests/Unit/Console/MakeModelCommandTest.php index 26981e4b..1bde3f09 100644 --- a/tests/Unit/Console/MakeModelCommandTest.php +++ b/tests/Unit/Console/MakeModelCommandTest.php @@ -27,7 +27,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/User.php] successfully generated!'); }); it('does not create the model because it already exists', function () { @@ -78,7 +78,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/User.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -101,7 +101,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/Admin/User.php] successfully generated!'); }); it('creates model with custom collection', function () { @@ -139,8 +139,8 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); - expect($command->getDisplay())->toContain('Collection successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/User.php] successfully generated!'); + expect($command->getDisplay())->toContain('Collection [app/Collections/UserCollection.php] successfully generated!'); }); it('creates model with custom query builder', function () { @@ -178,8 +178,8 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); - expect($command->getDisplay())->toContain('Query successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/User.php] successfully generated!'); + expect($command->getDisplay())->toContain('Query [app/Queries/UserQuery.php] successfully generated!'); }); it('creates model with all', function () { @@ -223,9 +223,9 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); - expect($command->getDisplay())->toContain('Query successfully generated!'); - expect($command->getDisplay())->toContain('Collection successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/User.php] successfully generated!'); + expect($command->getDisplay())->toContain('Query [app/Queries/UserQuery.php] successfully generated!'); + expect($command->getDisplay())->toContain('Collection [app/Collections/UserCollection.php] successfully generated!'); }); it('creates model with migration', function () { @@ -261,8 +261,9 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); - expect($command->getDisplay())->toContain('Migration successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/User.php] successfully generated!'); + expect($command->getDisplay())->toContain('Migration [database/migrations/'); + expect($command->getDisplay())->toContain('_create_users_table.php] successfully generated!'); }); it('creates model with controller', function () { @@ -298,6 +299,6 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Model successfully generated!'); - expect($command->getDisplay())->toContain('Controller successfully generated!'); + expect($command->getDisplay())->toContain('Model [app/Models/User.php] successfully generated!'); + expect($command->getDisplay())->toContain('Controller [app/Http/Controllers/UserController.php] successfully generated!'); }); diff --git a/tests/Unit/Console/MakeQueryCommandTest.php b/tests/Unit/Console/MakeQueryCommandTest.php index 16b4399c..4ca9ba42 100644 --- a/tests/Unit/Console/MakeQueryCommandTest.php +++ b/tests/Unit/Console/MakeQueryCommandTest.php @@ -27,7 +27,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Query successfully generated!'); + expect($command->getDisplay())->toContain('Query [app/Queries/UserQuery.php] successfully generated!'); }); it('does not create the query because it already exists', function () { @@ -78,7 +78,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Query successfully generated!'); + expect($command->getDisplay())->toContain('Query [app/Queries/UserQuery.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -101,5 +101,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Query successfully generated!'); + expect($command->getDisplay())->toContain('Query [app/Queries/Domain/UserQuery.php] successfully generated!'); }); diff --git a/tests/Unit/Console/MakeRequestCommandTest.php b/tests/Unit/Console/MakeRequestCommandTest.php index 9e41f65a..27faaeab 100644 --- a/tests/Unit/Console/MakeRequestCommandTest.php +++ b/tests/Unit/Console/MakeRequestCommandTest.php @@ -28,7 +28,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Request successfully generated!'); + expect($command->getDisplay())->toContain('Request [app/Http/Requests/StoreUserRequest.php] successfully generated!'); }); it('does not create the form request because it already exists', function () { @@ -79,7 +79,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Request successfully generated!'); + expect($command->getDisplay())->toContain('Request [app/Http/Requests/StoreUserRequest.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -106,5 +106,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Request successfully generated!'); + expect($command->getDisplay())->toContain('Request [app/Http/Requests/Admin/StoreUserRequest.php] successfully generated!'); }); diff --git a/tests/Unit/Console/MakeServiceProviderCommandTest.php b/tests/Unit/Console/MakeServiceProviderCommandTest.php index d21cf691..e2583a27 100644 --- a/tests/Unit/Console/MakeServiceProviderCommandTest.php +++ b/tests/Unit/Console/MakeServiceProviderCommandTest.php @@ -28,5 +28,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Service provider successfully generated!'); + expect($command->getDisplay())->toContain('Service provider [app/Providers/SlackServiceProvider.php] successfully generated!'); }); diff --git a/tests/Unit/Console/MakeTestCommandTest.php b/tests/Unit/Console/MakeTestCommandTest.php index b79fcdef..f218e0af 100644 --- a/tests/Unit/Console/MakeTestCommandTest.php +++ b/tests/Unit/Console/MakeTestCommandTest.php @@ -28,7 +28,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Test successfully generated!'); + expect($command->getDisplay())->toContain('Test [tests/Feature/ExampleTest.php] successfully generated!'); }); it('does not create the test because it already exists', function () { @@ -79,7 +79,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Test successfully generated!'); + expect($command->getDisplay())->toContain('Test [tests/Feature/ExampleTest.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -102,7 +102,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Test successfully generated!'); + expect($command->getDisplay())->toContain('Test [tests/Feature/Admin/ExampleTest.php] successfully generated!'); }); it('creates test successfully with unit option', function () { @@ -129,5 +129,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Test successfully generated!'); + expect($command->getDisplay())->toContain('Test [tests/Unit/ExampleTest.php] successfully generated!'); }); diff --git a/tests/Unit/Database/Console/MakeMigrationCommandTest.php b/tests/Unit/Database/Console/MakeMigrationCommandTest.php index 9907938b..2eb89fd4 100644 --- a/tests/Unit/Database/Console/MakeMigrationCommandTest.php +++ b/tests/Unit/Database/Console/MakeMigrationCommandTest.php @@ -24,5 +24,6 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Migration successfully generated!'); + expect($command->getDisplay())->toContain('Migration [database/migrations/'); + expect($command->getDisplay())->toContain('_create_products_table.php] successfully generated!'); }); diff --git a/tests/Unit/Database/Console/MakeSeederCommandTest.php b/tests/Unit/Database/Console/MakeSeederCommandTest.php index 4784fedb..a4152f5e 100644 --- a/tests/Unit/Database/Console/MakeSeederCommandTest.php +++ b/tests/Unit/Database/Console/MakeSeederCommandTest.php @@ -24,5 +24,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Seeder successfully generated!'); + expect($command->getDisplay())->toContain('Seeder [database/seeders/UsersSeeder.php] successfully generated!'); }); diff --git a/tests/Unit/Events/Console/MakeEventCommandTest.php b/tests/Unit/Events/Console/MakeEventCommandTest.php new file mode 100644 index 00000000..293954a4 --- /dev/null +++ b/tests/Unit/Events/Console/MakeEventCommandTest.php @@ -0,0 +1,110 @@ +expect( + exists: fn (string $path) => false, + get: fn (string $path) => '', + put: function (string $path) { + expect($path)->toBe(base_path('app/Events/AwesomeEvent.php')); + + return true; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:event', [ + 'name' => 'AwesomeEvent', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Event [app/Events/AwesomeEvent.php] successfully generated!'); +}); + +it('does not create the event because it already exists', function () { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => true, + ); + + $this->app->swap(File::class, $mock); + + $this->phenix('make:event', [ + 'name' => 'TestEvent', + ]); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:event', [ + 'name' => 'TestEvent', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Event already exists!'); +}); + +it('creates event successfully with force option', function () { + $tempDir = sys_get_temp_dir(); + $tempPath = $tempDir . DIRECTORY_SEPARATOR . 'TestEvent.php'; + + file_put_contents($tempPath, 'old content'); + + $this->assertEquals('old content', file_get_contents($tempPath)); + + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => false, + get: fn (string $path) => 'new content', + put: fn (string $path, string $content) => file_put_contents($tempPath, $content), + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:event', [ + 'name' => 'TestEvent', + '--force' => true, + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Event [app/Events/TestEvent.php] successfully generated!'); + expect('new content')->toBe(file_get_contents($tempPath)); +}); + +it('creates event successfully in nested namespace', function () { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => false, + get: fn (string $path) => '', + put: function (string $path) { + expect($path)->toBe(base_path('app/Events/Admin/TestEvent.php')); + + return true; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:event', [ + 'name' => 'Admin/TestEvent', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Event [app/Events/Admin/TestEvent.php] successfully generated!'); +}); diff --git a/tests/Unit/Events/Console/MakeListenerCommandTest.php b/tests/Unit/Events/Console/MakeListenerCommandTest.php new file mode 100644 index 00000000..9ac325f6 --- /dev/null +++ b/tests/Unit/Events/Console/MakeListenerCommandTest.php @@ -0,0 +1,110 @@ +expect( + exists: fn (string $path) => false, + get: fn (string $path) => '', + put: function (string $path) { + expect($path)->toBe(base_path('app/Listeners/AwesomeListener.php')); + + return true; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:listener', [ + 'name' => 'AwesomeListener', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Listener [app/Listeners/AwesomeListener.php] successfully generated!'); +}); + +it('does not create the listener because it already exists', function () { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => true, + ); + + $this->app->swap(File::class, $mock); + + $this->phenix('make:listener', [ + 'name' => 'TestListener', + ]); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:listener', [ + 'name' => 'TestListener', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Listener already exists!'); +}); + +it('creates listener successfully with force option', function () { + $tempDir = sys_get_temp_dir(); + $tempPath = $tempDir . DIRECTORY_SEPARATOR . 'TestListener.php'; + + file_put_contents($tempPath, 'old content'); + + $this->assertEquals('old content', file_get_contents($tempPath)); + + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => false, + get: fn (string $path) => 'new content', + put: fn (string $path, string $content) => file_put_contents($tempPath, $content), + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:listener', [ + 'name' => 'TestListener', + '--force' => true, + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Listener [app/Listeners/TestListener.php] successfully generated!'); + expect('new content')->toBe(file_get_contents($tempPath)); +}); + +it('creates listener successfully in nested namespace', function () { + $mock = Mock::of(File::class)->expect( + exists: fn (string $path) => false, + get: fn (string $path) => '', + put: function (string $path) { + expect($path)->toBe(base_path('app/Listeners/Admin/TestListener.php')); + + return true; + }, + createDirectory: function (string $path): void { + // .. + } + ); + + $this->app->swap(File::class, $mock); + + /** @var \Symfony\Component\Console\Tester\CommandTester $command */ + $command = $this->phenix('make:listener', [ + 'name' => 'Admin/TestListener', + ]); + + $command->assertCommandIsSuccessful(); + + expect($command->getDisplay())->toContain('Listener [app/Listeners/Admin/TestListener.php] successfully generated!'); +}); diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php new file mode 100644 index 00000000..e12259ab --- /dev/null +++ b/tests/Unit/Events/EventEmitterTest.php @@ -0,0 +1,468 @@ +on('test.event', function (EventContract $event) use (&$called): void { + $called = true; + expect($event->getName())->toBe('test.event'); + expect($event->getPayload())->toBe('test data'); + }); + + $emitter->emit('test.event', 'test data'); + + expect($called)->toBeTrue(); +}); + +it('can register and emit string-class events', function (): void { + $emitter = new EventEmitter(); + + $emitter->on(StandardEvent::class, fn (EventContract $event): string => 'string result'); + + $results = $emitter->emit(StandardEvent::class, 'test data'); + + expect($results)->toBe(['string result']); +}); + +it('can register and emit async events', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('test.event', fn (EventContract $event): string => $event->getPayload()); + + $future = $emitter->emitAsync('test.event', 'test data'); + + $results = $future->await(); + + expect($results)->toBe(['test data']); +}); + +it('can register and emit basic events with string-class listeners', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('test.event', StandardListener::class); + + $results = $emitter->emit('test.event', 'test data'); + + expect($results)->toBe(['Event name: test.event']); +}); + +it('can register and emit basic events and listener with custom priority', function (): void { + $emitter = new EventEmitter(); + + $listener = new StandardListener(); + $listener->setPriority(10); + + $emitter->on('test.event', $listener); + + $results = $emitter->emit('test.event', 'test data'); + + expect($results)->toBe(['Event name: test.event']); +}); + +it('returns null for invalid listeners', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('test.event', InvalidListener::class); + + $results = $emitter->emit('test.event', 'test data'); + + expect($results)->toBe([null]); +}); + +it('can register and emit events with facade syntax', function (): void { + $called = false; + + EventFacade::on('facade.event', function (EventContract $event) use (&$called): void { + $called = true; + expect($event->getName())->toBe('facade.event'); + expect($event->getPayload())->toBe('facade data'); + }); + + EventFacade::emit('facade.event', 'facade data'); + + expect($called)->toBeTrue(); +}); + +it('can register multiple listeners for same event', function (): void { + $emitter = new EventEmitter(); + $count = 0; + + $emitter->on('multi.event', function () use (&$count): void { + $count++; + }); + + $emitter->on('multi.event', function () use (&$count): void { + $count++; + }); + + $emitter->emit('multi.event'); + + expect($count)->toBe(2); +}); + +it('respects listener priorities', function (): void { + $emitter = new EventEmitter(); + $order = []; + + $emitter->on('priority.test', function () use (&$order): void { + $order[] = 'low'; + }, 1); + + $emitter->on('priority.test', function () use (&$order): void { + $order[] = 'high'; + }, 10); + + $emitter->on('priority.test', function () use (&$order): void { + $order[] = 'medium'; + }, 5); + + $emitter->emit('priority.test'); + + expect($order)->toBe(['high', 'medium', 'low']); +}); + +it('can register one-time listeners', function (): void { + $emitter = new EventEmitter(); + $count = 0; + + $emitter->once('once.event', function () use (&$count): void { + $count++; + }); + + $emitter->emit('once.event'); + $emitter->emit('once.event'); + $emitter->emit('once.event'); + + expect($count)->toBe(1); +}); + +it('can remove listeners', function (): void { + $emitter = new EventEmitter(); + $called = false; + + $listener = function () use (&$called): void { + $called = true; + }; + + $emitter->on('removable.event', $listener); + $emitter->off('removable.event', $listener); + $emitter->emit('removable.event'); + + expect($called)->toBeFalse(); +}); + +it('tries to remove non registered event', function (): void { + $emitter = new EventEmitter(); + $called = false; + + $listener = function () use (&$called): void { + $called = true; + }; + + $emitter->off('removable.event', $listener); + + expect($called)->toBeFalse(); +}); + +it('can remove all listeners for an event', function (): void { + $emitter = new EventEmitter(); + $count = 0; + + $emitter->on('clear.event', function () use (&$count): void { + $count++; + }); + + $emitter->on('clear.event', function () use (&$count): void { + $count++; + }); + + $emitter->off('clear.event'); + $emitter->emit('clear.event'); + + expect($count)->toBe(0); +}); + +it('can stop event propagation', function (): void { + $emitter = new EventEmitter(); + $count = 0; + + $emitter->on('stop.event', function (EventContract $event) use (&$count): void { + $count++; + $event->stopPropagation(); + }); + + $emitter->on('stop.event', function (EventContract $event) use (&$count): void { + $count++; + }); + + $emitter->emit('stop.event'); + + expect($count)->toBe(1); +}); + +it('can stop async event propagation', function (): void { + $emitter = new EventEmitter(); + $count = 0; + + $emitter->on('stop.event', function (EventContract $event) use (&$count): void { + $count++; + $event->stopPropagation(); + }); + + $emitter->on('stop.event', function (EventContract $event) use (&$count): void { + $count++; + }); + + $future = $emitter->emitAsync('stop.event'); + + $future->await(); + + expect($count)->toBe(1); +}); + +it('returns results from listeners', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('result.event', fn (): string => 'first result'); + + $emitter->on('result.event', fn (): string => 'second result'); + + $results = $emitter->emit('result.event'); + + expect($results)->toBe(['first result', 'second result']); +}); + +it('can handle Event objects', function (): void { + $emitter = new EventEmitter(); + $called = false; + + $emitter->on('custom.event', function ($event) use (&$called): void { + $called = true; + expect($event->getName())->toBe('custom.event'); + expect($event->getPayload())->toBe('custom data'); + }); + + $event = new Event('custom.event', 'custom data'); + $emitter->emit($event); + + expect($called)->toBeTrue(); + expect($event->getTimestamp())->toBeFloat(); +}); + +it('skip the listener when this should not be handled', function (): void { + $emitter = new EventEmitter(); + + $listener = $this->getMockBuilder(StandardListener::class) + ->onlyMethods(['shouldHandle', 'handle']) + ->getMock(); + + $listener->expects($this->once()) + ->method('shouldHandle') + ->willReturn(false); + + $listener->expects($this->never()) + ->method('handle'); + + $emitter->on('custom.event', $listener); + + $emitter->emit('custom.event', 'data'); +}); + +it('skip the listener when this should not be handled in async event', function (): void { + $emitter = new EventEmitter(); + + $listener = $this->getMockBuilder(StandardListener::class) + ->onlyMethods(['shouldHandle', 'handle']) + ->getMock(); + + $listener->expects($this->once()) + ->method('shouldHandle') + ->willReturn(false); + + $listener->expects($this->never()) + ->method('handle'); + + $emitter->on('custom.event', $listener); + + $future = $emitter->emitAsync('custom.event', 'data'); + + $future->await(); +}); + +it('uses listener once and removes this after use', function (): void { + $emitter = new EventEmitter(); + + $listener = $this->getMockBuilder(StandardListener::class) + ->onlyMethods(['shouldHandle', 'isOnce', 'handle']) + ->getMock(); + + $listener->expects($this->once()) + ->method('shouldHandle') + ->willReturn(true); + + $listener->expects($this->once()) + ->method('handle') + ->willReturn('Event name: custom.event'); + + $listener->expects($this->once()) + ->method('isOnce') + ->willReturn(true); + + + $emitter->on('custom.event', $listener); + + $future = $emitter->emitAsync('custom.event', 'data'); + + $future->await(); +}); + +it('handle listener error gracefully', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('error.event', function (): never { + throw new RuntimeError('Listener error'); + }); + + Log::shouldReceive('error') + ->once(); + + $emitter->emit('error.event'); +})->throws(EventException::class); + +it('handle listener error gracefully in async event', function (): void { + $emitter = new EventEmitter(); + $emitter->setEmitWarnings(true); + + $emitter->on('error.event', function (): never { + throw new RuntimeError('Listener error'); + }); + + Log::shouldReceive('error') + ->times(2); + + $future = $emitter->emitAsync('error.event'); + + $future->await(); +}); + +it('handle listener error gracefully in async event without warnings', function (): void { + $emitter = new EventEmitter(); + $emitter->setEmitWarnings(false); + + $emitter->on('error.event', function (): never { + throw new RuntimeError('Listener error'); + }); + + Log::shouldReceive('error')->once(); + + $future = $emitter->emitAsync('error.event'); + + $future->await(); +}); + +it('can check if event has listeners', function (): void { + $emitter = new EventEmitter(); + + expect($emitter->hasListeners('nonexistent.event'))->toBeFalse(); + + $emitter->on('existing.event', function (): void { + // Do something + }); + + expect($emitter->hasListeners('existing.event'))->toBeTrue(); +}); + +it('can get listener count', function (): void { + $emitter = new EventEmitter(); + + expect($emitter->getListenerCount('count.event'))->toBe(0); + + $emitter->on('count.event', function (): void { + // Do something + }); + $emitter->on('count.event', function (): void { + // Do something + }); + + expect($emitter->getListenerCount('count.event'))->toBe(2); +}); + +it('can get event names', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('event.one', function (): void { + // Do something + }); + $emitter->on('event.two', function (): void { + // Do something + }); + + $eventNames = $emitter->getEventNames(); + + expect($eventNames)->toContain('event.one'); + expect($eventNames)->toContain('event.two'); +}); + +it('can set max listeners', function () { + $emitter = new EventEmitter(); + $emitter->setMaxListeners(2); + + expect($emitter->getMaxListeners())->toBe(2); +}); + +it('can clear all listeners', function (): void { + $emitter = new EventEmitter(); + + $emitter->on('event.one', function (): void { + // Do something + }); + $emitter->on('event.two', function (): void { + // Do something + }); + + $emitter->removeAllListeners(); + + expect($emitter->getEventNames())->toBeEmpty(); +}); + +it('warns when exceeding the maximum number of listeners for an event', function (): void { + $emitter = new EventEmitter(); + + $emitter->setMaxListeners(1); + $emitter->setEmitWarnings(true); + + Log::shouldReceive('warning')->once(); + + $emitter->on('warn.event', fn (): null => null); + $emitter->on('warn.event', fn (): null => null); // This pushes it over the limit and should log a warning + + expect($emitter->getListenerCount('warn.event'))->toBe(2); +}); + +it('does not warn when exceeding maximum listeners if warnings disabled', function (): void { + $emitter = new EventEmitter(); + + $emitter->setMaxListeners(1); + $emitter->setEmitWarnings(false); + + Log::shouldReceive('warning')->never(); + + $emitter->on('warn.event', fn (): null => null); + $emitter->on('warn.event', fn (): null => null); + + expect($emitter->getListenerCount('warn.event'))->toBe(2); +}); diff --git a/tests/Unit/Events/Internal/InvalidListener.php b/tests/Unit/Events/Internal/InvalidListener.php new file mode 100644 index 00000000..77e4441f --- /dev/null +++ b/tests/Unit/Events/Internal/InvalidListener.php @@ -0,0 +1,10 @@ +getName(); + } +} diff --git a/tests/Unit/Queue/Console/TableCommandTest.php b/tests/Unit/Queue/Console/TableCommandTest.php index 89b679b1..f05f15cf 100644 --- a/tests/Unit/Queue/Console/TableCommandTest.php +++ b/tests/Unit/Queue/Console/TableCommandTest.php @@ -27,5 +27,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Queue table successfully generated!'); + expect($command->getDisplay())->toContain('Queue table [database/migrations/20250101205638_create_tasks_table.php] successfully generated!'); }); diff --git a/tests/Unit/Tasks/Console/MakeTaskCommandTest.php b/tests/Unit/Tasks/Console/MakeTaskCommandTest.php index 23778d18..6fa74ded 100644 --- a/tests/Unit/Tasks/Console/MakeTaskCommandTest.php +++ b/tests/Unit/Tasks/Console/MakeTaskCommandTest.php @@ -28,7 +28,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Task successfully generated!'); + expect($command->getDisplay())->toContain('Task [app/Tasks/AwesomeTask.php] successfully generated!'); }); it('creates queuable task successfully', function () { @@ -55,7 +55,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Task successfully generated!'); + expect($command->getDisplay())->toContain('Task [app/Tasks/AwesomeTask.php] successfully generated!'); }); it('does not create the task because it already exists', function () { @@ -106,7 +106,7 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Task successfully generated!'); + expect($command->getDisplay())->toContain('Task [app/Tasks/TestTask.php] successfully generated!'); expect('new content')->toBe(file_get_contents($tempPath)); }); @@ -133,5 +133,5 @@ $command->assertCommandIsSuccessful(); - expect($command->getDisplay())->toContain('Task successfully generated!'); + expect($command->getDisplay())->toContain('Task [app/Tasks/Admin/TestTask.php] successfully generated!'); }); diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index 6393975d..4778ad35 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -27,5 +27,6 @@ \Phenix\Mail\MailServiceProvider::class, \Phenix\Crypto\CryptoServiceProvider::class, \Phenix\Queue\QueueServiceProvider::class, + \Phenix\Events\EventServiceProvider::class, ], ];