From 53fa765198d39717425c7a0a8187371aff240a31 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:41:23 +0400 Subject: [PATCH 1/8] [#593] Record the app type on the app context AppContext takes the type (AppType::WEB or AppType::CONSOLE) as a required third argument and returns it from getType(). AppFactory passes the type it creates, and WebContext and ConsoleContext copy it. --- src/App/Adapters/Console/ConsoleContext.php | 2 +- src/App/Adapters/Web/WebContext.php | 2 +- src/App/AppContext.php | 18 ++++++++++++-- src/App/Factories/AppFactory.php | 2 +- .../Adapters/Console/ConsoleContextTest.php | 6 +++-- .../Runtime/DispatchRequestStageTest.php | 3 ++- .../Runtime/HandlePreflightStageTest.php | 3 ++- .../Runtime/HandleRouteNotFoundStageTest.php | 3 ++- .../Runtime/PrepareRequestStageTest.php | 5 ++-- .../Stages/Runtime/ResolveRouteStageTest.php | 3 ++- .../Stages/Runtime/SendResponseStageTest.php | 3 ++- .../Unit/App/Adapters/Web/WebContextTest.php | 8 ++++--- tests/Unit/App/AppContextTest.php | 24 ++++++++++++------- tests/Unit/App/Factories/AppFactoryTest.php | 13 ++++++++++ tests/Unit/App/PipelineTest.php | 7 +++--- tests/Unit/AppTestCase.php | 4 ++-- tests/Unit/Environment/EnvironmentTest.php | 4 ++-- .../Uploads/UploadConfigProviderTest.php | 2 +- 18 files changed, 79 insertions(+), 33 deletions(-) diff --git a/src/App/Adapters/Console/ConsoleContext.php b/src/App/Adapters/Console/ConsoleContext.php index 4c10a3d25..e40e3c936 100644 --- a/src/App/Adapters/Console/ConsoleContext.php +++ b/src/App/Adapters/Console/ConsoleContext.php @@ -28,7 +28,7 @@ class ConsoleContext extends AppContext { public function __construct(AppContext $appContext) { - parent::__construct($appContext->getBaseDir(), $appContext->getContainer()); + parent::__construct($appContext->getBaseDir(), $appContext->getContainer(), $appContext->getType()); } public function getApplication(): Application diff --git a/src/App/Adapters/Web/WebContext.php b/src/App/Adapters/Web/WebContext.php index 6d0e5bf22..459cf97dd 100644 --- a/src/App/Adapters/Web/WebContext.php +++ b/src/App/Adapters/Web/WebContext.php @@ -33,7 +33,7 @@ class WebContext extends AppContext public function __construct(AppContext $appContext) { - parent::__construct($appContext->getBaseDir(), $appContext->getContainer()); + parent::__construct($appContext->getBaseDir(), $appContext->getContainer(), $appContext->getType()); } public function getRequest(): Request diff --git a/src/App/AppContext.php b/src/App/AppContext.php index 33ab4362a..5ea17b369 100644 --- a/src/App/AppContext.php +++ b/src/App/AppContext.php @@ -18,7 +18,7 @@ /** * Class AppContext * - * Carries the base directory and the container of an application, and the state of the run it is passed through: + * Carries the base directory, the container and the type (web or console) of an application, and the state of the run it is passed through: * the stopped flag, which ends the pipeline run early, and the exit code. * * @package Quantum\App @@ -29,14 +29,20 @@ class AppContext private DiContainer $container; + private string $type; + private bool $stopped = false; private int $exitCode = ExitCode::SUCCESS; - public function __construct(string $baseDir, DiContainer $container) + /** + * @param string $type AppType::WEB or AppType::CONSOLE + */ + public function __construct(string $baseDir, DiContainer $container, string $type) { $this->baseDir = $baseDir; $this->container = $container; + $this->type = $type; } public function getBaseDir(): string @@ -49,6 +55,14 @@ public function getContainer(): DiContainer return $this->container; } + /** + * Gets the type of the application: AppType::WEB or AppType::CONSOLE + */ + public function getType(): string + { + return $this->type; + } + public function getEnvironment(): Environment { return $this->resolveFromContainer(Environment::class); diff --git a/src/App/Factories/AppFactory.php b/src/App/Factories/AppFactory.php index 58e00e79c..d6ae28b1a 100644 --- a/src/App/Factories/AppFactory.php +++ b/src/App/Factories/AppFactory.php @@ -63,7 +63,7 @@ private static function createInstance(string $type, string $baseDir): App $container = new DiContainer(); - $context = new AppContext($baseDir, $container); + $context = new AppContext($baseDir, $container, $type); App::setContext($context); $adapterClass = self::ADAPTERS[$type]; diff --git a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php index 1c4bcc706..b7e3c9dfb 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleContextTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleContextTest.php @@ -10,6 +10,7 @@ use Quantum\Tests\Unit\AppTestCase; use Quantum\App\Adapters\Console\ConsoleContext; use Quantum\App\Enums\ExitCode; +use Quantum\App\Enums\AppType; use Quantum\App\AppContext; use Quantum\Di\DiContainer; use Quantum\App\App; @@ -47,11 +48,12 @@ public function testConsoleContextIsAnAppContextBuiltFromTheOneItIsGiven(): void { $container = new DiContainer(); - $consoleContext = new ConsoleContext(new AppContext('/base/dir', $container)); + $consoleContext = new ConsoleContext(new AppContext('/base/dir', $container, AppType::CONSOLE)); $this->assertInstanceOf(AppContext::class, $consoleContext); $this->assertSame('/base/dir', $consoleContext->getBaseDir()); $this->assertSame($container, $consoleContext->getContainer()); + $this->assertSame(AppType::CONSOLE, $consoleContext->getType()); } public function testConsoleContextExposesApplicationInputAndOutputFromTheContainer(): void @@ -63,7 +65,7 @@ public function testConsoleContextExposesApplicationInputAndOutputFromTheContain public function testConsoleContextReadsTheConsoleObjectsFromTheContainerWhenAsked(): void { - $context = new AppContext(PROJECT_ROOT, new \Quantum\Di\DiContainer()); + $context = new AppContext(PROJECT_ROOT, new \Quantum\Di\DiContainer(), AppType::CONSOLE); $consoleContext = new ConsoleContext($context); $application = new Application('Late', '1.0'); diff --git a/tests/Unit/App/Adapters/Web/Stages/Runtime/DispatchRequestStageTest.php b/tests/Unit/App/Adapters/Web/Stages/Runtime/DispatchRequestStageTest.php index 5ef189809..8d920f0c4 100644 --- a/tests/Unit/App/Adapters/Web/Stages/Runtime/DispatchRequestStageTest.php +++ b/tests/Unit/App/Adapters/Web/Stages/Runtime/DispatchRequestStageTest.php @@ -50,6 +50,7 @@ public function apply(Request $request, Closure $next): Response use Quantum\ResourceCache\ViewCache; use Quantum\App\Adapters\Web\WebContext; use Quantum\Router\MatchedRoute; + use Quantum\App\Enums\AppType; use Quantum\Di\DiContainer; use Quantum\App\AppContext; use Quantum\Http\Request; @@ -190,7 +191,7 @@ public function testDispatchRequestStagePassesTheContextRequestToTheMiddlewares( DispatchStageMwRecorder::$request = null; - (new DispatchRequestStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container))); + (new DispatchRequestStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container, AppType::WEB))); $this->assertSame($request, DispatchStageMwRecorder::$request); } diff --git a/tests/Unit/App/Adapters/Web/Stages/Runtime/HandlePreflightStageTest.php b/tests/Unit/App/Adapters/Web/Stages/Runtime/HandlePreflightStageTest.php index 451d297b3..2fdb620ce 100644 --- a/tests/Unit/App/Adapters/Web/Stages/Runtime/HandlePreflightStageTest.php +++ b/tests/Unit/App/Adapters/Web/Stages/Runtime/HandlePreflightStageTest.php @@ -6,6 +6,7 @@ use Quantum\Tests\Unit\AppTestCase; use Quantum\Http\Enums\StatusCode; use Quantum\App\Adapters\Web\WebContext; +use Quantum\App\Enums\AppType; use Quantum\Di\DiContainer; use Quantum\App\AppContext; use Quantum\Http\Request; @@ -53,7 +54,7 @@ public function testHandlePreflightStageReadsTheRequestFromTheContext(): void $container = new DiContainer(); $container->set(Request::class, $request); - $webContext = new WebContext(new AppContext(PROJECT_ROOT, $container)); + $webContext = new WebContext(new AppContext(PROJECT_ROOT, $container, AppType::WEB)); (new HandlePreflightStage())->process($webContext); diff --git a/tests/Unit/App/Adapters/Web/Stages/Runtime/HandleRouteNotFoundStageTest.php b/tests/Unit/App/Adapters/Web/Stages/Runtime/HandleRouteNotFoundStageTest.php index f33586a50..624a1b631 100644 --- a/tests/Unit/App/Adapters/Web/Stages/Runtime/HandleRouteNotFoundStageTest.php +++ b/tests/Unit/App/Adapters/Web/Stages/Runtime/HandleRouteNotFoundStageTest.php @@ -8,6 +8,7 @@ use Quantum\Http\Enums\StatusCode; use Quantum\App\Adapters\Web\WebContext; use Quantum\Router\MatchedRoute; +use Quantum\App\Enums\AppType; use Quantum\Di\DiContainer; use Quantum\App\AppContext; use Quantum\Router\Route; @@ -70,7 +71,7 @@ public function testHandleRouteNotFoundStageDecidesByTheContextRequest(): void $container = new DiContainer(); $container->set(Request::class, $request); - $webContext = new WebContext(new AppContext(PROJECT_ROOT, $container)); + $webContext = new WebContext(new AppContext(PROJECT_ROOT, $container, AppType::WEB)); (new HandleRouteNotFoundStage())->process($webContext); diff --git a/tests/Unit/App/Adapters/Web/Stages/Runtime/PrepareRequestStageTest.php b/tests/Unit/App/Adapters/Web/Stages/Runtime/PrepareRequestStageTest.php index 08665af74..943867bfc 100644 --- a/tests/Unit/App/Adapters/Web/Stages/Runtime/PrepareRequestStageTest.php +++ b/tests/Unit/App/Adapters/Web/Stages/Runtime/PrepareRequestStageTest.php @@ -7,6 +7,7 @@ use Quantum\ResourceCache\ViewCache; use Quantum\App\Adapters\Web\WebContext; use Quantum\Router\MatchedRoute; +use Quantum\App\Enums\AppType; use Quantum\Di\DiContainer; use Quantum\App\AppContext; use Quantum\Router\Route; @@ -88,7 +89,7 @@ public function testPrepareRequestStageRegistersViewCacheInTheContextContainer() { $container = new DiContainer(); - (new PrepareRequestStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container))); + (new PrepareRequestStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container, AppType::WEB))); $this->assertTrue($container->isRegistered(ViewCache::class)); $this->assertFalse(Di::isRegistered(ViewCache::class)); @@ -105,7 +106,7 @@ public function testPrepareRequestStageSetsUpTheViewCacheOfTheContextContainer() $container->get(ViewCache::class)->enableCaching(true); try { - (new PrepareRequestStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container))); + (new PrepareRequestStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container, AppType::WEB))); $this->assertDirectoryExists($moduleDir); } finally { diff --git a/tests/Unit/App/Adapters/Web/Stages/Runtime/ResolveRouteStageTest.php b/tests/Unit/App/Adapters/Web/Stages/Runtime/ResolveRouteStageTest.php index 137fb09c8..d8bf611d1 100644 --- a/tests/Unit/App/Adapters/Web/Stages/Runtime/ResolveRouteStageTest.php +++ b/tests/Unit/App/Adapters/Web/Stages/Runtime/ResolveRouteStageTest.php @@ -7,6 +7,7 @@ use Quantum\App\Adapters\Web\WebContext; use Quantum\Router\RouteCollection; use Quantum\Router\MatchedRoute; +use Quantum\App\Enums\AppType; use Quantum\Di\DiContainer; use Quantum\App\AppContext; use Quantum\Http\Request; @@ -72,7 +73,7 @@ public function testResolveRouteStageReadsTheRequestAndRoutesFromTheContext(): v $container->set(Request::class, $request); $container->set(RouteCollection::class, $routes); - (new ResolveRouteStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container))); + (new ResolveRouteStage())->process(new WebContext(new AppContext(PROJECT_ROOT, $container, AppType::WEB))); $this->assertSame('contextAction', $request->getMatchedRoute()->getRoute()->getAction()); $this->assertNull(request()->getMatchedRoute()); diff --git a/tests/Unit/App/Adapters/Web/Stages/Runtime/SendResponseStageTest.php b/tests/Unit/App/Adapters/Web/Stages/Runtime/SendResponseStageTest.php index 0758ffd61..71933e526 100644 --- a/tests/Unit/App/Adapters/Web/Stages/Runtime/SendResponseStageTest.php +++ b/tests/Unit/App/Adapters/Web/Stages/Runtime/SendResponseStageTest.php @@ -7,6 +7,7 @@ use Quantum\Tests\Unit\AppTestCase; use Quantum\App\Adapters\Web\WebContext; use Quantum\Router\MatchedRoute; +use Quantum\App\Enums\AppType; use Quantum\Di\DiContainer; use Quantum\App\AppContext; use Quantum\Config\Config; @@ -125,7 +126,7 @@ public function testSendResponseStageReadsTheConfigAndRequestFromTheContext(): v $container->set(Request::class, $request); $container->set(Config::class, $config); - $webContext = new WebContext(new AppContext(PROJECT_ROOT, $container)); + $webContext = new WebContext(new AppContext(PROJECT_ROOT, $container, AppType::WEB)); $webContext->setResponse(response()->html('')); ob_start(); diff --git a/tests/Unit/App/Adapters/Web/WebContextTest.php b/tests/Unit/App/Adapters/Web/WebContextTest.php index 012fdab21..69e3156fd 100644 --- a/tests/Unit/App/Adapters/Web/WebContextTest.php +++ b/tests/Unit/App/Adapters/Web/WebContextTest.php @@ -7,6 +7,7 @@ use Quantum\Router\RouteCollection; use Quantum\Tests\Unit\AppTestCase; use Quantum\Router\MatchedRoute; +use Quantum\App\Enums\AppType; use Quantum\Di\DiContainer; use Quantum\App\AppContext; use Quantum\Http\Request; @@ -32,11 +33,12 @@ public function testWebContextIsAnAppContextBuiltFromTheOneItIsGiven(): void { $container = new DiContainer(); - $webContext = new WebContext(new AppContext('/base/dir', $container)); + $webContext = new WebContext(new AppContext('/base/dir', $container, AppType::WEB)); $this->assertInstanceOf(AppContext::class, $webContext); $this->assertSame('/base/dir', $webContext->getBaseDir()); $this->assertSame($container, $webContext->getContainer()); + $this->assertSame(AppType::WEB, $webContext->getType()); } public function testWebContextExposesRequestAndRoutesFromTheContainer(): void @@ -48,7 +50,7 @@ public function testWebContextExposesRequestAndRoutesFromTheContainer(): void $container->set(Request::class, $request); $container->set(RouteCollection::class, $routes); - $webContext = new WebContext(new AppContext('/base/dir', $container)); + $webContext = new WebContext(new AppContext('/base/dir', $container, AppType::WEB)); $this->assertSame($request, $webContext->getRequest()); $this->assertSame($routes, $webContext->getRoutes()); @@ -80,7 +82,7 @@ public function testWebContextReadsViewCacheFromTheContainer(): void $viewCache = Mockery::mock(ViewCache::class); $container->set(ViewCache::class, $viewCache); - $webContext = new WebContext(new AppContext('/base/dir', $container)); + $webContext = new WebContext(new AppContext('/base/dir', $container, AppType::WEB)); $this->assertSame($viewCache, $webContext->getViewCache()); } diff --git a/tests/Unit/App/AppContextTest.php b/tests/Unit/App/AppContextTest.php index d999812cf..7077a6141 100644 --- a/tests/Unit/App/AppContextTest.php +++ b/tests/Unit/App/AppContextTest.php @@ -5,6 +5,7 @@ use Quantum\Environment\Environment; use PHPUnit\Framework\TestCase; use Quantum\App\Enums\ExitCode; +use Quantum\App\Enums\AppType; use Quantum\Di\DiContainer; use Quantum\App\AppContext; use Quantum\Config\Config; @@ -14,15 +15,22 @@ class AppContextTest extends TestCase { public function testAppContextBaseDir(): void { - $context = new AppContext('/my/base/dir', new DiContainer()); + $context = new AppContext('/my/base/dir', new DiContainer(), AppType::WEB); $this->assertSame('/my/base/dir', $context->getBaseDir()); } + public function testAppContextType(): void + { + $context = new AppContext('/tmp', new DiContainer(), AppType::CONSOLE); + + $this->assertSame(AppType::CONSOLE, $context->getType()); + } + public function testAppContextContainer(): void { $container = new DiContainer(); - $context = new AppContext('/tmp', $container); + $context = new AppContext('/tmp', $container, AppType::WEB); $this->assertSame($container, $context->getContainer()); } @@ -33,7 +41,7 @@ public function testAppContextGetEnvironment(): void $environment = Mockery::mock(Environment::class); $container->set(Environment::class, $environment); - $context = new AppContext('/tmp', $container); + $context = new AppContext('/tmp', $container, AppType::WEB); $this->assertSame($environment, $context->getEnvironment()); } @@ -44,21 +52,21 @@ public function testAppContextGetConfig(): void $config = Mockery::mock(Config::class); $container->set(Config::class, $config); - $context = new AppContext('/tmp', $container); + $context = new AppContext('/tmp', $container, AppType::WEB); $this->assertSame($config, $context->getConfig()); } public function testAppContextIsNotStoppedByDefault(): void { - $context = new AppContext('/tmp', new DiContainer()); + $context = new AppContext('/tmp', new DiContainer(), AppType::WEB); $this->assertFalse($context->isStopped()); } public function testAppContextStop(): void { - $context = new AppContext('/tmp', new DiContainer()); + $context = new AppContext('/tmp', new DiContainer(), AppType::WEB); $context->stop(); @@ -67,14 +75,14 @@ public function testAppContextStop(): void public function testAppContextExitCodeDefaultsToSuccess(): void { - $context = new AppContext('/tmp', new DiContainer()); + $context = new AppContext('/tmp', new DiContainer(), AppType::WEB); $this->assertSame(ExitCode::SUCCESS, $context->getExitCode()); } public function testAppContextSetExitCode(): void { - $context = new AppContext('/tmp', new DiContainer()); + $context = new AppContext('/tmp', new DiContainer(), AppType::WEB); $context->setExitCode(ExitCode::INVALID); diff --git a/tests/Unit/App/Factories/AppFactoryTest.php b/tests/Unit/App/Factories/AppFactoryTest.php index b216b6a3d..e3218ecd2 100644 --- a/tests/Unit/App/Factories/AppFactoryTest.php +++ b/tests/Unit/App/Factories/AppFactoryTest.php @@ -90,6 +90,19 @@ public function testAppFactoryDestroy(): void $this->assertNotSame($app1, $app2); } + public function testAppFactorySetsTheContextTypeOfTheCreatedApp(): void + { + AppFactory::destroy(AppType::WEB); + AppFactory::create(AppType::WEB, PROJECT_ROOT); + + $this->assertSame(AppType::WEB, App::getContext()->getType()); + + AppFactory::destroy(AppType::CONSOLE); + AppFactory::create(AppType::CONSOLE, PROJECT_ROOT); + + $this->assertSame(AppType::CONSOLE, App::getContext()->getType()); + } + public function testAppFactoryResetsContainerOnCreate(): void { AppFactory::destroy(AppType::WEB); diff --git a/tests/Unit/App/PipelineTest.php b/tests/Unit/App/PipelineTest.php index b1cb35867..584f5d5e4 100644 --- a/tests/Unit/App/PipelineTest.php +++ b/tests/Unit/App/PipelineTest.php @@ -15,6 +15,7 @@ use Quantum\App\Adapters\Web\WebContext; use Quantum\Tests\Unit\AppTestCase; use Quantum\App\Enums\ExitCode; +use Quantum\App\Enums\AppType; use InvalidArgumentException; use Quantum\Di\DiContainer; use Quantum\App\AppContext; @@ -47,21 +48,21 @@ public function testPipelineRunsStagesInOrder(): void $this->createStage(function () use (&$log): void { $log[] = 'third'; }), - ]))->run(new AppContext('', new DiContainer())); + ]))->run(new AppContext('', new DiContainer(), AppType::WEB)); $this->assertSame(['first', 'second', 'third'], $log); } public function testEmptyPipelineRunsWithoutError(): void { - (new Pipeline([]))->run(new AppContext('', new DiContainer())); + (new Pipeline([]))->run(new AppContext('', new DiContainer(), AppType::WEB)); $this->assertTrue(true); } public function testPipelinePassesTheContextToEveryStage(): void { - $context = new AppContext('/test/dir', new DiContainer()); + $context = new AppContext('/test/dir', new DiContainer(), AppType::WEB); $received = []; $stage = $this->createStage(function (AppContext $context) use (&$received): void { diff --git a/tests/Unit/AppTestCase.php b/tests/Unit/AppTestCase.php index 40f9f4b61..b5013893d 100644 --- a/tests/Unit/AppTestCase.php +++ b/tests/Unit/AppTestCase.php @@ -57,9 +57,9 @@ public function tearDown(): void $this->clearAppContext(); } - protected function createContext(): AppContext + protected function createContext(string $type = AppType::WEB): AppContext { - $context = new AppContext(PROJECT_ROOT, new DiContainer()); + $context = new AppContext(PROJECT_ROOT, new DiContainer(), $type); App::setContext($context); return $context; diff --git a/tests/Unit/Environment/EnvironmentTest.php b/tests/Unit/Environment/EnvironmentTest.php index b9a13a8f1..70faa5cf6 100644 --- a/tests/Unit/Environment/EnvironmentTest.php +++ b/tests/Unit/Environment/EnvironmentTest.php @@ -29,7 +29,7 @@ public function testLoadReportsMissingBootstrapConfig(): void $context = App::getContext(); try { - App::setContext(new AppContext(PROJECT_ROOT . DS . 'cron-command-tests-empty', $context->getContainer())); + App::setContext(new AppContext(PROJECT_ROOT . DS . 'cron-command-tests-empty', $context->getContainer(), $context->getType())); $this->expectException(EnvException::class); $this->expectExceptionMessage('shared' . DS . 'config' . DS . 'env.php'); @@ -65,7 +65,7 @@ public function testLoadDoesNotReadBootstrapAgain(): void $context = App::getContext(); try { - App::setContext(new AppContext(PROJECT_ROOT . DS . 'cron-command-tests-empty', $context->getContainer())); + App::setContext(new AppContext(PROJECT_ROOT . DS . 'cron-command-tests-empty', $context->getContainer(), $context->getType())); $environment->load(); diff --git a/tests/Unit/Storage/Uploads/UploadConfigProviderTest.php b/tests/Unit/Storage/Uploads/UploadConfigProviderTest.php index ea13bdb1e..79fc7fcdd 100644 --- a/tests/Unit/Storage/Uploads/UploadConfigProviderTest.php +++ b/tests/Unit/Storage/Uploads/UploadConfigProviderTest.php @@ -25,7 +25,7 @@ public function testMissingOptionalUploadConfigReturnsEmptyMap(): void $context = App::getContext(); try { - App::setContext(new AppContext(PROJECT_ROOT . DS . 'cron-command-tests-empty', $context->getContainer())); + App::setContext(new AppContext(PROJECT_ROOT . DS . 'cron-command-tests-empty', $context->getContainer(), $context->getType())); $this->assertSame([], (new UploadConfigProvider())->getAllowedMimeTypesMap()); $this->assertFalse(config()->has('uploads')); From fc9e97b15884f6350ab5403caaa20b9dc56494b5 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:00:16 +0400 Subject: [PATCH 2/8] [#593] Choose the console or web error output by the app type ErrorHandler::handleException() reads the app type from the context instead of checking PHP_SAPI. The console adapter tests now create a console context. --- src/Tracer/ErrorHandler.php | 4 ++- .../Console/ConsoleAppAdapterTest.php | 15 +++++----- .../Stages/Boot/InitConsoleStageTest.php | 3 +- .../Boot/SetupConsoleApplicationStageTest.php | 5 ++-- tests/Unit/Tracer/ErrorHandlerTest.php | 30 +++++++++++++++++++ 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/Tracer/ErrorHandler.php b/src/Tracer/ErrorHandler.php index b1b39263f..6e36f9867 100644 --- a/src/Tracer/ErrorHandler.php +++ b/src/Tracer/ErrorHandler.php @@ -17,9 +17,11 @@ use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; use Quantum\Http\Enums\StatusCode; +use Quantum\App\Enums\AppType; use Quantum\Logger\Logger; use ReflectionException; use ErrorException; +use Quantum\App\App; use Throwable; use Exception; @@ -79,7 +81,7 @@ public function handleError(int $severity, string $message, string $file, int $l */ public function handleException(Throwable $throwable): void { - if (PHP_SAPI === 'cli') { + if (App::getContext()->getType() === AppType::CONSOLE) { $this->handleCliException($throwable); } else { $this->handleWebException($throwable); diff --git a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php index c10d39e4d..4ac3982ce 100644 --- a/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php +++ b/tests/Unit/App/Adapters/Console/ConsoleAppAdapterTest.php @@ -15,6 +15,7 @@ use Quantum\Environment\Environment; use Quantum\Tests\Unit\AppTestCase; use Quantum\Tracer\ErrorHandler; +use Quantum\App\Enums\AppType; use Quantum\App\AppContext; use Quantum\App\Pipeline; use ReflectionProperty; @@ -244,7 +245,7 @@ public function testConsoleAppAdapterConstructorDoesNotBoot(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; - new ConsoleAppAdapter($this->createContext()); + new ConsoleAppAdapter($this->createContext(AppType::CONSOLE)); $this->assertFalse(Di::isRegistered(Application::class)); $this->assertFalse(Di::has(Environment::class)); @@ -254,7 +255,7 @@ public function testConsoleAppAdapterBootRunsTheBootStages(): void { $_SERVER['argv'] = ['qt', 'list', '--quiet']; - $adapter = new ConsoleAppAdapter($this->createContext()); + $adapter = new ConsoleAppAdapter($this->createContext(AppType::CONSOLE)); $adapter->boot(); $this->assertTrue(Di::isRegistered(Application::class)); @@ -266,7 +267,7 @@ public function testConsoleAppAdapterRegistersTheConsoleObjectsInTheContainerAft { $_SERVER['argv'] = ['qt', 'core:env', '--yes']; - (new ConsoleAppAdapter($this->createContext()))->boot(); + (new ConsoleAppAdapter($this->createContext(AppType::CONSOLE)))->boot(); $this->assertTrue(Di::isRegistered(Application::class)); $this->assertTrue(Di::isRegistered(OutputInterface::class)); @@ -279,7 +280,7 @@ public function testConsoleAppAdapterRegistersTheConsoleObjectsInTheContainerWhe $_SERVER['argv'] = ['qt', 'list', '--quiet']; $this->withoutEnvironmentFile(function (): void { - (new ConsoleAppAdapter($this->createContext()))->boot(); + (new ConsoleAppAdapter($this->createContext(AppType::CONSOLE)))->boot(); $this->assertTrue(Di::isRegistered(Application::class)); $this->assertTrue(Di::isRegistered(OutputInterface::class)); @@ -316,7 +317,7 @@ public function testConsoleAppAdapterNamesTheApplicationUnknownWhenTheAppNameIsE { $_SERVER['argv'] = ['qt', 'list', '--quiet']; - $context = $this->createContext(); + $context = $this->createContext(AppType::CONSOLE); (new Pipeline([new LoadHelpersStage()]))->run($context); @@ -333,7 +334,7 @@ public function testConsoleAppAdapterKeepsAnEmptyAppNameAndVersionFromTheConfig( { $_SERVER['argv'] = ['qt', 'list', '--quiet']; - $context = $this->createContext(); + $context = $this->createContext(AppType::CONSOLE); (new Pipeline([new LoadHelpersStage()]))->run($context); @@ -677,7 +678,7 @@ private function withFileContent(string $file, string $content, callable $test): */ private function createAdapter(?Application $application = null, ?AppContext $context = null): ConsoleAppAdapter { - $context ??= $this->createContext(); + $context ??= $this->createContext(AppType::CONSOLE); if ($application !== null) { Di::set(Application::class, $application); diff --git a/tests/Unit/App/Adapters/Console/Stages/Boot/InitConsoleStageTest.php b/tests/Unit/App/Adapters/Console/Stages/Boot/InitConsoleStageTest.php index bf7104a28..d7adff49a 100644 --- a/tests/Unit/App/Adapters/Console/Stages/Boot/InitConsoleStageTest.php +++ b/tests/Unit/App/Adapters/Console/Stages/Boot/InitConsoleStageTest.php @@ -11,13 +11,14 @@ use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Application; use Quantum\Tests\Unit\AppTestCase; +use Quantum\App\Enums\AppType; use Quantum\Di\Di; class InitConsoleStageTest extends AppTestCase { public function setUp(): void { - $this->context = $this->createContext(); + $this->context = $this->createContext(AppType::CONSOLE); } public function tearDown(): void diff --git a/tests/Unit/App/Adapters/Console/Stages/Boot/SetupConsoleApplicationStageTest.php b/tests/Unit/App/Adapters/Console/Stages/Boot/SetupConsoleApplicationStageTest.php index cbac2050a..c922a2a4b 100644 --- a/tests/Unit/App/Adapters/Console/Stages/Boot/SetupConsoleApplicationStageTest.php +++ b/tests/Unit/App/Adapters/Console/Stages/Boot/SetupConsoleApplicationStageTest.php @@ -6,13 +6,14 @@ use Quantum\App\Stages\Boot\LoadHelpersStage; use Symfony\Component\Console\Application; use Quantum\Tests\Unit\AppTestCase; +use Quantum\App\Enums\AppType; use Quantum\Di\Di; class SetupConsoleApplicationStageTest extends AppTestCase { public function setUp(): void { - $this->context = $this->createContext(); + $this->context = $this->createContext(AppType::CONSOLE); (new LoadHelpersStage())->process($this->context); @@ -59,7 +60,7 @@ public function testSetupConsoleApplicationStageNamesAnApplicationThatIsAlreadyR { $application = new Application('Registered', '0.1'); - $this->context = $this->createContext(); + $this->context = $this->createContext(AppType::CONSOLE); (new LoadHelpersStage())->process($this->context); Di::set(Application::class, $application); diff --git a/tests/Unit/Tracer/ErrorHandlerTest.php b/tests/Unit/Tracer/ErrorHandlerTest.php index 3c12df59a..c6af4a7fb 100644 --- a/tests/Unit/Tracer/ErrorHandlerTest.php +++ b/tests/Unit/Tracer/ErrorHandlerTest.php @@ -6,9 +6,12 @@ use Quantum\Tracer\WebExceptionRenderer; use Quantum\Tests\Unit\AppTestCase; use Quantum\Tracer\ErrorHandler; +use Quantum\App\Enums\AppType; +use Quantum\App\AppContext; use Quantum\Logger\Logger; use ReflectionException; use ErrorException; +use Quantum\App\App; use ParseError; use Exception; use Mockery; @@ -92,6 +95,8 @@ public function testHandleErrorReturnsFalseForSuppressedErrors(): void public function testHandleExceptionCliWritesExceptionMessage(): void { + $this->useAppType(AppType::CONSOLE); + config()->set('app.debug', false); $output = new BufferedOutput(); @@ -104,6 +109,8 @@ public function testHandleExceptionCliWritesExceptionMessage(): void public function testHandleExceptionCliInDebugModeWritesVerboseDiagnostics(): void { + $this->useAppType(AppType::CONSOLE); + config()->set('app.debug', true); $output = new BufferedOutput(); @@ -117,6 +124,21 @@ public function testHandleExceptionCliInDebugModeWritesVerboseDiagnostics(): voi $this->assertStringContainsString(__FILE__, $display); } + public function testHandleExceptionInWebAppRendersTheErrorPage(): void + { + config()->set('app.debug', true); + + $output = new BufferedOutput(); + $this->errorHandler->setCliOutput($output); + + ob_start(); + $this->errorHandler->handleException(new Exception('Web app failure')); + ob_get_clean(); + + $this->assertStringContainsString('Error :: Web app failure', response()->getContent()); + $this->assertSame('', $output->fetch()); + } + public function testHandleWebExceptionInDebugModeRendersTraceView(): void { config()->set('app.debug', true); @@ -222,4 +244,12 @@ public function errorTypeProvider(): array ]; } + /** + * Replaces the app context with one of the given type that keeps the current container + */ + private function useAppType(string $type): void + { + App::setContext(new AppContext(PROJECT_ROOT, App::getContext()->getContainer(), $type)); + } + } From cd033e07d4287008c9a043c8ab4c56475fa80a99 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:03:13 +0400 Subject: [PATCH 3/8] [#593] Log to the configured adapter from the console in debug mode LoggerFactory forces the in-memory message adapter in debug mode only for the web app, where the debug toolbar reads it. A console command in debug mode logs to the configured or requested adapter, at every level. --- src/Logger/Factories/LoggerFactory.php | 6 +++- tests/Unit/AppTestCase.php | 8 ++++++ .../Logger/Factories/LoggerFactoryTest.php | 28 +++++++++++++++++++ tests/Unit/Tracer/ErrorHandlerTest.php | 10 ------- 4 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/Logger/Factories/LoggerFactory.php b/src/Logger/Factories/LoggerFactory.php index f8e6afc39..f3988d7f9 100644 --- a/src/Logger/Factories/LoggerFactory.php +++ b/src/Logger/Factories/LoggerFactory.php @@ -20,9 +20,11 @@ use Quantum\Di\Exceptions\DiException; use Quantum\Logger\Enums\LoggerType; use Quantum\Logger\LoggerConfig; +use Quantum\App\Enums\AppType; use Quantum\Logger\Logger; use Quantum\Config\Setup; use ReflectionException; +use Quantum\App\App; use Quantum\Di\Di; /** @@ -69,7 +71,9 @@ public function resolve(?string $adapter = null): Logger throw LoggerException::adapterNotSupported(LoggerType::MESSAGE); } - $adapter = $isDebug ? LoggerType::MESSAGE : ($adapter ?? config()->get('logging.default')); + $inMemory = $isDebug && App::getContext()->getType() === AppType::WEB; + + $adapter = $inMemory ? LoggerType::MESSAGE : ($adapter ?? config()->get('logging.default')); $adapterClass = $this->getAdapterClass($adapter); diff --git a/tests/Unit/AppTestCase.php b/tests/Unit/AppTestCase.php index b5013893d..7ac1fed5e 100644 --- a/tests/Unit/AppTestCase.php +++ b/tests/Unit/AppTestCase.php @@ -65,6 +65,14 @@ protected function createContext(string $type = AppType::WEB): AppContext return $context; } + /** + * Replaces the app context with one of the given type that keeps the current container + */ + protected function useAppType(string $type): void + { + App::setContext(new AppContext(PROJECT_ROOT, App::getContext()->getContainer(), $type)); + } + protected function clearAppContext(): void { $prop = new ReflectionProperty(App::class, 'context'); diff --git a/tests/Unit/Logger/Factories/LoggerFactoryTest.php b/tests/Unit/Logger/Factories/LoggerFactoryTest.php index 077d26160..c92f6edc7 100644 --- a/tests/Unit/Logger/Factories/LoggerFactoryTest.php +++ b/tests/Unit/Logger/Factories/LoggerFactoryTest.php @@ -10,6 +10,7 @@ use Quantum\Logger\Adapters\DailyAdapter; use Quantum\Logger\Enums\LoggerType; use Quantum\Tests\Unit\AppTestCase; +use Quantum\App\Enums\AppType; use Quantum\Logger\Logger; use Quantum\Di\Di; @@ -69,6 +70,33 @@ public function testLoggerFactoryGetMessageAdapter(): void $this->assertInstanceOf(ReportableInterface::class, $logger->getAdapter()); } + public function testLoggerFactoryInConsoleDebugModeGetsTheDefaultAdapter(): void + { + $this->useAppType(AppType::CONSOLE); + + config()->set('app.debug', true); + + $this->assertInstanceOf(SingleAdapter::class, LoggerFactory::get()->getAdapter()); + } + + public function testLoggerFactoryInConsoleDebugModeGetsTheRequestedAdapter(): void + { + $this->useAppType(AppType::CONSOLE); + + config()->set('app.debug', true); + + $this->assertInstanceOf(DailyAdapter::class, LoggerFactory::get(LoggerType::DAILY)->getAdapter()); + } + + public function testLoggerFactoryInConsoleDebugModeGetsTheMessageAdapterWhenAsked(): void + { + $this->useAppType(AppType::CONSOLE); + + config()->set('app.debug', true); + + $this->assertInstanceOf(MessageAdapter::class, LoggerFactory::get(LoggerType::MESSAGE)->getAdapter()); + } + public function testLoggerFactoryTryingToGetMessageAdapter(): void { $this->expectException(LoggerException::class); diff --git a/tests/Unit/Tracer/ErrorHandlerTest.php b/tests/Unit/Tracer/ErrorHandlerTest.php index c6af4a7fb..9ff87fe84 100644 --- a/tests/Unit/Tracer/ErrorHandlerTest.php +++ b/tests/Unit/Tracer/ErrorHandlerTest.php @@ -7,11 +7,9 @@ use Quantum\Tests\Unit\AppTestCase; use Quantum\Tracer\ErrorHandler; use Quantum\App\Enums\AppType; -use Quantum\App\AppContext; use Quantum\Logger\Logger; use ReflectionException; use ErrorException; -use Quantum\App\App; use ParseError; use Exception; use Mockery; @@ -244,12 +242,4 @@ public function errorTypeProvider(): array ]; } - /** - * Replaces the app context with one of the given type that keeps the current container - */ - private function useAppType(string $type): void - { - App::setContext(new AppContext(PROJECT_ROOT, App::getContext()->getContainer(), $type)); - } - } From 85377457ed2855d4c96b27766bd7b61f0c985480 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:06:57 +0400 Subject: [PATCH 4/8] [#593] Keep only the last messages in the debug-mode logger MessageAdapter keeps the last logging.message.limit messages (default 1000) and drops the oldest; 0 keeps every message. --- src/Logger/Adapters/MessageAdapter.php | 14 ++++++++ src/Logger/Factories/LoggerFactory.php | 2 +- .../Logger/Adapters/MessageAdapterTest.php | 35 +++++++++++++++++++ .../Logger/Factories/LoggerFactoryTest.php | 28 +++++++++++++++ tests/_root/shared/config/logging.php | 4 +++ 5 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/Logger/Adapters/MessageAdapter.php b/src/Logger/Adapters/MessageAdapter.php index fd641073c..c9e0626b2 100644 --- a/src/Logger/Adapters/MessageAdapter.php +++ b/src/Logger/Adapters/MessageAdapter.php @@ -25,9 +25,23 @@ class MessageAdapter implements ReportableInterface */ private array $messages = []; + /** + * The number of last messages kept; 0 keeps every message + */ + private int $limit; + + public function __construct(int $limit = 1000) + { + $this->limit = $limit; + } + public function report(string $level, string|Stringable $message, ?array $context = []): void { $this->messages[] = [$level => $message]; + + if ($this->limit > 0 && count($this->messages) > $this->limit) { + $this->messages = array_slice($this->messages, -$this->limit); + } } /** diff --git a/src/Logger/Factories/LoggerFactory.php b/src/Logger/Factories/LoggerFactory.php index f3988d7f9..5234886e7 100644 --- a/src/Logger/Factories/LoggerFactory.php +++ b/src/Logger/Factories/LoggerFactory.php @@ -90,7 +90,7 @@ public function resolve(?string $adapter = null): Logger private function createInstance(string $adapterClass, string $adapter): Logger { if ($adapter === LoggerType::MESSAGE) { - return new Logger(new MessageAdapter()); + return new Logger(new MessageAdapter((int) config()->get('logging.message.limit', 1000))); } $adapterInstance = new $adapterClass(config()->get('logging.' . $adapter)); diff --git a/tests/Unit/Logger/Adapters/MessageAdapterTest.php b/tests/Unit/Logger/Adapters/MessageAdapterTest.php index 928378683..99ebf6461 100644 --- a/tests/Unit/Logger/Adapters/MessageAdapterTest.php +++ b/tests/Unit/Logger/Adapters/MessageAdapterTest.php @@ -28,4 +28,39 @@ public function testReportKeepsTheMessagesInOrder(): void $this->assertSame([['info' => 'Test message'], ['error' => 'Error message']], $this->adapter->getMessages()); } + + public function testReportKeepsOnlyTheLastMessagesOverTheLimit(): void + { + $adapter = new MessageAdapter(2); + + $adapter->report('info', 'First'); + $adapter->report('info', 'Second'); + $adapter->report('info', 'Third'); + + $this->assertSame([['info' => 'Second'], ['info' => 'Third']], $adapter->getMessages()); + } + + public function testReportKeepsEveryMessageWithZeroLimit(): void + { + $adapter = new MessageAdapter(0); + + for ($i = 1; $i <= 1001; $i++) { + $adapter->report('info', 'Message ' . $i); + } + + $this->assertCount(1001, $adapter->getMessages()); + } + + public function testReportKeepsTheLastThousandMessagesByDefault(): void + { + for ($i = 1; $i <= 1001; $i++) { + $this->adapter->report('info', 'Message ' . $i); + } + + $messages = $this->adapter->getMessages(); + + $this->assertCount(1000, $messages); + $this->assertSame(['info' => 'Message 2'], $messages[0]); + $this->assertSame(['info' => 'Message 1001'], $messages[999]); + } } diff --git a/tests/Unit/Logger/Factories/LoggerFactoryTest.php b/tests/Unit/Logger/Factories/LoggerFactoryTest.php index c92f6edc7..496edb863 100644 --- a/tests/Unit/Logger/Factories/LoggerFactoryTest.php +++ b/tests/Unit/Logger/Factories/LoggerFactoryTest.php @@ -70,6 +70,34 @@ public function testLoggerFactoryGetMessageAdapter(): void $this->assertInstanceOf(ReportableInterface::class, $logger->getAdapter()); } + public function testLoggerFactoryPassesTheMessageLimitFromTheConfig(): void + { + config()->set('logging.message.limit', 2); + config()->set('app.debug', true); + + $logger = LoggerFactory::get(); + + $logger->info('First'); + $logger->info('Second'); + $logger->info('Third'); + + $this->assertSame([['info' => 'Second'], ['info' => 'Third']], $logger->getAdapter()->getMessages()); + } + + public function testLoggerFactoryUsesTheDefaultMessageLimitWithoutTheKey(): void + { + config()->set('logging.message', []); + config()->set('app.debug', true); + + $logger = LoggerFactory::get(); + + for ($i = 1; $i <= 1001; $i++) { + $logger->info('Message ' . $i); + } + + $this->assertCount(1000, $logger->getAdapter()->getMessages()); + } + public function testLoggerFactoryInConsoleDebugModeGetsTheDefaultAdapter(): void { $this->useAppType(AppType::CONSOLE); diff --git a/tests/_root/shared/config/logging.php b/tests/_root/shared/config/logging.php index 0fff5b72b..1d5261020 100644 --- a/tests/_root/shared/config/logging.php +++ b/tests/_root/shared/config/logging.php @@ -12,4 +12,8 @@ 'path' => logs_dir(), 'level' => 'warning', ], + + 'message' => [ + 'limit' => 1000, + ], ]; From c6b7f46319042abfaabd3fffe9563705e405cf02 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:14:32 +0400 Subject: [PATCH 5/8] [#593] Cut the mailer transport log to its last lines before each send MailerTrait::send() first cuts the transport log to its last mailer.transport_log_limit lines (default 1000); 0 keeps every line. Every send reaches that point, also after the previous one threw, so the log holds at most the limit plus the lines of the send in progress. --- src/Mailer/Traits/MailerTrait.php | 9 +- .../Adapters/MailerAdapterTraitTest.php | 102 ++++++++++++++++++ .../Unit/Mailer/Adapters/SmtpAdapterTest.php | 71 ++++++++++++ tests/_root/shared/config/mailer.php | 2 + 4 files changed, 183 insertions(+), 1 deletion(-) diff --git a/src/Mailer/Traits/MailerTrait.php b/src/Mailer/Traits/MailerTrait.php index fe381be17..50a0da22b 100644 --- a/src/Mailer/Traits/MailerTrait.php +++ b/src/Mailer/Traits/MailerTrait.php @@ -84,7 +84,7 @@ trait MailerTrait protected array $stringAttachments = []; /** - * Transport log + * Transport log, cut to its last mailer.transport_log_limit lines at the start of each send * @var array */ private array $transportLog = []; @@ -222,6 +222,13 @@ public function getBody() */ public function send(): bool { + // Cut here, as every send reaches this point, also after the previous one threw + $limit = (int) config()->get('mailer.transport_log_limit', 1000); + + if ($limit > 0 && count($this->transportLog) > $limit) { + $this->transportLog = array_slice($this->transportLog, -$limit); + } + $this->prepare(); $sent = config()->get('mailer.mail_trap') ? $this->saveEmail() : $this->sendEmail(); diff --git a/tests/Unit/Mailer/Adapters/MailerAdapterTraitTest.php b/tests/Unit/Mailer/Adapters/MailerAdapterTraitTest.php index dbfba7a69..147dce1b8 100644 --- a/tests/Unit/Mailer/Adapters/MailerAdapterTraitTest.php +++ b/tests/Unit/Mailer/Adapters/MailerAdapterTraitTest.php @@ -128,4 +128,106 @@ protected function getTransportErrors(): array $this->assertSame(['Connection refused'], $adapter->getTransportLog()); $this->assertSame([['warning' => 'Connection refused']], logger()->getAdapter()->getMessages()); } + + public function testMailerTraitSendCutsTheTransportLogToItsLastLinesBeforeSending(): void + { + config()->set('mailer.transport_log_limit', 2); + + $adapter = $this->createFailingAdapter(1); + + for ($i = 1; $i <= 4; $i++) { + $adapter->send(); + } + + $this->assertSame(['Send 2 line 1', 'Send 3 line 1', 'Send 4 line 1'], $adapter->getTransportLog()); + } + + public function testMailerTraitSendKeepsEveryLineOfTheSendInProgress(): void + { + config()->set('mailer.transport_log_limit', 1); + + $adapter = $this->createFailingAdapter(3); + + $adapter->send(); + + $this->assertSame(['Send 1 line 1', 'Send 1 line 2', 'Send 1 line 3'], $adapter->getTransportLog()); + + $adapter->send(); + + $this->assertSame( + ['Send 1 line 3', 'Send 2 line 1', 'Send 2 line 2', 'Send 2 line 3'], + $adapter->getTransportLog() + ); + } + + public function testMailerTraitSendKeepsTheWholeTransportLogWithZeroLimit(): void + { + config()->set('mailer.transport_log_limit', 0); + + $adapter = $this->createFailingAdapter(1); + + for ($i = 1; $i <= 5; $i++) { + $adapter->send(); + } + + $this->assertCount(5, $adapter->getTransportLog()); + } + + public function testMailerTraitSendKeepsTheLastThousandLinesWithoutTheKey(): void + { + config()->delete('mailer.transport_log_limit'); + + $adapter = $this->createFailingAdapter(1001); + + $adapter->send(); + $adapter->send(); + + $log = $adapter->getTransportLog(); + + $this->assertCount(2001, $log); + $this->assertSame('Send 1 line 2', $log[0]); + } + + /** + * Creates an adapter whose every send fails with the given number of transport error lines + */ + private function createFailingAdapter(int $linesPerSend): MailerInterface + { + config()->set('mailer.mail_trap', false); + + return new class ($linesPerSend) implements MailerInterface { + use MailerTrait; + + private int $linesPerSend; + + private int $sends = 0; + + public function __construct(int $linesPerSend) + { + $this->linesPerSend = $linesPerSend; + } + + protected function prepare(): void + { + } + + protected function sendEmail(): bool + { + $this->sends++; + + return false; + } + + protected function getTransportErrors(): array + { + $lines = []; + + for ($i = 1; $i <= $this->linesPerSend; $i++) { + $lines[] = 'Send ' . $this->sends . ' line ' . $i; + } + + return $lines; + } + }; + } } diff --git a/tests/Unit/Mailer/Adapters/SmtpAdapterTest.php b/tests/Unit/Mailer/Adapters/SmtpAdapterTest.php index 30e86f423..5a6b60220 100644 --- a/tests/Unit/Mailer/Adapters/SmtpAdapterTest.php +++ b/tests/Unit/Mailer/Adapters/SmtpAdapterTest.php @@ -5,6 +5,8 @@ use Quantum\Tests\Unit\Mailer\MailerTestCase; use Quantum\Mailer\Contracts\MailerInterface; use Quantum\Mailer\Adapters\SmtpAdapter; +use PHPMailer\PHPMailer\Exception; +use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\SMTP; use Quantum\Config\Setup; @@ -114,6 +116,75 @@ public function testSmtpAdapterKeepsTheSmtpExchangeInDebugMode(): void $this->assertSame(['CLIENT -> SERVER: EHLO localhost'], $adapter->getTransportLog()); } + public function testSmtpAdapterCutsTheSmtpExchangeToItsLastLinesAtTheNextSend(): void + { + config()->set('app.debug', true); + config()->set('mailer.transport_log_limit', 2); + + $adapter = new SmtpAdapter(config()->get('mailer.smtp')); + + $debugOutput = $this->getPrivateProperty($adapter, 'mailer')->Debugoutput; + + $debugOutput('CLIENT -> SERVER: EHLO localhost', 2); + $debugOutput('CLIENT -> SERVER: MAIL FROM:', 2); + $debugOutput('CLIENT -> SERVER: QUIT', 2); + + $adapter->setFrom('john@hotmail.com', 'John Doe'); + $adapter->setAddress('benny@gmail.com', 'Benny'); + $adapter->setBody('Lorem ipsum dolor sit amet'); + + $this->assertTrue($adapter->send()); + + $this->assertSame( + ['CLIENT -> SERVER: MAIL FROM:', 'CLIENT -> SERVER: QUIT'], + $adapter->getTransportLog() + ); + } + + public function testSmtpAdapterKeepsTheSmtpExchangeOfAFailedSendAndCutsItAtTheNextSend(): void + { + config()->set('app.debug', true); + config()->set('mailer.mail_trap', false); + config()->set('mailer.transport_log_limit', 1); + + $adapter = new SmtpAdapter(config()->get('mailer.smtp')); + + $failingMailer = new class (true) extends PHPMailer { + public int $sends = 0; + + public function send() + { + $this->sends++; + + call_user_func($this->Debugoutput, 'Send ' . $this->sends . ': CLIENT -> SERVER: EHLO localhost', 2); + call_user_func($this->Debugoutput, 'Send ' . $this->sends . ': SERVER -> CLIENT: 554 Rejected', 2); + + throw new Exception('SMTP Error: data not accepted.'); + } + }; + + $failingMailer->Debugoutput = $this->getPrivateProperty($adapter, 'mailer')->Debugoutput; + $this->setPrivateProperty($adapter, 'mailer', $failingMailer); + + for ($i = 1; $i <= 2; $i++) { + $adapter->setFrom('john@hotmail.com', 'John Doe'); + $adapter->setAddress('benny@gmail.com', 'Benny'); + + try { + $adapter->send(); + $this->fail('Expected the SMTP send to throw'); + } catch (Exception $e) { + $this->assertSame('SMTP Error: data not accepted.', $e->getMessage()); + } + } + + $this->assertSame([ + 'Send 1: SERVER -> CLIENT: 554 Rejected', + 'Send 2: CLIENT -> SERVER: EHLO localhost', + 'Send 2: SERVER -> CLIENT: 554 Rejected', + ], $adapter->getTransportLog()); + } + public function testSmtpAdapterKeepsNoSmtpExchangeOutsideDebugMode(): void { config()->set('app.debug', false); diff --git a/tests/_root/shared/config/mailer.php b/tests/_root/shared/config/mailer.php index 87b0fc3a2..1869c8130 100644 --- a/tests/_root/shared/config/mailer.php +++ b/tests/_root/shared/config/mailer.php @@ -5,6 +5,8 @@ 'mail_trap' => true, + 'transport_log_limit' => 1000, + 'smtp' => [ 'host' => '127.0.0.1', 'secure' => 'ssl', From 1f752338ca70fc839935785c8c1cf52d0684a905 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:46:30 +0400 Subject: [PATCH 6/8] [#593] Add is_web_app() and is_console_app(), and keep no query log outside debug mode The helpers read the app type from the context; LoggerFactory and ErrorHandler use them instead of comparing the type themselves. Idiorm's logging option reads is_debug_mode(), so DEBUG=false from .env, which arrives as the text "false", no longer keeps every query. --- src/App/Helpers/app.php | 19 +++++++++ src/Database/Adapters/Idiorm/IdiormDbal.php | 6 +-- src/Logger/Factories/LoggerFactory.php | 6 +-- src/Tracer/ErrorHandler.php | 4 +- tests/Unit/App/Helpers/AppHelperTest.php | 25 ++++++++++++ .../Adapters/Idiorm/IdiormDbalTest.php | 39 +++++++++++++++++++ 6 files changed, 88 insertions(+), 11 deletions(-) create mode 100644 tests/Unit/App/Helpers/AppHelperTest.php diff --git a/src/App/Helpers/app.php b/src/App/Helpers/app.php index 86da60067..8f7daf2f4 100644 --- a/src/App/Helpers/app.php +++ b/src/App/Helpers/app.php @@ -5,3 +5,22 @@ * An open-source software development framework for PHP * @link https://quantumphp.io */ + +use Quantum\App\Enums\AppType; +use Quantum\App\App; + +/** + * Checks whether the running application is the web app + */ +function is_web_app(): bool +{ + return App::getContext()->getType() === AppType::WEB; +} + +/** + * Checks whether the running application is the console app + */ +function is_console_app(): bool +{ + return App::getContext()->getType() === AppType::CONSOLE; +} diff --git a/src/Database/Adapters/Idiorm/IdiormDbal.php b/src/Database/Adapters/Idiorm/IdiormDbal.php index 0649c5824..74376e162 100644 --- a/src/Database/Adapters/Idiorm/IdiormDbal.php +++ b/src/Database/Adapters/Idiorm/IdiormDbal.php @@ -21,11 +21,11 @@ use Quantum\Database\Exceptions\DatabaseException; use Quantum\Database\Contracts\DbalInterface; use Quantum\App\Exceptions\BaseException; +use Quantum\Di\Exceptions\DiException; use InvalidArgumentException; +use ReflectionException; use ORM; use PDO; -use Quantum\Di\Exceptions\DiException; -use ReflectionException; /** * Class IdiormDbal @@ -259,7 +259,7 @@ protected static function getBaseConfig(string $driver, array $config): array { return [ 'connection_string' => self::buildConnectionString($driver, $config), - 'logging' => config()->get('app.debug', false), + 'logging' => is_debug_mode(), 'error_mode' => PDO::ERRMODE_EXCEPTION, ]; } diff --git a/src/Logger/Factories/LoggerFactory.php b/src/Logger/Factories/LoggerFactory.php index 5234886e7..9af5895f4 100644 --- a/src/Logger/Factories/LoggerFactory.php +++ b/src/Logger/Factories/LoggerFactory.php @@ -20,11 +20,9 @@ use Quantum\Di\Exceptions\DiException; use Quantum\Logger\Enums\LoggerType; use Quantum\Logger\LoggerConfig; -use Quantum\App\Enums\AppType; use Quantum\Logger\Logger; use Quantum\Config\Setup; use ReflectionException; -use Quantum\App\App; use Quantum\Di\Di; /** @@ -71,9 +69,7 @@ public function resolve(?string $adapter = null): Logger throw LoggerException::adapterNotSupported(LoggerType::MESSAGE); } - $inMemory = $isDebug && App::getContext()->getType() === AppType::WEB; - - $adapter = $inMemory ? LoggerType::MESSAGE : ($adapter ?? config()->get('logging.default')); + $adapter = $isDebug && is_web_app() ? LoggerType::MESSAGE : ($adapter ?? config()->get('logging.default')); $adapterClass = $this->getAdapterClass($adapter); diff --git a/src/Tracer/ErrorHandler.php b/src/Tracer/ErrorHandler.php index 6e36f9867..5aebf9514 100644 --- a/src/Tracer/ErrorHandler.php +++ b/src/Tracer/ErrorHandler.php @@ -17,11 +17,9 @@ use Quantum\App\Exceptions\BaseException; use Quantum\Di\Exceptions\DiException; use Quantum\Http\Enums\StatusCode; -use Quantum\App\Enums\AppType; use Quantum\Logger\Logger; use ReflectionException; use ErrorException; -use Quantum\App\App; use Throwable; use Exception; @@ -81,7 +79,7 @@ public function handleError(int $severity, string $message, string $file, int $l */ public function handleException(Throwable $throwable): void { - if (App::getContext()->getType() === AppType::CONSOLE) { + if (is_console_app()) { $this->handleCliException($throwable); } else { $this->handleWebException($throwable); diff --git a/tests/Unit/App/Helpers/AppHelperTest.php b/tests/Unit/App/Helpers/AppHelperTest.php new file mode 100644 index 000000000..a39f62c5b --- /dev/null +++ b/tests/Unit/App/Helpers/AppHelperTest.php @@ -0,0 +1,25 @@ +useAppType(AppType::WEB); + + $this->assertTrue(is_web_app()); + $this->assertFalse(is_console_app()); + } + + public function testIsConsoleAppInTheConsole(): void + { + $this->useAppType(AppType::CONSOLE); + + $this->assertTrue(is_console_app()); + $this->assertFalse(is_web_app()); + } +} diff --git a/tests/Unit/Database/Adapters/Idiorm/IdiormDbalTest.php b/tests/Unit/Database/Adapters/Idiorm/IdiormDbalTest.php index 3ae3f09fe..9dcda6841 100644 --- a/tests/Unit/Database/Adapters/Idiorm/IdiormDbalTest.php +++ b/tests/Unit/Database/Adapters/Idiorm/IdiormDbalTest.php @@ -3,6 +3,7 @@ namespace Quantum\Tests\Unit\Database\Adapters\Idiorm; use Quantum\Database\Adapters\Idiorm\IdiormDbal; +use Quantum\App\Enums\AppType; class IdiormDbalTest extends IdiormDbalTestCase { @@ -19,4 +20,42 @@ public function testIdiormGetTable(): void $this->assertEquals('users', $userModel->getTable()); } + + public function testIdiormLogsQueriesInTheWebAppInDebugMode(): void + { + config()->set('app.debug', true); + + $this->assertTrue($this->getBaseConfig()['logging']); + } + + public function testIdiormLogsQueriesInTheConsoleInDebugMode(): void + { + $this->useAppType(AppType::CONSOLE); + + config()->set('app.debug', true); + + $this->assertTrue($this->getBaseConfig()['logging']); + } + + public function testIdiormLogsNoQueriesOutsideDebugMode(): void + { + config()->set('app.debug', false); + + $this->assertFalse($this->getBaseConfig()['logging']); + } + + public function testIdiormLogsNoQueriesWhenDebugIsTheTextFalseFromEnv(): void + { + config()->set('app.debug', 'false'); + + $this->assertFalse($this->getBaseConfig()['logging']); + } + + /** + * @return array + */ + private function getBaseConfig(): array + { + return $this->invokePrivateMethod(new IdiormDbal('users'), 'getBaseConfig', ['sqlite', ['database' => ':memory:']]); + } } From 04f3b109b006266ba1d3dc12aa40e91c6204b8e2 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:54:01 +0400 Subject: [PATCH 7/8] [#593] Describe the debug buffer limits, console logging and app type in the changelog --- CHANGELOG.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dbb8405b..2e5beb008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,20 +10,21 @@ The format is based on Keep a Changelog. - Added `Request::expectsJson()`: true when the `Accept` header lists a JSON media type (`application/json` or a `+json` type, parameters and case ignored), or for an AJAX request that accepts any content type (#581) - Added lifecycle events for the web and console runtime: the runtime stages declare `web.preflight`, `web.route`, `web.not_found`, `web.prepare`, `web.dispatch`, `web.send` and `console.command`, each with `.before` and `.after`, and the `Pipeline` dispatches them with the runtime context as a named `context` payload. A stage that does not run dispatches nothing, a stage that throws dispatches no `.after`, and the closing `web.send` events also follow an OPTIONS or not found response. Boot stages declare no events. `console.command.after` fires only with a Symfony application that does not exit after the command (#536, #538) - Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533) +- Added the `is_web_app()` and `is_console_app()` helpers, which tell whether the running application is the web app or the console (#593) ### Changed - **BREAKING:** Took the debugbar out of the app flow and out of the packages that feed it (#539): no package outside `Quantum\Debugger` references it. Each package keeps its own state, and `Debugger::render()` reads it when the toolbar renders: every tab shows the entries written with `debugbar()->addToStoreCell()`, followed by the event listeners, the matched route and the rendered view, the query log, the log messages and the mailer transport log as they are at that point. The debugger store keeps only those direct writes, and the debugbar builds no database connection or mailer the request did not use. - Removed `InitDebuggerStage` (`Quantum\App\Stages\InitDebuggerStage` in 3.0.3) from the web boot. The debugger store creates a tab on its first write; code that wants the tabs up front can call `debugbar()->initStore()`. - The `events` tab lists the listeners registered when the toolbar renders, so listeners added later in the request, for example in a controller, are listed too. - `View::__construct()` no longer takes a `Debugger`: it is `(Renderer, AssetManager, ViewCache)`. Code that gets the view through `view()` or `ViewFactory` is unaffected. Added `View::getViewFile()`, the last view rendered with `render()`. - - `MessageAdapter`, the logger in debug mode, keeps its messages in memory (`getMessages()`) instead of writing them into the debugger store, and no longer reads `$context['tab']`: a message logged with a tab is listed in the `messages` tab, and `debugbar()->getStoreCell('messages')` returns only direct writes. - - `MailerInterface` gains `getTransportLog()`, implemented in `MailerTrait`: the errors of failed sends and, for SMTP in debug mode, the exchange with the server. A custom adapter that implements the interface without the trait must add it. A failed send is still logged as a warning. -- **BREAKING:** Reworked the app boot and runtime into pipelines of stages that the adapters run in explicit steps (#537, #538, #575, #576, #579, #583, #589): + - `MessageAdapter`, the logger in debug mode, keeps its messages in memory (`getMessages()`) instead of writing them into the debugger store, and no longer reads `$context['tab']`: a message logged with a tab is listed in the `messages` tab, and `debugbar()->getStoreCell('messages')` returns only direct writes. It keeps the last `logging.message.limit` messages (default `1000`, `0` keeps every message) and drops the oldest (#593). + - `MailerInterface` gains `getTransportLog()`, implemented in `MailerTrait`: the errors of failed sends and, for SMTP in debug mode, the exchange with the server. A custom adapter that implements the interface without the trait must add it. A failed send is still logged as a warning. At the start of each send the log is cut to its last `mailer.transport_log_limit` lines (default `1000`, `0` keeps every line), so it holds at most the limit plus the lines of the send in progress (#593). +- **BREAKING:** Reworked the app boot and runtime into pipelines of stages that the adapters run in explicit steps (#537, #538, #575, #576, #579, #583, #589, #593): - `AppInterface` gains `boot()`. The adapter constructors only store the context, and `AppFactory` calls `boot()` right after creating the adapter, so `AppFactory::create(...)->start()` is unchanged. Code that creates an adapter directly must call `boot()` before `start()`. - Removed the `AppAdapter` base class and the `WebAppTrait` and `ConsoleAppTrait` traits: `WebAppAdapter` and `ConsoleAppAdapter` implement `AppInterface` directly and moved to `Quantum\App\Adapters\Web` and `Quantum\App\Adapters\Console`. The stages are grouped by the adapter that runs them: the boot stages both adapters run in `Quantum\App\Stages\Boot`, the others in `Quantum\App\Adapters\Web\Stages\Boot`, `...\Web\Stages\Runtime`, `...\Console\Stages\Boot` and `...\Console\Stages\Runtime`. Code that goes through `AppFactory` is unaffected; code that references these classes directly must update its imports. - Replaced `BootPipeline` and `BootStageInterface` with one generic `Quantum\App\Pipeline`, which runs `StageInterface` stages in order and dispatches the `BEFORE`/`AFTER` events they declare. The context type is a PHPDoc template checked by PHPStan: a stage states it with `@implements StageInterface` (or `WebContext`, `ConsoleContext`) and takes `process(AppContext $context)`. A stage that calls `stop()` on the context ends the run; an optional closing stage, the second constructor argument, runs last, also after a stop, but not after a stage throws. - - `AppContext` keeps `getBaseDir()`, `getContainer()`, `getEnvironment()` and `getConfig()`, and carries the state of a run: `stop()`, `isStopped()`, `getExitCode()` and `setExitCode()`. Removed `AppContext::getRequest()`, `getResponse()` and `getRoutes()`: use `WebContext::getRequest()` and `WebContext::getRoutes()`, and `response()` for the shared response. - - The new `WebContext` and `ConsoleContext` extend `AppContext` with the same base directory and container. `WebContext` reads the request, the routes and the view cache from the container and stores the response to send; `ConsoleContext` reads the Symfony `Application`, the input and the output from the container. + - `AppContext` keeps `getBaseDir()`, `getContainer()`, `getEnvironment()` and `getConfig()`, and carries the state of a run: `stop()`, `isStopped()`, `getExitCode()` and `setExitCode()`. Removed `AppContext::getRequest()`, `getResponse()` and `getRoutes()`: use `WebContext::getRequest()` and `WebContext::getRoutes()`, and `response()` for the shared response. `AppContext` also records the app type: its constructor takes a third, required `$type` (`AppType::WEB` or `AppType::CONSOLE`) and `getType()` returns it. `AppFactory` passes it; code that creates an `AppContext` itself must pass it too. + - The new `WebContext` and `ConsoleContext` extend `AppContext` with the same base directory, container and app type. `WebContext` reads the request, the routes and the view cache from the container and stores the response to send; `ConsoleContext` reads the Symfony `Application`, the input and the output from the container. - The web runtime runs the preflight, route resolution, not found, prepare and dispatch stages, with `SendResponseStage` as its closing stage. Behavior is unchanged. - The console boot registers the Symfony `Application`, the input and the output in the container (`InitConsoleStage`), loads the helpers, registers the core commands, loads the environment, the config and the error handler, names the application from `app.name` and `app.version` (`UNKNOWN` without them), and registers the app commands last, so their constructors can use `env()` and `config()`. The console runtime runs the command and rejects a missing or unknown one. Registration goes through the new `Quantum\Console\CommandRegistrar`. Removed `ConsoleAppAdapter::createApplication()`: get the application with `Di::get(Application::class)`. - The console no longer decides by the `core:env` command name. Added `CliCommand::$requiresEnvironment` (default `true`) with `CliCommand::requiresEnvironment()`. When the `.env` file is missing or malformed, the new `ResolveEnvironmentStage` stops the boot for a core command that sets it to `false` (`core:env`) and for Symfony's `list` and `help`, so they still run; every other command fails with the exception of the environment file. With a valid `.env`, `core:env` now runs the full boot. A core command constructor must not use the environment or any config, since the core commands are registered before the environment is loaded. @@ -47,6 +48,8 @@ The format is based on Keep a Changelog. - **BREAKING:** Reshaped Lang configuration so `lang.default` now selects the adapter, locale fallback moved to `lang.default_locale`, and the unused `lang.enabled` toggle was removed (#533) - **BREAKING:** Removed `Lang::isEnabled()` from the public Lang API because it no longer affected runtime behavior (#533) - Applied the pending Rector refactors across `src/` (null-coalescing assignment for lazy initialization, explicit `instanceof` checks instead of truthiness checks on nullable objects, native return types on `env()`, `CurlAdapter::getId()` and `CurlAdapter::getResponseCookies()`) and added `composer rector:check` to the CI workflow after PHPStan (#587) +- In debug mode the console logs to the configured adapter, or the one a caller asks for, at every level. Before, debug mode always used the in-memory `message` adapter, so the console's log lines were never written anywhere, and `CronManager`'s `single` logger was replaced too. The web app keeps the `message` adapter for the debug toolbar (#593) +- The error handler chooses the console or the web output by the app type instead of checking `PHP_SAPI`; `php qt`, `php -S` and PHP-FPM behave as before (#593) ### Fixed - The page not found response is JSON for every client that asks for JSON, not only when the `Accept` header is exactly `application/json`: clients such as axios (`application/json, text/plain, */*`), a `charset` parameter, `+json` types and AJAX requests used to get the HTML page. `page_not_found_response()` decides with `Request::expectsJson()` and takes an optional request (#581) @@ -55,6 +58,7 @@ The format is based on Keep a Changelog. - The debugbar `routes` tab shows the matched route again (methods, pattern, module, controller, action or `Closure`, name, group, prefix, middlewares and params) next to the rendered view; since 3.0.0 it showed only the view path (#539) - The debugbar `queries` tab lists the queries the request ran; it has been empty since 2.6.0 (#539) - `SmtpAdapter` records the SMTP exchange in debug mode: it checked `config()->get('debug')`, which is never set, instead of `app.debug`, so the exchange never reached the `mails` tab (#539) +- Idiorm keeps no query log when debug mode is off. It received `app.debug` unconverted, and `DEBUG=false` from `.env` arrives as the text `"false"`, which PHP reads as true, so every query was kept in memory, also in production (#593) ## [3.0.3] - 2026-07-10 From b4859dcc13323ca4aa92b15fd0a0d86de3c80015 Mon Sep 17 00:00:00 2001 From: Arman <407448+armanist@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:35:08 +0400 Subject: [PATCH 8/8] [#593] Read config values from .env as the types their users expect Values from .env arrive as text. MailerTrait::send() reads mailer.mail_trap with FILTER_VALIDATE_BOOLEAN, so MAIL_TRAP=false sends mail; SessionAuthAdapter casts remember_lifetime to int before Cookie::set(); the Redis and Memcached cache adapters and the Redis rate limiter cast port to int. --- CHANGELOG.md | 3 ++ src/Auth/Adapters/SessionAuthAdapter.php | 2 +- src/Cache/Adapters/MemcachedAdapter.php | 2 +- src/Cache/Adapters/RedisAdapter.php | 2 +- src/Mailer/Traits/MailerTrait.php | 2 +- .../Adapters/RedisRateLimitAdapter.php | 2 +- .../Auth/Adapters/SessionAuthAdapterTest.php | 10 ++++++ .../Cache/Adapters/MemcachedAdapterTest.php | 14 ++++++++ .../Unit/Cache/Adapters/RedisAdapterTest.php | 14 ++++++++ .../Unit/Mailer/Adapters/SmtpAdapterTest.php | 35 +++++++++++++++++++ .../Adapters/RedisRateLimitAdapterTest.php | 11 ++++++ 11 files changed, 92 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e5beb008..fa51d3074 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,9 @@ The format is based on Keep a Changelog. - The debugbar `queries` tab lists the queries the request ran; it has been empty since 2.6.0 (#539) - `SmtpAdapter` records the SMTP exchange in debug mode: it checked `config()->get('debug')`, which is never set, instead of `app.debug`, so the exchange never reached the `mails` tab (#539) - Idiorm keeps no query log when debug mode is off. It received `app.debug` unconverted, and `DEBUG=false` from `.env` arrives as the text `"false"`, which PHP reads as true, so every query was kept in memory, also in production (#593) +- `mailer.mail_trap` set from `.env` is read as a boolean: `MAIL_TRAP=false` arrives as the text `"false"`, which kept every mail in the trap instead of sending it (#593) +- A "remember me" login works with `REMEMBER_LIFETIME` set in `.env`: the session auth adapter passed the text on to `Cookie::set()`, which takes an `int`, and threw a `TypeError` (#593) +- The Redis and Memcached cache adapters and the Redis rate limiter accept a `port` set from `.env`: `Memcached::addServer()` takes an `int` and threw a `TypeError` for the text (#593) ## [3.0.3] - 2026-07-10 diff --git a/src/Auth/Adapters/SessionAuthAdapter.php b/src/Auth/Adapters/SessionAuthAdapter.php index 3d676b641..b12be1881 100644 --- a/src/Auth/Adapters/SessionAuthAdapter.php +++ b/src/Auth/Adapters/SessionAuthAdapter.php @@ -200,7 +200,7 @@ private function setRememberToken(User $user): void [$this->keyFields[AuthKeys::REMEMBER_TOKEN] => $rememberToken] ); - $rememberLifetime = $this->config['session']['remember_lifetime'] ?? self::DEFAULT_REMEMBER_LIFETIME; + $rememberLifetime = (int) ($this->config['session']['remember_lifetime'] ?? self::DEFAULT_REMEMBER_LIFETIME); cookie()->set( $this->keyFields[AuthKeys::REMEMBER_TOKEN], diff --git a/src/Cache/Adapters/MemcachedAdapter.php b/src/Cache/Adapters/MemcachedAdapter.php index 88935b8a6..536d47218 100644 --- a/src/Cache/Adapters/MemcachedAdapter.php +++ b/src/Cache/Adapters/MemcachedAdapter.php @@ -39,7 +39,7 @@ public function __construct(array $params) $this->prefix = $params['prefix']; $this->memcached = new Memcached(); - $this->memcached->addServer($params['host'], $params['port']); + $this->memcached->addServer($params['host'], (int) $params['port']); if (!$this->memcached->getStats()) { throw CacheException::cantConnect('Memcached server'); diff --git a/src/Cache/Adapters/RedisAdapter.php b/src/Cache/Adapters/RedisAdapter.php index f134e9e52..2785c90cf 100644 --- a/src/Cache/Adapters/RedisAdapter.php +++ b/src/Cache/Adapters/RedisAdapter.php @@ -38,7 +38,7 @@ public function __construct(array $params) $this->prefix = $params['prefix']; $this->redis = new Redis(); - $this->redis->connect($params['host'], $params['port']); + $this->redis->connect($params['host'], (int) $params['port']); } /** diff --git a/src/Mailer/Traits/MailerTrait.php b/src/Mailer/Traits/MailerTrait.php index 50a0da22b..ffa1f925d 100644 --- a/src/Mailer/Traits/MailerTrait.php +++ b/src/Mailer/Traits/MailerTrait.php @@ -231,7 +231,7 @@ public function send(): bool $this->prepare(); - $sent = config()->get('mailer.mail_trap') ? $this->saveEmail() : $this->sendEmail(); + $sent = filter_var(config()->get('mailer.mail_trap'), FILTER_VALIDATE_BOOLEAN) ? $this->saveEmail() : $this->sendEmail(); $this->resetFields(); diff --git a/src/RateLimit/Adapters/RedisRateLimitAdapter.php b/src/RateLimit/Adapters/RedisRateLimitAdapter.php index 7796ee456..cf1356ce8 100644 --- a/src/RateLimit/Adapters/RedisRateLimitAdapter.php +++ b/src/RateLimit/Adapters/RedisRateLimitAdapter.php @@ -36,7 +36,7 @@ public function __construct(array $params) $this->prefix = (string) ($params['prefix'] ?? ''); $this->redis = new Redis(); - $this->redis->connect($params['host'], $params['port']); + $this->redis->connect($params['host'], (int) $params['port']); } public function hit(string $key, int $limit, int $interval): bool diff --git a/tests/Unit/Auth/Adapters/SessionAuthAdapterTest.php b/tests/Unit/Auth/Adapters/SessionAuthAdapterTest.php index 48e707e3b..ff20637c0 100644 --- a/tests/Unit/Auth/Adapters/SessionAuthAdapterTest.php +++ b/tests/Unit/Auth/Adapters/SessionAuthAdapterTest.php @@ -239,4 +239,14 @@ public function testWebRememberTokenLifetimeIsConfigurable(): void $this->assertTrue(cookie()->has('remember_token')); } + + public function testWebRememberTokenLifetimeAcceptsTheTextFromEnv(): void + { + config()->set('auth.session.remember_lifetime', '86400'); + $this->sessionAuth = $this->createSessionAuth(); + + $this->sessionAuth->signin('admin@qt.com', 'qwerty', true); + + $this->assertTrue(cookie()->has('remember_token')); + } } diff --git a/tests/Unit/Cache/Adapters/MemcachedAdapterTest.php b/tests/Unit/Cache/Adapters/MemcachedAdapterTest.php index fd63e8425..ad016225f 100644 --- a/tests/Unit/Cache/Adapters/MemcachedAdapterTest.php +++ b/tests/Unit/Cache/Adapters/MemcachedAdapterTest.php @@ -29,6 +29,20 @@ public function tearDown(): void $this->memCached->clear(); } + public function testMemcachedAdapterConnectsWithThePortAsText(): void + { + $adapter = new MemcachedAdapter([ + 'prefix' => 'test', + 'host' => '127.0.0.1', + 'port' => '11211', + 'ttl' => 60, + ]); + + $adapter->set('port-as-text', 'connected'); + + $this->assertSame('connected', $adapter->get('port-as-text')); + } + public function testMemcachedAdapterSetGetDelete(): void { diff --git a/tests/Unit/Cache/Adapters/RedisAdapterTest.php b/tests/Unit/Cache/Adapters/RedisAdapterTest.php index 5d1340d07..66689a97e 100644 --- a/tests/Unit/Cache/Adapters/RedisAdapterTest.php +++ b/tests/Unit/Cache/Adapters/RedisAdapterTest.php @@ -29,6 +29,20 @@ public function tearDown(): void $this->redis->clear(); } + public function testRedisAdapterConnectsWithThePortAsText(): void + { + $adapter = new RedisAdapter([ + 'prefix' => 'test', + 'host' => '127.0.0.1', + 'port' => '6379', + 'ttl' => 60, + ]); + + $adapter->set('port-as-text', 'connected'); + + $this->assertSame('connected', $adapter->get('port-as-text')); + } + public function testRedisAdapterSetGetDelete(): void { diff --git a/tests/Unit/Mailer/Adapters/SmtpAdapterTest.php b/tests/Unit/Mailer/Adapters/SmtpAdapterTest.php index 5a6b60220..873569386 100644 --- a/tests/Unit/Mailer/Adapters/SmtpAdapterTest.php +++ b/tests/Unit/Mailer/Adapters/SmtpAdapterTest.php @@ -185,6 +185,41 @@ public function send() ], $adapter->getTransportLog()); } + public function testSmtpAdapterSendsThroughTheTransportWhenMailTrapIsTheTextFalse(): void + { + config()->set('mailer.mail_trap', 'false'); + + $adapter = new SmtpAdapter(config()->get('mailer.smtp')); + + $this->setPrivateProperty($adapter, 'mailer', new class (true) extends PHPMailer { + public function send() + { + throw new Exception('Sent through the transport'); + } + }); + + $adapter->setFrom('john@hotmail.com', 'John Doe'); + $adapter->setAddress('benny@gmail.com', 'Benny'); + $adapter->setBody('Lorem ipsum dolor sit amet'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Sent through the transport'); + + $adapter->send(); + } + + public function testSmtpAdapterSavesToTheMailTrapWhenMailTrapIsTheTextTrue(): void + { + config()->set('mailer.mail_trap', 'true'); + + $this->adapter->setFrom('john@hotmail.com', 'John Doe'); + $this->adapter->setAddress('benny@gmail.com', 'Benny'); + $this->adapter->setSubject('Lorem'); + $this->adapter->setBody('Lorem ipsum dolor sit amet'); + + $this->assertTrue($this->adapter->send()); + } + public function testSmtpAdapterKeepsNoSmtpExchangeOutsideDebugMode(): void { config()->set('app.debug', false); diff --git a/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php b/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php index 075a1c0c9..aea28a14f 100644 --- a/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php +++ b/tests/Unit/RateLimit/Adapters/RedisRateLimitAdapterTest.php @@ -25,6 +25,17 @@ public function tearDown(): void $this->adapter->reset('k2'); } + public function testRedisAdapterConnectsWithThePortAsText(): void + { + $adapter = new RedisRateLimitAdapter([ + 'host' => '127.0.0.1', + 'port' => '6379', + 'ttl' => 30, + ]); + + $this->assertTrue($adapter->hit('k2', 5, 30)); + } + public function testRedisAdapterHitAndResetFlow(): void { $this->assertTrue($this->adapter->hit('k2', 1, 60));