diff --git a/CHANGELOG.md b/CHANGELOG.md index 21f90c870..70e2b7810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ The format is based on Keep a Changelog. - Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533) ### Changed +- **BREAKING:** Refactored the web and console runtime into staged pipelines (#537): + - `WebAppAdapter::start()` now runs a `RequestPipeline` of the preflight, route resolution, not found, prepare, dispatch, and send stages with a `RequestContext`, and `ConsoleAppAdapter::start()` runs a `ConsolePipeline` of the register core commands, register app commands, and run stages with a `ConsoleContext`; the run stage rejects a missing or unknown command before running anything, and registration goes through the new `Quantum\Console\CommandRegistrar`. Behavior is unchanged, and runtime stage events are not dispatched yet. + - Moved the web adapter, context, and pipeline to `Quantum\App\Adapters\Web`, the console ones to `Quantum\App\Adapters\Console`, and the boot stages to `Quantum\App\Stages\Boot`. Code that goes through `AppFactory` is unaffected; code that references these classes directly must update its imports. + - Removed the `AppAdapter` base class and the `WebAppTrait` and `ConsoleAppTrait` traits. `WebAppAdapter` and `ConsoleAppAdapter` now implement `AppInterface` directly, and `ConsoleAppAdapter::createApplication()` stays public. - **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`. diff --git a/src/App/Adapters/AppAdapter.php b/src/App/Adapters/AppAdapter.php deleted file mode 100644 index d71603040..000000000 --- a/src/App/Adapters/AppAdapter.php +++ /dev/null @@ -1,28 +0,0 @@ -context = $context; - } -} diff --git a/src/App/Adapters/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php similarity index 59% rename from src/App/Adapters/ConsoleAppAdapter.php rename to src/App/Adapters/Console/ConsoleAppAdapter.php index 8700ecdde..7d511613a 100644 --- a/src/App/Adapters/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -8,16 +8,19 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Adapters; +namespace Quantum\App\Adapters\Console; +use Quantum\App\Stages\Console\RegisterCoreCommandsStage; +use Quantum\App\Stages\Console\RegisterAppCommandsStage; use Symfony\Component\Console\Output\ConsoleOutput; +use Quantum\App\Stages\Console\RunCommandStage; use Symfony\Component\Console\Input\ArgvInput; -use Quantum\App\Stages\SetupErrorHandlerStage; -use Quantum\App\Stages\LoadEnvironmentStage; +use Quantum\App\Stages\Boot\SetupErrorHandlerStage; +use Quantum\App\Stages\Boot\LoadEnvironmentStage; use Symfony\Component\Console\Application; -use Quantum\App\Stages\LoadAppConfigStage; -use Quantum\App\Stages\LoadHelpersStage; -use Quantum\App\Traits\ConsoleAppTrait; +use Quantum\App\Stages\Boot\LoadAppConfigStage; +use Quantum\App\Stages\Boot\LoadHelpersStage; +use Quantum\App\Contracts\AppInterface; use Quantum\App\Enums\ExitCode; use Quantum\App\BootPipeline; use Quantum\App\AppContext; @@ -31,9 +34,9 @@ * Class ConsoleAppAdapter * @package Quantum\App */ -class ConsoleAppAdapter extends AppAdapter +class ConsoleAppAdapter implements AppInterface { - use ConsoleAppTrait; + protected AppContext $context; protected ArgvInput $input; @@ -43,7 +46,7 @@ class ConsoleAppAdapter extends AppAdapter public function __construct(AppContext $context) { - parent::__construct($context); + $this->context = $context; $this->input = new ArgvInput(); $this->output = new ConsoleOutput(); @@ -73,18 +76,26 @@ public function __construct(AppContext $context) ); } + public function createApplication(string $name, string $version): Application + { + return new Application($name, $version); + } + /** * @throws Exception */ public function start(): ?int { - $this->registerCoreCommands(); - $this->registerAppCommands(); + $context = new ConsoleContext($this->context, $this->application, $this->input, $this->output); - $this->validateCommand(); + $pipeline = new ConsolePipeline([ + new RegisterCoreCommandsStage(), + new RegisterAppCommandsStage(), + new RunCommandStage(), + ]); - $exitCode = $this->application->run($this->input, $this->output); + $pipeline->run($context); - return $exitCode ?: ExitCode::SUCCESS; + return $context->getExitCode() ?: ExitCode::SUCCESS; } } diff --git a/src/App/Adapters/Console/ConsoleContext.php b/src/App/Adapters/Console/ConsoleContext.php new file mode 100644 index 000000000..c97cbe683 --- /dev/null +++ b/src/App/Adapters/Console/ConsoleContext.php @@ -0,0 +1,80 @@ +appContext = $appContext; + $this->application = $application; + $this->input = $input; + $this->output = $output; + } + + public function getAppContext(): AppContext + { + return $this->appContext; + } + + public function getApplication(): Application + { + return $this->application; + } + + public function getInput(): InputInterface + { + return $this->input; + } + + public function getOutput(): OutputInterface + { + return $this->output; + } + + public function getExitCode(): int + { + return $this->exitCode; + } + + public function setExitCode(int $exitCode): void + { + $this->exitCode = $exitCode; + } +} diff --git a/src/App/Adapters/Console/ConsolePipeline.php b/src/App/Adapters/Console/ConsolePipeline.php new file mode 100644 index 000000000..15f484da8 --- /dev/null +++ b/src/App/Adapters/Console/ConsolePipeline.php @@ -0,0 +1,53 @@ +stages = $stages; + } + + public function run(ConsoleContext $context): void + { + foreach ($this->stages as $stage) { + $stage->process($context); + } + } +} diff --git a/src/App/Adapters/Web/RequestContext.php b/src/App/Adapters/Web/RequestContext.php new file mode 100644 index 000000000..fa587a2bb --- /dev/null +++ b/src/App/Adapters/Web/RequestContext.php @@ -0,0 +1,91 @@ +appContext = $appContext; + } + + public function getAppContext(): AppContext + { + return $this->appContext; + } + + public function getMatchedRoute(): ?MatchedRoute + { + return $this->matchedRoute; + } + + public function setMatchedRoute(?MatchedRoute $matchedRoute): void + { + $this->matchedRoute = $matchedRoute; + } + + public function getResponse(): ?Response + { + return $this->response; + } + + public function setResponse(Response $response): void + { + $this->response = $response; + } + + public function getViewCache(): ?ViewCache + { + return $this->viewCache; + } + + public function setViewCache(ViewCache $viewCache): void + { + $this->viewCache = $viewCache; + } + + /** + * Marks the request as finished early, so stages that compute a response skip their work. + * The stage that sends the response still runs. + */ + public function stop(): void + { + $this->stopped = true; + } + + public function isStopped(): bool + { + return $this->stopped; + } +} diff --git a/src/App/Adapters/Web/RequestPipeline.php b/src/App/Adapters/Web/RequestPipeline.php new file mode 100644 index 000000000..fe56aabe1 --- /dev/null +++ b/src/App/Adapters/Web/RequestPipeline.php @@ -0,0 +1,54 @@ +stages = $stages; + } + + public function run(RequestContext $context): void + { + foreach ($this->stages as $stage) { + $stage->process($context); + } + } +} diff --git a/src/App/Adapters/Web/WebAppAdapter.php b/src/App/Adapters/Web/WebAppAdapter.php new file mode 100644 index 000000000..f975070d4 --- /dev/null +++ b/src/App/Adapters/Web/WebAppAdapter.php @@ -0,0 +1,71 @@ +context = $context; + + $pipeline = new BootPipeline([ + new LoadHelpersStage(), + new LoadEnvironmentStage(), + new LoadAppConfigStage(), + new SetupErrorHandlerStage(), + new InitHttpStage(), + new InitDebuggerStage(), + new LoadModulesStage(), + ]); + + $pipeline->run($this->context); + } + + public function start(): ?int + { + $pipeline = new RequestPipeline([ + new HandlePreflightStage(), + new ResolveRouteStage(), + new HandleRouteNotFoundStage(), + new PrepareRequestStage(), + new DispatchRequestStage(), + new SendResponseStage(), + ]); + + $pipeline->run(new RequestContext($this->context)); + + return ExitCode::SUCCESS; + } +} diff --git a/src/App/Adapters/WebAppAdapter.php b/src/App/Adapters/WebAppAdapter.php deleted file mode 100644 index 90d533ad2..000000000 --- a/src/App/Adapters/WebAppAdapter.php +++ /dev/null @@ -1,97 +0,0 @@ -run($this->context); - } - - /** - * Starts the web app - * @throws MiddlewareException|LangException|RouteException|CsrfException|ConfigException|DiException|BaseException|ReflectionException - */ - public function start(): ?int - { - if (request()->isMethod('OPTIONS')) { - $this->sendResponse(response()->setStatusCode(StatusCode::NO_CONTENT)); - return ExitCode::SUCCESS; - } - - $matchedRoute = $this->resolveRoute(); - - if ($matchedRoute === null) { - $this->sendResponse(page_not_found_response()); - - return ExitCode::SUCCESS; - } - - $this->loadLanguage(); - - $this->logDebugInfo(); - - $viewCache = $this->setupViewCache(); - - $terminal = fn (Request $request): Response => $viewCache->getCachedResponse($request->getUri() ?? '') - ?? (new RouteDispatcher())->dispatch($matchedRoute, $request); - - $response = (new MiddlewareManager($matchedRoute))->applyMiddlewares(request(), $terminal); - - $this->sendResponse($response); - - return ExitCode::SUCCESS; - } -} diff --git a/src/App/Contracts/ConsoleStageInterface.php b/src/App/Contracts/ConsoleStageInterface.php new file mode 100644 index 000000000..5870bd01d --- /dev/null +++ b/src/App/Contracts/ConsoleStageInterface.php @@ -0,0 +1,25 @@ +getApplication(), + $this->directory(), + $this->namespace(), + ); + } + + private function namespace(): string + { + return '\\Shared\\Commands\\'; + } + + private function directory(): string + { + return base_dir() . DS . 'shared' . DS . 'Commands'; + } +} diff --git a/src/App/Stages/Console/RegisterCoreCommandsStage.php b/src/App/Stages/Console/RegisterCoreCommandsStage.php new file mode 100644 index 000000000..63b9c91c5 --- /dev/null +++ b/src/App/Stages/Console/RegisterCoreCommandsStage.php @@ -0,0 +1,46 @@ +getApplication(), + $this->directory(), + $this->namespace(), + ); + } + + private function namespace(): string + { + return '\\Quantum\\Console\\Commands\\'; + } + + private function directory(): string + { + return framework_dir() . DS . 'Console' . DS . 'Commands'; + } +} diff --git a/src/App/Stages/Console/RunCommandStage.php b/src/App/Stages/Console/RunCommandStage.php new file mode 100644 index 000000000..fb8cc28c9 --- /dev/null +++ b/src/App/Stages/Console/RunCommandStage.php @@ -0,0 +1,41 @@ +getInput()->getFirstArgument(); + + if ($commandName === null || !$context->getApplication()->has($commandName)) { + throw new Exception("Command `$commandName` is not defined"); + } + + $context->setExitCode( + $context->getApplication()->run($context->getInput(), $context->getOutput()) + ); + } +} diff --git a/src/App/Stages/Request/DispatchRequestStage.php b/src/App/Stages/Request/DispatchRequestStage.php new file mode 100644 index 000000000..1781f5624 --- /dev/null +++ b/src/App/Stages/Request/DispatchRequestStage.php @@ -0,0 +1,58 @@ +isStopped()) { + return; + } + + $matchedRoute = $context->getMatchedRoute(); + $viewCache = $context->getViewCache(); + + if ($matchedRoute === null) { + throw new RuntimeException('Matched route is not set.'); + } + + if ($viewCache === null) { + throw new RuntimeException('View cache is not set.'); + } + + $terminal = fn (Request $request): Response => $viewCache->getCachedResponse($request->getUri() ?? '') + ?? (new RouteDispatcher())->dispatch($matchedRoute, $request); + + $context->setResponse((new MiddlewareManager($matchedRoute))->applyMiddlewares(request(), $terminal)); + } +} diff --git a/src/App/Stages/Request/HandlePreflightStage.php b/src/App/Stages/Request/HandlePreflightStage.php new file mode 100644 index 000000000..c99cdd85b --- /dev/null +++ b/src/App/Stages/Request/HandlePreflightStage.php @@ -0,0 +1,35 @@ +isMethod('OPTIONS')) { + return; + } + + $context->setResponse(response()->setStatusCode(StatusCode::NO_CONTENT)); + $context->stop(); + } +} diff --git a/src/App/Stages/Request/HandleRouteNotFoundStage.php b/src/App/Stages/Request/HandleRouteNotFoundStage.php new file mode 100644 index 000000000..a405e11d4 --- /dev/null +++ b/src/App/Stages/Request/HandleRouteNotFoundStage.php @@ -0,0 +1,39 @@ +isStopped() || $context->getMatchedRoute() !== null) { + return; + } + + $context->setResponse(page_not_found_response()); + $context->stop(); + } +} diff --git a/src/App/Stages/Request/PrepareRequestStage.php b/src/App/Stages/Request/PrepareRequestStage.php new file mode 100644 index 000000000..ddcfeabc8 --- /dev/null +++ b/src/App/Stages/Request/PrepareRequestStage.php @@ -0,0 +1,79 @@ +isStopped()) { + return; + } + + LangFactory::get(); + + $this->logDebugInfo(); + + $context->setViewCache($this->setupViewCache()); + } + + /** + * @throws DiException|ReflectionException + */ + private function logDebugInfo(): void + { + $debugBar = debugbar(); + + if ($debugBar->isEnabled()) { + $debugBar->addToStoreCell(Debugger::EVENTS, 'info', event()->getRegistered()); + } + } + + /** + * @throws ConfigException|DiException|ReflectionException + */ + private function setupViewCache(): ViewCache + { + if (!Di::isRegistered(ViewCache::class)) { + Di::register(ViewCache::class); + } + + $viewCache = Di::get(ViewCache::class); + + if ($viewCache->isEnabled()) { + $viewCache->setup(); + } + + return $viewCache; + } +} diff --git a/src/App/Stages/Request/ResolveRouteStage.php b/src/App/Stages/Request/ResolveRouteStage.php new file mode 100644 index 000000000..729ce8eca --- /dev/null +++ b/src/App/Stages/Request/ResolveRouteStage.php @@ -0,0 +1,46 @@ +isStopped()) { + return; + } + + $matchedRoute = (new RouteFinder(Di::get(RouteCollection::class)))->find(request()); + + if ($matchedRoute === null) { + return; + } + + request()->setMatchedRoute($matchedRoute); + $context->setMatchedRoute($matchedRoute); + } +} diff --git a/src/App/Stages/Request/SendResponseStage.php b/src/App/Stages/Request/SendResponseStage.php new file mode 100644 index 000000000..d1d615190 --- /dev/null +++ b/src/App/Stages/Request/SendResponseStage.php @@ -0,0 +1,74 @@ +getResponse(); + + if ($response === null) { + throw new RuntimeException('The request has no response to send.'); + } + + $this->handleCors($response); + $response->send(); + } finally { + $this->cleanupRequestContext(); + } + } + + /** + * @throws ConfigException|DiException|ReflectionException + */ + private function handleCors(Response $response): void + { + if (!config()->has('cors')) { + config()->import(new Setup('config', 'cors')); + } + + foreach (config()->get('cors') as $key => $value) { + $response->setHeader($key, (string) $value); + } + } + + /** + * Clears request-scoped state after the response has been sent. + */ + private function cleanupRequestContext(): void + { + request()->setMatchedRoute(null); + request()->flush(); + } +} diff --git a/src/App/Traits/ConsoleAppTrait.php b/src/App/Traits/ConsoleAppTrait.php deleted file mode 100644 index ec28b4592..000000000 --- a/src/App/Traits/ConsoleAppTrait.php +++ /dev/null @@ -1,78 +0,0 @@ -registerCommands($directory, $namespace); - } - - /** - * @throws ReflectionException - */ - private function registerAppCommands(): void - { - $directory = base_dir() . DS . 'shared' . DS . 'Commands'; - $namespace = '\\Shared\\Commands\\'; - - $this->registerCommands($directory, $namespace); - } - - /** - * @throws ReflectionException - */ - private function registerCommands(string $directory, string $namespace): void - { - $commands = CommandDiscovery::discover($directory, $namespace); - - foreach ($commands as $command) { - $instance = new $command['class'](); - if ($instance instanceof Command) { - $this->application->add($instance); - } - } - } - - /** - * @throws Exception - */ - private function validateCommand(): void - { - $commandName = $this->input->getFirstArgument(); - - if ($commandName === null || !$this->application->has($commandName)) { - throw new Exception("Command `$commandName` is not defined"); - } - } -} diff --git a/src/App/Traits/WebAppTrait.php b/src/App/Traits/WebAppTrait.php deleted file mode 100644 index 91cb88663..000000000 --- a/src/App/Traits/WebAppTrait.php +++ /dev/null @@ -1,128 +0,0 @@ -find(request()); - - if ($matchedRoute === null) { - return null; - } - - request()->setMatchedRoute($matchedRoute); - - return $matchedRoute; - } - - /** - * Resolve lang config and current locale before controller hooks run. - * @throws LangException|ConfigException|DiException|ReflectionException - */ - private function loadLanguage(): void - { - LangFactory::get(); - } - - /** - * @throws DiException|ReflectionException - */ - private function logDebugInfo(): void - { - $debugbar = debugbar(); - - if ($debugbar->isEnabled()) { - $debugbar->addToStoreCell(Debugger::EVENTS, 'info', event()->getRegistered()); - } - } - - /** - * @throws ConfigException|DiException|ReflectionException - */ - private function setupViewCache(): ViewCache - { - if (!Di::isRegistered(ViewCache::class)) { - Di::register(ViewCache::class); - } - - $viewCache = Di::get(ViewCache::class); - - if ($viewCache->isEnabled()) { - $viewCache->setup(); - } - - return $viewCache; - } - - /** - * @throws ConfigException|DiException|ReflectionException - */ - private function handleCors(Response $response): void - { - if (!config()->has('cors')) { - config()->import(new Setup('config', 'cors')); - } - - foreach (config()->get('cors') as $key => $value) { - $response->setHeader($key, (string) $value); - } - } - - /** - * @throws ConfigException|DiException|ReflectionException|Exception - */ - private function sendResponse(Response $response): void - { - try { - $this->handleCors($response); - $response->send(); - } finally { - $this->cleanupRequestContext(); - } - } - - /** - * Clears request-scoped state after the response has been sent. - */ - private function cleanupRequestContext(): void - { - request()->setMatchedRoute(null); - request()->flush(); - } -} diff --git a/src/Console/CommandRegistrar.php b/src/Console/CommandRegistrar.php new file mode 100644 index 000000000..f7c9bd975 --- /dev/null +++ b/src/Console/CommandRegistrar.php @@ -0,0 +1,39 @@ +add($instance); + } + } + } +} diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php new file mode 100644 index 000000000..0be1b04a1 --- /dev/null +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -0,0 +1,338 @@ +makePartial(); + $applicationMock->shouldReceive('getName')->andReturn('Qt Console Application'); + $applicationMock->shouldReceive('run')->andReturn(0); + + $this->consoleAppAdapter = Mockery::mock(ConsoleAppAdapter::class) + ->shouldAllowMockingProtectedMethods() + ->makePartial(); + + $this->consoleAppAdapter + ->shouldReceive('createApplication') + ->andReturn($applicationMock); + } + + public function tearDown(): void + { + config()->flush(); + $this->clearAppContext(); + } + + public function testConsoleAppAdapterStartSuccessfully(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $this->consoleAppAdapter->__construct($this->createContext()); + + $result = $this->consoleAppAdapter->start(); + + $this->assertEquals(0, $result); + } + + public function testConsoleAppAdapterReturnsNonZeroExitCode(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $applicationMock = Mockery::mock(Application::class)->makePartial(); + $applicationMock->shouldReceive('getName')->andReturn('Qt Console Application'); + $applicationMock->shouldReceive('run')->andReturn(2); + + $consoleAppAdapter = Mockery::mock(ConsoleAppAdapter::class) + ->shouldAllowMockingProtectedMethods() + ->makePartial(); + + $consoleAppAdapter + ->shouldReceive('createApplication') + ->andReturn($applicationMock); + + $consoleAppAdapter->__construct($this->createContext()); + + $result = $consoleAppAdapter->start(); + + $this->assertEquals(2, $result); + } + + public function testConsoleAppAdapterStartFails(): void + { + $_SERVER['argv'] = ['qt', 'unknown', '--quiet']; + + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->expectException(Exception::class); + + $this->consoleAppAdapter->start(); + } + + public function testConsoleAppAdapterStartFailsWithoutCommandName(): void + { + $_SERVER['argv'] = ['qt']; + + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Command `` is not defined'); + + $this->consoleAppAdapter->start(); + } + + public function testConsoleAppAdapterStartRegistersCoreCommands(): void + { + $directory = PROJECT_ROOT . DS . 'vendor' . DS . 'quantum' . DS . 'framework' . DS . 'src' . DS . 'Console' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Quantum\Console\Commands', function (string $commandName): void { + $this->assertSame(0, $this->startWithRealApplication($commandName)); + $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); + }); + } + + public function testConsoleAppAdapterStartRegistersAppCommands(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->assertSame(0, $this->startWithRealApplication($commandName)); + $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); + }); + } + + public function testConsoleAppAdapterStartRegistersCoreCommandsBeforeAppCommands(): void + { + $coreDirectory = PROJECT_ROOT . DS . 'vendor' . DS . 'quantum' . DS . 'framework' . DS . 'src' . DS . 'Console' . DS . 'Commands'; + $appDirectory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($coreDirectory, 'Quantum\Console\Commands', function (string $coreCommand) use ($appDirectory): void { + $this->withTemporaryCommand($appDirectory, 'Shared\Commands', function (string $appCommand) use ($coreCommand): void { + $application = new Application('Qt Console Application', 'test'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + + $adapter = Mockery::mock(ConsoleAppAdapter::class) + ->shouldAllowMockingProtectedMethods() + ->makePartial(); + + $adapter->shouldReceive('createApplication')->andReturn($application); + + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $adapter->__construct($this->createContext()); + $adapter->start(); + + $names = array_keys($application->all()); + + $this->assertContains($coreCommand, $names); + $this->assertContains($appCommand, $names); + $this->assertLessThan(array_search($appCommand, $names, true), array_search($coreCommand, $names, true)); + }); + }); + } + + public function testConsoleAppAdapterStartLetsAppCommandOverrideCoreCommandWithSameName(): void + { + $coreDirectory = PROJECT_ROOT . DS . 'vendor' . DS . 'quantum' . DS . 'framework' . DS . 'src' . DS . 'Console' . DS . 'Commands'; + $appDirectory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($coreDirectory, 'Quantum\Console\Commands', function (string $sharedName) use ($appDirectory): void { + $this->withTemporaryCommand($appDirectory, 'Shared\Commands', function (string $commandName): void { + $this->assertSame(0, $this->startWithRealApplication($commandName)); + $this->assertSame('Shared\Commands', $GLOBALS['tmpCommandSource'] ?? null); + }, $sharedName); + }); + } + + public function testConsoleAppAdapterStartWorksWithoutAppCommandsDirectory(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + $hidden = PROJECT_ROOT . DS . 'shared' . DS . 'Commands_hidden'; + + $this->assertTrue(rename($directory, $hidden)); + + try { + $this->assertSame(0, $this->startWithRealApplication('list')); + } finally { + rename($hidden, $directory); + } + } + + public function testConsoleAppAdapterStartRegistersOnlyInstantiableCommands(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $before = $this->registeredCommandNames(); + + $plain = 'TmpPlain' . substr(md5(uniqid('', true)), 0, 8); + $abstract = 'TmpAbstract' . substr(md5(uniqid('', true)), 0, 8); + + $plainCode = "withTemporaryFile($directory, $plain, $plainCode, function () use ($directory, $abstract, $abstractCode, $before): void { + $this->withTemporaryFile($directory, $abstract, $abstractCode, function () use ($before): void { + $this->assertSame($before, $this->registeredCommandNames()); + }); + }); + } + + public function testConsoleAppAdapterBootsAllStagesForRegularCommands(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->assertTrue(Di::has(Environment::class)); + $this->assertTrue(Di::isRegistered(ErrorHandler::class)); + $this->assertTrue(config()->has('app')); + } + + public function testConsoleAppAdapterBootsReducedStagesForCoreEnvCommand(): void + { + $_SERVER['argv'] = ['qt', 'core:env']; + + $this->consoleAppAdapter->__construct($this->createContext()); + + $this->assertFalse(Di::has(Environment::class)); + $this->assertFalse(Di::isRegistered(ErrorHandler::class)); + $this->assertFalse(config()->has('app')); + } + + /** + * Starts the adapter with a real Symfony Application (no auto exit, no exception catching), + * so the registered command is really executed and its exit code comes from Symfony. + */ + private function startWithRealApplication(string $commandName): ?int + { + $application = new Application('Qt Console Application', 'test'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + + $adapter = Mockery::mock(ConsoleAppAdapter::class) + ->shouldAllowMockingProtectedMethods() + ->makePartial(); + + $adapter->shouldReceive('createApplication')->andReturn($application); + + $_SERVER['argv'] = ['qt', $commandName, '--quiet']; + + $adapter->__construct($this->createContext()); + + return $adapter->start(); + } + + /** + * Returns the names of the commands that start() leaves registered on a real Symfony Application. + * @return array + */ + private function registeredCommandNames(): array + { + $application = new Application('Qt Console Application', 'test'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + + $adapter = Mockery::mock(ConsoleAppAdapter::class) + ->shouldAllowMockingProtectedMethods() + ->makePartial(); + + $adapter->shouldReceive('createApplication')->andReturn($application); + + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $adapter->__construct($this->createContext()); + $adapter->start(); + + return array_keys($application->all()); + } + + /** + * Creates a temporary command class in the given directory, loads it, runs the test + * with the command name, and removes the file and any directories it had to create. + * The command records that it ran, and from which namespace, in $GLOBALS['tmpCommandRan'] and + * $GLOBALS['tmpCommandSource']. A fixed command name creates two commands with the same name. + */ + private function withTemporaryCommand(string $directory, string $namespace, callable $test, ?string $name = null): void + { + $suffix = substr(md5(uniqid('', true)), 0, 8); + $className = 'TmpCommand' . $suffix; + $commandName = $name ?? 'tmp:command' . $suffix; + + $command = <<withTemporaryFile($directory, $className, $command, function () use ($test, $commandName): void { + $test($commandName); + }); + } + + /** + * Writes a temporary PHP file named after the class into the directory, loads it, runs the test, + * and removes the file and any directories it had to create. + */ + private function withTemporaryFile(string $directory, string $className, string $code, callable $test): void + { + $file = $directory . DS . $className . '.php'; + + $missing = []; + + for ($dir = $directory; !is_dir($dir); $dir = dirname($dir)) { + array_unshift($missing, $dir); + } + + try { + if ($missing !== []) { + $this->assertTrue(mkdir($directory, 0777, true)); + } + + $this->assertNotFalse(file_put_contents($file, $code)); + + require_once $file; + + $test(); + } finally { + if (is_file($file)) { + unlink($file); + } + + foreach (array_reverse($missing) as $dir) { + if (is_dir($dir)) { + rmdir($dir); + } + } + + unset($GLOBALS['tmpCommandRan'], $GLOBALS['tmpCommandSource']); + } + } +} diff --git a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php new file mode 100644 index 000000000..ece86dae0 --- /dev/null +++ b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php @@ -0,0 +1,61 @@ +appContext = App::getContext(); + $this->application = new Application(); + $this->input = new ArrayInput([]); + $this->output = new BufferedOutput(); + + $this->consoleContext = new ConsoleContext($this->appContext, $this->application, $this->input, $this->output); + } + + public function testConsoleContextExposesAppContext(): void + { + $this->assertSame($this->appContext, $this->consoleContext->getAppContext()); + } + + public function testConsoleContextExposesApplicationInputAndOutput(): void + { + $this->assertSame($this->application, $this->consoleContext->getApplication()); + $this->assertSame($this->input, $this->consoleContext->getInput()); + $this->assertSame($this->output, $this->consoleContext->getOutput()); + } + + public function testConsoleContextDefaultsExitCodeToSuccess(): void + { + $this->assertSame(ExitCode::SUCCESS, $this->consoleContext->getExitCode()); + } + + public function testConsoleContextStoresExitCode(): void + { + $this->consoleContext->setExitCode(ExitCode::INVALID); + + $this->assertSame(ExitCode::INVALID, $this->consoleContext->getExitCode()); + } +} diff --git a/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php b/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php new file mode 100644 index 000000000..2eda00e96 --- /dev/null +++ b/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php @@ -0,0 +1,169 @@ +consoleContext = new ConsoleContext( + $this->createContext(), + new Application(), + new ArrayInput([]), + new BufferedOutput() + ); + } + + public function tearDown(): void + { + $this->clearAppContext(); + } + + public function testPipelineRunsStagesInOrder(): void + { + $log = []; + + $pipeline = new ConsolePipeline([ + $this->createStage(function () use (&$log): void { + $log[] = 'first'; + }), + $this->createStage(function () use (&$log): void { + $log[] = 'second'; + }), + $this->createStage(function () use (&$log): void { + $log[] = 'third'; + }), + ]); + + $pipeline->run($this->consoleContext); + + $this->assertSame(['first', 'second', 'third'], $log); + } + + public function testEmptyPipelineRunsWithoutError(): void + { + $pipeline = new ConsolePipeline([]); + + $pipeline->run($this->consoleContext); + + $this->assertSame(ExitCode::SUCCESS, $this->consoleContext->getExitCode()); + } + + public function testPipelinePassesContextToStages(): void + { + $received = null; + + $pipeline = new ConsolePipeline([ + $this->createStage(function (ConsoleContext $context) use (&$received): void { + $received = $context; + }), + ]); + + $pipeline->run($this->consoleContext); + + $this->assertSame($this->consoleContext, $received); + } + + public function testPipelineCarriesExitCodeBetweenStages(): void + { + $seen = null; + + $pipeline = new ConsolePipeline([ + $this->createStage(function (ConsoleContext $context): void { + $context->setExitCode(ExitCode::FAILURE); + }), + $this->createStage(function (ConsoleContext $context) use (&$seen): void { + $seen = $context->getExitCode(); + }), + ]); + + $pipeline->run($this->consoleContext); + + $this->assertSame(ExitCode::FAILURE, $seen); + $this->assertSame(ExitCode::FAILURE, $this->consoleContext->getExitCode()); + } + + public function testPipelinePropagatesException(): void + { + $pipeline = new ConsolePipeline([ + $this->createStage(function (): void { + throw new RuntimeException('Stage failed'); + }), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stage failed'); + + $pipeline->run($this->consoleContext); + } + + public function testPipelineRejectsInvalidStage(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All stages must implement'); + + new ConsolePipeline([new \stdClass()]); + } + + public function testPipelineRejectsBootStage(): void + { + $bootStage = new class () implements BootStageInterface { + public function process(AppContext $context): void + { + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All stages must implement'); + + new ConsolePipeline([$bootStage]); + } + + public function testPipelineRejectsRequestStage(): void + { + $requestStage = new class () implements RequestStageInterface { + public function process(RequestContext $context): void + { + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All stages must implement'); + + new ConsolePipeline([$requestStage]); + } + + private function createStage(callable $callback): ConsoleStageInterface + { + return new class ($callback) implements ConsoleStageInterface { + private $callback; + + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function process(ConsoleContext $context): void + { + ($this->callback)($context); + } + }; + } +} diff --git a/tests/Unit/App/Adapters/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/ConsoleAppAdapterTest.php deleted file mode 100644 index 7107e1b29..000000000 --- a/tests/Unit/App/Adapters/ConsoleAppAdapterTest.php +++ /dev/null @@ -1,80 +0,0 @@ -makePartial(); - $applicationMock->shouldReceive('getName')->andReturn('Qt Console Application'); - $applicationMock->shouldReceive('run')->andReturn(0); - - $this->consoleAppAdapter = Mockery::mock(ConsoleAppAdapter::class) - ->shouldAllowMockingProtectedMethods() - ->makePartial(); - - $this->consoleAppAdapter - ->shouldReceive('createApplication') - ->andReturn($applicationMock); - } - - public function tearDown(): void - { - config()->flush(); - $this->clearAppContext(); - } - - public function testConsoleAppAdapterStartSuccessfully(): void - { - $_SERVER['argv'] = ['qt', 'list', '--quiet']; - - $this->consoleAppAdapter->__construct($this->createContext()); - - $result = $this->consoleAppAdapter->start(); - - $this->assertEquals(0, $result); - } - - public function testConsoleAppAdapterReturnsNonZeroExitCode(): void - { - $_SERVER['argv'] = ['qt', 'list', '--quiet']; - - $applicationMock = Mockery::mock(Application::class)->makePartial(); - $applicationMock->shouldReceive('getName')->andReturn('Qt Console Application'); - $applicationMock->shouldReceive('run')->andReturn(2); - - $consoleAppAdapter = Mockery::mock(ConsoleAppAdapter::class) - ->shouldAllowMockingProtectedMethods() - ->makePartial(); - - $consoleAppAdapter - ->shouldReceive('createApplication') - ->andReturn($applicationMock); - - $consoleAppAdapter->__construct($this->createContext()); - - $result = $consoleAppAdapter->start(); - - $this->assertEquals(2, $result); - } - - public function testConsoleAppAdapterStartFails(): void - { - $_SERVER['argv'] = ['qt', 'unknown', '--quiet']; - - $this->consoleAppAdapter->__construct($this->createContext()); - - $this->expectException(Exception::class); - - $this->consoleAppAdapter->start(); - } -} diff --git a/tests/Unit/App/Adapters/Web/RequestContextTest.php b/tests/Unit/App/Adapters/Web/RequestContextTest.php new file mode 100644 index 000000000..3dafda209 --- /dev/null +++ b/tests/Unit/App/Adapters/Web/RequestContextTest.php @@ -0,0 +1,79 @@ +appContext = App::getContext(); + $this->requestContext = new RequestContext($this->appContext); + } + + public function testRequestContextExposesAppContext(): void + { + $this->assertSame($this->appContext, $this->requestContext->getAppContext()); + } + + public function testRequestContextStoresMatchedRoute(): void + { + $this->assertNull($this->requestContext->getMatchedRoute()); + + $matchedRoute = new MatchedRoute(new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), []); + + $this->requestContext->setMatchedRoute($matchedRoute); + + $this->assertSame($matchedRoute, $this->requestContext->getMatchedRoute()); + } + + public function testRequestContextStoresResponse(): void + { + $this->assertNull($this->requestContext->getResponse()); + + $this->requestContext->setResponse(response()); + + $this->assertSame(response(), $this->requestContext->getResponse()); + } + + public function testRequestContextStoresViewCache(): void + { + $this->assertNull($this->requestContext->getViewCache()); + + if (!Di::isRegistered(ViewCache::class)) { + Di::register(ViewCache::class); + } + + $viewCache = Di::get(ViewCache::class); + + $this->requestContext->setViewCache($viewCache); + + $this->assertSame($viewCache, $this->requestContext->getViewCache()); + } + + public function testRequestContextIsNotStoppedByDefault(): void + { + $this->assertFalse($this->requestContext->isStopped()); + } + + public function testRequestContextStopMarksContextStopped(): void + { + $this->requestContext->stop(); + + $this->assertTrue($this->requestContext->isStopped()); + } +} diff --git a/tests/Unit/App/Adapters/Web/RequestPipelineTest.php b/tests/Unit/App/Adapters/Web/RequestPipelineTest.php new file mode 100644 index 000000000..dd0e8b925 --- /dev/null +++ b/tests/Unit/App/Adapters/Web/RequestPipelineTest.php @@ -0,0 +1,145 @@ +requestContext = new RequestContext($this->createContext()); + } + + public function tearDown(): void + { + $this->clearAppContext(); + } + + public function testPipelineRunsStagesInOrder(): void + { + $log = []; + + $pipeline = new RequestPipeline([ + $this->createStage(function () use (&$log): void { + $log[] = 'first'; + }), + $this->createStage(function () use (&$log): void { + $log[] = 'second'; + }), + $this->createStage(function () use (&$log): void { + $log[] = 'third'; + }), + ]); + + $pipeline->run($this->requestContext); + + $this->assertSame(['first', 'second', 'third'], $log); + } + + public function testEmptyPipelineRunsWithoutError(): void + { + $pipeline = new RequestPipeline([]); + + $pipeline->run($this->requestContext); + + $this->assertFalse($this->requestContext->isStopped()); + } + + public function testPipelinePassesContextToStages(): void + { + $received = null; + + $pipeline = new RequestPipeline([ + $this->createStage(function (RequestContext $context) use (&$received): void { + $received = $context; + }), + ]); + + $pipeline->run($this->requestContext); + + $this->assertSame($this->requestContext, $received); + } + + public function testPipelinePropagatesException(): void + { + $pipeline = new RequestPipeline([ + $this->createStage(function (): void { + throw new RuntimeException('Stage failed'); + }), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stage failed'); + + $pipeline->run($this->requestContext); + } + + public function testPipelineKeepsRunningStagesAfterContextIsStopped(): void + { + $log = []; + + $pipeline = new RequestPipeline([ + $this->createStage(function (RequestContext $context) use (&$log): void { + $log[] = 'first'; + $context->stop(); + }), + $this->createStage(function () use (&$log): void { + $log[] = 'second'; + }), + ]); + + $pipeline->run($this->requestContext); + + $this->assertSame(['first', 'second'], $log); + $this->assertTrue($this->requestContext->isStopped()); + } + + public function testPipelineRejectsInvalidStage(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All stages must implement'); + + new RequestPipeline([new \stdClass()]); + } + + public function testPipelineRejectsBootStage(): void + { + $bootStage = new class () implements BootStageInterface { + public function process(AppContext $context): void + { + } + }; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('All stages must implement'); + + new RequestPipeline([$bootStage]); + } + + private function createStage(callable $callback): RequestStageInterface + { + return new class ($callback) implements RequestStageInterface { + private $callback; + + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + public function process(RequestContext $context): void + { + ($this->callback)($context); + } + }; + } +} diff --git a/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php b/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php new file mode 100644 index 000000000..9ab09af93 --- /dev/null +++ b/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php @@ -0,0 +1,303 @@ +webAppAdapter = new WebAppAdapter($this->createContext()); + } + + public function tearDown(): void + { + if (Di::isRegistered(Debugger::class)) { + Di::get(Debugger::class)->resetStore(); + } + + config()->flush(); + $this->clearAppContext(); + } + + public function testWebAppAdapterStartSuccessfully(): void + { + request()->create('GET', '/test/am/tests'); + $this->assertFalse(config()->has('lang')); + + ob_start(); + $result = $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertEquals(0, $result); + $this->assertTrue(config()->has('lang')); + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + public function testWebAppAdapterStartFails(): void + { + request()->create('POST', ''); + + ob_start(); + $result = $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertSame(0, $result); + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + public function testWebAppAdapterHandlesPageNotFoundGracefully(): void + { + request()->create('GET', '/non-existing-uri'); + + ob_start(); + $result = $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertSame(0, $result); + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + public function testWebAppAdapterStartHandlesOptionsPreflight(): void + { + request()->create('OPTIONS', '/test/am/tests'); + + ob_start(); + $result = $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertSame(0, $result); + $this->assertSame(StatusCode::NO_CONTENT, response()->getStatusCode()); + $this->assertSame('', response()->getContent()); + $this->assertSame('*', response()->getHeader('Access-Control-Allow-Origin')); + $this->assertSame('*', response()->getHeader('Access-Control-Allow-Methods')); + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + public function testWebAppAdapterStartSkipsLanguageLoadingForPreflight(): void + { + request()->create('OPTIONS', '/test/am/tests'); + + $this->assertFalse(config()->has('lang')); + + ob_start(); + $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertFalse(config()->has('lang')); + } + + public function testWebAppAdapterStartSkipsLanguageLoadingForNotFound(): void + { + request()->create('GET', '/non-existing-uri'); + + $this->assertFalse(config()->has('lang')); + + ob_start(); + $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertFalse(config()->has('lang')); + } + + public function testWebAppAdapterStartRespondsWithJsonNotFoundForJsonRequests(): void + { + request()->create('GET', '/non-existing-uri', [], ['Accept' => 'application/json']); + + ob_start(); + $result = $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertSame(0, $result); + $this->assertSame(StatusCode::NOT_FOUND, response()->getStatusCode()); + $this->assertSame(ContentType::JSON, response()->getContentType()); + $this->assertStringContainsString('Page not found', response()->getContent()); + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + public function testWebAppAdapterStartRespondsWithHtmlNotFoundForOtherRequests(): void + { + request()->create('GET', '/non-existing-uri'); + + ob_start(); + $result = $this->webAppAdapter->start(); + ob_end_clean(); + + $this->assertSame(0, $result); + $this->assertSame(StatusCode::NOT_FOUND, response()->getStatusCode()); + $this->assertSame(ContentType::HTML, response()->getContentType()); + $this->assertNotSame('', response()->getContent()); + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + public function testWebAppAdapterStartSendsMatchedRouteResponse(): void + { + request()->create('GET', '/test/am/tests'); + + ob_start(); + $result = $this->webAppAdapter->start(); + $output = ob_get_clean(); + + $this->assertSame(0, $result); + $this->assertSame(StatusCode::OK, response()->getStatusCode()); + $this->assertSame('', response()->getContent()); + $this->assertSame('', $output); + $this->assertSame('*', response()->getHeader('Access-Control-Allow-Origin')); + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + public function testWebAppAdapterStartServesViewCacheHit(): void + { + request()->create('GET', '/test/am/tests'); + + $uri = request()->getUri(); + + $viewsDir = base_dir() . DS . 'cache' . DS . 'views'; + $moduleDir = $viewsDir . DS . 'test'; + $viewsDirExisted = is_dir($viewsDir); + $moduleDirExisted = is_dir($moduleDir); + + // The view cache directory depends on the current module, which is only known once a route is matched. + request()->setMatchedRoute(new MatchedRoute( + (new Route(['GET'], '/test/am/tests', 'TestController', 'tests'))->module('Test'), + [] + )); + + if (!Di::isRegistered(ViewCache::class)) { + Di::register(ViewCache::class); + } + + $viewCache = Di::get(ViewCache::class); + $viewCache->setup(); + $viewCache->enableCaching(true); + $viewCache->set($uri, '

cached page

'); + + try { + ob_start(); + $result = $this->webAppAdapter->start(); + $output = ob_get_clean(); + + $this->assertSame(0, $result); + $this->assertSame('

cached page

', response()->getContent()); + $this->assertSame('

cached page

', $output); + } finally { + $viewCache->delete($uri); + $viewCache->enableCaching(false); + + if (!$moduleDirExisted && is_dir($moduleDir)) { + rmdir($moduleDir); + } + + if (!$viewsDirExisted && is_dir($viewsDir)) { + rmdir($viewsDir); + } + } + } + + public function testWebAppAdapterStartPropagatesDispatchExceptionWithoutCleanup(): void + { + request()->create('GET', '/test/Test/5'); + + ob_start(); + + try { + $this->webAppAdapter->start(); + $this->fail('Expected the dispatcher to fail for a missing controller action.'); + } catch (RouteException $exception) { + $this->assertInstanceOf(RouteException::class, $exception); + } finally { + ob_end_clean(); + } + + $this->assertNotNull(request()->getMatchedRoute()); + $this->assertNotNull(request()->getUri()); + } + + 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/Adapters/WebAppAdapterTest.php b/tests/Unit/App/Adapters/WebAppAdapterTest.php deleted file mode 100644 index c651d122b..000000000 --- a/tests/Unit/App/Adapters/WebAppAdapterTest.php +++ /dev/null @@ -1,213 +0,0 @@ -webAppAdapter = new WebAppAdapter($this->createContext()); - } - - public function tearDown(): void - { - if (Di::isRegistered(Debugger::class)) { - Di::get(Debugger::class)->resetStore(); - } - - config()->flush(); - $this->clearAppContext(); - } - - public function testWebAppAdapterStartSuccessfully(): void - { - request()->create('GET', '/test/am/tests'); - $this->assertFalse(config()->has('lang')); - - ob_start(); - $result = $this->webAppAdapter->start(); - ob_end_clean(); - - $this->assertEquals(0, $result); - $this->assertTrue(config()->has('lang')); - $this->assertNull(request()->getMatchedRoute()); - $this->assertNull(request()->getUri()); - } - - public function testWebAppAdapterStartFails(): void - { - request()->create('POST', ''); - - ob_start(); - $result = $this->webAppAdapter->start(); - ob_end_clean(); - - $this->assertSame(0, $result); - $this->assertNull(request()->getMatchedRoute()); - $this->assertNull(request()->getUri()); - } - - public function testWebAppAdapterHandlesPageNotFoundGracefully(): void - { - request()->create('GET', '/non-existing-uri'); - - ob_start(); - $result = $this->webAppAdapter->start(); - ob_end_clean(); - - $this->assertSame(0, $result); - $this->assertNull(request()->getMatchedRoute()); - $this->assertNull(request()->getUri()); - } - - public function testWebAppAdapterCleansUpOnException(): void - { - request()->create('GET', '/test/am/tests'); - request()->setMatchedRoute(null); - request()->setMatchedRoute(new \Quantum\Router\MatchedRoute( - new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), - [] - )); - response()->setHeader('X-Test', '1'); - response()->json(['foo' => 'bar']); - - $throwingResponse = new class () extends \Quantum\Http\Response { - public function send(): void - { - throw new HttpException('boom'); - } - }; - - try { - $this->invokePrivateMethod($this->webAppAdapter, 'sendResponse', [$throwingResponse]); - $this->fail('Expected response sending to fail.'); - } catch (Throwable $exception) { - $this->assertInstanceOf(HttpException::class, $exception); - } - - $this->assertNull(request()->getMatchedRoute()); - $this->assertNull(request()->getUri()); - $this->assertSame(['foo' => 'bar'], response()->all()); - $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/AppTest.php b/tests/Unit/App/AppTest.php index fcb480e93..f6e484384 100644 --- a/tests/Unit/App/AppTest.php +++ b/tests/Unit/App/AppTest.php @@ -2,9 +2,9 @@ namespace Quantum\Tests\Unit\App; -use Quantum\App\Adapters\ConsoleAppAdapter; +use Quantum\App\Adapters\Console\ConsoleAppAdapter; use Quantum\App\Exceptions\AppException; -use Quantum\App\Adapters\WebAppAdapter; +use Quantum\App\Adapters\Web\WebAppAdapter; use Quantum\App\Contracts\AppInterface; use Quantum\Tests\Unit\AppTestCase; use Quantum\App\App; diff --git a/tests/Unit/App/BootPipelineTest.php b/tests/Unit/App/BootPipelineTest.php index 03685fcab..eb02b7f82 100644 --- a/tests/Unit/App/BootPipelineTest.php +++ b/tests/Unit/App/BootPipelineTest.php @@ -2,14 +2,14 @@ namespace Quantum\Tests\Unit\App; -use Quantum\App\Stages\SetupErrorHandlerStage; +use Quantum\App\Stages\Boot\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\App\Stages\Boot\LoadEnvironmentStage; +use Quantum\App\Stages\Boot\LoadAppConfigStage; +use Quantum\App\Stages\Boot\InitDebuggerStage; +use Quantum\App\Stages\Boot\LoadModulesStage; +use Quantum\App\Stages\Boot\LoadHelpersStage; +use Quantum\App\Stages\Boot\InitHttpStage; use Quantum\Tests\Unit\AppTestCase; use InvalidArgumentException; use Quantum\App\BootPipeline; diff --git a/tests/Unit/App/Factories/AppFactoryTest.php b/tests/Unit/App/Factories/AppFactoryTest.php index 5a5b657c9..1dc30f9c5 100644 --- a/tests/Unit/App/Factories/AppFactoryTest.php +++ b/tests/Unit/App/Factories/AppFactoryTest.php @@ -2,10 +2,10 @@ namespace Quantum\Tests\Unit\App\Factories; -use Quantum\App\Adapters\ConsoleAppAdapter; +use Quantum\App\Adapters\Console\ConsoleAppAdapter; use Quantum\App\Exceptions\AppException; use Quantum\App\Contracts\AppInterface; -use Quantum\App\Adapters\WebAppAdapter; +use Quantum\App\Adapters\Web\WebAppAdapter; use Quantum\App\Factories\AppFactory; use PHPUnit\Framework\TestCase; use Quantum\App\Enums\AppType; diff --git a/tests/Unit/App/Stages/LoadAppConfigStageTest.php b/tests/Unit/App/Stages/Boot/LoadAppConfigStageTest.php similarity index 84% rename from tests/Unit/App/Stages/LoadAppConfigStageTest.php rename to tests/Unit/App/Stages/Boot/LoadAppConfigStageTest.php index e9deebba2..aa2bb4e26 100644 --- a/tests/Unit/App/Stages/LoadAppConfigStageTest.php +++ b/tests/Unit/App/Stages/Boot/LoadAppConfigStageTest.php @@ -1,10 +1,10 @@ $input + */ + protected function createConsoleContext(array $input = [], ?Application $application = null): ConsoleContext + { + $application ??= new Application(); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + + return new ConsoleContext(App::getContext(), $application, new ArrayInput($input), new BufferedOutput()); + } + + /** + * Creates a temporary command class in the given directory, loads it, runs the test + * with the command name, and removes the file and any directories it had to create. + */ + protected function withTemporaryCommand(string $directory, string $namespace, callable $test): void + { + $suffix = substr(md5(uniqid('', true)), 0, 8); + $className = 'TmpStageCommand' . $suffix; + $commandName = 'tmp:stage' . $suffix; + $file = $directory . DS . $className . '.php'; + + $missing = []; + + for ($dir = $directory; !is_dir($dir); $dir = dirname($dir)) { + array_unshift($missing, $dir); + } + + $command = <<assertTrue(mkdir($directory, 0777, true)); + } + + $this->assertNotFalse(file_put_contents($file, $command)); + + require_once $file; + + $test($commandName); + } finally { + if (is_file($file)) { + unlink($file); + } + + foreach (array_reverse($missing) as $dir) { + if (is_dir($dir)) { + rmdir($dir); + } + } + } + } +} diff --git a/tests/Unit/App/Stages/Console/RegisterAppCommandsStageTest.php b/tests/Unit/App/Stages/Console/RegisterAppCommandsStageTest.php new file mode 100644 index 000000000..0de21f78d --- /dev/null +++ b/tests/Unit/App/Stages/Console/RegisterAppCommandsStageTest.php @@ -0,0 +1,34 @@ +withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $context = $this->createConsoleContext(); + + $this->assertFalse($context->getApplication()->has($commandName)); + + (new RegisterAppCommandsStage())->process($context); + + $this->assertTrue($context->getApplication()->has($commandName)); + }); + } + + public function testRegisterAppCommandsStageSkipsClassesOutsideTheAppNamespace(): void + { + $context = $this->createConsoleContext(); + $commandCount = count($context->getApplication()->all()); + + // tests/_root/shared/Commands holds a fixture in the test namespace, not in Shared\Commands. + (new RegisterAppCommandsStage())->process($context); + + $this->assertCount($commandCount, $context->getApplication()->all()); + } +} diff --git a/tests/Unit/App/Stages/Console/RegisterCoreCommandsStageTest.php b/tests/Unit/App/Stages/Console/RegisterCoreCommandsStageTest.php new file mode 100644 index 000000000..0f97ac12f --- /dev/null +++ b/tests/Unit/App/Stages/Console/RegisterCoreCommandsStageTest.php @@ -0,0 +1,35 @@ +withTemporaryCommand($directory, 'Quantum\Console\Commands', function (string $commandName): void { + $context = $this->createConsoleContext(); + + $this->assertFalse($context->getApplication()->has($commandName)); + + (new RegisterCoreCommandsStage())->process($context); + + $this->assertTrue($context->getApplication()->has($commandName)); + }); + } + + public function testRegisterCoreCommandsStageIgnoresMissingDirectory(): void + { + $this->assertDirectoryDoesNotExist(framework_dir() . DS . 'Console' . DS . 'Commands'); + + $context = $this->createConsoleContext(); + $commandCount = count($context->getApplication()->all()); + + (new RegisterCoreCommandsStage())->process($context); + + $this->assertCount($commandCount, $context->getApplication()->all()); + } +} diff --git a/tests/Unit/App/Stages/Console/RunCommandStageTest.php b/tests/Unit/App/Stages/Console/RunCommandStageTest.php new file mode 100644 index 000000000..c823b5101 --- /dev/null +++ b/tests/Unit/App/Stages/Console/RunCommandStageTest.php @@ -0,0 +1,100 @@ +createConsoleContext(['command' => 'run:ok'], $this->createApplication('run:ok', 0)); + + (new RunCommandStage())->process($context); + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + } + + public function testRunCommandStageStoresNonZeroExitCode(): void + { + $context = $this->createConsoleContext(['command' => 'run:fail'], $this->createApplication('run:fail', 3)); + + (new RunCommandStage())->process($context); + + $this->assertSame(3, $context->getExitCode()); + } + + public function testRunCommandStageRunsTheCommandWithTheContextOutput(): void + { + $context = $this->createConsoleContext(['command' => 'run:ok'], $this->createApplication('run:ok', 0)); + + (new RunCommandStage())->process($context); + + $this->assertStringContainsString('hello from run:ok', $context->getOutput()->fetch()); + } + + public function testRunCommandStageRejectsUnknownCommand(): void + { + $context = $this->createConsoleContext(['command' => 'unknown:command'], $this->createApplication('run:ok', 0)); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Command `unknown:command` is not defined'); + + (new RunCommandStage())->process($context); + } + + public function testRunCommandStageRejectsMissingCommandName(): void + { + $context = $this->createConsoleContext([], $this->createApplication('run:ok', 0)); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Command `` is not defined'); + + (new RunCommandStage())->process($context); + } + + public function testRunCommandStageRunsNothingWhenTheCommandNameIsMissing(): void + { + $context = $this->createConsoleContext([], $this->createApplication('run:fail', 3)); + + try { + (new RunCommandStage())->process($context); + } catch (Exception $e) { + // expected, the command name is missing + } + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + $this->assertSame('', $context->getOutput()->fetch()); + } + + private function createApplication(string $commandName, int $exitCode): Application + { + $application = new Application(); + + $application->add( + (new Command($commandName))->setCode(function (InputInterface $input, OutputInterface $output) use ($commandName, $exitCode): int { + $output->writeln('hello from ' . $commandName); + + return $exitCode; + }) + ); + + return $application; + } +} diff --git a/tests/Unit/App/Stages/Request/DispatchRequestStageTest.php b/tests/Unit/App/Stages/Request/DispatchRequestStageTest.php new file mode 100644 index 000000000..0462b3e5a --- /dev/null +++ b/tests/Unit/App/Stages/Request/DispatchRequestStageTest.php @@ -0,0 +1,206 @@ +json(['status' => 'blocked']); + } + } +} + +namespace Quantum\Tests\Unit\App\Stages\Request { + use Quantum\Tests\_root\modules\Test\Middlewares\DispatchStageMwPassThrough; + use Quantum\App\Stages\Request\DispatchRequestStage; + use Quantum\App\Stages\Request\ResolveRouteStage; + use Quantum\Router\Exceptions\RouteException; + use Quantum\Tests\Unit\AppTestCase; + use Quantum\ResourceCache\ViewCache; + use Quantum\App\Adapters\Web\RequestContext; + use Quantum\Router\MatchedRoute; + use Quantum\Http\Enums\StatusCode; + use Quantum\Router\Route; + use Quantum\App\App; + use RuntimeException; + use Quantum\Di\Di; + + class DispatchRequestStageTest extends AppTestCase + { + private RequestContext $requestContext; + + public function setUp(): void + { + parent::setUp(); + + $this->requestContext = new RequestContext(App::getContext()); + + DispatchStageMwPassThrough::$calls = []; + } + + public function testDispatchRequestStageDispatchesMatchedRoute(): void + { + $this->prepareRequest('/test/am/tests'); + + (new DispatchRequestStage())->process($this->requestContext); + + $this->assertSame(response(), $this->requestContext->getResponse()); + $this->assertSame(StatusCode::OK, $this->requestContext->getResponse()->getStatusCode()); + $this->assertSame('', $this->requestContext->getResponse()->getContent()); + } + + public function testDispatchRequestStageServesViewCacheHit(): void + { + $this->prepareRequest('/test/am/tests'); + + $uri = request()->getUri(); + $viewsDir = base_dir() . DS . 'cache' . DS . 'views'; + $moduleDir = $viewsDir . DS . 'test'; + $viewsDirExisted = is_dir($viewsDir); + $moduleDirExisted = is_dir($moduleDir); + + $viewCache = $this->requestContext->getViewCache(); + $viewCache->setup(); + $viewCache->enableCaching(true); + $viewCache->set($uri, '

cached page

'); + + try { + (new DispatchRequestStage())->process($this->requestContext); + + $this->assertSame('

cached page

', $this->requestContext->getResponse()->getContent()); + } finally { + $viewCache->delete($uri); + $viewCache->enableCaching(false); + + if (!$moduleDirExisted && is_dir($moduleDir)) { + rmdir($moduleDir); + } + + if (!$viewsDirExisted && is_dir($viewsDir)) { + rmdir($viewsDir); + } + } + } + + public function testDispatchRequestStageRunsModuleMiddlewaresBeforeController(): void + { + $this->prepareRequest('/test/am/tests'); + + $this->requestContext->getMatchedRoute()->getRoute()->addMiddlewares(['DispatchStageMwPassThrough']); + + (new DispatchRequestStage())->process($this->requestContext); + + $this->assertSame(['DispatchStageMwPassThrough'], DispatchStageMwPassThrough::$calls); + $this->assertSame(StatusCode::OK, $this->requestContext->getResponse()->getStatusCode()); + } + + public function testDispatchRequestStageLetsMiddlewareShortCircuit(): void + { + $this->prepareRequest('/test/am/tests'); + + // The controller does not exist, so reaching the dispatcher would throw. + $matchedRoute = new MatchedRoute($this->createRoute('tests', ['DispatchStageMwBlocker'], 'MissingController'), []); + + $this->requestContext->setMatchedRoute($matchedRoute); + request()->setMatchedRoute($matchedRoute); + + (new DispatchRequestStage())->process($this->requestContext); + + $this->assertStringContainsString('blocked', $this->requestContext->getResponse()->getContent()); + } + + public function testDispatchRequestStagePropagatesDispatchException(): void + { + $this->prepareRequest('/test/Test/5'); + + try { + (new DispatchRequestStage())->process($this->requestContext); + $this->fail('Expected the dispatcher to fail for a missing controller action.'); + } catch (RouteException $exception) { + $this->assertInstanceOf(RouteException::class, $exception); + } + + $this->assertNull($this->requestContext->getResponse()); + } + + public function testDispatchRequestStageFailsWithoutMatchedRoute(): void + { + $this->requestContext->setViewCache($this->viewCache()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Matched route is not set.'); + + (new DispatchRequestStage())->process($this->requestContext); + } + + public function testDispatchRequestStageFailsWithoutViewCache(): void + { + $this->requestContext->setMatchedRoute(new MatchedRoute($this->createRoute('tests'), [])); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('View cache is not set.'); + + (new DispatchRequestStage())->process($this->requestContext); + } + + public function testDispatchRequestStageSkipsWhenContextIsStopped(): void + { + $this->prepareRequest('/test/am/tests'); + + $this->requestContext->stop(); + + (new DispatchRequestStage())->process($this->requestContext); + + $this->assertNull($this->requestContext->getResponse()); + } + + private function prepareRequest(string $uri): void + { + request()->create('GET', $uri); + + (new ResolveRouteStage())->process($this->requestContext); + + $this->requestContext->setViewCache($this->viewCache()); + } + + private function viewCache(): ViewCache + { + if (!Di::isRegistered(ViewCache::class)) { + Di::register(ViewCache::class); + } + + return Di::get(ViewCache::class); + } + + /** + * @param string[] $middlewares + */ + private function createRoute(string $action, array $middlewares = [], string $controller = 'TestController'): Route + { + $route = (new Route(['GET'], '/test/am/tests', $controller, $action))->module('Test'); + + if ($middlewares !== []) { + $route->addMiddlewares($middlewares); + } + + return $route; + } + } +} diff --git a/tests/Unit/App/Stages/Request/HandlePreflightStageTest.php b/tests/Unit/App/Stages/Request/HandlePreflightStageTest.php new file mode 100644 index 000000000..98e3854e7 --- /dev/null +++ b/tests/Unit/App/Stages/Request/HandlePreflightStageTest.php @@ -0,0 +1,42 @@ +requestContext = new RequestContext(App::getContext()); + } + + public function testHandlePreflightStageRespondsNoContentToOptionsRequest(): void + { + request()->create('OPTIONS', '/test/am/tests'); + + (new HandlePreflightStage())->process($this->requestContext); + + $this->assertSame(response(), $this->requestContext->getResponse()); + $this->assertSame(StatusCode::NO_CONTENT, $this->requestContext->getResponse()->getStatusCode()); + $this->assertTrue($this->requestContext->isStopped()); + } + + public function testHandlePreflightStageIgnoresOtherMethods(): void + { + request()->create('GET', '/test/am/tests'); + + (new HandlePreflightStage())->process($this->requestContext); + + $this->assertNull($this->requestContext->getResponse()); + $this->assertFalse($this->requestContext->isStopped()); + } +} diff --git a/tests/Unit/App/Stages/Request/HandleRouteNotFoundStageTest.php b/tests/Unit/App/Stages/Request/HandleRouteNotFoundStageTest.php new file mode 100644 index 000000000..7d2a3b721 --- /dev/null +++ b/tests/Unit/App/Stages/Request/HandleRouteNotFoundStageTest.php @@ -0,0 +1,71 @@ +requestContext = new RequestContext(App::getContext()); + } + + public function testHandleRouteNotFoundStageRespondsNotFoundWhenNoRouteMatched(): void + { + request()->create('GET', '/non-existing-uri'); + + (new HandleRouteNotFoundStage())->process($this->requestContext); + + $this->assertSame(response(), $this->requestContext->getResponse()); + $this->assertSame(StatusCode::NOT_FOUND, $this->requestContext->getResponse()->getStatusCode()); + $this->assertSame(ContentType::HTML, $this->requestContext->getResponse()->getContentType()); + $this->assertTrue($this->requestContext->isStopped()); + } + + public function testHandleRouteNotFoundStageRespondsWithJsonForJsonRequests(): void + { + request()->create('GET', '/non-existing-uri', [], ['Accept' => 'application/json']); + + (new HandleRouteNotFoundStage())->process($this->requestContext); + + $this->assertSame(StatusCode::NOT_FOUND, $this->requestContext->getResponse()->getStatusCode()); + $this->assertSame(ContentType::JSON, $this->requestContext->getResponse()->getContentType()); + $this->assertStringContainsString('Page not found', $this->requestContext->getResponse()->getContent()); + $this->assertTrue($this->requestContext->isStopped()); + } + + public function testHandleRouteNotFoundStageIgnoresMatchedRoute(): void + { + $this->requestContext->setMatchedRoute( + new MatchedRoute(new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), []) + ); + + (new HandleRouteNotFoundStage())->process($this->requestContext); + + $this->assertNull($this->requestContext->getResponse()); + $this->assertFalse($this->requestContext->isStopped()); + } + + public function testHandleRouteNotFoundStageSkipsWhenContextIsStopped(): void + { + request()->create('GET', '/non-existing-uri'); + + $this->requestContext->stop(); + + (new HandleRouteNotFoundStage())->process($this->requestContext); + + $this->assertNull($this->requestContext->getResponse()); + } +} diff --git a/tests/Unit/App/Stages/Request/PrepareRequestStageTest.php b/tests/Unit/App/Stages/Request/PrepareRequestStageTest.php new file mode 100644 index 000000000..1331d6f34 --- /dev/null +++ b/tests/Unit/App/Stages/Request/PrepareRequestStageTest.php @@ -0,0 +1,135 @@ +requestContext = new RequestContext(App::getContext()); + + request()->create('GET', '/test/am/tests'); + + request()->setMatchedRoute(new MatchedRoute( + (new Route(['GET'], '/test/am/tests', 'TestController', 'tests'))->module('Test'), + [] + )); + } + + public function testPrepareRequestStageLoadsLanguage(): void + { + $this->assertFalse(config()->has('lang')); + + (new PrepareRequestStage())->process($this->requestContext); + + $this->assertTrue(config()->has('lang')); + } + + public function testPrepareRequestStageLogsRegisteredEventsToDebugger(): void + { + config()->set('app.debug', true); + + $listener = function (): void { + }; + + event()->listen('test.event', $listener); + + (new PrepareRequestStage())->process($this->requestContext); + + $cell = debugbar()->getStoreCell(Debugger::EVENTS); + + $this->assertCount(1, $cell); + $this->assertSame(['test.event' => [$listener]], $cell[0]['info']); + } + + public function testPrepareRequestStageLogsNothingToDebuggerWithoutEventListeners(): void + { + config()->set('app.debug', true); + + $this->assertSame([], event()->getRegistered()); + + (new PrepareRequestStage())->process($this->requestContext); + + $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); + } + + public function testPrepareRequestStageSkipsDebuggerLoggingWhenDebugDisabled(): void + { + config()->set('app.debug', false); + + event()->listen('test.event', function (): void { + }); + + (new PrepareRequestStage())->process($this->requestContext); + + $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); + } + + public function testPrepareRequestStageStoresViewCacheOnContext(): void + { + $this->assertNull($this->requestContext->getViewCache()); + + (new PrepareRequestStage())->process($this->requestContext); + + $this->assertInstanceOf(ViewCache::class, $this->requestContext->getViewCache()); + $this->assertSame(Di::get(ViewCache::class), $this->requestContext->getViewCache()); + } + + public function testPrepareRequestStageSetsUpViewCacheWhenEnabled(): void + { + $viewsDir = base_dir() . DS . 'cache' . DS . 'views'; + $moduleDir = $viewsDir . DS . 'test'; + $viewsDirExisted = is_dir($viewsDir); + + $this->assertFalse(config()->has('view_cache')); + + Di::register(ViewCache::class); + Di::get(ViewCache::class)->enableCaching(true); + + try { + (new PrepareRequestStage())->process($this->requestContext); + + $this->assertTrue(config()->has('view_cache')); + $this->assertDirectoryExists($moduleDir); + } finally { + if (is_dir($moduleDir)) { + rmdir($moduleDir); + } + + if (!$viewsDirExisted && is_dir($viewsDir)) { + rmdir($viewsDir); + } + } + } + + public function testPrepareRequestStageDoesNotSetUpViewCacheWhenDisabled(): void + { + (new PrepareRequestStage())->process($this->requestContext); + + $this->assertFalse(config()->has('view_cache')); + } + + public function testPrepareRequestStageSkipsWhenContextIsStopped(): void + { + $this->requestContext->stop(); + + (new PrepareRequestStage())->process($this->requestContext); + + $this->assertFalse(config()->has('lang')); + $this->assertNull($this->requestContext->getViewCache()); + } +} diff --git a/tests/Unit/App/Stages/Request/ResolveRouteStageTest.php b/tests/Unit/App/Stages/Request/ResolveRouteStageTest.php new file mode 100644 index 000000000..085e852d3 --- /dev/null +++ b/tests/Unit/App/Stages/Request/ResolveRouteStageTest.php @@ -0,0 +1,55 @@ +requestContext = new RequestContext(App::getContext()); + } + + public function testResolveRouteStageStoresMatchedRoute(): void + { + request()->create('GET', '/test/am/tests'); + + (new ResolveRouteStage())->process($this->requestContext); + + $this->assertNotNull($this->requestContext->getMatchedRoute()); + $this->assertSame('tests', $this->requestContext->getMatchedRoute()->getRoute()->getAction()); + $this->assertSame($this->requestContext->getMatchedRoute(), request()->getMatchedRoute()); + $this->assertFalse($this->requestContext->isStopped()); + } + + public function testResolveRouteStageLeavesContextEmptyWhenNoRouteMatches(): void + { + request()->create('GET', '/non-existing-uri'); + + (new ResolveRouteStage())->process($this->requestContext); + + $this->assertNull($this->requestContext->getMatchedRoute()); + $this->assertNull(request()->getMatchedRoute()); + $this->assertFalse($this->requestContext->isStopped()); + } + + public function testResolveRouteStageSkipsWhenContextIsStopped(): void + { + request()->create('GET', '/test/am/tests'); + + $this->requestContext->stop(); + + (new ResolveRouteStage())->process($this->requestContext); + + $this->assertNull($this->requestContext->getMatchedRoute()); + $this->assertNull(request()->getMatchedRoute()); + } +} diff --git a/tests/Unit/App/Stages/Request/SendResponseStageTest.php b/tests/Unit/App/Stages/Request/SendResponseStageTest.php new file mode 100644 index 000000000..9e607d07c --- /dev/null +++ b/tests/Unit/App/Stages/Request/SendResponseStageTest.php @@ -0,0 +1,118 @@ +requestContext = new RequestContext(App::getContext()); + } + + public function testSendResponseStageSendsTheResponse(): void + { + $this->requestContext->setResponse(response()->html('

hello

')); + + ob_start(); + (new SendResponseStage())->process($this->requestContext); + $output = ob_get_clean(); + + $this->assertSame('

hello

', $output); + } + + public function testSendResponseStageAppliesCorsHeaders(): void + { + $this->assertFalse(config()->has('cors')); + + $this->requestContext->setResponse(response()->html('')); + + ob_start(); + (new SendResponseStage())->process($this->requestContext); + ob_end_clean(); + + $this->assertTrue(config()->has('cors')); + $this->assertSame('*', response()->getHeader('Access-Control-Allow-Origin')); + $this->assertSame('*', response()->getHeader('Access-Control-Allow-Methods')); + } + + public function testSendResponseStageCleansUpRequestAfterSending(): void + { + $this->prepareMatchedRequest(); + + $this->requestContext->setResponse(response()->html('')); + + ob_start(); + (new SendResponseStage())->process($this->requestContext); + ob_end_clean(); + + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + public function testSendResponseStageCleansUpRequestWhenSendFails(): void + { + $this->prepareMatchedRequest(); + + response()->setHeader('X-Test', '1'); + response()->json(['foo' => 'bar']); + + $this->requestContext->setResponse(new class () extends Response { + public function send(): void + { + throw new HttpException('boom'); + } + }); + + try { + (new SendResponseStage())->process($this->requestContext); + $this->fail('Expected response sending to fail.'); + } catch (HttpException $exception) { + $this->assertSame('boom', $exception->getMessage()); + } + + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + $this->assertSame(['foo' => 'bar'], response()->all()); + $this->assertSame('1', response()->getHeader('X-Test')); + $this->assertSame(200, response()->getStatusCode()); + } + + public function testSendResponseStageFailsAndCleansUpWithoutResponse(): void + { + $this->prepareMatchedRequest(); + + try { + (new SendResponseStage())->process($this->requestContext); + $this->fail('Expected the stage to fail without a response.'); + } catch (RuntimeException $exception) { + $this->assertSame('The request has no response to send.', $exception->getMessage()); + } + + $this->assertNull(request()->getMatchedRoute()); + $this->assertNull(request()->getUri()); + } + + private function prepareMatchedRequest(): void + { + request()->create('GET', '/test/am/tests'); + + request()->setMatchedRoute(new MatchedRoute( + new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), + [] + )); + } +} diff --git a/tests/Unit/Console/CommandRegistrarTest.php b/tests/Unit/Console/CommandRegistrarTest.php new file mode 100644 index 000000000..6fb0483fe --- /dev/null +++ b/tests/Unit/Console/CommandRegistrarTest.php @@ -0,0 +1,66 @@ +application = new Application('Qt Console Application', 'test'); + + $this->directory = base_dir() . DS . 'shared' . DS . 'Commands'; + } + + public function testCommandRegistrarAddsDiscoveredCommands(): void + { + $this->assertFalse($this->application->has('test:dummy')); + + CommandRegistrar::register($this->application, $this->directory, $this->namespace); + + $this->assertTrue($this->application->has('test:dummy')); + $this->assertInstanceOf(TestCommand::class, $this->application->get('test:dummy')); + } + + public function testCommandRegistrarIgnoresMissingDirectory(): void + { + $before = array_keys($this->application->all()); + + CommandRegistrar::register($this->application, $this->directory . DS . 'missing', $this->namespace); + + $this->assertSame($before, array_keys($this->application->all())); + } + + public function testCommandRegistrarIgnoresClassesOutsideTheNamespace(): void + { + $before = array_keys($this->application->all()); + + CommandRegistrar::register($this->application, $this->directory, '\\Some\\Other\\Namespace\\'); + + $this->assertSame($before, array_keys($this->application->all())); + } + + public function testCommandRegistrarReplacesAnExistingCommandWithTheSameName(): void + { + $this->application->add(new Command('test:dummy')); + + $this->assertNotInstanceOf(TestCommand::class, $this->application->get('test:dummy')); + + CommandRegistrar::register($this->application, $this->directory, $this->namespace); + + $this->assertInstanceOf(TestCommand::class, $this->application->get('test:dummy')); + } +}