diff --git a/CHANGELOG.md b/CHANGELOG.md index 821a0ea4..f705fe9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,24 +12,16 @@ 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:** Replaced the boot, web and console pipelines with one generic `Pipeline` (#583): - - `Pipeline` runs `StageInterface` stages in order and dispatches the `BEFORE`/`AFTER` events they declare. The context type is a PHPDoc template checked by PHPStan: a stage states it with `@implements StageInterface` (or `WebContext`, `ConsoleContext`) and takes `process(AppContext $context)`. - - Removed `BootPipeline` and `BootStageInterface`: build a `new Pipeline([...])`, and implement `StageInterface` with `@implements StageInterface` in a boot stage. `WebPipeline`, `ConsolePipeline`, `WebStageInterface` and `ConsoleStageInterface`, added earlier in 3.1.0, are removed too. - - A pipeline still rejects anything that is not a `StageInterface`, but no longer rejects a stage of another kind at runtime (a boot stage in a web pipeline); PHPStan reports it instead. -- **BREAKING:** The web and console contexts now extend `AppContext` (#579): - - `AppContext` keeps only what both the web and the console boot provide: `getBaseDir()`, `getContainer()`, `getEnvironment()` and `getConfig()`. Removed `AppContext::getRequest()`, `getResponse()` and `getRoutes()`, which only the web boot can serve: use `WebContext::getRequest()` and `WebContext::getRoutes()`, and `response()` for the shared response. - - `WebContext` and `ConsoleContext` extend `AppContext` and are built from it with the same base directory and container; `WebContext::getAppContext()` is removed. Like `ConsoleContext`, `WebContext` reads its services from the container (the request, the routes and the view cache) and the matched route from the request, and stores only what the run produces: the response that is sent and the stopped flag. `WebContext::setMatchedRoute()` and `setViewCache()` are removed. - - The web stages read the request, the routes and the config from the context instead of the global `request()` and `config()` helpers, as the console stages do. They are the same objects from the same container, so behavior is unchanged. -- **BREAKING:** The console adapter constructor is now only the boot pipeline, like the web adapter (#576): - - The Symfony `Application`, the input and the output are provided by the new `InitConsoleStage` through the container, and the new `SetupConsoleApplicationStage` names the application from `app.name` and `app.version` once the whole boot has succeeded (it stays `UNKNOWN` after a failed boot). `ConsoleContext` is built from the `AppContext` and the boot failure, and reads those objects from the container. Removed `ConsoleAppAdapter::createApplication()` (get the application with `Di::get(Application::class)`) and `ConsoleContext::getAppContext()`. - - The adapter no longer makes the environment mutable for every command. `core:key` and the toolkit installer already do it themselves; a command outside the framework that calls `environment()->updateRow()` must call `setMutable(true)` first, or it gets `EnvException::environmentImmutable()`. -- The console boot no longer decides by the `core:env` command name (#575): - - `ConsoleAppAdapter` always runs the full boot and, when it fails, keeps the failure instead of stopping. `RunCommandStage` re-throws that original failure only for a command that requires the environment, so `core:env`, `list`, and `help` still run and `core:env --yes` can repair a missing, empty, or malformed `.env`. The reduced boot for `core:env` is gone, and the Symfony `Application` is named `UNKNOWN` until a successful boot names it from the app config. - - Added `CliCommand::$requiresEnvironment` (default `true`) with `CliCommand::requiresEnvironment()`; a command that works without the environment, like `EnvCommand`, sets it to `false`. `ConsoleContext` takes an optional boot failure and exposes it with `getBootFailure()`. -- **BREAKING:** Refactored the web and console runtime into staged pipelines (#537): - - `WebAppAdapter::start()` now runs a `Pipeline` of the preflight, route resolution, not found, prepare, dispatch, and send stages with a `WebContext`, and `ConsoleAppAdapter::start()` runs a `Pipeline` 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. The pipeline dispatches a stage's `BEFORE`/`AFTER` events when the stage declares them, but the built-in web and console stages declare none yet. - - Moved the web adapter and context 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. +- **BREAKING:** Reworked the app boot and runtime into pipelines of stages that the adapters run in explicit steps (#537, #575, #576, #579, #583, #589): + - `AppInterface` gains `boot()`. The adapter constructors only store the context, and `AppFactory` calls `boot()` right after creating the adapter, so `AppFactory::create(...)->start()` is unchanged. Code that creates an adapter directly must call `boot()` before `start()`. + - Removed the `AppAdapter` base class and the `WebAppTrait` and `ConsoleAppTrait` traits: `WebAppAdapter` and `ConsoleAppAdapter` implement `AppInterface` directly and moved to `Quantum\App\Adapters\Web` and `Quantum\App\Adapters\Console`. The boot stages moved to `Quantum\App\Stages\Boot`. Code that goes through `AppFactory` is unaffected; code that references these classes directly must update its imports. + - Replaced `BootPipeline` and `BootStageInterface` with one generic `Quantum\App\Pipeline`, which runs `StageInterface` stages in order and dispatches the `BEFORE`/`AFTER` events they declare. The context type is a PHPDoc template checked by PHPStan: a stage states it with `@implements StageInterface` (or `WebContext`, `ConsoleContext`) and takes `process(AppContext $context)`. A stage that calls `stop()` on the context ends the run; an optional closing stage, the second constructor argument, runs last, also after a stop, but not after a stage throws. + - `AppContext` keeps `getBaseDir()`, `getContainer()`, `getEnvironment()` and `getConfig()`, and carries the state of a run: `stop()`, `isStopped()`, `getExitCode()` and `setExitCode()`. Removed `AppContext::getRequest()`, `getResponse()` and `getRoutes()`: use `WebContext::getRequest()` and `WebContext::getRoutes()`, and `response()` for the shared response. + - The new `WebContext` and `ConsoleContext` extend `AppContext` with the same base directory and container. `WebContext` reads the request, the routes and the view cache from the container and stores the response to send; `ConsoleContext` reads the Symfony `Application`, the input and the output from the container. + - The web runtime runs the preflight, route resolution, not found, prepare and dispatch stages, with `SendResponseStage` as its closing stage. Behavior is unchanged. + - The console boot registers the Symfony `Application`, the input and the output in the container (`InitConsoleStage`), loads the helpers, registers the core commands, loads the environment, the config and the error handler, names the application from `app.name` and `app.version` (`UNKNOWN` without them), and registers the app commands last, so their constructors can use `env()` and `config()`. The console runtime runs the command and rejects a missing or unknown one. Registration goes through the new `Quantum\Console\CommandRegistrar`. Removed `ConsoleAppAdapter::createApplication()`: get the application with `Di::get(Application::class)`. + - The console no longer decides by the `core:env` command name. Added `CliCommand::$requiresEnvironment` (default `true`) with `CliCommand::requiresEnvironment()`. When the `.env` file is missing or malformed, the new `ResolveEnvironmentStage` stops the boot for a core command that sets it to `false` (`core:env`) and for Symfony's `list` and `help`, so they still run; every other command fails with the exception of the environment file. With a valid `.env`, `core:env` now runs the full boot. A core command constructor must not use the environment or any config, since the core commands are registered before the environment is loaded. + - The console no longer makes the environment mutable for every command. `core:key` and the toolkit installer already do it themselves; a command outside the framework that calls `environment()->updateRow()` must call `setMutable(true)` first, or it gets `EnvException::environmentImmutable()`. - **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/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index 21e9d0e6..9ca781b6 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -11,8 +11,9 @@ namespace Quantum\App\Adapters\Console; use Quantum\App\Stages\Boot\SetupConsoleApplicationStage; -use Quantum\App\Stages\Console\RegisterCoreCommandsStage; -use Quantum\App\Stages\Console\RegisterAppCommandsStage; +use Quantum\App\Stages\Boot\RegisterCoreCommandsStage; +use Quantum\App\Stages\Boot\RegisterAppCommandsStage; +use Quantum\App\Stages\Boot\ResolveEnvironmentStage; use Quantum\App\Stages\Boot\SetupErrorHandlerStage; use Quantum\App\Stages\Boot\LoadEnvironmentStage; use Quantum\App\Stages\Console\RunCommandStage; @@ -22,7 +23,6 @@ use Quantum\App\Contracts\AppInterface; use Quantum\App\AppContext; use Quantum\App\Pipeline; -use Throwable; use Exception; if (!defined('DS')) { @@ -37,46 +37,40 @@ class ConsoleAppAdapter implements AppInterface { protected AppContext $context; - protected ?Throwable $bootFailure = null; - public function __construct(AppContext $context) { $this->context = $context; + } - try { - $pipeline = new Pipeline([ - new InitConsoleStage(), - new LoadHelpersStage(), - new LoadEnvironmentStage(), - new LoadAppConfigStage(), - new SetupErrorHandlerStage(), - new SetupConsoleApplicationStage(), - ]); + public function boot(): void + { + $pipeline = new Pipeline([ + new InitConsoleStage(), + new LoadHelpersStage(), + new RegisterCoreCommandsStage(), + new ResolveEnvironmentStage(), + new LoadEnvironmentStage(), + new LoadAppConfigStage(), + new SetupErrorHandlerStage(), + new SetupConsoleApplicationStage(), + new RegisterAppCommandsStage(), + ]); - $pipeline->run($this->context); - } catch (Throwable $exception) { - $this->bootFailure = $exception; - } + $pipeline->run($this->context); } /** - * @throws Exception|Throwable - */ + * @throws Exception + */ public function start(): ?int { $pipeline = new Pipeline([ - new RegisterCoreCommandsStage(), - new RegisterAppCommandsStage(), new RunCommandStage(), ]); - $context = new ConsoleContext($this->context, $this->bootFailure); + $context = new ConsoleContext($this->context); - try { - $pipeline->run($context); - } catch (Throwable $exception) { - throw $this->bootFailure ?? $exception; - } + $pipeline->run($context); return $context->getExitCode(); } diff --git a/src/App/Adapters/Console/ConsoleContext.php b/src/App/Adapters/Console/ConsoleContext.php index 764d85f1..4c10a3d2 100644 --- a/src/App/Adapters/Console/ConsoleContext.php +++ b/src/App/Adapters/Console/ConsoleContext.php @@ -13,9 +13,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Application; -use Quantum\App\Enums\ExitCode; use Quantum\App\AppContext; -use Throwable; /** * Class ConsoleContext @@ -28,15 +26,9 @@ */ class ConsoleContext extends AppContext { - private ?Throwable $bootFailure; - - private int $exitCode = ExitCode::SUCCESS; - - public function __construct(AppContext $appContext, ?Throwable $bootFailure = null) + public function __construct(AppContext $appContext) { parent::__construct($appContext->getBaseDir(), $appContext->getContainer()); - - $this->bootFailure = $bootFailure; } public function getApplication(): Application @@ -53,22 +45,4 @@ public function getOutput(): OutputInterface { return $this->resolveFromContainer(OutputInterface::class); } - - /** - * The failure of the boot that the console run continued after, if there was one - */ - public function getBootFailure(): ?Throwable - { - return $this->bootFailure; - } - - public function getExitCode(): int - { - return $this->exitCode; - } - - public function setExitCode(int $exitCode): void - { - $this->exitCode = $exitCode; - } } diff --git a/src/App/Adapters/Web/WebAppAdapter.php b/src/App/Adapters/Web/WebAppAdapter.php index 0a1fe968..ee94c4ba 100644 --- a/src/App/Adapters/Web/WebAppAdapter.php +++ b/src/App/Adapters/Web/WebAppAdapter.php @@ -39,7 +39,10 @@ class WebAppAdapter implements AppInterface public function __construct(AppContext $context) { $this->context = $context; + } + public function boot(): void + { $pipeline = new Pipeline([ new LoadHelpersStage(), new LoadEnvironmentStage(), @@ -61,8 +64,7 @@ public function start(): ?int new HandleRouteNotFoundStage(), new PrepareRequestStage(), new DispatchRequestStage(), - new SendResponseStage(), - ]); + ], new SendResponseStage()); $pipeline->run(new WebContext($this->context)); diff --git a/src/App/Adapters/Web/WebContext.php b/src/App/Adapters/Web/WebContext.php index cff8acff..6d0e5bf2 100644 --- a/src/App/Adapters/Web/WebContext.php +++ b/src/App/Adapters/Web/WebContext.php @@ -21,7 +21,7 @@ * Class WebContext * * Carries the state of a single web request through the web stages. - * It is an AppContext (same base directory and container) whose response and stopped flag are set by the stages; + * It is an AppContext (same base directory and container) whose response is set by the stages; * the response the stages set is the one that is sent. * The request, the routes and the view cache are read from the container, and the matched route from the request. * @@ -31,8 +31,6 @@ class WebContext extends AppContext { private ?Response $response = null; - private bool $stopped = false; - public function __construct(AppContext $appContext) { parent::__construct($appContext->getBaseDir(), $appContext->getContainer()); @@ -67,18 +65,4 @@ public function getViewCache(): ViewCache { return $this->resolveFromContainer(ViewCache::class); } - - /** - * 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/AppContext.php b/src/App/AppContext.php index 6897d63c..33ab4362 100644 --- a/src/App/AppContext.php +++ b/src/App/AppContext.php @@ -11,11 +11,16 @@ namespace Quantum\App; use Quantum\Environment\Environment; +use Quantum\App\Enums\ExitCode; use Quantum\Di\DiContainer; use Quantum\Config\Config; /** * Class AppContext + * + * Carries the base directory and the container of an application, and the state of the run it is passed through: + * the stopped flag, which ends the pipeline run early, and the exit code. + * * @package Quantum\App */ class AppContext @@ -24,6 +29,10 @@ class AppContext private DiContainer $container; + private bool $stopped = false; + + private int $exitCode = ExitCode::SUCCESS; + public function __construct(string $baseDir, DiContainer $container) { $this->baseDir = $baseDir; @@ -50,6 +59,29 @@ public function getConfig(): Config return $this->resolveFromContainer(Config::class); } + /** + * Ends the pipeline run after the current stage; only the closing stage still runs + */ + public function stop(): void + { + $this->stopped = true; + } + + public function isStopped(): bool + { + return $this->stopped; + } + + public function getExitCode(): int + { + return $this->exitCode; + } + + public function setExitCode(int $exitCode): void + { + $this->exitCode = $exitCode; + } + /** * @template T of object * @param class-string $class diff --git a/src/App/Contracts/AppInterface.php b/src/App/Contracts/AppInterface.php index c87f8dd9..ad16adb0 100644 --- a/src/App/Contracts/AppInterface.php +++ b/src/App/Contracts/AppInterface.php @@ -16,6 +16,11 @@ */ interface AppInterface { + /** + * Boots the app + */ + public function boot(): void; + /** * Starts the app */ diff --git a/src/App/Factories/AppFactory.php b/src/App/Factories/AppFactory.php index becfd518..58e00e79 100644 --- a/src/App/Factories/AppFactory.php +++ b/src/App/Factories/AppFactory.php @@ -68,6 +68,9 @@ private static function createInstance(string $type, string $baseDir): App $adapterClass = self::ADAPTERS[$type]; - return new App(new $adapterClass($context)); + $adapter = new $adapterClass($context); + $adapter->boot(); + + return new App($adapter); } } diff --git a/src/App/Pipeline.php b/src/App/Pipeline.php index f4cbc9a7..5c86ec09 100644 --- a/src/App/Pipeline.php +++ b/src/App/Pipeline.php @@ -18,6 +18,8 @@ * * Runs stages in order with a context. The event a stage declares in its BEFORE / AFTER * constants is dispatched around it, with the context as the payload. + * A stage that stops the context ends the run: no later stage runs or dispatches events. + * The optional closing stage runs last, also after a stop, but not after a stage throws. * * @template TContext of AppContext * @package Quantum\App @@ -29,10 +31,16 @@ final class Pipeline */ private array $stages; + /** + * @var StageInterface|null + */ + private ?StageInterface $closingStage; + /** * @param array> $stages + * @param StageInterface|null $closingStage */ - public function __construct(array $stages = []) + public function __construct(array $stages = [], ?StageInterface $closingStage = null) { foreach ($stages as $stage) { if (!$stage instanceof StageInterface) { @@ -41,6 +49,7 @@ public function __construct(array $stages = []) } $this->stages = $stages; + $this->closingStage = $closingStage; } /** @@ -49,14 +58,31 @@ public function __construct(array $stages = []) public function run(AppContext $context): void { foreach ($this->stages as $stage) { - $this->dispatchStageEvent($stage, 'BEFORE', $context); + $this->runStage($stage, $context); - $stage->process($context); + if ($context->isStopped()) { + break; + } + } - $this->dispatchStageEvent($stage, 'AFTER', $context); + if ($this->closingStage !== null) { + $this->runStage($this->closingStage, $context); } } + /** + * @param StageInterface $stage + * @param TContext $context + */ + private function runStage(StageInterface $stage, AppContext $context): void + { + $this->dispatchStageEvent($stage, 'BEFORE', $context); + + $stage->process($context); + + $this->dispatchStageEvent($stage, 'AFTER', $context); + } + /** * Dispatches the event a stage declares for the given point, if any. * Skipped while the event() helper is not loaded yet, since nothing can listen then. diff --git a/src/App/Stages/Boot/InitConsoleStage.php b/src/App/Stages/Boot/InitConsoleStage.php index c3f36d3a..4c026a4c 100644 --- a/src/App/Stages/Boot/InitConsoleStage.php +++ b/src/App/Stages/Boot/InitConsoleStage.php @@ -22,8 +22,8 @@ /** * Class InitConsoleStage * - * Runs before the helpers are loaded, so that the console application, input and output exist - * even when a helper file fails; for the same reason it declares no boot events. + * Runs first in the console boot, before the helpers are loaded, so it declares no boot events. + * The core command registration and ResolveEnvironmentStage read the application and the input it registers. * * @implements StageInterface * @package Quantum\App diff --git a/src/App/Stages/Console/RegisterAppCommandsStage.php b/src/App/Stages/Boot/RegisterAppCommandsStage.php similarity index 69% rename from src/App/Stages/Console/RegisterAppCommandsStage.php rename to src/App/Stages/Boot/RegisterAppCommandsStage.php index 556b5ed4..f9e6efc2 100644 --- a/src/App/Stages/Console/RegisterAppCommandsStage.php +++ b/src/App/Stages/Boot/RegisterAppCommandsStage.php @@ -8,30 +8,34 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Console; +namespace Quantum\App\Stages\Boot; -use Quantum\App\Adapters\Console\ConsoleContext; +use Symfony\Component\Console\Application; use Quantum\App\Contracts\StageInterface; +use Quantum\Di\Exceptions\DiException; use Quantum\Console\CommandRegistrar; use Quantum\App\AppContext; use ReflectionException; +use Quantum\Di\Di; /** * Class RegisterAppCommandsStage - * @implements StageInterface + * + * Runs last in the console boot, so the constructors of the app commands can use the environment and the config. + * + * @implements StageInterface * @package Quantum\App */ class RegisterAppCommandsStage implements StageInterface { /** * Registers the commands of the application - * @param ConsoleContext $context - * @throws ReflectionException + * @throws DiException|ReflectionException */ public function process(AppContext $context): void { CommandRegistrar::register( - $context->getApplication(), + Di::get(Application::class), $this->directory(), $this->namespace(), ); diff --git a/src/App/Stages/Console/RegisterCoreCommandsStage.php b/src/App/Stages/Boot/RegisterCoreCommandsStage.php similarity index 64% rename from src/App/Stages/Console/RegisterCoreCommandsStage.php rename to src/App/Stages/Boot/RegisterCoreCommandsStage.php index 7162b42c..ce2b047f 100644 --- a/src/App/Stages/Console/RegisterCoreCommandsStage.php +++ b/src/App/Stages/Boot/RegisterCoreCommandsStage.php @@ -8,30 +8,36 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Console; +namespace Quantum\App\Stages\Boot; -use Quantum\App\Adapters\Console\ConsoleContext; +use Symfony\Component\Console\Application; use Quantum\App\Contracts\StageInterface; +use Quantum\Di\Exceptions\DiException; use Quantum\Console\CommandRegistrar; use Quantum\App\AppContext; use ReflectionException; +use Quantum\Di\Di; /** * Class RegisterCoreCommandsStage - * @implements StageInterface + * + * Runs right after the helpers, before the environment, so that ResolveEnvironmentStage can find + * core:env when the .env file cannot be loaded. A core command constructor therefore uses + * no environment and no config. + * + * @implements StageInterface * @package Quantum\App */ class RegisterCoreCommandsStage implements StageInterface { /** * Registers the commands that ship with the framework - * @param ConsoleContext $context - * @throws ReflectionException + * @throws DiException|ReflectionException */ public function process(AppContext $context): void { CommandRegistrar::register( - $context->getApplication(), + Di::get(Application::class), $this->directory(), $this->namespace(), ); diff --git a/src/App/Stages/Boot/ResolveEnvironmentStage.php b/src/App/Stages/Boot/ResolveEnvironmentStage.php new file mode 100644 index 00000000..7a286ee5 --- /dev/null +++ b/src/App/Stages/Boot/ResolveEnvironmentStage.php @@ -0,0 +1,72 @@ + + * @package Quantum\App + */ +class ResolveEnvironmentStage implements StageInterface +{ + /** + * @throws EnvException|InvalidFileException|DiException|BaseException + */ + public function process(AppContext $context): void + { + try { + (new Environment())->load(); + } catch (EnvException|InvalidFileException $exception) { + if ($this->requiresEnvironment()) { + throw $exception; + } + + $context->stop(); + } + } + + /** + * A CliCommand tells it itself, other commands (Symfony's list or help) do not need it, + * and an unknown or missing command is treated as needing it + * @throws DiException + */ + private function requiresEnvironment(): bool + { + $application = Di::get(Application::class); + $commandName = Di::get(InputInterface::class)->getFirstArgument(); + + if ($commandName === null || !$application->has($commandName)) { + return true; + } + + $command = $application->get($commandName); + + return $command instanceof CliCommand && $command->requiresEnvironment(); + } +} diff --git a/src/App/Stages/Console/RunCommandStage.php b/src/App/Stages/Console/RunCommandStage.php index a139f84b..3904f278 100644 --- a/src/App/Stages/Console/RunCommandStage.php +++ b/src/App/Stages/Console/RunCommandStage.php @@ -12,16 +12,13 @@ use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\Contracts\StageInterface; -use Quantum\Console\CliCommand; use Quantum\App\AppContext; use Exception; -use Throwable; /** * Class RunCommandStage * - * Runs the requested command; a missing or unknown command is rejected before anything runs, - * and so is a command that needs the environment after the boot failed. + * Runs the requested command; a missing or unknown command is rejected before anything runs. * * @implements StageInterface * @package Quantum\App @@ -30,7 +27,7 @@ class RunCommandStage implements StageInterface { /** * @param ConsoleContext $context - * @throws Exception|Throwable + * @throws Exception */ public function process(AppContext $context): void { @@ -40,12 +37,6 @@ public function process(AppContext $context): void throw new Exception("Command `$commandName` is not defined"); } - $command = $context->getApplication()->get($commandName); - - if ($context->getBootFailure() !== null && $command instanceof CliCommand && $command->requiresEnvironment()) { - throw $context->getBootFailure(); - } - $context->setExitCode( $context->getApplication()->run($context->getInput(), $context->getOutput()) ); diff --git a/src/App/Stages/Web/DispatchRequestStage.php b/src/App/Stages/Web/DispatchRequestStage.php index e87a493a..9c3303aa 100644 --- a/src/App/Stages/Web/DispatchRequestStage.php +++ b/src/App/Stages/Web/DispatchRequestStage.php @@ -39,10 +39,6 @@ class DispatchRequestStage implements StageInterface */ public function process(AppContext $context): void { - if ($context->isStopped()) { - return; - } - $matchedRoute = $context->getMatchedRoute(); if ($matchedRoute === null) { diff --git a/src/App/Stages/Web/HandleRouteNotFoundStage.php b/src/App/Stages/Web/HandleRouteNotFoundStage.php index 1b68f950..f7d27c8a 100644 --- a/src/App/Stages/Web/HandleRouteNotFoundStage.php +++ b/src/App/Stages/Web/HandleRouteNotFoundStage.php @@ -32,7 +32,7 @@ class HandleRouteNotFoundStage implements StageInterface */ public function process(AppContext $context): void { - if ($context->isStopped() || $context->getMatchedRoute() !== null) { + if ($context->getMatchedRoute() !== null) { return; } diff --git a/src/App/Stages/Web/PrepareRequestStage.php b/src/App/Stages/Web/PrepareRequestStage.php index 7e080d93..f19989ca 100644 --- a/src/App/Stages/Web/PrepareRequestStage.php +++ b/src/App/Stages/Web/PrepareRequestStage.php @@ -38,10 +38,6 @@ class PrepareRequestStage implements StageInterface */ public function process(AppContext $context): void { - if ($context->isStopped()) { - return; - } - LangFactory::get(); $this->logDebugInfo(); diff --git a/src/App/Stages/Web/ResolveRouteStage.php b/src/App/Stages/Web/ResolveRouteStage.php index 2e802cb4..a261877c 100644 --- a/src/App/Stages/Web/ResolveRouteStage.php +++ b/src/App/Stages/Web/ResolveRouteStage.php @@ -32,10 +32,6 @@ class ResolveRouteStage implements StageInterface */ public function process(AppContext $context): void { - if ($context->isStopped()) { - return; - } - $request = $context->getRequest(); $request->setMatchedRoute((new RouteFinder($context->getRoutes()))->find($request)); diff --git a/src/App/Stages/Web/SendResponseStage.php b/src/App/Stages/Web/SendResponseStage.php index e3874e5b..66c06d6c 100644 --- a/src/App/Stages/Web/SendResponseStage.php +++ b/src/App/Stages/Web/SendResponseStage.php @@ -27,7 +27,7 @@ * Class SendResponseStage * * Sends the response of the request and always clears the request state afterwards, - * even when sending fails. This stage never checks WebContext::isStopped(). + * even when sending fails. It is the closing stage of the web runtime, so it also runs after a stop. * * @implements StageInterface * @package Quantum\App diff --git a/src/Console/Commands/DebugBarCommand.php b/src/Console/Commands/DebugBarCommand.php index 19cc23a5..aece0203 100644 --- a/src/Console/Commands/DebugBarCommand.php +++ b/src/Console/Commands/DebugBarCommand.php @@ -12,7 +12,9 @@ use Quantum\Storage\Exceptions\FileSystemException; use Quantum\Storage\Factories\FileSystemFactory; +use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Exceptions\BaseException; +use Quantum\Di\Exceptions\DiException; use Quantum\Storage\FileSystem; use Quantum\Console\CliCommand; use ReflectionException; @@ -23,11 +25,6 @@ */ class DebugBarCommand extends CliCommand { - /** - * File System - */ - protected FileSystem $fs; - /** * Command name */ @@ -53,28 +50,20 @@ class DebugBarCommand extends CliCommand */ private string $vendorDebugBarFolderPath = 'vendor/php-debugbar/php-debugbar/src/DebugBar/Resources'; - /** - * @throws BaseException|ReflectionException - */ - public function __construct() - { - parent::__construct(); - $this->fs = FileSystemFactory::get(); - } - /** * Executes the command and publishes the debug bar assets - * @throws FileSystemException|BaseException + * @throws FileSystemException|ConfigException|DiException|BaseException|ReflectionException */ public function exec(): void { + $fs = FileSystemFactory::get(); - if ($this->fs->exists(assets_dir() . DS . 'DebugBar' . DS . 'Resources' . DS . 'debugbar.css')) { + if ($fs->exists(assets_dir() . DS . 'DebugBar' . DS . 'Resources' . DS . 'debugbar.css')) { $this->error('The debug ber already installed'); return; } - $this->copyResources($this->vendorDebugBarFolderPath, $this->publicDebugBarFolderPath); + $this->copyResources($fs, $this->vendorDebugBarFolderPath, $this->publicDebugBarFolderPath); $this->info('Debugbar resources successfully published'); } @@ -83,19 +72,19 @@ public function exec(): void * Recursively copies the debug bar assets * @throws FileSystemException */ - private function copyResources(string $src, string $dst): void + private function copyResources(FileSystem $fs, string $src, string $dst): void { $dir = opendir($src); - if ($dst != $this->publicDebugBarFolderPath && $this->fs->makeDirectory($dst) === false) { + if ($dst != $this->publicDebugBarFolderPath && $fs->makeDirectory($dst) === false) { throw FileSystemException::directoryNotWritable($dst); } if (is_resource($dir)) { while (($file = readdir($dir))) { if (($file !== '.') && ($file !== '..')) { - if ($this->fs->isDirectory($src . DS . $file)) { - $this->copyResources($src . DS . $file, $dst . DS . $file); + if ($fs->isDirectory($src . DS . $file)) { + $this->copyResources($fs, $src . DS . $file, $dst . DS . $file); } else { copy($src . DS . $file, $dst . DS . $file); } diff --git a/src/Console/Commands/OpenApiCommand.php b/src/Console/Commands/OpenApiCommand.php index 7c8891d5..0a590245 100644 --- a/src/Console/Commands/OpenApiCommand.php +++ b/src/Console/Commands/OpenApiCommand.php @@ -11,6 +11,7 @@ namespace Quantum\Console\Commands; use Quantum\Storage\Factories\FileSystemFactory; +use Quantum\Config\Exceptions\ConfigException; use Quantum\Module\Exceptions\ModuleException; use Quantum\Router\Exceptions\RouteException; use Quantum\App\Exceptions\BaseException; @@ -30,18 +31,6 @@ */ class OpenApiCommand extends CliCommand { - /** - * File System - */ - protected FileSystem $fs; - - public function __construct() - { - parent::__construct(); - - $this->fs = FileSystemFactory::get(); - } - /** * Command name */ @@ -83,10 +72,12 @@ public function __construct() /** * Executes the command and generate Open API specifications - * @throws ModuleException|RouteException|DiException|BaseException|ReflectionException + * @throws ModuleException|RouteException|ConfigException|DiException|BaseException|ReflectionException */ public function exec(): void { + $fs = FileSystemFactory::get(); + if (!Di::isRegistered(ModuleLoader::class)) { Di::register(ModuleLoader::class); } @@ -110,34 +101,34 @@ public function exec(): void $routes = $modulePath . DS . 'routes' . DS . 'routes.php'; - if (!$this->fs->exists(assets_dir() . DS . 'OpenApiUi' . DS . 'index.css')) { + if (!$fs->exists(assets_dir() . DS . 'OpenApiUi' . DS . 'index.css')) { $this->copyResources(); $this->info('OpenApi resources successfully published'); } - if (!$this->fs->isDirectory($modulePath)) { + if (!$fs->isDirectory($modulePath)) { $this->error('The module `' . ucfirst($module) . '` not found'); return; } - if (request()->routeGroupExists('openapi', $module) && $this->fs->exists($modulePath . DS . 'resources' . DS . 'openapi' . DS . 'spec.json')) { + if (request()->routeGroupExists('openapi', $module) && $fs->exists($modulePath . DS . 'resources' . DS . 'openapi' . DS . 'spec.json')) { $this->error('The Open API specifications already installed for `' . ucfirst($module) . '` module'); return; } if (!request()->routeGroupExists('openapi', $module)) { - $routeContent = $this->fs->get($routes); + $routeContent = $fs->get($routes); if ($routeContent !== false) { - $this->fs->put($routes, str_replace('return function ($route) {', $this->openapiRoutes($module), $routeContent)); + $fs->put($routes, str_replace('return function ($route) {', $this->openapiRoutes($module), $routeContent)); } } - if (!$this->fs->isDirectory($modulePath . DS . 'resources' . DS . 'openapi')) { - $this->fs->makeDirectory($modulePath . DS . 'resources' . DS . 'openapi'); + if (!$fs->isDirectory($modulePath . DS . 'resources' . DS . 'openapi')) { + $fs->makeDirectory($modulePath . DS . 'resources' . DS . 'openapi'); } - $this->generateOpenapiSpecification($module); + $this->generateOpenapiSpecification($fs, $module); } /** @@ -161,7 +152,7 @@ private function copyResources(): void /** * Generates file with OpenApi specifications */ - private function generateOpenapiSpecification(string $module): void + private function generateOpenapiSpecification(FileSystem $fs, string $module): void { $annotationPath = modules_dir() . DS . $module . DS . 'Controllers' . DS . 'OpenApi' . DS; @@ -174,7 +165,7 @@ private function generateOpenapiSpecification(string $module): void return; } - $this->fs->put($specPath, $openApi->toJson()); + $fs->put($specPath, $openApi->toJson()); $this->info('OpenAPI specification generated successfully.'); } diff --git a/src/Console/Commands/ResourceCacheClearCommand.php b/src/Console/Commands/ResourceCacheClearCommand.php index fca33e9c..55ebb163 100644 --- a/src/Console/Commands/ResourceCacheClearCommand.php +++ b/src/Console/Commands/ResourceCacheClearCommand.php @@ -67,19 +67,13 @@ class ResourceCacheClearCommand extends CliCommand protected string $cacheDir = ''; - protected FileSystem $fs; - /** - * @throws BaseException|ReflectionException + * @throws ConfigException|DiException|BaseException|ReflectionException */ - public function __construct() - { - parent::__construct(); - $this->fs = FileSystemFactory::get(); - } - public function exec(): void { + $fs = FileSystemFactory::get(); + try { $this->importConfig(); $this->initModule($this->getOption('module')); @@ -89,15 +83,15 @@ public function exec(): void return; } - if (!$this->fs->isDirectory($this->cacheDir)) { + if (!$fs->isDirectory($this->cacheDir)) { $this->error('Cache directory does not exist or is not accessible.'); return; } if ($this->module || $this->type) { - $this->clearResourceModuleAndType($this->module, $this->type); + $this->clearResourceModuleAndType($fs, $this->module, $this->type); } elseif (!empty($this->getOption('all'))) { - $this->removeFilesFromDirectory($this->cacheDir); + $this->removeFilesFromDirectory($fs, $this->cacheDir); } else { $this->error('Please specify at least one of the following options: --all, --module=moduleName or --type=typeName!'); return; @@ -167,7 +161,7 @@ private function initType(?string $typeOption): void } } - private function clearResourceModuleAndType(?string $moduleName = null, ?string $type = null): void + private function clearResourceModuleAndType(FileSystem $fs, ?string $moduleName = null, ?string $type = null): void { $dir = $this->cacheDir; @@ -183,24 +177,24 @@ private function clearResourceModuleAndType(?string $moduleName = null, ?string $dir = $dir . DS . strtolower($moduleName); } - $this->removeFilesFromDirectory($dir); + $this->removeFilesFromDirectory($fs, $dir); } - private function removeFilesFromDirectory(string $dir): void + private function removeFilesFromDirectory(FileSystem $fs, string $dir): void { - $entries = $this->fs->glob($dir . DS . '*'); + $entries = $fs->glob($dir . DS . '*'); foreach ($entries as $entry) { - if ($this->fs->isDirectory($entry)) { - $this->removeFilesFromDirectory($entry); + if ($fs->isDirectory($entry)) { + $this->removeFilesFromDirectory($fs, $entry); - if ($this->fs->fileName($entry) !== config()->get('view_cache.cache_dir', 'cache') && - count($this->fs->glob($entry . DS . '*')) === 0 + if ($fs->fileName($entry) !== config()->get('view_cache.cache_dir', 'cache') && + count($fs->glob($entry . DS . '*')) === 0 ) { - $this->fs->removeDirectory($entry); + $fs->removeDirectory($entry); } } else { - $this->fs->remove($entry); + $fs->remove($entry); } } } diff --git a/src/Environment/Environment.php b/src/Environment/Environment.php index 2f3d4d70..887e28de 100644 --- a/src/Environment/Environment.php +++ b/src/Environment/Environment.php @@ -12,6 +12,7 @@ use Quantum\Environment\Exceptions\EnvException; use Quantum\Config\Exceptions\ConfigException; +use Dotenv\Exception\InvalidFileException; use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; use Quantum\Environment\Enums\Env; @@ -46,7 +47,7 @@ public function setMutable(bool $isMutable): Environment /** * Loads environment variables from file - * @throws EnvException|DiException|BaseException + * @throws EnvException|InvalidFileException|DiException|BaseException */ public function load(): void { @@ -191,6 +192,7 @@ private function resolveEnvironmentFile(): array /** * @return array + * @throws InvalidFileException */ private function loadDotenvFile(string $envFile): array { diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index 07aa0d75..4b75af4f 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -20,7 +20,6 @@ use RuntimeException; use Quantum\Di\Di; use ParseError; -use Throwable; use Exception; use Mockery; @@ -196,11 +195,33 @@ public function testConsoleAppAdapterBootsAllStagesForCoreEnvCommandWhenTheEnvFi $this->assertTrue(config()->has('app')); } + public function testConsoleAppAdapterConstructorDoesNotBoot(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + new ConsoleAppAdapter($this->createContext()); + + $this->assertFalse(Di::isRegistered(Application::class)); + $this->assertFalse(Di::has(Environment::class)); + } + + public function testConsoleAppAdapterBootRunsTheBootStages(): void + { + $_SERVER['argv'] = ['qt', 'list', '--quiet']; + + $adapter = new ConsoleAppAdapter($this->createContext()); + $adapter->boot(); + + $this->assertTrue(Di::isRegistered(Application::class)); + $this->assertTrue(Di::has(Environment::class)); + $this->assertTrue(config()->has('app')); + } + public function testConsoleAppAdapterRegistersTheConsoleObjectsInTheContainerAfterASuccessfulBoot(): void { $_SERVER['argv'] = ['qt', 'core:env', '--yes']; - new ConsoleAppAdapter($this->createContext()); + (new ConsoleAppAdapter($this->createContext()))->boot(); $this->assertTrue(Di::isRegistered(Application::class)); $this->assertTrue(Di::isRegistered(OutputInterface::class)); @@ -208,37 +229,30 @@ public function testConsoleAppAdapterRegistersTheConsoleObjectsInTheContainerAft $this->assertSame('core:env', Di::get(InputInterface::class)->getFirstArgument()); } - public function testConsoleAppAdapterRegistersTheConsoleObjectsInTheContainerAfterAFailedBoot(): void + public function testConsoleAppAdapterRegistersTheConsoleObjectsInTheContainerWhenTheBootStopsBeforeTheEnvironment(): void { - $_SERVER['argv'] = ['qt', 'core:env', '--yes']; + $_SERVER['argv'] = ['qt', 'list', '--quiet']; $this->withoutEnvironmentFile(function (): void { - new ConsoleAppAdapter($this->createContext()); + (new ConsoleAppAdapter($this->createContext()))->boot(); $this->assertTrue(Di::isRegistered(Application::class)); $this->assertTrue(Di::isRegistered(OutputInterface::class)); $this->assertInstanceOf(ArgvInput::class, Di::get(InputInterface::class)); - $this->assertSame('core:env', Di::get(InputInterface::class)->getFirstArgument()); + $this->assertSame('list', Di::get(InputInterface::class)->getFirstArgument()); }); } - public function testConsoleAppAdapterRecordsNoBootFailureAfterASuccessfulBoot(): void - { - $_SERVER['argv'] = ['qt', 'list', '--quiet']; - - $adapter = $this->createAdapter(); - - $this->assertNull($this->bootFailureOf($adapter)); - } - - public function testConsoleAppAdapterRecordsTheBootFailureInsteadOfThrowingWhenTheEnvFileIsMissing(): void + public function testConsoleAppAdapterBootStopsBeforeTheEnvironmentForListWhenTheEnvFileIsMissing(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; $this->withoutEnvironmentFile(function (): void { - $adapter = $this->createAdapter(); + $this->createAdapter(); - $this->assertInstanceOf(EnvException::class, $this->bootFailureOf($adapter)); + $this->assertFalse(Di::has(Environment::class)); + $this->assertFalse(Di::isRegistered(ErrorHandler::class)); + $this->assertFalse(config()->has('app')); }); } @@ -266,7 +280,6 @@ public function testConsoleAppAdapterNamesTheApplicationUnknownWhenTheAppNameIsE $adapter = $this->createAdapter(null, $context); $adapter->start(); - $this->assertNull($this->bootFailureOf($adapter)); $this->assertSame('UNKNOWN', Di::get(Application::class)->getName()); $this->assertSame('UNKNOWN', Di::get(Application::class)->getVersion()); } @@ -284,7 +297,6 @@ public function testConsoleAppAdapterKeepsAnEmptyAppNameAndVersionFromTheConfig( $adapter = $this->createAdapter(null, $context); $adapter->start(); - $this->assertNull($this->bootFailureOf($adapter)); $this->assertSame('', Di::get(Application::class)->getName()); $this->assertSame('0', Di::get(Application::class)->getVersion()); } @@ -323,7 +335,7 @@ public function testConsoleAppAdapterNamesTheApplicationFromTheAppConfigAfterThe $this->assertNotSame('UNKNOWN', Di::get(Application::class)->getName()); } - public function testConsoleAppAdapterNamesTheApplicationUnknownAfterAFailedBoot(): void + public function testConsoleAppAdapterNamesTheApplicationUnknownWhenTheEnvFileIsMissing(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; @@ -335,43 +347,33 @@ public function testConsoleAppAdapterNamesTheApplicationUnknownAfterAFailedBoot( }); } - public function testConsoleAppAdapterKeepsTheApplicationUnknownWhenTheErrorHandlerCannotBeSetUp(): void + public function testConsoleAppAdapterBootFailsWhenTheErrorHandlerCannotBeSetUp(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; $this->withFileContent(PROJECT_ROOT . DS . 'shared' . DS . 'config' . DS . 'logging.php', "createAdapter(); - $adapter->start(); + $this->expectException(ParseError::class); - $this->assertNotNull($this->bootFailureOf($adapter)); - $this->assertTrue(config()->has('app')); - $this->assertSame('UNKNOWN', Di::get(Application::class)->getName()); - $this->assertSame('UNKNOWN', Di::get(Application::class)->getVersion()); + $this->createAdapter(); }); } - public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMissing(): void + public function testConsoleAppAdapterBootRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMissing(): void { - $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; - - $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withTemporaryCommand(PROJECT_ROOT . DS . 'vendor' . DS . 'quantum' . DS . 'framework' . DS . 'src' . DS . 'Console' . DS . 'Commands', 'Quantum\Console\Commands', function (string $commandName): void { $_SERVER['argv'] = ['qt', $commandName, '--quiet']; $this->withoutEnvironmentFile(function (): void { - $adapter = $this->createAdapter(); - $this->expectException(EnvException::class); - $adapter->start(); + $this->createAdapter(); }); }); } - public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMissing(): void + public function testConsoleAppAdapterStartRunsACoreCommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMissing(): void { - $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; - - $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withTemporaryCommand(PROJECT_ROOT . DS . 'vendor' . DS . 'quantum' . DS . 'framework' . DS . 'src' . DS . 'Console' . DS . 'Commands', 'Quantum\Console\Commands', function (string $commandName): void { $this->withoutEnvironmentFile(function () use ($commandName): void { $this->assertSame(0, $this->startWithRealApplication($commandName)); $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); @@ -379,6 +381,63 @@ public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvir }, null, false); } + public function testConsoleAppAdapterBootRejectsAnAppCommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMissing(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $_SERVER['argv'] = ['qt', $commandName, '--quiet']; + + $this->withoutEnvironmentFile(function (): void { + $this->expectException(EnvException::class); + + $this->createAdapter(); + }); + }, null, false); + } + + public function testConsoleAppAdapterStartRunsAnAppCommandWhoseConstructorUsesTheEnvironment(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + $className = 'TmpEnvCommand' . substr(md5(uniqid('', true)), 0, 8); + $commandName = 'tmp:env-constructor'; + + $command = <<appKey = (string) env('APP_KEY'); + } + + public function exec(): void + { + \$GLOBALS['tmpCommandRan'] = \$this->appKey; + } +} +PHP; + + $this->withTemporaryFile($directory, $className, $command, function () use ($commandName): void { + $this->assertSame(0, $this->startWithRealApplication($commandName)); + $this->assertSame(env('APP_KEY'), $GLOBALS['tmpCommandRan'] ?? null); + $this->assertNotEmpty($GLOBALS['tmpCommandRan']); + }); + } + public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMissing(): void { $this->withoutEnvironmentFile(function (): void { @@ -386,11 +445,9 @@ public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMissing(): voi }); } - public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMalformed(): void + public function testConsoleAppAdapterStartRunsACoreCommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMalformed(): void { - $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; - - $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withTemporaryCommand(PROJECT_ROOT . DS . 'vendor' . DS . 'quantum' . DS . 'framework' . DS . 'src' . DS . 'Console' . DS . 'Commands', 'Quantum\Console\Commands', function (string $commandName): void { $this->withEnvironmentFileContent("APP_NAME=two words here\n", function () use ($commandName): void { $adapter = $this->bootRealAdapter($commandName); @@ -414,16 +471,12 @@ public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvir }, null, false); } - public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMalformed(): void + public function testConsoleAppAdapterBootRejectsACommandThatRequiresTheEnvironmentWhenTheEnvFileIsMalformed(): void { - $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; - - $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withTemporaryCommand(PROJECT_ROOT . DS . 'vendor' . DS . 'quantum' . DS . 'framework' . DS . 'src' . DS . 'Console' . DS . 'Commands', 'Quantum\Console\Commands', function (string $commandName): void { $this->withEnvironmentFileContent("APP_NAME=two words here\n", function () use ($commandName): void { - $adapter = $this->bootRealAdapter($commandName); - try { - $adapter->start(); + $this->bootRealAdapter($commandName); $this->fail('The command must not run when the environment file is malformed.'); } catch (InvalidFileException $exception) { $this->assertArrayNotHasKey('tmpCommandRan', $GLOBALS); @@ -432,6 +485,19 @@ public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironm }); } + public function testConsoleAppAdapterBootRejectsAnAppCommandThatDoesNotRequireTheEnvironmentWhenTheEnvFileIsMalformed(): void + { + $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; + + $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { + $this->withEnvironmentFileContent("APP_NAME=two words here\n", function () use ($commandName): void { + $this->expectException(InvalidFileException::class); + + $this->bootRealAdapter($commandName); + }); + }, null, false); + } + public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMalformed(): void { $this->withEnvironmentFileContent("APP_NAME=two words here\n", function (): void { @@ -439,97 +505,66 @@ public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMalformed(): v }); } - public function testConsoleAppAdapterStartReportsTheBootFailureForAnUnknownCommandWhenTheEnvFileIsMalformed(): void + public function testConsoleAppAdapterBootReportsTheEnvFileExceptionForAnUnknownCommandWhenTheEnvFileIsMalformed(): void { $this->withEnvironmentFileContent("APP_NAME=two words here\n", function (): void { - $adapter = $this->bootRealAdapter('unknown:command'); - $this->expectException(InvalidFileException::class); - $adapter->start(); + $this->bootRealAdapter('unknown:command'); }); } - public function testConsoleAppAdapterStartReportsTheBootFailureForAnUnknownCommandWhenTheEnvFileIsMissing(): void + public function testConsoleAppAdapterBootReportsTheEnvFileExceptionForAnUnknownCommandWhenTheEnvFileIsMissing(): void { $this->withoutEnvironmentFile(function (): void { - $adapter = $this->bootRealAdapter('unknown:command'); + $this->expectException(EnvException::class); + + $this->bootRealAdapter('unknown:command'); + }); + } + public function testConsoleAppAdapterBootReportsTheEnvFileExceptionWithoutACommandWhenTheEnvFileIsMissing(): void + { + $_SERVER['argv'] = ['qt']; + + $this->withoutEnvironmentFile(function (): void { $this->expectException(EnvException::class); - $adapter->start(); + $this->createAdapter(); }); } - public function testConsoleAppAdapterStartReportsTheBootFailureWhenACommandCannotBeCreatedAfterAFailedBoot(): void + public function testConsoleAppAdapterStartRunsListWhenTheEnvFileIsMissingAndAnAppCommandCannotBeCreated(): void { $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; $this->withBrokenCommand($directory, function (): void { $this->withoutEnvironmentFile(function (): void { - $adapter = $this->bootRealAdapter('list'); - - $this->expectException(EnvException::class); - - $adapter->start(); + $this->assertSame(0, $this->bootRealAdapter('list')->start()); }); }); } - public function testConsoleAppAdapterStartReportsTheCommandFailureWhenACommandCannotBeCreatedAfterASuccessfulBoot(): void + public function testConsoleAppAdapterBootReportsTheCommandFailureWhenAnAppCommandCannotBeCreated(): void { $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; $this->withBrokenCommand($directory, function (): void { - $adapter = $this->bootRealAdapter('list'); - $this->expectException(RuntimeException::class); $this->expectExceptionMessage('The command cannot be created'); - $adapter->start(); + $this->bootRealAdapter('list'); }); } - public function testConsoleAppAdapterStartRunsListWhenAnAppHelperFileCannotBeLoaded(): void + public function testConsoleAppAdapterBootFailsWhenAnAppHelperFileCannotBeLoaded(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; $this->withBrokenAppHelper(function (): void { - $adapter = $this->createAdapter(); - - $this->assertSame(0, $adapter->start()); - $this->assertInstanceOf(ParseError::class, $this->bootFailureOf($adapter)); - }); - } - - public function testConsoleAppAdapterStartRunsACommandThatDoesNotRequireTheEnvironmentWhenAnAppHelperFileCannotBeLoaded(): void - { - $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; - - $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { - $_SERVER['argv'] = ['qt', $commandName, '--quiet']; - - $this->withBrokenAppHelper(function () use ($commandName): void { - $this->assertSame(0, $this->createAdapter()->start()); - $this->assertSame($commandName, $GLOBALS['tmpCommandRan'] ?? null); - }); - }, null, false); - } - - public function testConsoleAppAdapterStartRejectsACommandThatRequiresTheEnvironmentWhenAnAppHelperFileCannotBeLoaded(): void - { - $directory = PROJECT_ROOT . DS . 'shared' . DS . 'Commands'; - - $this->withTemporaryCommand($directory, 'Shared\Commands', function (string $commandName): void { - $_SERVER['argv'] = ['qt', $commandName, '--quiet']; - - $this->withBrokenAppHelper(function (): void { - $adapter = $this->createAdapter(); - - $this->expectException(ParseError::class); + $this->expectException(ParseError::class); - $adapter->start(); - }); + $this->createAdapter(); }); } @@ -592,7 +627,7 @@ private function withFileContent(string $file, string $content, callable $test): } /** - * Creates the adapter on a fresh context (or the given one) with a test Application in the container: + * Creates and boots the adapter on a fresh context (or the given one) with a test Application in the container: * the given one, or one that does not exit and is built, and named, when start() first fetches it */ private function createAdapter(?Application $application = null, ?AppContext $context = null): ConsoleAppAdapter @@ -605,7 +640,10 @@ private function createAdapter(?Application $application = null, ?AppContext $co Di::register(NonExitingApplication::class, Application::class); } - return new ConsoleAppAdapter($context); + $adapter = new ConsoleAppAdapter($context); + $adapter->boot(); + + return $adapter; } /** @@ -669,16 +707,6 @@ private function withoutEnvironmentFile(callable $test): void } } - /** - * The failure the adapter recorded while booting, if there was one - */ - private function bootFailureOf(ConsoleAppAdapter $adapter): ?Throwable - { - $property = new ReflectionProperty(ConsoleAppAdapter::class, 'bootFailure'); - $property->setAccessible(true); - - return $property->getValue($adapter); - } /** * 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. diff --git a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php index 80027d3c..1c4bcc70 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php @@ -12,7 +12,6 @@ use Quantum\App\Enums\ExitCode; use Quantum\App\AppContext; use Quantum\Di\DiContainer; -use RuntimeException; use Quantum\App\App; use Quantum\Di\Di; @@ -73,20 +72,6 @@ public function testConsoleContextReadsTheConsoleObjectsFromTheContainerWhenAske $this->assertSame($application, $consoleContext->getApplication()); } - public function testConsoleContextHasNoBootFailureByDefault(): void - { - $this->assertNull($this->consoleContext->getBootFailure()); - } - - public function testConsoleContextExposesTheBootFailure(): void - { - $failure = new RuntimeException('The boot failed'); - - $consoleContext = new ConsoleContext($this->appContext, $failure); - - $this->assertSame($failure, $consoleContext->getBootFailure()); - } - public function testConsoleContextDefaultsExitCodeToSuccess(): void { $this->assertSame(ExitCode::SUCCESS, $this->consoleContext->getExitCode()); diff --git a/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php b/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php index 47236e5a..5e35b03d 100644 --- a/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Web/WebAppAdapterTest.php @@ -4,6 +4,7 @@ use Quantum\Router\Exceptions\RouteException; use Quantum\App\Adapters\Web\WebAppAdapter; +use Quantum\Environment\Environment; use Quantum\ResourceCache\ViewCache; use Quantum\Tests\Unit\AppTestCase; use Quantum\Http\Enums\ContentType; @@ -20,6 +21,7 @@ class WebAppAdapterTest extends AppTestCase public function setUp(): void { $this->webAppAdapter = new WebAppAdapter($this->createContext()); + $this->webAppAdapter->boot(); } public function tearDown(): void @@ -246,12 +248,32 @@ public function testWebAppAdapterStartPropagatesDispatchExceptionWithoutCleanup( $this->assertNotNull(request()->getUri()); } + public function testWebAppAdapterConstructorDoesNotBoot(): void + { + $this->clearAppContext(); + + new WebAppAdapter($this->createContext()); + + $this->assertFalse(Di::has(Environment::class)); + } + + public function testWebAppAdapterBootRunsTheBootStages(): void + { + $this->clearAppContext(); + + $adapter = new WebAppAdapter($this->createContext()); + $adapter->boot(); + + $this->assertTrue(Di::has(Environment::class)); + $this->assertTrue(config()->has('app')); + } + public function testWebAppAdapterBootFiresAppHelperListenerAtModulesBeforeEvent(): void { $this->withTemporaryAppHelper(function (): void { $context = $this->createContext(); - new WebAppAdapter($context); + (new WebAppAdapter($context))->boot(); $this->assertCount(1, $GLOBALS['bootEventPayloads']); $this->assertSame($context, $GLOBALS['bootEventPayloads'][0]['context']); @@ -261,7 +283,7 @@ public function testWebAppAdapterBootFiresAppHelperListenerAtModulesBeforeEvent( public function testWebAppAdapterBootRegistersAppHelperListenerBeforeAppConfigIsLoaded(): void { $this->withTemporaryAppHelper(function (): void { - new WebAppAdapter($this->createContext()); + (new WebAppAdapter($this->createContext()))->boot(); $this->assertFalse($GLOBALS['bootEventConfigLoadedAtRegistration']); $this->assertTrue(config()->has('app')); @@ -271,11 +293,11 @@ public function testWebAppAdapterBootRegistersAppHelperListenerBeforeAppConfigIs public function testWebAppAdapterSecondBootDoesNotRegisterAppHelperListenerAgain(): void { $this->withTemporaryAppHelper(function (): void { - new WebAppAdapter($this->createContext()); + (new WebAppAdapter($this->createContext()))->boot(); $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()); + (new WebAppAdapter($this->createContext()))->boot(); $this->assertCount(1, $GLOBALS['bootEventPayloads']); }); } diff --git a/tests/Unit/App/AppContextTest.php b/tests/Unit/App/AppContextTest.php index a075bb4c..d999812c 100644 --- a/tests/Unit/App/AppContextTest.php +++ b/tests/Unit/App/AppContextTest.php @@ -4,6 +4,7 @@ use Quantum\Environment\Environment; use PHPUnit\Framework\TestCase; +use Quantum\App\Enums\ExitCode; use Quantum\Di\DiContainer; use Quantum\App\AppContext; use Quantum\Config\Config; @@ -47,4 +48,36 @@ public function testAppContextGetConfig(): void $this->assertSame($config, $context->getConfig()); } + + public function testAppContextIsNotStoppedByDefault(): void + { + $context = new AppContext('/tmp', new DiContainer()); + + $this->assertFalse($context->isStopped()); + } + + public function testAppContextStop(): void + { + $context = new AppContext('/tmp', new DiContainer()); + + $context->stop(); + + $this->assertTrue($context->isStopped()); + } + + public function testAppContextExitCodeDefaultsToSuccess(): void + { + $context = new AppContext('/tmp', new DiContainer()); + + $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); + } + + public function testAppContextSetExitCode(): void + { + $context = new AppContext('/tmp', new DiContainer()); + + $context->setExitCode(ExitCode::INVALID); + + $this->assertSame(ExitCode::INVALID, $context->getExitCode()); + } } diff --git a/tests/Unit/App/AppTest.php b/tests/Unit/App/AppTest.php index f6e48438..be313d6c 100644 --- a/tests/Unit/App/AppTest.php +++ b/tests/Unit/App/AppTest.php @@ -28,7 +28,10 @@ public function tearDown(): void public function testAppGetAdapter(): void { - $app = new App(new WebAppAdapter($this->createContext())); + $adapter = new WebAppAdapter($this->createContext()); + $adapter->boot(); + + $app = new App($adapter); $this->assertInstanceOf(WebAppAdapter::class, $app->getAdapter()); @@ -36,7 +39,10 @@ public function testAppGetAdapter(): void config()->flush(); - $app = new App(new ConsoleAppAdapter($this->createContext())); + $adapter = new ConsoleAppAdapter($this->createContext()); + $adapter->boot(); + + $app = new App($adapter); $this->assertInstanceOf(ConsoleAppAdapter::class, $app->getAdapter()); @@ -45,7 +51,10 @@ public function testAppGetAdapter(): void public function testAppCallingValidMethod(): void { - $app = new App(new WebAppAdapter($this->createContext())); + $adapter = new WebAppAdapter($this->createContext()); + $adapter->boot(); + + $app = new App($adapter); request()->create('GET', '/test/am/tests'); @@ -56,7 +65,10 @@ public function testAppCallingValidMethod(): void public function testAppCallingInvalidMethod(): void { - $app = new App(new WebAppAdapter($this->createContext())); + $adapter = new WebAppAdapter($this->createContext()); + $adapter->boot(); + + $app = new App($adapter); $this->expectException(AppException::class); diff --git a/tests/Unit/App/Factories/AppFactoryTest.php b/tests/Unit/App/Factories/AppFactoryTest.php index 1dc30f9c..b216b6a3 100644 --- a/tests/Unit/App/Factories/AppFactoryTest.php +++ b/tests/Unit/App/Factories/AppFactoryTest.php @@ -7,6 +7,7 @@ use Quantum\App\Contracts\AppInterface; use Quantum\App\Adapters\Web\WebAppAdapter; use Quantum\App\Factories\AppFactory; +use Quantum\Environment\Environment; use PHPUnit\Framework\TestCase; use Quantum\App\Enums\AppType; use Quantum\App\App; @@ -49,6 +50,16 @@ public function testAppFactoryWebAdapter(): void $this->assertInstanceOf(AppInterface::class, $app->getAdapter()); } + public function testAppFactoryBootsTheCreatedApp(): void + { + AppFactory::destroy(AppType::WEB); + + AppFactory::create(AppType::WEB, PROJECT_ROOT); + + $this->assertTrue(Di::has(Environment::class)); + $this->assertTrue(config()->has('app')); + } + public function testAppFactoryInvalidAdapter(): void { $this->expectException(AppException::class); diff --git a/tests/Unit/App/PipelineTest.php b/tests/Unit/App/PipelineTest.php index 15fd1315..ffac2687 100644 --- a/tests/Unit/App/PipelineTest.php +++ b/tests/Unit/App/PipelineTest.php @@ -212,20 +212,137 @@ public function testPipelineSkipsStageEventsWhileEventHelperIsNotLoaded(): void $this->assertTrue($processed); } - public function testPipelineCarriesTheStoppedFlagOfAWebContextBetweenStages(): void + public function testPipelineEndsTheRunWhenAStageStopsTheContext(): void { - $stoppedSeenBySecondStage = null; + $log = []; (new Pipeline([ - $this->createStage(function (WebContext $context): void { + $this->createStage(function (AppContext $context) use (&$log): void { + $log[] = 'first'; $context->stop(); }), - $this->createStage(function (WebContext $context) use (&$stoppedSeenBySecondStage): void { - $stoppedSeenBySecondStage = $context->isStopped(); + $this->createStage(function () use (&$log): void { + $log[] = 'second'; }), ]))->run(new WebContext($this->context)); - $this->assertTrue($stoppedSeenBySecondStage); + $this->assertSame(['first'], $log); + } + + public function testPipelineDispatchesNoEventsOfTheStagesAfterAStop(): void + { + $this->loadHelpers(); + + $log = []; + + foreach (['test.first.before', 'test.first.after', 'test.second.before', 'test.second.after'] as $name) { + event()->listen($name, function () use (&$log, $name): void { + $log[] = $name; + }); + } + + (new Pipeline([ + $this->createFirstEventStage(function (AppContext $context): void { + $context->stop(); + }), + $this->createSecondEventStage(function (): void { + }), + ]))->run($this->context); + + $this->assertSame(['test.first.before', 'test.first.after'], $log); + } + + public function testPipelineRunsTheClosingStageAfterTheStages(): void + { + $log = []; + + (new Pipeline( + [ + $this->createStage(function () use (&$log): void { + $log[] = 'first'; + }), + $this->createStage(function () use (&$log): void { + $log[] = 'second'; + }), + ], + $this->createStage(function () use (&$log): void { + $log[] = 'closing'; + }) + ))->run($this->context); + + $this->assertSame(['first', 'second', 'closing'], $log); + } + + public function testPipelineRunsTheClosingStageAfterAStop(): void + { + $log = []; + + (new Pipeline( + [ + $this->createStage(function (AppContext $context) use (&$log): void { + $log[] = 'first'; + $context->stop(); + }), + $this->createStage(function () use (&$log): void { + $log[] = 'second'; + }), + ], + $this->createStage(function () use (&$log): void { + $log[] = 'closing'; + }) + ))->run($this->context); + + $this->assertSame(['first', 'closing'], $log); + } + + public function testPipelineDispatchesTheEventsOfTheClosingStageAfterAStop(): void + { + $this->loadHelpers(); + + $log = []; + + foreach (['test.second.before', 'test.second.after'] as $name) { + event()->listen($name, function () use (&$log, $name): void { + $log[] = $name; + }); + } + + (new Pipeline( + [ + $this->createStage(function (AppContext $context): void { + $context->stop(); + }), + ], + $this->createSecondEventStage(function () use (&$log): void { + $log[] = 'closing'; + }) + ))->run($this->context); + + $this->assertSame(['test.second.before', 'closing', 'test.second.after'], $log); + } + + public function testPipelineSkipsTheClosingStageWhenAStageThrows(): void + { + $closed = false; + + try { + (new Pipeline( + [ + $this->createStage(function (): void { + throw new RuntimeException('Stage failed'); + }), + ], + $this->createStage(function () use (&$closed): void { + $closed = true; + }) + ))->run($this->context); + + $this->fail('Expected the stage exception to propagate.'); + } catch (RuntimeException $exception) { + $this->assertSame('Stage failed', $exception->getMessage()); + } + + $this->assertFalse($closed); } public function testPipelineCarriesTheExitCodeOfAConsoleContextBetweenStages(): void diff --git a/tests/Unit/App/Stages/Console/RegisterAppCommandsStageTest.php b/tests/Unit/App/Stages/Boot/RegisterAppCommandsStageTest.php similarity index 87% rename from tests/Unit/App/Stages/Console/RegisterAppCommandsStageTest.php rename to tests/Unit/App/Stages/Boot/RegisterAppCommandsStageTest.php index 0de21f78..9bba1167 100644 --- a/tests/Unit/App/Stages/Console/RegisterAppCommandsStageTest.php +++ b/tests/Unit/App/Stages/Boot/RegisterAppCommandsStageTest.php @@ -1,8 +1,9 @@ createConsoleContext(['command' => 'needs:env'], $this->createApplication()); + + (new ResolveEnvironmentStage())->process($context); + + $this->assertFalse($context->isStopped()); + } + + /** + * @dataProvider commandsThatRunWithoutTheEnvironment + */ + public function testResolveEnvironmentStageStopsTheBootWhenTheEnvFileIsMissing(string $commandName): void + { + $context = $this->createConsoleContext(['command' => $commandName], $this->createApplication()); + + $this->withoutEnvironmentFile(function () use ($context): void { + (new ResolveEnvironmentStage())->process($context); + }); + + $this->assertTrue($context->isStopped()); + } + + /** + * @dataProvider commandsThatRunWithoutTheEnvironment + */ + public function testResolveEnvironmentStageStopsTheBootWhenTheEnvFileIsMalformed(string $commandName): void + { + $context = $this->createConsoleContext(['command' => $commandName], $this->createApplication()); + + $this->withEnvironmentFileContent("APP_NAME=two words here\n", function () use ($context): void { + (new ResolveEnvironmentStage())->process($context); + }); + + $this->assertTrue($context->isStopped()); + } + + /** + * @dataProvider inputsThatNeedTheEnvironment + * @param array $input + */ + public function testResolveEnvironmentStageThrowsTheEnvExceptionWhenTheEnvFileIsMissing(array $input): void + { + $context = $this->createConsoleContext($input, $this->createApplication()); + + $this->withoutEnvironmentFile(function () use ($context): void { + try { + (new ResolveEnvironmentStage())->process($context); + + $this->fail('The command needs the environment, so the boot must fail.'); + } catch (EnvException $exception) { + $this->assertFalse($context->isStopped()); + } + }); + } + + /** + * @dataProvider inputsThatNeedTheEnvironment + * @param array $input + */ + public function testResolveEnvironmentStageThrowsTheParseExceptionWhenTheEnvFileIsMalformed(array $input): void + { + $context = $this->createConsoleContext($input, $this->createApplication()); + + $this->withEnvironmentFileContent("APP_NAME=two words here\n", function () use ($context): void { + try { + (new ResolveEnvironmentStage())->process($context); + + $this->fail('The command needs the environment, so the boot must fail.'); + } catch (InvalidFileException $exception) { + $this->assertFalse($context->isStopped()); + } + }); + } + + /** + * @return array + */ + public function commandsThatRunWithoutTheEnvironment(): array + { + return [ + 'a CliCommand that does not require the environment' => ['no:env'], + 'list' => ['list'], + 'help' => ['help'], + ]; + } + + /** + * @return array}> + */ + public function inputsThatNeedTheEnvironment(): array + { + return [ + 'a CliCommand that requires the environment' => [['command' => 'needs:env']], + 'an unknown command' => [['command' => 'unknown:command']], + 'no command' => [[]], + ]; + } + + /** + * An application with a CliCommand that requires the environment and one that does not + */ + private function createApplication(): Application + { + $application = new Application(); + + foreach (['needs:env' => true, 'no:env' => false] as $commandName => $requiresEnvironment) { + $application->add(new class ($commandName, $requiresEnvironment) extends CliCommand { + public function __construct(string $commandName, bool $requiresEnvironment) + { + $this->name = $commandName; + $this->requiresEnvironment = $requiresEnvironment; + + parent::__construct(); + } + + public function exec(): void + { + } + }); + } + + return $application; + } + + /** + * Hides the environment file of the test app for the duration of the callback + */ + private function withoutEnvironmentFile(callable $test): void + { + $file = PROJECT_ROOT . DS . '.env.testing'; + $hidden = $file . '.hidden'; + + $this->assertTrue(rename($file, $hidden)); + + try { + $test(); + } finally { + rename($hidden, $file); + } + } + + /** + * Replaces the content of the environment file of the test app for the duration of the callback + */ + private function withEnvironmentFileContent(string $content, callable $test): void + { + $file = PROJECT_ROOT . DS . '.env.testing'; + $backup = $file . '.backup'; + + $this->assertTrue(rename($file, $backup)); + + try { + $this->assertNotFalse(file_put_contents($file, $content)); + + $test(); + } finally { + if (is_file($file)) { + unlink($file); + } + + rename($backup, $file); + } + } +} diff --git a/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php b/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php index fd1e3fef..9c1f526f 100644 --- a/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php +++ b/tests/Unit/App/Stages/Console/ConsoleStageTestCase.php @@ -11,7 +11,6 @@ use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\App; use Quantum\Di\Di; -use Throwable; abstract class ConsoleStageTestCase extends AppTestCase { @@ -19,7 +18,7 @@ abstract class ConsoleStageTestCase extends AppTestCase * Creates a console context around a real Symfony application that neither exits nor catches exceptions. * @param array $input */ - protected function createConsoleContext(array $input = [], ?Application $application = null, ?Throwable $bootFailure = null): ConsoleContext + protected function createConsoleContext(array $input = [], ?Application $application = null): ConsoleContext { $application ??= new Application(); $application->setAutoExit(false); @@ -29,7 +28,7 @@ protected function createConsoleContext(array $input = [], ?Application $applica Di::set(InputInterface::class, new ArrayInput($input)); Di::set(OutputInterface::class, new BufferedOutput()); - return new ConsoleContext(App::getContext(), $bootFailure); + return new ConsoleContext(App::getContext()); } /** diff --git a/tests/Unit/App/Stages/Console/RunCommandStageTest.php b/tests/Unit/App/Stages/Console/RunCommandStageTest.php index 9daf8a8b..146115ec 100644 --- a/tests/Unit/App/Stages/Console/RunCommandStageTest.php +++ b/tests/Unit/App/Stages/Console/RunCommandStageTest.php @@ -9,7 +9,6 @@ use Symfony\Component\Console\Application; use Quantum\App\Enums\ExitCode; use Quantum\Console\CliCommand; -use RuntimeException; use Exception; class RunCommandStageTest extends ConsoleStageTestCase @@ -85,75 +84,27 @@ public function testRunCommandStageRunsNothingWhenTheCommandNameIsMissing(): voi $this->assertSame('', $context->getOutput()->fetch()); } - public function testRunCommandStageRejectsUnknownCommandWhenTheBootFailed(): void + public function testRunCommandStageRunsACliCommand(): void { - $context = $this->createConsoleContext(['command' => 'unknown:command'], $this->createApplication('run:ok', 0), new RuntimeException('The boot failed')); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Command `unknown:command` is not defined'); - - (new RunCommandStage())->process($context); - } - - public function testRunCommandStageRejectsACommandThatRequiresTheEnvironmentWhenTheBootFailed(): void - { - $failure = new RuntimeException('The boot failed'); - $context = $this->createConsoleContext(['command' => 'needs:env'], $this->createCliApplication('needs:env', true), $failure); - - try { - (new RunCommandStage())->process($context); - - $this->fail('The command must not run after a failed boot.'); - } catch (RuntimeException $e) { - $this->assertSame($failure, $e); - } - - $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); - $this->assertSame('', $context->getOutput()->fetch()); - } - - public function testRunCommandStageRunsACommandThatDoesNotRequireTheEnvironmentWhenTheBootFailed(): void - { - $context = $this->createConsoleContext(['command' => 'opt:out'], $this->createCliApplication('opt:out', false), new RuntimeException('The boot failed')); - - (new RunCommandStage())->process($context); - - $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); - $this->assertStringContainsString('ran opt:out', $context->getOutput()->fetch()); - } - - public function testRunCommandStageRunsAPlainSymfonyCommandWhenTheBootFailed(): void - { - $context = $this->createConsoleContext(['command' => 'run:ok'], $this->createApplication('run:ok', 0), new RuntimeException('The boot failed')); - - (new RunCommandStage())->process($context); - - $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); - $this->assertStringContainsString('hello from run:ok', $context->getOutput()->fetch()); - } - - public function testRunCommandStageRunsACommandThatRequiresTheEnvironmentWhenTheBootSucceeded(): void - { - $context = $this->createConsoleContext(['command' => 'needs:env'], $this->createCliApplication('needs:env', true)); + $context = $this->createConsoleContext(['command' => 'cli:ok'], $this->createCliApplication('cli:ok')); (new RunCommandStage())->process($context); $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); - $this->assertStringContainsString('ran needs:env', $context->getOutput()->fetch()); + $this->assertStringContainsString('ran cli:ok', $context->getOutput()->fetch()); } /** - * An application with one CliCommand that does or does not require the environment + * An application with one CliCommand */ - private function createCliApplication(string $commandName, bool $requiresEnvironment): Application + private function createCliApplication(string $commandName): Application { $application = new Application(); - $application->add(new class ($commandName, $requiresEnvironment) extends CliCommand { - public function __construct(string $commandName, bool $requiresEnvironment) + $application->add(new class ($commandName) extends CliCommand { + public function __construct(string $commandName) { $this->name = $commandName; - $this->requiresEnvironment = $requiresEnvironment; parent::__construct(); } diff --git a/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php b/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php index 0ba2d180..55a50775 100644 --- a/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php +++ b/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php @@ -176,17 +176,6 @@ public function testDispatchRequestStageFailsWithoutViewCache(): void (new DispatchRequestStage())->process($this->webContext); } - public function testDispatchRequestStageSkipsWhenContextIsStopped(): void - { - $this->prepareRequest('/test/am/tests'); - - $this->webContext->stop(); - - (new DispatchRequestStage())->process($this->webContext); - - $this->assertNull($this->webContext->getResponse()); - } - public function testDispatchRequestStagePassesTheContextRequestToTheMiddlewares(): void { $this->prepareRequest('/test/am/tests'); diff --git a/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php b/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php index af6223f7..aa6ed2df 100644 --- a/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php +++ b/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php @@ -88,15 +88,4 @@ public function testHandleRouteNotFoundStageIgnoresMatchedRoute(): void $this->assertNull($this->webContext->getResponse()); $this->assertFalse($this->webContext->isStopped()); } - - public function testHandleRouteNotFoundStageSkipsWhenContextIsStopped(): void - { - request()->create('GET', '/non-existing-uri'); - - $this->webContext->stop(); - - (new HandleRouteNotFoundStage())->process($this->webContext); - - $this->assertNull($this->webContext->getResponse()); - } } diff --git a/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php b/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php index 2fd13f97..93e341cc 100644 --- a/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php +++ b/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php @@ -159,14 +159,4 @@ public function testPrepareRequestStageSetsUpTheViewCacheOfTheContextContainer() } } } - - public function testPrepareRequestStageSkipsWhenContextIsStopped(): void - { - $this->webContext->stop(); - - (new PrepareRequestStage())->process($this->webContext); - - $this->assertFalse(config()->has('lang')); - $this->assertFalse(Di::isRegistered(ViewCache::class)); - } } diff --git a/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php b/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php index 306b48a4..726dc19a 100644 --- a/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php +++ b/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php @@ -58,18 +58,6 @@ public function testResolveRouteStageClearsARouteSetBeforeItWhenNoRouteMatches() $this->assertNull(request()->getMatchedRoute()); } - public function testResolveRouteStageSkipsWhenContextIsStopped(): void - { - request()->create('GET', '/test/am/tests'); - - $this->webContext->stop(); - - (new ResolveRouteStage())->process($this->webContext); - - $this->assertNull($this->webContext->getMatchedRoute()); - $this->assertNull(request()->getMatchedRoute()); - } - public function testResolveRouteStageReadsTheRequestAndRoutesFromTheContext(): void { request()->create('GET', '/test/am/tests'); diff --git a/tests/Unit/Console/CommandRegistrarTest.php b/tests/Unit/Console/CommandRegistrarTest.php index 6fb0483f..10f26a4d 100644 --- a/tests/Unit/Console/CommandRegistrarTest.php +++ b/tests/Unit/Console/CommandRegistrarTest.php @@ -4,9 +4,12 @@ use Quantum\Tests\_root\shared\Commands\TestCommand; use Symfony\Component\Console\Command\Command; +use Quantum\App\Stages\Boot\LoadHelpersStage; use Symfony\Component\Console\Application; +use Quantum\Environment\Environment; use Quantum\Console\CommandRegistrar; use Quantum\Tests\Unit\AppTestCase; +use Quantum\Di\Di; class CommandRegistrarTest extends AppTestCase { @@ -63,4 +66,17 @@ public function testCommandRegistrarReplacesAnExistingCommandWithTheSameName(): $this->assertInstanceOf(TestCommand::class, $this->application->get('test:dummy')); } + + public function testCommandRegistrarRegistersTheCoreCommandsWithoutLoadingTheEnvironmentOrAnyConfig(): void + { + $this->clearAppContext(); + + (new LoadHelpersStage())->process($this->createContext()); + + CommandRegistrar::register($this->application, dirname(__DIR__, 3) . DS . 'src' . DS . 'Console' . DS . 'Commands', '\\Quantum\\Console\\Commands\\'); + + $this->assertTrue($this->application->has('core:env')); + $this->assertFalse(Di::has(Environment::class)); + $this->assertNull(config()->all()); + } } diff --git a/tests/Unit/Console/Commands/DebugBarCommandTest.php b/tests/Unit/Console/Commands/DebugBarCommandTest.php index 589a2ea9..d1e4898b 100644 --- a/tests/Unit/Console/Commands/DebugBarCommandTest.php +++ b/tests/Unit/Console/Commands/DebugBarCommandTest.php @@ -5,7 +5,6 @@ use Symfony\Component\Console\Tester\CommandTester; use Quantum\Console\Commands\DebugBarCommand; use Quantum\Tests\Unit\AppTestCase; -use Quantum\Storage\FileSystem; use ReflectionMethod; class DebugBarCommandTest extends AppTestCase @@ -29,12 +28,6 @@ public function testCommandMetadata(): void $this->assertSame('The command will publish debugbar assets', $this->command->getHelp()); } - public function testConstructorInitializesFileSystem(): void - { - $fs = $this->getPrivateProperty($this->command, 'fs'); - $this->assertInstanceOf(FileSystem::class, $fs); - } - public function testExecShowsErrorWhenAlreadyInstalled(): void { $assetsPath = assets_dir() . DS . 'DebugBar' . DS . 'Resources'; @@ -67,7 +60,7 @@ public function testCopyResourcesRecursivelyCopiesFiles(): void $method = new ReflectionMethod($this->command, 'copyResources'); $method->setAccessible(true); - $method->invoke($this->command, $sourceDir, $targetDir); + $method->invoke($this->command, $this->fs, $sourceDir, $targetDir); $this->assertFileExists($targetDir . DS . 'root.css'); $this->assertFileExists($targetDir . DS . 'nested' . DS . 'nested.js'); diff --git a/tests/Unit/Console/Commands/OpenApiCommandTest.php b/tests/Unit/Console/Commands/OpenApiCommandTest.php index cfe6db7c..f4572829 100644 --- a/tests/Unit/Console/Commands/OpenApiCommandTest.php +++ b/tests/Unit/Console/Commands/OpenApiCommandTest.php @@ -6,7 +6,6 @@ use Quantum\Console\Commands\OpenApiCommand; use Quantum\Tests\Unit\AppTestCase; use Quantum\Router\RouteCollection; -use Quantum\Storage\FileSystem; use ReflectionMethod; use Quantum\Di\Di; @@ -36,12 +35,6 @@ public function testCommandArgumentsAreRegistered(): void $this->assertTrue($definition->getArgument('module')->isRequired()); } - public function testConstructorInitializesFileSystem(): void - { - $fs = $this->getPrivateProperty($this->command, 'fs'); - $this->assertInstanceOf(FileSystem::class, $fs); - } - public function testExecShowsErrorWhenModuleIsMissing(): void { $openApiAssets = assets_dir() . DS . 'OpenApiUi'; @@ -191,7 +184,7 @@ public function error(string $message): void try { $method = new ReflectionMethod($command, 'generateOpenapiSpecification'); $method->setAccessible(true); - $method->invoke($command, $moduleName); + $method->invoke($command, $this->fs, $moduleName); $this->assertFileExists($specPath); $this->assertNotSame('', trim((string) file_get_contents($specPath))); diff --git a/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php b/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php index 8fa6a4c3..6268ae76 100644 --- a/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php +++ b/tests/Unit/Console/Commands/ResourceCacheClearCommandTest.php @@ -5,7 +5,6 @@ use Quantum\Console\Commands\ResourceCacheClearCommand; use Symfony\Component\Console\Tester\CommandTester; use Quantum\Tests\Unit\AppTestCase; -use Quantum\Storage\FileSystem; use ReflectionMethod; class ResourceCacheClearCommandTest extends AppTestCase @@ -38,12 +37,6 @@ public function testCommandOptionsAreRegistered(): void $this->assertTrue($definition->hasOption('module')); } - public function testConstructorInitializesFileSystem(): void - { - $fs = $this->getPrivateProperty($this->command, 'fs'); - $this->assertInstanceOf(FileSystem::class, $fs); - } - public function testExecShowsErrorWhenNoOptionsProvided(): void { config()->set('view_cache', ['cache_dir' => 'cache']); @@ -104,7 +97,7 @@ public function testClearResourceModuleAndTypeRemovesMatchedFiles(): void $method = new ReflectionMethod($this->command, 'clearResourceModuleAndType'); $method->setAccessible(true); - $method->invoke($this->command, 'blog', 'views'); + $method->invoke($this->command, $this->fs, 'blog', 'views'); $this->assertFileDoesNotExist($moduleDir . DS . 'view.php');