From b96befa49a33e163162a2a8e55d055f9185e41dd Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:38:55 +0400 Subject: [PATCH 1/9] [#579] Rename the web pipeline, context and stages to match the console ones RequestPipeline, RequestContext and RequestStageInterface become WebPipeline, WebContext and WebStageInterface, and the Quantum\App\Stages\Request namespace and folder become Quantum\App\Stages\Web, like ConsolePipeline, ConsoleContext, ConsoleStageInterface and Stages\Console. The stage class names stay, because "request" there means the HTTP request. The tests follow with the same names. SendResponseStage::cleanupRequestContext() is now cleanupRequestState(): it clears the state held by the HTTP request and had nothing to do with the class that was renamed. The #537 changelog entry uses the new names. No behavior change: applying only this name mapping to the previous version of every changed file gives the new file, apart from three docblock lines. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- src/App/Adapters/Web/WebAppAdapter.php | 16 ++-- .../{RequestContext.php => WebContext.php} | 6 +- .../{RequestPipeline.php => WebPipeline.php} | 20 ++--- ...ageInterface.php => WebStageInterface.php} | 10 +-- .../{Request => Web}/DispatchRequestStage.php | 10 +-- .../{Request => Web}/HandlePreflightStage.php | 10 +-- .../HandleRouteNotFoundStage.php | 10 +-- .../{Request => Web}/PrepareRequestStage.php | 10 +-- .../{Request => Web}/ResolveRouteStage.php | 10 +-- .../{Request => Web}/SendResponseStage.php | 16 ++-- .../Adapters/Console/ConsolePipelineTest.php | 8 +- .../App/Adapters/Web/RequestContextTest.php | 79 ------------------- .../Unit/App/Adapters/Web/WebContextTest.php | 79 +++++++++++++++++++ ...stPipelineTest.php => WebPipelineTest.php} | 52 ++++++------ .../Request/HandlePreflightStageTest.php | 42 ---------- .../Request/HandleRouteNotFoundStageTest.php | 71 ----------------- .../Stages/Request/ResolveRouteStageTest.php | 55 ------------- .../DispatchRequestStageTest.php | 60 +++++++------- .../Stages/Web/HandlePreflightStageTest.php | 42 ++++++++++ .../Web/HandleRouteNotFoundStageTest.php | 71 +++++++++++++++++ .../PrepareRequestStageTest.php | 36 ++++----- .../App/Stages/Web/ResolveRouteStageTest.php | 55 +++++++++++++ .../SendResponseStageTest.php | 28 +++---- 24 files changed, 399 insertions(+), 399 deletions(-) rename src/App/Adapters/Web/{RequestContext.php => WebContext.php} (93%) rename src/App/Adapters/Web/{RequestPipeline.php => WebPipeline.php} (57%) rename src/App/Contracts/{RequestStageInterface.php => WebStageInterface.php} (53%) rename src/App/Stages/{Request => Web}/DispatchRequestStage.php (85%) rename src/App/Stages/{Request => Web}/HandlePreflightStage.php (69%) rename src/App/Stages/{Request => Web}/HandleRouteNotFoundStage.php (75%) rename src/App/Stages/{Request => Web}/PrepareRequestStage.php (86%) rename src/App/Stages/{Request => Web}/ResolveRouteStage.php (78%) rename src/App/Stages/{Request => Web}/SendResponseStage.php (77%) delete mode 100644 tests/Unit/App/Adapters/Web/RequestContextTest.php create mode 100644 tests/Unit/App/Adapters/Web/WebContextTest.php rename tests/Unit/App/Adapters/Web/{RequestPipelineTest.php => WebPipelineTest.php} (65%) delete mode 100644 tests/Unit/App/Stages/Request/HandlePreflightStageTest.php delete mode 100644 tests/Unit/App/Stages/Request/HandleRouteNotFoundStageTest.php delete mode 100644 tests/Unit/App/Stages/Request/ResolveRouteStageTest.php rename tests/Unit/App/Stages/{Request => Web}/DispatchRequestStageTest.php (69%) create mode 100644 tests/Unit/App/Stages/Web/HandlePreflightStageTest.php create mode 100644 tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php rename tests/Unit/App/Stages/{Request => Web}/PrepareRequestStageTest.php (71%) create mode 100644 tests/Unit/App/Stages/Web/ResolveRouteStageTest.php rename tests/Unit/App/Stages/{Request => Web}/SendResponseStageTest.php (76%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25956c6b..54329bc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on Keep a Changelog. - `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 `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. + - `WebAppAdapter::start()` now runs a `WebPipeline` of the preflight, route resolution, not found, prepare, dispatch, and send stages with a `WebContext`, 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. - **BREAKING:** Replaced the `Quantum\Hook` package with `Quantum\Event` (#536): diff --git a/src/App/Adapters/Web/WebAppAdapter.php b/src/App/Adapters/Web/WebAppAdapter.php index f975070d..83c2682f 100644 --- a/src/App/Adapters/Web/WebAppAdapter.php +++ b/src/App/Adapters/Web/WebAppAdapter.php @@ -10,12 +10,12 @@ namespace Quantum\App\Adapters\Web; -use Quantum\App\Stages\Request\HandleRouteNotFoundStage; -use Quantum\App\Stages\Request\HandlePreflightStage; -use Quantum\App\Stages\Request\DispatchRequestStage; -use Quantum\App\Stages\Request\PrepareRequestStage; -use Quantum\App\Stages\Request\SendResponseStage; -use Quantum\App\Stages\Request\ResolveRouteStage; +use Quantum\App\Stages\Web\HandleRouteNotFoundStage; +use Quantum\App\Stages\Web\HandlePreflightStage; +use Quantum\App\Stages\Web\DispatchRequestStage; +use Quantum\App\Stages\Web\PrepareRequestStage; +use Quantum\App\Stages\Web\SendResponseStage; +use Quantum\App\Stages\Web\ResolveRouteStage; use Quantum\App\Stages\Boot\SetupErrorHandlerStage; use Quantum\App\Stages\Boot\LoadEnvironmentStage; use Quantum\App\Stages\Boot\LoadAppConfigStage; @@ -55,7 +55,7 @@ public function __construct(AppContext $context) public function start(): ?int { - $pipeline = new RequestPipeline([ + $pipeline = new WebPipeline([ new HandlePreflightStage(), new ResolveRouteStage(), new HandleRouteNotFoundStage(), @@ -64,7 +64,7 @@ public function start(): ?int new SendResponseStage(), ]); - $pipeline->run(new RequestContext($this->context)); + $pipeline->run(new WebContext($this->context)); return ExitCode::SUCCESS; } diff --git a/src/App/Adapters/Web/RequestContext.php b/src/App/Adapters/Web/WebContext.php similarity index 93% rename from src/App/Adapters/Web/RequestContext.php rename to src/App/Adapters/Web/WebContext.php index fa587a2b..dce8bd13 100644 --- a/src/App/Adapters/Web/RequestContext.php +++ b/src/App/Adapters/Web/WebContext.php @@ -16,14 +16,14 @@ use Quantum\Http\Response; /** - * Class RequestContext + * Class WebContext * - * Carries the state of a single web request through the request stages. + * Carries the state of a single web request through the web stages. * Unlike AppContext, which is immutable, this object is mutated by the stages. * * @package Quantum\App */ -class RequestContext +class WebContext { private AppContext $appContext; diff --git a/src/App/Adapters/Web/RequestPipeline.php b/src/App/Adapters/Web/WebPipeline.php similarity index 57% rename from src/App/Adapters/Web/RequestPipeline.php rename to src/App/Adapters/Web/WebPipeline.php index fe56aabe..ed20529d 100644 --- a/src/App/Adapters/Web/RequestPipeline.php +++ b/src/App/Adapters/Web/WebPipeline.php @@ -10,34 +10,34 @@ namespace Quantum\App\Adapters\Web; -use Quantum\App\Contracts\RequestStageInterface; +use Quantum\App\Contracts\WebStageInterface; use InvalidArgumentException; /** - * Class RequestPipeline + * Class WebPipeline * - * Runs the web request stages in order. The pipeline does not skip stages by itself; - * a stage checks RequestContext::isStopped() when it must be skipped. + * Runs the web stages in order. The pipeline does not skip stages by itself; + * a stage checks WebContext::isStopped() when it must be skipped. * Stage lifecycle events are not dispatched here yet. * * @package Quantum\App */ -class RequestPipeline +class WebPipeline { /** - * @var RequestStageInterface[] + * @var WebStageInterface[] */ private array $stages; /** - * @param RequestStageInterface[] $stages + * @param WebStageInterface[] $stages */ public function __construct(array $stages = []) { foreach ($stages as $stage) { - if (!$stage instanceof RequestStageInterface) { + if (!$stage instanceof WebStageInterface) { throw new InvalidArgumentException( - 'All stages must implement ' . RequestStageInterface::class + 'All stages must implement ' . WebStageInterface::class ); } } @@ -45,7 +45,7 @@ public function __construct(array $stages = []) $this->stages = $stages; } - public function run(RequestContext $context): void + public function run(WebContext $context): void { foreach ($this->stages as $stage) { $stage->process($context); diff --git a/src/App/Contracts/RequestStageInterface.php b/src/App/Contracts/WebStageInterface.php similarity index 53% rename from src/App/Contracts/RequestStageInterface.php rename to src/App/Contracts/WebStageInterface.php index 921529bf..88720bbb 100644 --- a/src/App/Contracts/RequestStageInterface.php +++ b/src/App/Contracts/WebStageInterface.php @@ -10,16 +10,16 @@ namespace Quantum\App\Contracts; -use Quantum\App\Adapters\Web\RequestContext; +use Quantum\App\Adapters\Web\WebContext; /** - * Interface RequestStageInterface + * Interface WebStageInterface * @package Quantum\App */ -interface RequestStageInterface +interface WebStageInterface { /** - * Processes a single request stage + * Processes a single web stage */ - public function process(RequestContext $context): void; + public function process(WebContext $context): void; } diff --git a/src/App/Stages/Request/DispatchRequestStage.php b/src/App/Stages/Web/DispatchRequestStage.php similarity index 85% rename from src/App/Stages/Request/DispatchRequestStage.php rename to src/App/Stages/Web/DispatchRequestStage.php index 1781f562..4125731b 100644 --- a/src/App/Stages/Request/DispatchRequestStage.php +++ b/src/App/Stages/Web/DispatchRequestStage.php @@ -8,14 +8,14 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Request; +namespace Quantum\App\Stages\Web; use Quantum\Middleware\Exceptions\MiddlewareException; -use Quantum\App\Contracts\RequestStageInterface; +use Quantum\App\Contracts\WebStageInterface; use Quantum\App\Exceptions\BaseException; use Quantum\Middleware\MiddlewareManager; use Quantum\Router\RouteDispatcher; -use Quantum\App\Adapters\Web\RequestContext; +use Quantum\App\Adapters\Web\WebContext; use Quantum\Http\Response; use Quantum\Http\Request; use RuntimeException; @@ -28,12 +28,12 @@ * * @package Quantum\App */ -class DispatchRequestStage implements RequestStageInterface +class DispatchRequestStage implements WebStageInterface { /** * @throws MiddlewareException|BaseException */ - public function process(RequestContext $context): void + public function process(WebContext $context): void { if ($context->isStopped()) { return; diff --git a/src/App/Stages/Request/HandlePreflightStage.php b/src/App/Stages/Web/HandlePreflightStage.php similarity index 69% rename from src/App/Stages/Request/HandlePreflightStage.php rename to src/App/Stages/Web/HandlePreflightStage.php index c99cdd85..d7142188 100644 --- a/src/App/Stages/Request/HandlePreflightStage.php +++ b/src/App/Stages/Web/HandlePreflightStage.php @@ -8,22 +8,22 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Request; +namespace Quantum\App\Stages\Web; -use Quantum\App\Contracts\RequestStageInterface; +use Quantum\App\Contracts\WebStageInterface; use Quantum\Http\Enums\StatusCode; -use Quantum\App\Adapters\Web\RequestContext; +use Quantum\App\Adapters\Web\WebContext; /** * Class HandlePreflightStage * @package Quantum\App */ -class HandlePreflightStage implements RequestStageInterface +class HandlePreflightStage implements WebStageInterface { /** * Answers an OPTIONS request with an empty response and stops the remaining stages */ - public function process(RequestContext $context): void + public function process(WebContext $context): void { if (!request()->isMethod('OPTIONS')) { return; diff --git a/src/App/Stages/Request/HandleRouteNotFoundStage.php b/src/App/Stages/Web/HandleRouteNotFoundStage.php similarity index 75% rename from src/App/Stages/Request/HandleRouteNotFoundStage.php rename to src/App/Stages/Web/HandleRouteNotFoundStage.php index a405e11d..75c00f5a 100644 --- a/src/App/Stages/Request/HandleRouteNotFoundStage.php +++ b/src/App/Stages/Web/HandleRouteNotFoundStage.php @@ -8,26 +8,26 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Request; +namespace Quantum\App\Stages\Web; -use Quantum\App\Contracts\RequestStageInterface; +use Quantum\App\Contracts\WebStageInterface; use Quantum\Config\Exceptions\ConfigException; use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; -use Quantum\App\Adapters\Web\RequestContext; +use Quantum\App\Adapters\Web\WebContext; use ReflectionException; /** * Class HandleRouteNotFoundStage * @package Quantum\App */ -class HandleRouteNotFoundStage implements RequestStageInterface +class HandleRouteNotFoundStage implements WebStageInterface { /** * Answers a request that matched no route with the page not found response and stops the remaining stages * @throws ConfigException|BaseException|DiException|ReflectionException */ - public function process(RequestContext $context): void + public function process(WebContext $context): void { if ($context->isStopped() || $context->getMatchedRoute() !== null) { return; diff --git a/src/App/Stages/Request/PrepareRequestStage.php b/src/App/Stages/Web/PrepareRequestStage.php similarity index 86% rename from src/App/Stages/Request/PrepareRequestStage.php rename to src/App/Stages/Web/PrepareRequestStage.php index ddcfeabc..a48b70a1 100644 --- a/src/App/Stages/Request/PrepareRequestStage.php +++ b/src/App/Stages/Web/PrepareRequestStage.php @@ -8,15 +8,15 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Request; +namespace Quantum\App\Stages\Web; -use Quantum\App\Contracts\RequestStageInterface; +use Quantum\App\Contracts\WebStageInterface; use Quantum\Config\Exceptions\ConfigException; use Quantum\Lang\Exceptions\LangException; use Quantum\Lang\Factories\LangFactory; use Quantum\Di\Exceptions\DiException; use Quantum\ResourceCache\ViewCache; -use Quantum\App\Adapters\Web\RequestContext; +use Quantum\App\Adapters\Web\WebContext; use Quantum\Debugger\Debugger; use ReflectionException; use Quantum\Di\Di; @@ -29,12 +29,12 @@ * * @package Quantum\App */ -class PrepareRequestStage implements RequestStageInterface +class PrepareRequestStage implements WebStageInterface { /** * @throws LangException|ConfigException|DiException|ReflectionException */ - public function process(RequestContext $context): void + public function process(WebContext $context): void { if ($context->isStopped()) { return; diff --git a/src/App/Stages/Request/ResolveRouteStage.php b/src/App/Stages/Web/ResolveRouteStage.php similarity index 78% rename from src/App/Stages/Request/ResolveRouteStage.php rename to src/App/Stages/Web/ResolveRouteStage.php index 729ce8ec..26c6e73d 100644 --- a/src/App/Stages/Request/ResolveRouteStage.php +++ b/src/App/Stages/Web/ResolveRouteStage.php @@ -8,13 +8,13 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Request; +namespace Quantum\App\Stages\Web; -use Quantum\App\Contracts\RequestStageInterface; +use Quantum\App\Contracts\WebStageInterface; use Quantum\Router\Exceptions\RouteException; use Quantum\App\Exceptions\BaseException; use Quantum\Router\RouteCollection; -use Quantum\App\Adapters\Web\RequestContext; +use Quantum\App\Adapters\Web\WebContext; use Quantum\Router\RouteFinder; use Quantum\Di\Di; @@ -22,13 +22,13 @@ * Class ResolveRouteStage * @package Quantum\App */ -class ResolveRouteStage implements RequestStageInterface +class ResolveRouteStage implements WebStageInterface { /** * Finds the route for the current request and stores it on the context and the request * @throws RouteException|BaseException */ - public function process(RequestContext $context): void + public function process(WebContext $context): void { if ($context->isStopped()) { return; diff --git a/src/App/Stages/Request/SendResponseStage.php b/src/App/Stages/Web/SendResponseStage.php similarity index 77% rename from src/App/Stages/Request/SendResponseStage.php rename to src/App/Stages/Web/SendResponseStage.php index d1d61519..812154a8 100644 --- a/src/App/Stages/Request/SendResponseStage.php +++ b/src/App/Stages/Web/SendResponseStage.php @@ -8,12 +8,12 @@ * @link https://quantumphp.io */ -namespace Quantum\App\Stages\Request; +namespace Quantum\App\Stages\Web; -use Quantum\App\Contracts\RequestStageInterface; +use Quantum\App\Contracts\WebStageInterface; use Quantum\Config\Exceptions\ConfigException; use Quantum\Di\Exceptions\DiException; -use Quantum\App\Adapters\Web\RequestContext; +use Quantum\App\Adapters\Web\WebContext; use Quantum\Http\Response; use Quantum\Config\Setup; use ReflectionException; @@ -24,16 +24,16 @@ * Class SendResponseStage * * Sends the response of the request and always clears the request state afterwards, - * even when sending fails. This stage never checks RequestContext::isStopped(). + * even when sending fails. This stage never checks WebContext::isStopped(). * * @package Quantum\App */ -class SendResponseStage implements RequestStageInterface +class SendResponseStage implements WebStageInterface { /** * @throws ConfigException|DiException|ReflectionException|Exception */ - public function process(RequestContext $context): void + public function process(WebContext $context): void { try { $response = $context->getResponse(); @@ -45,7 +45,7 @@ public function process(RequestContext $context): void $this->handleCors($response); $response->send(); } finally { - $this->cleanupRequestContext(); + $this->cleanupRequestState(); } } @@ -66,7 +66,7 @@ private function handleCors(Response $response): void /** * Clears request-scoped state after the response has been sent. */ - private function cleanupRequestContext(): void + private function cleanupRequestState(): void { request()->setMatchedRoute(null); request()->flush(); diff --git a/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php b/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php index caee0e55..746f0e56 100644 --- a/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php +++ b/tests/Unit/App/Adapters/Console/ConsolePipelineTest.php @@ -3,12 +3,12 @@ namespace Quantum\Tests\Unit\App\Adapters\Console; use Quantum\App\Contracts\ConsoleStageInterface; -use Quantum\App\Contracts\RequestStageInterface; +use Quantum\App\Contracts\WebStageInterface; use Quantum\App\Contracts\BootStageInterface; use Quantum\Tests\Unit\AppTestCase; use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\Adapters\Console\ConsolePipeline; -use Quantum\App\Adapters\Web\RequestContext; +use Quantum\App\Adapters\Web\WebContext; use InvalidArgumentException; use Quantum\App\Enums\ExitCode; use Quantum\App\AppContext; @@ -130,8 +130,8 @@ public function process(AppContext $context): void public function testPipelineRejectsRequestStage(): void { - $requestStage = new class () implements RequestStageInterface { - public function process(RequestContext $context): void + $requestStage = new class () implements WebStageInterface { + public function process(WebContext $context): void { } }; diff --git a/tests/Unit/App/Adapters/Web/RequestContextTest.php b/tests/Unit/App/Adapters/Web/RequestContextTest.php deleted file mode 100644 index 3dafda20..00000000 --- a/tests/Unit/App/Adapters/Web/RequestContextTest.php +++ /dev/null @@ -1,79 +0,0 @@ -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/WebContextTest.php b/tests/Unit/App/Adapters/Web/WebContextTest.php new file mode 100644 index 00000000..9436fb4b --- /dev/null +++ b/tests/Unit/App/Adapters/Web/WebContextTest.php @@ -0,0 +1,79 @@ +appContext = App::getContext(); + $this->webContext = new WebContext($this->appContext); + } + + public function testWebContextExposesAppContext(): void + { + $this->assertSame($this->appContext, $this->webContext->getAppContext()); + } + + public function testWebContextStoresMatchedRoute(): void + { + $this->assertNull($this->webContext->getMatchedRoute()); + + $matchedRoute = new MatchedRoute(new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), []); + + $this->webContext->setMatchedRoute($matchedRoute); + + $this->assertSame($matchedRoute, $this->webContext->getMatchedRoute()); + } + + public function testWebContextStoresResponse(): void + { + $this->assertNull($this->webContext->getResponse()); + + $this->webContext->setResponse(response()); + + $this->assertSame(response(), $this->webContext->getResponse()); + } + + public function testWebContextStoresViewCache(): void + { + $this->assertNull($this->webContext->getViewCache()); + + if (!Di::isRegistered(ViewCache::class)) { + Di::register(ViewCache::class); + } + + $viewCache = Di::get(ViewCache::class); + + $this->webContext->setViewCache($viewCache); + + $this->assertSame($viewCache, $this->webContext->getViewCache()); + } + + public function testWebContextIsNotStoppedByDefault(): void + { + $this->assertFalse($this->webContext->isStopped()); + } + + public function testWebContextStopMarksContextStopped(): void + { + $this->webContext->stop(); + + $this->assertTrue($this->webContext->isStopped()); + } +} diff --git a/tests/Unit/App/Adapters/Web/RequestPipelineTest.php b/tests/Unit/App/Adapters/Web/WebPipelineTest.php similarity index 65% rename from tests/Unit/App/Adapters/Web/RequestPipelineTest.php rename to tests/Unit/App/Adapters/Web/WebPipelineTest.php index dd0e8b92..f93eaa4e 100644 --- a/tests/Unit/App/Adapters/Web/RequestPipelineTest.php +++ b/tests/Unit/App/Adapters/Web/WebPipelineTest.php @@ -2,22 +2,22 @@ namespace Quantum\Tests\Unit\App\Adapters\Web; -use Quantum\App\Contracts\RequestStageInterface; +use Quantum\App\Contracts\WebStageInterface; use Quantum\App\Contracts\BootStageInterface; -use Quantum\App\Adapters\Web\RequestPipeline; +use Quantum\App\Adapters\Web\WebPipeline; use Quantum\Tests\Unit\AppTestCase; -use Quantum\App\Adapters\Web\RequestContext; +use Quantum\App\Adapters\Web\WebContext; use InvalidArgumentException; use Quantum\App\AppContext; use RuntimeException; -class RequestPipelineTest extends AppTestCase +class WebPipelineTest extends AppTestCase { - private RequestContext $requestContext; + private WebContext $webContext; public function setUp(): void { - $this->requestContext = new RequestContext($this->createContext()); + $this->webContext = new WebContext($this->createContext()); } public function tearDown(): void @@ -29,7 +29,7 @@ public function testPipelineRunsStagesInOrder(): void { $log = []; - $pipeline = new RequestPipeline([ + $pipeline = new WebPipeline([ $this->createStage(function () use (&$log): void { $log[] = 'first'; }), @@ -41,38 +41,38 @@ public function testPipelineRunsStagesInOrder(): void }), ]); - $pipeline->run($this->requestContext); + $pipeline->run($this->webContext); $this->assertSame(['first', 'second', 'third'], $log); } public function testEmptyPipelineRunsWithoutError(): void { - $pipeline = new RequestPipeline([]); + $pipeline = new WebPipeline([]); - $pipeline->run($this->requestContext); + $pipeline->run($this->webContext); - $this->assertFalse($this->requestContext->isStopped()); + $this->assertFalse($this->webContext->isStopped()); } public function testPipelinePassesContextToStages(): void { $received = null; - $pipeline = new RequestPipeline([ - $this->createStage(function (RequestContext $context) use (&$received): void { + $pipeline = new WebPipeline([ + $this->createStage(function (WebContext $context) use (&$received): void { $received = $context; }), ]); - $pipeline->run($this->requestContext); + $pipeline->run($this->webContext); - $this->assertSame($this->requestContext, $received); + $this->assertSame($this->webContext, $received); } public function testPipelinePropagatesException(): void { - $pipeline = new RequestPipeline([ + $pipeline = new WebPipeline([ $this->createStage(function (): void { throw new RuntimeException('Stage failed'); }), @@ -81,15 +81,15 @@ public function testPipelinePropagatesException(): void $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Stage failed'); - $pipeline->run($this->requestContext); + $pipeline->run($this->webContext); } public function testPipelineKeepsRunningStagesAfterContextIsStopped(): void { $log = []; - $pipeline = new RequestPipeline([ - $this->createStage(function (RequestContext $context) use (&$log): void { + $pipeline = new WebPipeline([ + $this->createStage(function (WebContext $context) use (&$log): void { $log[] = 'first'; $context->stop(); }), @@ -98,10 +98,10 @@ public function testPipelineKeepsRunningStagesAfterContextIsStopped(): void }), ]); - $pipeline->run($this->requestContext); + $pipeline->run($this->webContext); $this->assertSame(['first', 'second'], $log); - $this->assertTrue($this->requestContext->isStopped()); + $this->assertTrue($this->webContext->isStopped()); } public function testPipelineRejectsInvalidStage(): void @@ -109,7 +109,7 @@ public function testPipelineRejectsInvalidStage(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('All stages must implement'); - new RequestPipeline([new \stdClass()]); + new WebPipeline([new \stdClass()]); } public function testPipelineRejectsBootStage(): void @@ -123,12 +123,12 @@ public function process(AppContext $context): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('All stages must implement'); - new RequestPipeline([$bootStage]); + new WebPipeline([$bootStage]); } - private function createStage(callable $callback): RequestStageInterface + private function createStage(callable $callback): WebStageInterface { - return new class ($callback) implements RequestStageInterface { + return new class ($callback) implements WebStageInterface { private $callback; public function __construct(callable $callback) @@ -136,7 +136,7 @@ public function __construct(callable $callback) $this->callback = $callback; } - public function process(RequestContext $context): void + public function process(WebContext $context): void { ($this->callback)($context); } diff --git a/tests/Unit/App/Stages/Request/HandlePreflightStageTest.php b/tests/Unit/App/Stages/Request/HandlePreflightStageTest.php deleted file mode 100644 index 98e3854e..00000000 --- a/tests/Unit/App/Stages/Request/HandlePreflightStageTest.php +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 7d2a3b72..00000000 --- a/tests/Unit/App/Stages/Request/HandleRouteNotFoundStageTest.php +++ /dev/null @@ -1,71 +0,0 @@ -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/ResolveRouteStageTest.php b/tests/Unit/App/Stages/Request/ResolveRouteStageTest.php deleted file mode 100644 index 085e852d..00000000 --- a/tests/Unit/App/Stages/Request/ResolveRouteStageTest.php +++ /dev/null @@ -1,55 +0,0 @@ -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/DispatchRequestStageTest.php b/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php similarity index 69% rename from tests/Unit/App/Stages/Request/DispatchRequestStageTest.php rename to tests/Unit/App/Stages/Web/DispatchRequestStageTest.php index 0462b3e5..a21e08cb 100644 --- a/tests/Unit/App/Stages/Request/DispatchRequestStageTest.php +++ b/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php @@ -27,14 +27,14 @@ public function apply(Request $request, Closure $next): Response } } -namespace Quantum\Tests\Unit\App\Stages\Request { +namespace Quantum\Tests\Unit\App\Stages\Web { use Quantum\Tests\_root\modules\Test\Middlewares\DispatchStageMwPassThrough; - use Quantum\App\Stages\Request\DispatchRequestStage; - use Quantum\App\Stages\Request\ResolveRouteStage; + use Quantum\App\Stages\Web\DispatchRequestStage; + use Quantum\App\Stages\Web\ResolveRouteStage; use Quantum\Router\Exceptions\RouteException; use Quantum\Tests\Unit\AppTestCase; use Quantum\ResourceCache\ViewCache; - use Quantum\App\Adapters\Web\RequestContext; + use Quantum\App\Adapters\Web\WebContext; use Quantum\Router\MatchedRoute; use Quantum\Http\Enums\StatusCode; use Quantum\Router\Route; @@ -44,13 +44,13 @@ public function apply(Request $request, Closure $next): Response class DispatchRequestStageTest extends AppTestCase { - private RequestContext $requestContext; + private WebContext $webContext; public function setUp(): void { parent::setUp(); - $this->requestContext = new RequestContext(App::getContext()); + $this->webContext = new WebContext(App::getContext()); DispatchStageMwPassThrough::$calls = []; } @@ -59,11 +59,11 @@ public function testDispatchRequestStageDispatchesMatchedRoute(): void { $this->prepareRequest('/test/am/tests'); - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); - $this->assertSame(response(), $this->requestContext->getResponse()); - $this->assertSame(StatusCode::OK, $this->requestContext->getResponse()->getStatusCode()); - $this->assertSame('', $this->requestContext->getResponse()->getContent()); + $this->assertSame(response(), $this->webContext->getResponse()); + $this->assertSame(StatusCode::OK, $this->webContext->getResponse()->getStatusCode()); + $this->assertSame('', $this->webContext->getResponse()->getContent()); } public function testDispatchRequestStageServesViewCacheHit(): void @@ -76,15 +76,15 @@ public function testDispatchRequestStageServesViewCacheHit(): void $viewsDirExisted = is_dir($viewsDir); $moduleDirExisted = is_dir($moduleDir); - $viewCache = $this->requestContext->getViewCache(); + $viewCache = $this->webContext->getViewCache(); $viewCache->setup(); $viewCache->enableCaching(true); $viewCache->set($uri, '

cached page

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

cached page

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

cached page

', $this->webContext->getResponse()->getContent()); } finally { $viewCache->delete($uri); $viewCache->enableCaching(false); @@ -103,12 +103,12 @@ public function testDispatchRequestStageRunsModuleMiddlewaresBeforeController(): { $this->prepareRequest('/test/am/tests'); - $this->requestContext->getMatchedRoute()->getRoute()->addMiddlewares(['DispatchStageMwPassThrough']); + $this->webContext->getMatchedRoute()->getRoute()->addMiddlewares(['DispatchStageMwPassThrough']); - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); $this->assertSame(['DispatchStageMwPassThrough'], DispatchStageMwPassThrough::$calls); - $this->assertSame(StatusCode::OK, $this->requestContext->getResponse()->getStatusCode()); + $this->assertSame(StatusCode::OK, $this->webContext->getResponse()->getStatusCode()); } public function testDispatchRequestStageLetsMiddlewareShortCircuit(): void @@ -118,12 +118,12 @@ public function testDispatchRequestStageLetsMiddlewareShortCircuit(): void // The controller does not exist, so reaching the dispatcher would throw. $matchedRoute = new MatchedRoute($this->createRoute('tests', ['DispatchStageMwBlocker'], 'MissingController'), []); - $this->requestContext->setMatchedRoute($matchedRoute); + $this->webContext->setMatchedRoute($matchedRoute); request()->setMatchedRoute($matchedRoute); - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); - $this->assertStringContainsString('blocked', $this->requestContext->getResponse()->getContent()); + $this->assertStringContainsString('blocked', $this->webContext->getResponse()->getContent()); } public function testDispatchRequestStagePropagatesDispatchException(): void @@ -131,53 +131,53 @@ public function testDispatchRequestStagePropagatesDispatchException(): void $this->prepareRequest('/test/Test/5'); try { - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); $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()); + $this->assertNull($this->webContext->getResponse()); } public function testDispatchRequestStageFailsWithoutMatchedRoute(): void { - $this->requestContext->setViewCache($this->viewCache()); + $this->webContext->setViewCache($this->viewCache()); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Matched route is not set.'); - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); } public function testDispatchRequestStageFailsWithoutViewCache(): void { - $this->requestContext->setMatchedRoute(new MatchedRoute($this->createRoute('tests'), [])); + $this->webContext->setMatchedRoute(new MatchedRoute($this->createRoute('tests'), [])); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('View cache is not set.'); - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); } public function testDispatchRequestStageSkipsWhenContextIsStopped(): void { $this->prepareRequest('/test/am/tests'); - $this->requestContext->stop(); + $this->webContext->stop(); - (new DispatchRequestStage())->process($this->requestContext); + (new DispatchRequestStage())->process($this->webContext); - $this->assertNull($this->requestContext->getResponse()); + $this->assertNull($this->webContext->getResponse()); } private function prepareRequest(string $uri): void { request()->create('GET', $uri); - (new ResolveRouteStage())->process($this->requestContext); + (new ResolveRouteStage())->process($this->webContext); - $this->requestContext->setViewCache($this->viewCache()); + $this->webContext->setViewCache($this->viewCache()); } private function viewCache(): ViewCache diff --git a/tests/Unit/App/Stages/Web/HandlePreflightStageTest.php b/tests/Unit/App/Stages/Web/HandlePreflightStageTest.php new file mode 100644 index 00000000..a2025f0e --- /dev/null +++ b/tests/Unit/App/Stages/Web/HandlePreflightStageTest.php @@ -0,0 +1,42 @@ +webContext = new WebContext(App::getContext()); + } + + public function testHandlePreflightStageRespondsNoContentToOptionsRequest(): void + { + request()->create('OPTIONS', '/test/am/tests'); + + (new HandlePreflightStage())->process($this->webContext); + + $this->assertSame(response(), $this->webContext->getResponse()); + $this->assertSame(StatusCode::NO_CONTENT, $this->webContext->getResponse()->getStatusCode()); + $this->assertTrue($this->webContext->isStopped()); + } + + public function testHandlePreflightStageIgnoresOtherMethods(): void + { + request()->create('GET', '/test/am/tests'); + + (new HandlePreflightStage())->process($this->webContext); + + $this->assertNull($this->webContext->getResponse()); + $this->assertFalse($this->webContext->isStopped()); + } +} diff --git a/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php b/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php new file mode 100644 index 00000000..c3c5cd59 --- /dev/null +++ b/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php @@ -0,0 +1,71 @@ +webContext = new WebContext(App::getContext()); + } + + public function testHandleRouteNotFoundStageRespondsNotFoundWhenNoRouteMatched(): void + { + request()->create('GET', '/non-existing-uri'); + + (new HandleRouteNotFoundStage())->process($this->webContext); + + $this->assertSame(response(), $this->webContext->getResponse()); + $this->assertSame(StatusCode::NOT_FOUND, $this->webContext->getResponse()->getStatusCode()); + $this->assertSame(ContentType::HTML, $this->webContext->getResponse()->getContentType()); + $this->assertTrue($this->webContext->isStopped()); + } + + public function testHandleRouteNotFoundStageRespondsWithJsonForJsonRequests(): void + { + request()->create('GET', '/non-existing-uri', [], ['Accept' => 'application/json']); + + (new HandleRouteNotFoundStage())->process($this->webContext); + + $this->assertSame(StatusCode::NOT_FOUND, $this->webContext->getResponse()->getStatusCode()); + $this->assertSame(ContentType::JSON, $this->webContext->getResponse()->getContentType()); + $this->assertStringContainsString('Page not found', $this->webContext->getResponse()->getContent()); + $this->assertTrue($this->webContext->isStopped()); + } + + public function testHandleRouteNotFoundStageIgnoresMatchedRoute(): void + { + $this->webContext->setMatchedRoute( + new MatchedRoute(new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), []) + ); + + (new HandleRouteNotFoundStage())->process($this->webContext); + + $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/Request/PrepareRequestStageTest.php b/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php similarity index 71% rename from tests/Unit/App/Stages/Request/PrepareRequestStageTest.php rename to tests/Unit/App/Stages/Web/PrepareRequestStageTest.php index 1331d6f3..bbe2f73d 100644 --- a/tests/Unit/App/Stages/Request/PrepareRequestStageTest.php +++ b/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php @@ -1,11 +1,11 @@ requestContext = new RequestContext(App::getContext()); + $this->webContext = new WebContext(App::getContext()); request()->create('GET', '/test/am/tests'); @@ -34,7 +34,7 @@ public function testPrepareRequestStageLoadsLanguage(): void { $this->assertFalse(config()->has('lang')); - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); $this->assertTrue(config()->has('lang')); } @@ -48,7 +48,7 @@ public function testPrepareRequestStageLogsRegisteredEventsToDebugger(): void event()->listen('test.event', $listener); - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); $cell = debugbar()->getStoreCell(Debugger::EVENTS); @@ -62,7 +62,7 @@ public function testPrepareRequestStageLogsNothingToDebuggerWithoutEventListener $this->assertSame([], event()->getRegistered()); - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); } @@ -74,19 +74,19 @@ public function testPrepareRequestStageSkipsDebuggerLoggingWhenDebugDisabled(): event()->listen('test.event', function (): void { }); - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); } public function testPrepareRequestStageStoresViewCacheOnContext(): void { - $this->assertNull($this->requestContext->getViewCache()); + $this->assertNull($this->webContext->getViewCache()); - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); - $this->assertInstanceOf(ViewCache::class, $this->requestContext->getViewCache()); - $this->assertSame(Di::get(ViewCache::class), $this->requestContext->getViewCache()); + $this->assertInstanceOf(ViewCache::class, $this->webContext->getViewCache()); + $this->assertSame(Di::get(ViewCache::class), $this->webContext->getViewCache()); } public function testPrepareRequestStageSetsUpViewCacheWhenEnabled(): void @@ -101,7 +101,7 @@ public function testPrepareRequestStageSetsUpViewCacheWhenEnabled(): void Di::get(ViewCache::class)->enableCaching(true); try { - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); $this->assertTrue(config()->has('view_cache')); $this->assertDirectoryExists($moduleDir); @@ -118,18 +118,18 @@ public function testPrepareRequestStageSetsUpViewCacheWhenEnabled(): void public function testPrepareRequestStageDoesNotSetUpViewCacheWhenDisabled(): void { - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); $this->assertFalse(config()->has('view_cache')); } public function testPrepareRequestStageSkipsWhenContextIsStopped(): void { - $this->requestContext->stop(); + $this->webContext->stop(); - (new PrepareRequestStage())->process($this->requestContext); + (new PrepareRequestStage())->process($this->webContext); $this->assertFalse(config()->has('lang')); - $this->assertNull($this->requestContext->getViewCache()); + $this->assertNull($this->webContext->getViewCache()); } } diff --git a/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php b/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php new file mode 100644 index 00000000..f2a2cc9d --- /dev/null +++ b/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php @@ -0,0 +1,55 @@ +webContext = new WebContext(App::getContext()); + } + + public function testResolveRouteStageStoresMatchedRoute(): void + { + request()->create('GET', '/test/am/tests'); + + (new ResolveRouteStage())->process($this->webContext); + + $this->assertNotNull($this->webContext->getMatchedRoute()); + $this->assertSame('tests', $this->webContext->getMatchedRoute()->getRoute()->getAction()); + $this->assertSame($this->webContext->getMatchedRoute(), request()->getMatchedRoute()); + $this->assertFalse($this->webContext->isStopped()); + } + + public function testResolveRouteStageLeavesContextEmptyWhenNoRouteMatches(): void + { + request()->create('GET', '/non-existing-uri'); + + (new ResolveRouteStage())->process($this->webContext); + + $this->assertNull($this->webContext->getMatchedRoute()); + $this->assertNull(request()->getMatchedRoute()); + $this->assertFalse($this->webContext->isStopped()); + } + + 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()); + } +} diff --git a/tests/Unit/App/Stages/Request/SendResponseStageTest.php b/tests/Unit/App/Stages/Web/SendResponseStageTest.php similarity index 76% rename from tests/Unit/App/Stages/Request/SendResponseStageTest.php rename to tests/Unit/App/Stages/Web/SendResponseStageTest.php index 9e607d07..e6a34158 100644 --- a/tests/Unit/App/Stages/Request/SendResponseStageTest.php +++ b/tests/Unit/App/Stages/Web/SendResponseStageTest.php @@ -1,11 +1,11 @@ requestContext = new RequestContext(App::getContext()); + $this->webContext = new WebContext(App::getContext()); } public function testSendResponseStageSendsTheResponse(): void { - $this->requestContext->setResponse(response()->html('

hello

')); + $this->webContext->setResponse(response()->html('

hello

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

hello

', $output); @@ -38,10 +38,10 @@ public function testSendResponseStageAppliesCorsHeaders(): void { $this->assertFalse(config()->has('cors')); - $this->requestContext->setResponse(response()->html('')); + $this->webContext->setResponse(response()->html('')); ob_start(); - (new SendResponseStage())->process($this->requestContext); + (new SendResponseStage())->process($this->webContext); ob_end_clean(); $this->assertTrue(config()->has('cors')); @@ -53,10 +53,10 @@ public function testSendResponseStageCleansUpRequestAfterSending(): void { $this->prepareMatchedRequest(); - $this->requestContext->setResponse(response()->html('')); + $this->webContext->setResponse(response()->html('')); ob_start(); - (new SendResponseStage())->process($this->requestContext); + (new SendResponseStage())->process($this->webContext); ob_end_clean(); $this->assertNull(request()->getMatchedRoute()); @@ -70,7 +70,7 @@ public function testSendResponseStageCleansUpRequestWhenSendFails(): void response()->setHeader('X-Test', '1'); response()->json(['foo' => 'bar']); - $this->requestContext->setResponse(new class () extends Response { + $this->webContext->setResponse(new class () extends Response { public function send(): void { throw new HttpException('boom'); @@ -78,7 +78,7 @@ public function send(): void }); try { - (new SendResponseStage())->process($this->requestContext); + (new SendResponseStage())->process($this->webContext); $this->fail('Expected response sending to fail.'); } catch (HttpException $exception) { $this->assertSame('boom', $exception->getMessage()); @@ -96,7 +96,7 @@ public function testSendResponseStageFailsAndCleansUpWithoutResponse(): void $this->prepareMatchedRequest(); try { - (new SendResponseStage())->process($this->requestContext); + (new SendResponseStage())->process($this->webContext); $this->fail('Expected the stage to fail without a response.'); } catch (RuntimeException $exception) { $this->assertSame('The request has no response to send.', $exception->getMessage()); From 89c909c92d9cbc5a8af85dc2ae5f8e85a290db71 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:52:02 +0400 Subject: [PATCH 2/9] [#579] Make the web and console contexts extend AppContext AppContext keeps only what both boots provide: the base directory, the container, the environment and the config. The request and the routes are read from the container by WebContext, which also keeps the response the stages set. getAppContext() is removed from both contexts. Co-Authored-By: Claude Opus 5.5 --- src/App/Adapters/Console/ConsoleContext.php | 17 +++++---- src/App/Adapters/Web/WebContext.php | 21 +++++++---- src/App/AppContext.php | 20 +---------- .../Adapters/Console/ConsoleContextTest.php | 12 +++++++ .../Unit/App/Adapters/Web/WebContextTest.php | 31 ++++++++++++++-- tests/Unit/App/AppContextTest.php | 36 ------------------- 6 files changed, 63 insertions(+), 74 deletions(-) diff --git a/src/App/Adapters/Console/ConsoleContext.php b/src/App/Adapters/Console/ConsoleContext.php index 6a0b4faa..764d85f1 100644 --- a/src/App/Adapters/Console/ConsoleContext.php +++ b/src/App/Adapters/Console/ConsoleContext.php @@ -21,38 +21,37 @@ * Class ConsoleContext * * Carries the state of a single console run through the console stages. - * Unlike AppContext, which is immutable, the exit code is set by the stages. - * The Symfony application, input and output are read from the container of the AppContext. + * It is an AppContext (same base directory and container) whose exit code is set by the stages. + * The Symfony application, input and output are read from the container. * * @package Quantum\App */ -class ConsoleContext +class ConsoleContext extends AppContext { - private AppContext $appContext; - private ?Throwable $bootFailure; private int $exitCode = ExitCode::SUCCESS; public function __construct(AppContext $appContext, ?Throwable $bootFailure = null) { - $this->appContext = $appContext; + parent::__construct($appContext->getBaseDir(), $appContext->getContainer()); + $this->bootFailure = $bootFailure; } public function getApplication(): Application { - return $this->appContext->getContainer()->get(Application::class); + return $this->resolveFromContainer(Application::class); } public function getInput(): InputInterface { - return $this->appContext->getContainer()->get(InputInterface::class); + return $this->resolveFromContainer(InputInterface::class); } public function getOutput(): OutputInterface { - return $this->appContext->getContainer()->get(OutputInterface::class); + return $this->resolveFromContainer(OutputInterface::class); } /** diff --git a/src/App/Adapters/Web/WebContext.php b/src/App/Adapters/Web/WebContext.php index dce8bd13..fc38d6eb 100644 --- a/src/App/Adapters/Web/WebContext.php +++ b/src/App/Adapters/Web/WebContext.php @@ -11,22 +11,24 @@ namespace Quantum\App\Adapters\Web; use Quantum\ResourceCache\ViewCache; +use Quantum\Router\RouteCollection; use Quantum\Router\MatchedRoute; use Quantum\App\AppContext; use Quantum\Http\Response; +use Quantum\Http\Request; /** * Class WebContext * * Carries the state of a single web request through the web stages. - * Unlike AppContext, which is immutable, this object is mutated by the stages. + * It is an AppContext (same base directory and container) that the stages mutate, and it holds + * the response of the request: the one the stages set, which is what is sent. + * The request and the routes are read from the container. * * @package Quantum\App */ -class WebContext +class WebContext extends AppContext { - private AppContext $appContext; - private ?MatchedRoute $matchedRoute = null; private ?Response $response = null; @@ -37,12 +39,17 @@ class WebContext public function __construct(AppContext $appContext) { - $this->appContext = $appContext; + parent::__construct($appContext->getBaseDir(), $appContext->getContainer()); + } + + public function getRequest(): Request + { + return $this->resolveFromContainer(Request::class); } - public function getAppContext(): AppContext + public function getRoutes(): RouteCollection { - return $this->appContext; + return $this->resolveFromContainer(RouteCollection::class); } public function getMatchedRoute(): ?MatchedRoute diff --git a/src/App/AppContext.php b/src/App/AppContext.php index 6e614d26..6897d63c 100644 --- a/src/App/AppContext.php +++ b/src/App/AppContext.php @@ -11,11 +11,8 @@ namespace Quantum\App; use Quantum\Environment\Environment; -use Quantum\Router\RouteCollection; use Quantum\Di\DiContainer; use Quantum\Config\Config; -use Quantum\Http\Response; -use Quantum\Http\Request; /** * Class AppContext @@ -53,27 +50,12 @@ public function getConfig(): Config return $this->resolveFromContainer(Config::class); } - public function getRequest(): Request - { - return $this->resolveFromContainer(Request::class); - } - - public function getResponse(): Response - { - return $this->resolveFromContainer(Response::class); - } - - public function getRoutes(): RouteCollection - { - return $this->resolveFromContainer(RouteCollection::class); - } - /** * @template T of object * @param class-string $class * @return T */ - private function resolveFromContainer(string $class) + protected function resolveFromContainer(string $class) { return $this->container->get($class); } diff --git a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php index c549c156..80027d3c 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php @@ -11,6 +11,7 @@ use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\Enums\ExitCode; use Quantum\App\AppContext; +use Quantum\Di\DiContainer; use RuntimeException; use Quantum\App\App; use Quantum\Di\Di; @@ -43,6 +44,17 @@ public function setUp(): void $this->consoleContext = new ConsoleContext($this->appContext); } + public function testConsoleContextIsAnAppContextBuiltFromTheOneItIsGiven(): void + { + $container = new DiContainer(); + + $consoleContext = new ConsoleContext(new AppContext('/base/dir', $container)); + + $this->assertInstanceOf(AppContext::class, $consoleContext); + $this->assertSame('/base/dir', $consoleContext->getBaseDir()); + $this->assertSame($container, $consoleContext->getContainer()); + } + public function testConsoleContextExposesApplicationInputAndOutputFromTheContainer(): void { $this->assertSame($this->application, $this->consoleContext->getApplication()); diff --git a/tests/Unit/App/Adapters/Web/WebContextTest.php b/tests/Unit/App/Adapters/Web/WebContextTest.php index 9436fb4b..ee6d64c9 100644 --- a/tests/Unit/App/Adapters/Web/WebContextTest.php +++ b/tests/Unit/App/Adapters/Web/WebContextTest.php @@ -2,14 +2,18 @@ namespace Quantum\Tests\Unit\App\Adapters\Web; +use Quantum\App\Adapters\Web\WebContext; use Quantum\ResourceCache\ViewCache; +use Quantum\Router\RouteCollection; use Quantum\Tests\Unit\AppTestCase; use Quantum\Router\MatchedRoute; -use Quantum\App\Adapters\Web\WebContext; +use Quantum\Di\DiContainer; use Quantum\App\AppContext; +use Quantum\Http\Request; use Quantum\Router\Route; use Quantum\App\App; use Quantum\Di\Di; +use Mockery; class WebContextTest extends AppTestCase { @@ -25,9 +29,30 @@ public function setUp(): void $this->webContext = new WebContext($this->appContext); } - public function testWebContextExposesAppContext(): void + public function testWebContextIsAnAppContextBuiltFromTheOneItIsGiven(): void + { + $container = new DiContainer(); + + $webContext = new WebContext(new AppContext('/base/dir', $container)); + + $this->assertInstanceOf(AppContext::class, $webContext); + $this->assertSame('/base/dir', $webContext->getBaseDir()); + $this->assertSame($container, $webContext->getContainer()); + } + + public function testWebContextExposesRequestAndRoutesFromTheContainer(): void { - $this->assertSame($this->appContext, $this->webContext->getAppContext()); + $container = new DiContainer(); + $request = Mockery::mock(Request::class); + $routes = new RouteCollection(); + + $container->set(Request::class, $request); + $container->set(RouteCollection::class, $routes); + + $webContext = new WebContext(new AppContext('/base/dir', $container)); + + $this->assertSame($request, $webContext->getRequest()); + $this->assertSame($routes, $webContext->getRoutes()); } public function testWebContextStoresMatchedRoute(): void diff --git a/tests/Unit/App/AppContextTest.php b/tests/Unit/App/AppContextTest.php index bdbb16ab..a075bb4c 100644 --- a/tests/Unit/App/AppContextTest.php +++ b/tests/Unit/App/AppContextTest.php @@ -2,14 +2,11 @@ namespace Quantum\Tests\Unit\App; -use Quantum\Router\RouteCollection; use Quantum\Environment\Environment; use PHPUnit\Framework\TestCase; use Quantum\Di\DiContainer; use Quantum\App\AppContext; use Quantum\Config\Config; -use Quantum\Http\Response; -use Quantum\Http\Request; use Mockery; class AppContextTest extends TestCase @@ -50,37 +47,4 @@ public function testAppContextGetConfig(): void $this->assertSame($config, $context->getConfig()); } - - public function testAppContextGetRequest(): void - { - $container = new DiContainer(); - $request = Mockery::mock(Request::class); - $container->set(Request::class, $request); - - $context = new AppContext('/tmp', $container); - - $this->assertSame($request, $context->getRequest()); - } - - public function testAppContextGetResponse(): void - { - $container = new DiContainer(); - $response = Mockery::mock(Response::class); - $container->set(Response::class, $response); - - $context = new AppContext('/tmp', $container); - - $this->assertSame($response, $context->getResponse()); - } - - public function testAppContextGetRoutes(): void - { - $container = new DiContainer(); - $routes = new RouteCollection(); - $container->set(RouteCollection::class, $routes); - - $context = new AppContext('/tmp', $container); - - $this->assertSame($routes, $context->getRoutes()); - } } From 9b45890e27501557af6633e6a44161dd4bb2e302 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:09:51 +0400 Subject: [PATCH 3/9] [#579] Read the matched route from the request only The route was stored both on the request and on the web context, and the send stage cleared only the request's copy. ResolveRouteStage now stores it on the request, and WebContext::getMatchedRoute() reads it from there; setMatchedRoute() is removed from the context. Co-Authored-By: Claude Opus 5.5 --- src/App/Adapters/Web/WebContext.php | 11 ++--------- src/App/Stages/Web/ResolveRouteStage.php | 3 +-- tests/Unit/App/Adapters/Web/WebContextTest.php | 4 ++-- .../Unit/App/Stages/Web/DispatchRequestStageTest.php | 3 +-- .../App/Stages/Web/HandleRouteNotFoundStageTest.php | 2 +- 5 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/App/Adapters/Web/WebContext.php b/src/App/Adapters/Web/WebContext.php index fc38d6eb..3342d4ef 100644 --- a/src/App/Adapters/Web/WebContext.php +++ b/src/App/Adapters/Web/WebContext.php @@ -23,14 +23,12 @@ * Carries the state of a single web request through the web stages. * It is an AppContext (same base directory and container) that the stages mutate, and it holds * the response of the request: the one the stages set, which is what is sent. - * The request and the routes are read from the container. + * The request and the routes are read from the container, and the matched route from the request. * * @package Quantum\App */ class WebContext extends AppContext { - private ?MatchedRoute $matchedRoute = null; - private ?Response $response = null; private ?ViewCache $viewCache = null; @@ -54,12 +52,7 @@ public function getRoutes(): RouteCollection public function getMatchedRoute(): ?MatchedRoute { - return $this->matchedRoute; - } - - public function setMatchedRoute(?MatchedRoute $matchedRoute): void - { - $this->matchedRoute = $matchedRoute; + return $this->getRequest()->getMatchedRoute(); } public function getResponse(): ?Response diff --git a/src/App/Stages/Web/ResolveRouteStage.php b/src/App/Stages/Web/ResolveRouteStage.php index 26c6e73d..01ae11c6 100644 --- a/src/App/Stages/Web/ResolveRouteStage.php +++ b/src/App/Stages/Web/ResolveRouteStage.php @@ -25,7 +25,7 @@ class ResolveRouteStage implements WebStageInterface { /** - * Finds the route for the current request and stores it on the context and the request + * Finds the route for the current request and stores it on the request * @throws RouteException|BaseException */ public function process(WebContext $context): void @@ -41,6 +41,5 @@ public function process(WebContext $context): void } request()->setMatchedRoute($matchedRoute); - $context->setMatchedRoute($matchedRoute); } } diff --git a/tests/Unit/App/Adapters/Web/WebContextTest.php b/tests/Unit/App/Adapters/Web/WebContextTest.php index ee6d64c9..6d5124ff 100644 --- a/tests/Unit/App/Adapters/Web/WebContextTest.php +++ b/tests/Unit/App/Adapters/Web/WebContextTest.php @@ -55,13 +55,13 @@ public function testWebContextExposesRequestAndRoutesFromTheContainer(): void $this->assertSame($routes, $webContext->getRoutes()); } - public function testWebContextStoresMatchedRoute(): void + public function testWebContextReadsMatchedRouteFromTheRequest(): void { $this->assertNull($this->webContext->getMatchedRoute()); $matchedRoute = new MatchedRoute(new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), []); - $this->webContext->setMatchedRoute($matchedRoute); + request()->setMatchedRoute($matchedRoute); $this->assertSame($matchedRoute, $this->webContext->getMatchedRoute()); } diff --git a/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php b/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php index a21e08cb..e8ff935d 100644 --- a/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php +++ b/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php @@ -118,7 +118,6 @@ public function testDispatchRequestStageLetsMiddlewareShortCircuit(): void // The controller does not exist, so reaching the dispatcher would throw. $matchedRoute = new MatchedRoute($this->createRoute('tests', ['DispatchStageMwBlocker'], 'MissingController'), []); - $this->webContext->setMatchedRoute($matchedRoute); request()->setMatchedRoute($matchedRoute); (new DispatchRequestStage())->process($this->webContext); @@ -152,7 +151,7 @@ public function testDispatchRequestStageFailsWithoutMatchedRoute(): void public function testDispatchRequestStageFailsWithoutViewCache(): void { - $this->webContext->setMatchedRoute(new MatchedRoute($this->createRoute('tests'), [])); + request()->setMatchedRoute(new MatchedRoute($this->createRoute('tests'), [])); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('View cache is not set.'); diff --git a/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php b/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php index c3c5cd59..a3b322ef 100644 --- a/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php +++ b/tests/Unit/App/Stages/Web/HandleRouteNotFoundStageTest.php @@ -48,7 +48,7 @@ public function testHandleRouteNotFoundStageRespondsWithJsonForJsonRequests(): v public function testHandleRouteNotFoundStageIgnoresMatchedRoute(): void { - $this->webContext->setMatchedRoute( + request()->setMatchedRoute( new MatchedRoute(new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), []) ); From d682fd9a6521ea3ab78a2eb3e7727dd1c0188928 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:28:56 +0400 Subject: [PATCH 4/9] [#579] Read the view cache from the container PrepareRequestStage stored the view cache on the web context so that DispatchRequestStage could read it back. The context now reads it from the container, where PrepareRequestStage registers it, and setViewCache() is removed. Co-Authored-By: Claude Opus 5.5 --- src/App/Adapters/Web/WebContext.php | 13 +++---------- src/App/Stages/Web/DispatchRequestStage.php | 5 +---- src/App/Stages/Web/PrepareRequestStage.php | 6 ++---- tests/Unit/App/Adapters/Web/WebContextTest.php | 17 ++++++----------- .../App/Stages/Web/DispatchRequestStageTest.php | 13 ++++++------- .../App/Stages/Web/PrepareRequestStageTest.php | 6 +++--- 6 files changed, 21 insertions(+), 39 deletions(-) diff --git a/src/App/Adapters/Web/WebContext.php b/src/App/Adapters/Web/WebContext.php index 3342d4ef..0310048e 100644 --- a/src/App/Adapters/Web/WebContext.php +++ b/src/App/Adapters/Web/WebContext.php @@ -23,7 +23,7 @@ * Carries the state of a single web request through the web stages. * It is an AppContext (same base directory and container) that the stages mutate, and it holds * the response of the request: the one the stages set, which is what is sent. - * The request and the routes are read from the container, and the matched route from the request. + * The request, the routes and the view cache are read from the container, and the matched route from the request. * * @package Quantum\App */ @@ -31,8 +31,6 @@ class WebContext extends AppContext { private ?Response $response = null; - private ?ViewCache $viewCache = null; - private bool $stopped = false; public function __construct(AppContext $appContext) @@ -65,14 +63,9 @@ public function setResponse(Response $response): void $this->response = $response; } - public function getViewCache(): ?ViewCache - { - return $this->viewCache; - } - - public function setViewCache(ViewCache $viewCache): void + public function getViewCache(): ViewCache { - $this->viewCache = $viewCache; + return $this->resolveFromContainer(ViewCache::class); } /** diff --git a/src/App/Stages/Web/DispatchRequestStage.php b/src/App/Stages/Web/DispatchRequestStage.php index 4125731b..cd8cf60e 100644 --- a/src/App/Stages/Web/DispatchRequestStage.php +++ b/src/App/Stages/Web/DispatchRequestStage.php @@ -40,15 +40,12 @@ public function process(WebContext $context): void } $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.'); - } + $viewCache = $context->getViewCache(); $terminal = fn (Request $request): Response => $viewCache->getCachedResponse($request->getUri() ?? '') ?? (new RouteDispatcher())->dispatch($matchedRoute, $request); diff --git a/src/App/Stages/Web/PrepareRequestStage.php b/src/App/Stages/Web/PrepareRequestStage.php index a48b70a1..8517978a 100644 --- a/src/App/Stages/Web/PrepareRequestStage.php +++ b/src/App/Stages/Web/PrepareRequestStage.php @@ -44,7 +44,7 @@ public function process(WebContext $context): void $this->logDebugInfo(); - $context->setViewCache($this->setupViewCache()); + $this->setupViewCache(); } /** @@ -62,7 +62,7 @@ private function logDebugInfo(): void /** * @throws ConfigException|DiException|ReflectionException */ - private function setupViewCache(): ViewCache + private function setupViewCache(): void { if (!Di::isRegistered(ViewCache::class)) { Di::register(ViewCache::class); @@ -73,7 +73,5 @@ private function setupViewCache(): ViewCache if ($viewCache->isEnabled()) { $viewCache->setup(); } - - return $viewCache; } } diff --git a/tests/Unit/App/Adapters/Web/WebContextTest.php b/tests/Unit/App/Adapters/Web/WebContextTest.php index 6d5124ff..012fdab2 100644 --- a/tests/Unit/App/Adapters/Web/WebContextTest.php +++ b/tests/Unit/App/Adapters/Web/WebContextTest.php @@ -12,7 +12,6 @@ use Quantum\Http\Request; use Quantum\Router\Route; use Quantum\App\App; -use Quantum\Di\Di; use Mockery; class WebContextTest extends AppTestCase @@ -75,19 +74,15 @@ public function testWebContextStoresResponse(): void $this->assertSame(response(), $this->webContext->getResponse()); } - public function testWebContextStoresViewCache(): void + public function testWebContextReadsViewCacheFromTheContainer(): void { - $this->assertNull($this->webContext->getViewCache()); - - if (!Di::isRegistered(ViewCache::class)) { - Di::register(ViewCache::class); - } - - $viewCache = Di::get(ViewCache::class); + $container = new DiContainer(); + $viewCache = Mockery::mock(ViewCache::class); + $container->set(ViewCache::class, $viewCache); - $this->webContext->setViewCache($viewCache); + $webContext = new WebContext(new AppContext('/base/dir', $container)); - $this->assertSame($viewCache, $this->webContext->getViewCache()); + $this->assertSame($viewCache, $webContext->getViewCache()); } public function testWebContextIsNotStoppedByDefault(): void diff --git a/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php b/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php index e8ff935d..863ae855 100644 --- a/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php +++ b/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php @@ -32,6 +32,7 @@ public function apply(Request $request, Closure $next): Response use Quantum\App\Stages\Web\DispatchRequestStage; use Quantum\App\Stages\Web\ResolveRouteStage; use Quantum\Router\Exceptions\RouteException; + use Quantum\Di\Exceptions\DiException; use Quantum\Tests\Unit\AppTestCase; use Quantum\ResourceCache\ViewCache; use Quantum\App\Adapters\Web\WebContext; @@ -141,7 +142,7 @@ public function testDispatchRequestStagePropagatesDispatchException(): void public function testDispatchRequestStageFailsWithoutMatchedRoute(): void { - $this->webContext->setViewCache($this->viewCache()); + $this->registerViewCache(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Matched route is not set.'); @@ -153,8 +154,8 @@ public function testDispatchRequestStageFailsWithoutViewCache(): void { request()->setMatchedRoute(new MatchedRoute($this->createRoute('tests'), [])); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('View cache is not set.'); + $this->expectException(DiException::class); + $this->expectExceptionMessage('The dependency `' . ViewCache::class . '` is not registered.'); (new DispatchRequestStage())->process($this->webContext); } @@ -176,16 +177,14 @@ private function prepareRequest(string $uri): void (new ResolveRouteStage())->process($this->webContext); - $this->webContext->setViewCache($this->viewCache()); + $this->registerViewCache(); } - private function viewCache(): ViewCache + private function registerViewCache(): void { if (!Di::isRegistered(ViewCache::class)) { Di::register(ViewCache::class); } - - return Di::get(ViewCache::class); } /** diff --git a/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php b/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php index bbe2f73d..c749a796 100644 --- a/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php +++ b/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php @@ -79,9 +79,9 @@ public function testPrepareRequestStageSkipsDebuggerLoggingWhenDebugDisabled(): $this->assertSame([], debugbar()->getStoreCell(Debugger::EVENTS)); } - public function testPrepareRequestStageStoresViewCacheOnContext(): void + public function testPrepareRequestStageRegistersViewCache(): void { - $this->assertNull($this->webContext->getViewCache()); + $this->assertFalse(Di::isRegistered(ViewCache::class)); (new PrepareRequestStage())->process($this->webContext); @@ -130,6 +130,6 @@ public function testPrepareRequestStageSkipsWhenContextIsStopped(): void (new PrepareRequestStage())->process($this->webContext); $this->assertFalse(config()->has('lang')); - $this->assertNull($this->webContext->getViewCache()); + $this->assertFalse(Di::isRegistered(ViewCache::class)); } } From 3cc1c484ccc63db39736609d811420e0a7dfa8f7 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:40:15 +0400 Subject: [PATCH 5/9] [#579] Read the request, routes and config in the web stages from the context The web stages read these services through the global request() and config() helpers and Di::get(RouteCollection::class). They now use $context->getRequest(), getRoutes() and getConfig(), as the console stages already read their services from the console context. The objects are the same ones from the same container, so nothing changes at runtime. The WebContext docblock is updated to describe what the stages set on it. Co-Authored-By: Claude Opus 5.5 --- src/App/Adapters/Web/WebContext.php | 4 +-- src/App/Stages/Web/DispatchRequestStage.php | 2 +- src/App/Stages/Web/HandlePreflightStage.php | 2 +- src/App/Stages/Web/ResolveRouteStage.php | 8 ++--- src/App/Stages/Web/SendResponseStage.php | 20 ++++++----- .../Stages/Web/DispatchRequestStageTest.php | 35 +++++++++++++++++++ .../Stages/Web/HandlePreflightStageTest.php | 20 +++++++++++ .../App/Stages/Web/ResolveRouteStageTest.php | 25 +++++++++++++ .../App/Stages/Web/SendResponseStageTest.php | 34 ++++++++++++++++++ 9 files changed, 133 insertions(+), 17 deletions(-) diff --git a/src/App/Adapters/Web/WebContext.php b/src/App/Adapters/Web/WebContext.php index 0310048e..cff8acff 100644 --- a/src/App/Adapters/Web/WebContext.php +++ b/src/App/Adapters/Web/WebContext.php @@ -21,8 +21,8 @@ * Class WebContext * * Carries the state of a single web request through the web stages. - * It is an AppContext (same base directory and container) that the stages mutate, and it holds - * the response of the request: the one the stages set, which is what is sent. + * It is an AppContext (same base directory and container) whose response and stopped flag are 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. * * @package Quantum\App diff --git a/src/App/Stages/Web/DispatchRequestStage.php b/src/App/Stages/Web/DispatchRequestStage.php index cd8cf60e..1b848264 100644 --- a/src/App/Stages/Web/DispatchRequestStage.php +++ b/src/App/Stages/Web/DispatchRequestStage.php @@ -50,6 +50,6 @@ public function process(WebContext $context): void $terminal = fn (Request $request): Response => $viewCache->getCachedResponse($request->getUri() ?? '') ?? (new RouteDispatcher())->dispatch($matchedRoute, $request); - $context->setResponse((new MiddlewareManager($matchedRoute))->applyMiddlewares(request(), $terminal)); + $context->setResponse((new MiddlewareManager($matchedRoute))->applyMiddlewares($context->getRequest(), $terminal)); } } diff --git a/src/App/Stages/Web/HandlePreflightStage.php b/src/App/Stages/Web/HandlePreflightStage.php index d7142188..52aec7bf 100644 --- a/src/App/Stages/Web/HandlePreflightStage.php +++ b/src/App/Stages/Web/HandlePreflightStage.php @@ -25,7 +25,7 @@ class HandlePreflightStage implements WebStageInterface */ public function process(WebContext $context): void { - if (!request()->isMethod('OPTIONS')) { + if (!$context->getRequest()->isMethod('OPTIONS')) { return; } diff --git a/src/App/Stages/Web/ResolveRouteStage.php b/src/App/Stages/Web/ResolveRouteStage.php index 01ae11c6..ced4e158 100644 --- a/src/App/Stages/Web/ResolveRouteStage.php +++ b/src/App/Stages/Web/ResolveRouteStage.php @@ -13,10 +13,8 @@ use Quantum\App\Contracts\WebStageInterface; use Quantum\Router\Exceptions\RouteException; use Quantum\App\Exceptions\BaseException; -use Quantum\Router\RouteCollection; use Quantum\App\Adapters\Web\WebContext; use Quantum\Router\RouteFinder; -use Quantum\Di\Di; /** * Class ResolveRouteStage @@ -34,12 +32,14 @@ public function process(WebContext $context): void return; } - $matchedRoute = (new RouteFinder(Di::get(RouteCollection::class)))->find(request()); + $request = $context->getRequest(); + + $matchedRoute = (new RouteFinder($context->getRoutes()))->find($request); if ($matchedRoute === null) { return; } - request()->setMatchedRoute($matchedRoute); + $request->setMatchedRoute($matchedRoute); } } diff --git a/src/App/Stages/Web/SendResponseStage.php b/src/App/Stages/Web/SendResponseStage.php index 812154a8..b10795bc 100644 --- a/src/App/Stages/Web/SendResponseStage.php +++ b/src/App/Stages/Web/SendResponseStage.php @@ -14,7 +14,9 @@ use Quantum\Config\Exceptions\ConfigException; use Quantum\Di\Exceptions\DiException; use Quantum\App\Adapters\Web\WebContext; +use Quantum\Config\Config; use Quantum\Http\Response; +use Quantum\Http\Request; use Quantum\Config\Setup; use ReflectionException; use RuntimeException; @@ -42,23 +44,23 @@ public function process(WebContext $context): void throw new RuntimeException('The request has no response to send.'); } - $this->handleCors($response); + $this->handleCors($response, $context->getConfig()); $response->send(); } finally { - $this->cleanupRequestState(); + $this->cleanupRequestState($context->getRequest()); } } /** * @throws ConfigException|DiException|ReflectionException */ - private function handleCors(Response $response): void + private function handleCors(Response $response, Config $config): void { - if (!config()->has('cors')) { - config()->import(new Setup('config', 'cors')); + if (!$config->has('cors')) { + $config->import(new Setup('config', 'cors')); } - foreach (config()->get('cors') as $key => $value) { + foreach ($config->get('cors') as $key => $value) { $response->setHeader($key, (string) $value); } } @@ -66,9 +68,9 @@ private function handleCors(Response $response): void /** * Clears request-scoped state after the response has been sent. */ - private function cleanupRequestState(): void + private function cleanupRequestState(Request $request): void { - request()->setMatchedRoute(null); - request()->flush(); + $request->setMatchedRoute(null); + $request->flush(); } } diff --git a/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php b/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php index 863ae855..0ba2d180 100644 --- a/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php +++ b/tests/Unit/App/Stages/Web/DispatchRequestStageTest.php @@ -18,6 +18,18 @@ public function apply(Request $request, Closure $next): Response } } + class DispatchStageMwRecorder extends Middleware + { + public static ?Request $request = null; + + public function apply(Request $request, Closure $next): Response + { + self::$request = $request; + + return response()->json(['status' => 'recorded']); + } + } + class DispatchStageMwBlocker extends Middleware { public function apply(Request $request, Closure $next): Response @@ -29,6 +41,7 @@ public function apply(Request $request, Closure $next): Response namespace Quantum\Tests\Unit\App\Stages\Web { use Quantum\Tests\_root\modules\Test\Middlewares\DispatchStageMwPassThrough; + use Quantum\Tests\_root\modules\Test\Middlewares\DispatchStageMwRecorder; use Quantum\App\Stages\Web\DispatchRequestStage; use Quantum\App\Stages\Web\ResolveRouteStage; use Quantum\Router\Exceptions\RouteException; @@ -37,6 +50,9 @@ public function apply(Request $request, Closure $next): Response use Quantum\ResourceCache\ViewCache; use Quantum\App\Adapters\Web\WebContext; use Quantum\Router\MatchedRoute; + use Quantum\Di\DiContainer; + use Quantum\App\AppContext; + use Quantum\Http\Request; use Quantum\Http\Enums\StatusCode; use Quantum\Router\Route; use Quantum\App\App; @@ -171,6 +187,25 @@ public function testDispatchRequestStageSkipsWhenContextIsStopped(): void $this->assertNull($this->webContext->getResponse()); } + public function testDispatchRequestStagePassesTheContextRequestToTheMiddlewares(): void + { + $this->prepareRequest('/test/am/tests'); + + $request = new Request(); + $request->create('GET', '/test/am/tests'); + $request->setMatchedRoute(new MatchedRoute($this->createRoute('tests', ['DispatchStageMwRecorder']), [])); + + $container = new DiContainer(); + $container->set(Request::class, $request); + $container->register(ViewCache::class); + + DispatchStageMwRecorder::$request = null; + + (new DispatchRequestStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container))); + + $this->assertSame($request, DispatchStageMwRecorder::$request); + } + private function prepareRequest(string $uri): void { request()->create('GET', $uri); diff --git a/tests/Unit/App/Stages/Web/HandlePreflightStageTest.php b/tests/Unit/App/Stages/Web/HandlePreflightStageTest.php index a2025f0e..8c20e443 100644 --- a/tests/Unit/App/Stages/Web/HandlePreflightStageTest.php +++ b/tests/Unit/App/Stages/Web/HandlePreflightStageTest.php @@ -6,6 +6,9 @@ use Quantum\Tests\Unit\AppTestCase; use Quantum\Http\Enums\StatusCode; use Quantum\App\Adapters\Web\WebContext; +use Quantum\Di\DiContainer; +use Quantum\App\AppContext; +use Quantum\Http\Request; use Quantum\App\App; class HandlePreflightStageTest extends AppTestCase @@ -39,4 +42,21 @@ public function testHandlePreflightStageIgnoresOtherMethods(): void $this->assertNull($this->webContext->getResponse()); $this->assertFalse($this->webContext->isStopped()); } + + public function testHandlePreflightStageReadsTheRequestFromTheContext(): void + { + request()->create('GET', '/test/am/tests'); + + $request = new Request(); + $request->create('OPTIONS', '/test/am/tests'); + + $container = new DiContainer(); + $container->set(Request::class, $request); + + $webContext = new WebContext(new AppContext(PROJECT_ROOT, $container)); + + (new HandlePreflightStage())->process($webContext); + + $this->assertTrue($webContext->isStopped()); + } } diff --git a/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php b/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php index f2a2cc9d..99b4fe61 100644 --- a/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php +++ b/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php @@ -5,6 +5,11 @@ use Quantum\App\Stages\Web\ResolveRouteStage; use Quantum\Tests\Unit\AppTestCase; use Quantum\App\Adapters\Web\WebContext; +use Quantum\Router\RouteCollection; +use Quantum\Di\DiContainer; +use Quantum\App\AppContext; +use Quantum\Http\Request; +use Quantum\Router\Route; use Quantum\App\App; class ResolveRouteStageTest extends AppTestCase @@ -52,4 +57,24 @@ public function testResolveRouteStageSkipsWhenContextIsStopped(): void $this->assertNull($this->webContext->getMatchedRoute()); $this->assertNull(request()->getMatchedRoute()); } + + public function testResolveRouteStageReadsTheRequestAndRoutesFromTheContext(): void + { + request()->create('GET', '/test/am/tests'); + + $request = new Request(); + $request->create('GET', '/context/route'); + + $routes = new RouteCollection(); + $routes->add(new Route(['GET'], '/context/route', 'TestController', 'contextAction')); + + $container = new DiContainer(); + $container->set(Request::class, $request); + $container->set(RouteCollection::class, $routes); + + (new ResolveRouteStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container))); + + $this->assertSame('contextAction', $request->getMatchedRoute()->getRoute()->getAction()); + $this->assertNull(request()->getMatchedRoute()); + } } diff --git a/tests/Unit/App/Stages/Web/SendResponseStageTest.php b/tests/Unit/App/Stages/Web/SendResponseStageTest.php index e6a34158..95481986 100644 --- a/tests/Unit/App/Stages/Web/SendResponseStageTest.php +++ b/tests/Unit/App/Stages/Web/SendResponseStageTest.php @@ -7,8 +7,12 @@ use Quantum\Tests\Unit\AppTestCase; use Quantum\App\Adapters\Web\WebContext; use Quantum\Router\MatchedRoute; +use Quantum\Di\DiContainer; +use Quantum\App\AppContext; +use Quantum\Config\Config; use Quantum\Router\Route; use Quantum\Http\Response; +use Quantum\Http\Request; use Quantum\App\App; use RuntimeException; @@ -106,6 +110,36 @@ public function testSendResponseStageFailsAndCleansUpWithoutResponse(): void $this->assertNull(request()->getUri()); } + public function testSendResponseStageReadsTheConfigAndRequestFromTheContext(): void + { + $this->prepareMatchedRequest(); + + $request = new Request(); + $request->create('GET', '/context/route'); + $request->setMatchedRoute(new MatchedRoute(new Route(['GET'], '/context/route', 'TestController', 'tests'), [])); + + $config = new Config(); + $config->set('cors', ['X-Context-Cors' => 'yes']); + + $container = new DiContainer(); + $container->set(Request::class, $request); + $container->set(Config::class, $config); + + $webContext = new WebContext(new AppContext(PROJECT_ROOT, $container)); + $webContext->setResponse(response()->html('')); + + ob_start(); + (new SendResponseStage())->process($webContext); + ob_end_clean(); + + $this->assertSame('yes', response()->getHeader('X-Context-Cors')); + $this->assertFalse(config()->has('cors')); + $this->assertNull($request->getMatchedRoute()); + $this->assertNull($request->getUri()); + $this->assertNotNull(request()->getMatchedRoute()); + $this->assertNotNull(request()->getUri()); + } + private function prepareMatchedRequest(): void { request()->create('GET', '/test/am/tests'); From dfb61b7337ba7f2f6f96b196a343139719d47c9a Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:58:06 +0400 Subject: [PATCH 6/9] [#579] Add the changelog entry for the context changes Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54329bc8..945d4cb4 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:** 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()`. From 9d3beb584c2cd8b5a5754ec44d1a315d33edca64 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:29:14 +0400 Subject: [PATCH 7/9] [#579] Clear a stale matched route when no route matches ResolveRouteStage left the request's route untouched when nothing matched, so a route set on the request before the stage ran would skip the 404 and be dispatched. The stage now always stores the result of the route lookup, null included. Also order the imports in the web stages and adapter longest first and declare the DiException the context getters can throw. Co-Authored-By: Claude Opus 5.5 --- src/App/Adapters/Web/WebAppAdapter.php | 10 +++++----- src/App/Stages/Web/DispatchRequestStage.php | 5 +++-- src/App/Stages/Web/HandlePreflightStage.php | 4 +++- src/App/Stages/Web/HandleRouteNotFoundStage.php | 4 ++-- src/App/Stages/Web/PrepareRequestStage.php | 4 ++-- src/App/Stages/Web/ResolveRouteStage.php | 15 +++++---------- src/App/Stages/Web/SendResponseStage.php | 4 ++-- .../Unit/App/Stages/Web/ResolveRouteStageTest.php | 12 ++++++++++++ 8 files changed, 34 insertions(+), 24 deletions(-) diff --git a/src/App/Adapters/Web/WebAppAdapter.php b/src/App/Adapters/Web/WebAppAdapter.php index 83c2682f..366e55fc 100644 --- a/src/App/Adapters/Web/WebAppAdapter.php +++ b/src/App/Adapters/Web/WebAppAdapter.php @@ -11,19 +11,19 @@ namespace Quantum\App\Adapters\Web; use Quantum\App\Stages\Web\HandleRouteNotFoundStage; +use Quantum\App\Stages\Boot\SetupErrorHandlerStage; +use Quantum\App\Stages\Boot\LoadEnvironmentStage; use Quantum\App\Stages\Web\HandlePreflightStage; use Quantum\App\Stages\Web\DispatchRequestStage; use Quantum\App\Stages\Web\PrepareRequestStage; -use Quantum\App\Stages\Web\SendResponseStage; -use Quantum\App\Stages\Web\ResolveRouteStage; -use Quantum\App\Stages\Boot\SetupErrorHandlerStage; -use Quantum\App\Stages\Boot\LoadEnvironmentStage; use Quantum\App\Stages\Boot\LoadAppConfigStage; use Quantum\App\Stages\Boot\InitDebuggerStage; -use Quantum\App\Contracts\AppInterface; +use Quantum\App\Stages\Web\SendResponseStage; +use Quantum\App\Stages\Web\ResolveRouteStage; use Quantum\App\Stages\Boot\LoadModulesStage; use Quantum\App\Stages\Boot\LoadHelpersStage; use Quantum\App\Stages\Boot\InitHttpStage; +use Quantum\App\Contracts\AppInterface; use Quantum\App\Enums\ExitCode; use Quantum\App\BootPipeline; use Quantum\App\AppContext; diff --git a/src/App/Stages/Web/DispatchRequestStage.php b/src/App/Stages/Web/DispatchRequestStage.php index 1b848264..9a06921d 100644 --- a/src/App/Stages/Web/DispatchRequestStage.php +++ b/src/App/Stages/Web/DispatchRequestStage.php @@ -14,8 +14,9 @@ use Quantum\App\Contracts\WebStageInterface; use Quantum\App\Exceptions\BaseException; use Quantum\Middleware\MiddlewareManager; -use Quantum\Router\RouteDispatcher; use Quantum\App\Adapters\Web\WebContext; +use Quantum\Di\Exceptions\DiException; +use Quantum\Router\RouteDispatcher; use Quantum\Http\Response; use Quantum\Http\Request; use RuntimeException; @@ -31,7 +32,7 @@ class DispatchRequestStage implements WebStageInterface { /** - * @throws MiddlewareException|BaseException + * @throws MiddlewareException|BaseException|DiException */ public function process(WebContext $context): void { diff --git a/src/App/Stages/Web/HandlePreflightStage.php b/src/App/Stages/Web/HandlePreflightStage.php index 52aec7bf..c911b3d3 100644 --- a/src/App/Stages/Web/HandlePreflightStage.php +++ b/src/App/Stages/Web/HandlePreflightStage.php @@ -11,8 +11,9 @@ namespace Quantum\App\Stages\Web; use Quantum\App\Contracts\WebStageInterface; -use Quantum\Http\Enums\StatusCode; use Quantum\App\Adapters\Web\WebContext; +use Quantum\Di\Exceptions\DiException; +use Quantum\Http\Enums\StatusCode; /** * Class HandlePreflightStage @@ -22,6 +23,7 @@ class HandlePreflightStage implements WebStageInterface { /** * Answers an OPTIONS request with an empty response and stops the remaining stages + * @throws DiException */ public function process(WebContext $context): void { diff --git a/src/App/Stages/Web/HandleRouteNotFoundStage.php b/src/App/Stages/Web/HandleRouteNotFoundStage.php index 75c00f5a..a3e7fad4 100644 --- a/src/App/Stages/Web/HandleRouteNotFoundStage.php +++ b/src/App/Stages/Web/HandleRouteNotFoundStage.php @@ -10,11 +10,11 @@ namespace Quantum\App\Stages\Web; -use Quantum\App\Contracts\WebStageInterface; use Quantum\Config\Exceptions\ConfigException; +use Quantum\App\Contracts\WebStageInterface; use Quantum\App\Exceptions\BaseException; -use Quantum\Di\Exceptions\DiException; use Quantum\App\Adapters\Web\WebContext; +use Quantum\Di\Exceptions\DiException; use ReflectionException; /** diff --git a/src/App/Stages/Web/PrepareRequestStage.php b/src/App/Stages/Web/PrepareRequestStage.php index 8517978a..66ba5312 100644 --- a/src/App/Stages/Web/PrepareRequestStage.php +++ b/src/App/Stages/Web/PrepareRequestStage.php @@ -10,13 +10,13 @@ namespace Quantum\App\Stages\Web; -use Quantum\App\Contracts\WebStageInterface; use Quantum\Config\Exceptions\ConfigException; +use Quantum\App\Contracts\WebStageInterface; use Quantum\Lang\Exceptions\LangException; +use Quantum\App\Adapters\Web\WebContext; use Quantum\Lang\Factories\LangFactory; use Quantum\Di\Exceptions\DiException; use Quantum\ResourceCache\ViewCache; -use Quantum\App\Adapters\Web\WebContext; use Quantum\Debugger\Debugger; use ReflectionException; use Quantum\Di\Di; diff --git a/src/App/Stages/Web/ResolveRouteStage.php b/src/App/Stages/Web/ResolveRouteStage.php index ced4e158..6aee907b 100644 --- a/src/App/Stages/Web/ResolveRouteStage.php +++ b/src/App/Stages/Web/ResolveRouteStage.php @@ -10,10 +10,11 @@ namespace Quantum\App\Stages\Web; -use Quantum\App\Contracts\WebStageInterface; use Quantum\Router\Exceptions\RouteException; +use Quantum\App\Contracts\WebStageInterface; use Quantum\App\Exceptions\BaseException; use Quantum\App\Adapters\Web\WebContext; +use Quantum\Di\Exceptions\DiException; use Quantum\Router\RouteFinder; /** @@ -23,8 +24,8 @@ class ResolveRouteStage implements WebStageInterface { /** - * Finds the route for the current request and stores it on the request - * @throws RouteException|BaseException + * Finds the route for the current request and stores it on the request, or null when nothing matches + * @throws RouteException|BaseException|DiException */ public function process(WebContext $context): void { @@ -34,12 +35,6 @@ public function process(WebContext $context): void $request = $context->getRequest(); - $matchedRoute = (new RouteFinder($context->getRoutes()))->find($request); - - if ($matchedRoute === null) { - return; - } - - $request->setMatchedRoute($matchedRoute); + $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 b10795bc..bcfac5d6 100644 --- a/src/App/Stages/Web/SendResponseStage.php +++ b/src/App/Stages/Web/SendResponseStage.php @@ -10,10 +10,10 @@ namespace Quantum\App\Stages\Web; -use Quantum\App\Contracts\WebStageInterface; use Quantum\Config\Exceptions\ConfigException; -use Quantum\Di\Exceptions\DiException; +use Quantum\App\Contracts\WebStageInterface; use Quantum\App\Adapters\Web\WebContext; +use Quantum\Di\Exceptions\DiException; use Quantum\Config\Config; use Quantum\Http\Response; use Quantum\Http\Request; diff --git a/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php b/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php index 99b4fe61..306b48a4 100644 --- a/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php +++ b/tests/Unit/App/Stages/Web/ResolveRouteStageTest.php @@ -6,6 +6,7 @@ use Quantum\Tests\Unit\AppTestCase; use Quantum\App\Adapters\Web\WebContext; use Quantum\Router\RouteCollection; +use Quantum\Router\MatchedRoute; use Quantum\Di\DiContainer; use Quantum\App\AppContext; use Quantum\Http\Request; @@ -46,6 +47,17 @@ public function testResolveRouteStageLeavesContextEmptyWhenNoRouteMatches(): voi $this->assertFalse($this->webContext->isStopped()); } + public function testResolveRouteStageClearsARouteSetBeforeItWhenNoRouteMatches(): void + { + request()->create('GET', '/non-existing-uri'); + request()->setMatchedRoute(new MatchedRoute(new Route(['GET'], '/test/am/tests', 'TestController', 'tests'), [])); + + (new ResolveRouteStage())->process($this->webContext); + + $this->assertNull($this->webContext->getMatchedRoute()); + $this->assertNull(request()->getMatchedRoute()); + } + public function testResolveRouteStageSkipsWhenContextIsStopped(): void { request()->create('GET', '/test/am/tests'); From 5fc4585fd91fe2806ee1c0767366cf83975009bc Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:29:15 +0400 Subject: [PATCH 8/9] [#579] Return the console exit code without a redundant fallback ConsoleContext's exit code already defaults to ExitCode::SUCCESS, so the "?: ExitCode::SUCCESS" fallback in ConsoleAppAdapter::start() never changed the result. Co-Authored-By: Claude Opus 5.5 --- src/App/Adapters/Console/ConsoleAppAdapter.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/App/Adapters/Console/ConsoleAppAdapter.php b/src/App/Adapters/Console/ConsoleAppAdapter.php index 1f68f646..a51338c1 100644 --- a/src/App/Adapters/Console/ConsoleAppAdapter.php +++ b/src/App/Adapters/Console/ConsoleAppAdapter.php @@ -20,7 +20,6 @@ use Quantum\App\Stages\Boot\InitConsoleStage; use Quantum\App\Stages\Boot\LoadHelpersStage; use Quantum\App\Contracts\AppInterface; -use Quantum\App\Enums\ExitCode; use Quantum\App\BootPipeline; use Quantum\App\AppContext; use Throwable; @@ -79,6 +78,6 @@ public function start(): ?int throw $this->bootFailure ?? $exception; } - return $context->getExitCode() ?: ExitCode::SUCCESS; + return $context->getExitCode(); } } From e0de5cddab8fda1f725d1d89492f724d630ab594 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:16:39 +0400 Subject: [PATCH 9/9] [#579] Register the view cache in the web context's container PrepareRequestStage registered and set up the view cache through the static Di facade, while DispatchRequestStage reads it with $context->getViewCache(). With a context over another container the dispatch stage found no view cache, or one that was never set up. The prepare stage now registers and sets it up through the context, like the other web stages. --- src/App/Stages/Web/PrepareRequestStage.php | 13 ++++--- .../Stages/Web/PrepareRequestStageTest.php | 37 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/App/Stages/Web/PrepareRequestStage.php b/src/App/Stages/Web/PrepareRequestStage.php index 66ba5312..1eaabebf 100644 --- a/src/App/Stages/Web/PrepareRequestStage.php +++ b/src/App/Stages/Web/PrepareRequestStage.php @@ -19,7 +19,6 @@ use Quantum\ResourceCache\ViewCache; use Quantum\Debugger\Debugger; use ReflectionException; -use Quantum\Di\Di; /** * Class PrepareRequestStage @@ -44,7 +43,7 @@ public function process(WebContext $context): void $this->logDebugInfo(); - $this->setupViewCache(); + $this->setupViewCache($context); } /** @@ -62,13 +61,15 @@ private function logDebugInfo(): void /** * @throws ConfigException|DiException|ReflectionException */ - private function setupViewCache(): void + private function setupViewCache(WebContext $context): void { - if (!Di::isRegistered(ViewCache::class)) { - Di::register(ViewCache::class); + $container = $context->getContainer(); + + if (!$container->isRegistered(ViewCache::class)) { + $container->register(ViewCache::class); } - $viewCache = Di::get(ViewCache::class); + $viewCache = $context->getViewCache(); if ($viewCache->isEnabled()) { $viewCache->setup(); diff --git a/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php b/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php index c749a796..2fd13f97 100644 --- a/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php +++ b/tests/Unit/App/Stages/Web/PrepareRequestStageTest.php @@ -8,6 +8,8 @@ use Quantum\App\Adapters\Web\WebContext; use Quantum\Router\MatchedRoute; use Quantum\Debugger\Debugger; +use Quantum\Di\DiContainer; +use Quantum\App\AppContext; use Quantum\Router\Route; use Quantum\App\App; use Quantum\Di\Di; @@ -123,6 +125,41 @@ public function testPrepareRequestStageDoesNotSetUpViewCacheWhenDisabled(): void $this->assertFalse(config()->has('view_cache')); } + public function testPrepareRequestStageRegistersViewCacheInTheContextContainer(): void + { + $container = new DiContainer(); + + (new PrepareRequestStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container))); + + $this->assertTrue($container->isRegistered(ViewCache::class)); + $this->assertFalse(Di::isRegistered(ViewCache::class)); + } + + public function testPrepareRequestStageSetsUpTheViewCacheOfTheContextContainer(): void + { + $viewsDir = base_dir() . DS . 'cache' . DS . 'views'; + $moduleDir = $viewsDir . DS . 'test'; + $viewsDirExisted = is_dir($viewsDir); + + $container = new DiContainer(); + $container->register(ViewCache::class); + $container->get(ViewCache::class)->enableCaching(true); + + try { + (new PrepareRequestStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container))); + + $this->assertDirectoryExists($moduleDir); + } finally { + if (is_dir($moduleDir)) { + rmdir($moduleDir); + } + + if (!$viewsDirExisted && is_dir($viewsDir)) { + rmdir($viewsDir); + } + } + } + public function testPrepareRequestStageSkipsWhenContextIsStopped(): void { $this->webContext->stop();