From ed945c735736f7dc57f96e7f8ca74b5df3ef4627 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Mon, 16 Feb 2026 19:25:07 +0100 Subject: [PATCH 1/5] feat: added Symfony Kernel --- composer.json | 2 + composer.lock | 213 +++++- phpmyfaq/admin/api/index.php | 33 +- phpmyfaq/admin/index.php | 31 +- phpmyfaq/api/index.php | 32 +- phpmyfaq/index.php | 34 +- phpmyfaq/setup/index.php | 30 +- phpmyfaq/src/phpMyFAQ/Application.php | 373 ---------- .../Controller/AbstractController.php | 41 +- .../EventListener/ApiExceptionListener.php | 144 ++++ .../ControllerContainerListener.php | 49 ++ .../EventListener/LanguageListener.php | 104 +++ .../phpMyFAQ/EventListener/RouterListener.php | 56 ++ .../EventListener/WebExceptionListener.php | 133 ++++ phpmyfaq/src/phpMyFAQ/Kernel.php | 193 +++++ phpunit.xml | 4 + tests/phpMyFAQ/ApplicationTest.php | 679 ------------------ .../ApiExceptionListenerTest.php | 144 ++++ .../ControllerContainerListenerTest.php | 81 +++ .../EventListener/RouterListenerTest.php | 79 ++ .../WebExceptionListenerTest.php | 118 +++ .../phpMyFAQ/Functional/KernelRoutingTest.php | 197 +++++ .../Functional/PhpMyFaqTestKernel.php | 33 + tests/phpMyFAQ/Functional/WebTestCase.php | 120 ++++ tests/phpMyFAQ/KernelTest.php | 40 ++ 25 files changed, 1808 insertions(+), 1155 deletions(-) delete mode 100644 phpmyfaq/src/phpMyFAQ/Application.php create mode 100644 phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php create mode 100644 phpmyfaq/src/phpMyFAQ/EventListener/ControllerContainerListener.php create mode 100644 phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php create mode 100644 phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php create mode 100644 phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php create mode 100644 phpmyfaq/src/phpMyFAQ/Kernel.php delete mode 100644 tests/phpMyFAQ/ApplicationTest.php create mode 100644 tests/phpMyFAQ/EventListener/ApiExceptionListenerTest.php create mode 100644 tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php create mode 100644 tests/phpMyFAQ/EventListener/RouterListenerTest.php create mode 100644 tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php create mode 100644 tests/phpMyFAQ/Functional/KernelRoutingTest.php create mode 100644 tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php create mode 100644 tests/phpMyFAQ/Functional/WebTestCase.php create mode 100644 tests/phpMyFAQ/KernelTest.php diff --git a/composer.json b/composer.json index 05113399e3..78917a1fb7 100644 --- a/composer.json +++ b/composer.json @@ -66,6 +66,8 @@ "phpdocumentor/reflection-docblock": "6.*", "phpunit/phpunit": "^12.3", "rector/rector": "^2", + "symfony/browser-kit": "^8.0", + "symfony/css-selector": "^8.0", "symfony/yaml": "8.*", "zircote/swagger-php": "^6.0" }, diff --git a/composer.lock b/composer.lock index d805881531..d71ee3909f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "79ff3c92e8f8cb849e567308e01471bf", + "content-hash": "375068fca3740daea063b677a252a07e", "packages": [ { "name": "2tvenom/cborencode", @@ -9212,6 +9212,217 @@ ], "time": "2024-10-20T05:08:20+00:00" }, + { + "name": "symfony/browser-kit", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "0d998c101e1920fc68572209d1316fec0db728ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/0d998c101e1920fc68572209d1316fec0db728ef", + "reference": "0d998c101e1920fc68572209d1316fec0db728ef", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/dom-crawler": "^7.4|^8.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-13T13:06:50+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.0.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.0.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-10-30T14:17:19+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v8.0.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "fd78228fa362b41729173183493f46b1df49485f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/fd78228fa362b41729173183493f46b1df49485f", + "reference": "fd78228fa362b41729173183493f46b1df49485f", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.0" + }, + "require-dev": { + "symfony/css-selector": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v8.0.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T09:27:50+00:00" + }, { "name": "symfony/finder", "version": "v8.0.5", diff --git a/phpmyfaq/admin/api/index.php b/phpmyfaq/admin/api/index.php index ae25f6fcaf..1be85190d9 100644 --- a/phpmyfaq/admin/api/index.php +++ b/phpmyfaq/admin/api/index.php @@ -16,13 +16,11 @@ * @since 2023-07-02 */ -use phpMyFAQ\Application; use phpMyFAQ\Core\Exception\DatabaseConnectionException; use phpMyFAQ\Environment; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use phpMyFAQ\Kernel; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; try { @@ -49,24 +47,11 @@ exit(1); } -// -// Service Containers -// -$container = new ContainerBuilder(); -$loader = new PhpFileLoader($container, new FileLocator(__DIR__)); -try { - $loader->load('../../src/services.php'); -} catch (Exception $exception) { - echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); -} +$kernel = new Kernel( + routingContext: 'admin-api', + debug: Environment::isDebugMode(), +); -$app = new Application($container); -$app->setAdminContext(true); -$app->setApiContext(true); -$app->routingContext = 'admin-api'; -try { - // Autoload routes from attributes (falls back to api-routes.php during migration) - $app->run(); -} catch (Exception $exception) { - echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); -} +$request = Request::createFromGlobals(); +$response = $kernel->handle($request); +$response->send(); diff --git a/phpmyfaq/admin/index.php b/phpmyfaq/admin/index.php index 606e0a0ce8..4dab3b4d41 100755 --- a/phpmyfaq/admin/index.php +++ b/phpmyfaq/admin/index.php @@ -19,13 +19,11 @@ * @since 2002-09-16 */ -use phpMyFAQ\Application; use phpMyFAQ\Controller\Frontend\ErrorController; use phpMyFAQ\Core\Exception\DatabaseConnectionException; use phpMyFAQ\Environment; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use phpMyFAQ\Kernel; +use Symfony\Component\HttpFoundation\Request; try { require dirname(__DIR__) . '/src/Bootstrap.php'; @@ -36,22 +34,11 @@ exit(1); } -// -// Service Containers -// -$container = new ContainerBuilder(); -$loader = new PhpFileLoader($container, new FileLocator(__DIR__)); -try { - $loader->load('../src/services.php'); -} catch (Exception $exception) { - echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); -} +$kernel = new Kernel( + routingContext: 'admin', + debug: Environment::isDebugMode(), +); -$app = new Application($container); -$app->routingContext = 'admin'; -try { - // Auto-loads routes from attributes (falls back to admin-routes.php during migration) - $app->run(); -} catch (Exception $exception) { - echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); -} +$request = Request::createFromGlobals(); +$response = $kernel->handle($request); +$response->send(); diff --git a/phpmyfaq/api/index.php b/phpmyfaq/api/index.php index ba5ad6add7..f686c5309e 100644 --- a/phpmyfaq/api/index.php +++ b/phpmyfaq/api/index.php @@ -17,13 +17,11 @@ declare(strict_types=1); -use phpMyFAQ\Application; use phpMyFAQ\Core\Exception\DatabaseConnectionException; use phpMyFAQ\Environment; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use phpMyFAQ\Kernel; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; try { @@ -50,23 +48,11 @@ exit(1); } -// -// Service Containers -// -$container = new ContainerBuilder(); -$loader = new PhpFileLoader($container, new FileLocator(__DIR__)); -try { - $loader->load('../src/services.php'); -} catch (Exception $e) { - echo $e->getMessage(); -} +$kernel = new Kernel( + routingContext: 'api', + debug: Environment::isDebugMode(), +); -$app = new Application($container); -$app->setApiContext(true); -$app->routingContext = 'api'; -try { - // Autoload routes from attributes (falls back to api-routes.php during migration) - $app->run(); -} catch (Exception $exception) { - echo $exception->getMessage(); -} +$request = Request::createFromGlobals(); +$response = $kernel->handle($request); +$response->send(); diff --git a/phpmyfaq/index.php b/phpmyfaq/index.php index 654591ffbb..43da19d64c 100755 --- a/phpmyfaq/index.php +++ b/phpmyfaq/index.php @@ -22,15 +22,11 @@ declare(strict_types=1); - -use phpMyFAQ\Application; use phpMyFAQ\Controller\Frontend\ErrorController; -use phpMyFAQ\Core\Exception; use phpMyFAQ\Core\Exception\DatabaseConnectionException; use phpMyFAQ\Environment; -use Symfony\Component\Config\FileLocator; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use phpMyFAQ\Kernel; +use Symfony\Component\HttpFoundation\Request; // // Bootstrapping @@ -44,23 +40,11 @@ exit(1); } -// -// Service Containers -// -$container = new ContainerBuilder(); -$loader = new PhpFileLoader($container, new FileLocator(__DIR__)); -try { - $loader->load('./src/services.php'); -} catch (Exception $exception) { - echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); -} - -$app = new Application($container); -$app->routingContext = 'public'; +$kernel = new Kernel( + routingContext: 'public', + debug: Environment::isDebugMode(), +); -try { - // Auto-loads routes from attributes (falls back to public-routes.php during migration) - $app->run(); -} catch (Exception $exception) { - echo sprintf('Error: %s at line %d at %s', $exception->getMessage(), $exception->getLine(), $exception->getFile()); -} +$request = Request::createFromGlobals(); +$response = $kernel->handle($request); +$response->send(); diff --git a/phpmyfaq/setup/index.php b/phpmyfaq/setup/index.php index db764794d5..b4266cc582 100644 --- a/phpmyfaq/setup/index.php +++ b/phpmyfaq/setup/index.php @@ -24,11 +24,19 @@ */ use Composer\Autoload\ClassLoader; -use phpMyFAQ\Application; use phpMyFAQ\Controller\Frontend\SetupController; use phpMyFAQ\Environment; +use phpMyFAQ\EventListener\RouterListener; +use phpMyFAQ\EventListener\WebExceptionListener; use phpMyFAQ\Strings; use phpMyFAQ\Translation; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpKernel\Controller\ControllerResolver; +use Symfony\Component\HttpKernel\HttpKernel; +use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; @@ -95,9 +103,25 @@ $routes->add($name, new Route($path, ['_controller' => [$controller, $action]])); } -$app = new Application(); +$dispatcher = new EventDispatcher(); + +$routerListener = new RouterListener($routes); +$dispatcher->addListener(KernelEvents::REQUEST, [$routerListener, 'onKernelRequest'], 256); + +$webExceptionListener = new WebExceptionListener(); +$dispatcher->addListener(KernelEvents::EXCEPTION, [$webExceptionListener, 'onKernelException'], -10); + +$kernel = new HttpKernel( + $dispatcher, + new ControllerResolver(), + new RequestStack(), + new ArgumentResolver(), +); + try { - $app->run($routes); + $request = Request::createFromGlobals(); + $response = $kernel->handle($request); + $response->send(); } catch (Exception $exception) { echo $exception->getMessage(); } diff --git a/phpmyfaq/src/phpMyFAQ/Application.php b/phpmyfaq/src/phpMyFAQ/Application.php deleted file mode 100644 index 86e0ae3754..0000000000 --- a/phpmyfaq/src/phpMyFAQ/Application.php +++ /dev/null @@ -1,373 +0,0 @@ - - * @copyright 2023-2026 phpMyFAQ Team - * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 - * @link https://www.phpmyfaq.de - * @since 2023-10-24 - */ - -declare(strict_types=1); - -namespace phpMyFAQ; - -use phpMyFAQ\Api\ProblemDetails; -use phpMyFAQ\Controller\Exception\ForbiddenException; -use phpMyFAQ\Controller\Frontend\PageNotFoundController; -use phpMyFAQ\Core\Exception; -use phpMyFAQ\Routing\RouteCacheManager; -use phpMyFAQ\Routing\RouteCollectionBuilder; -use Symfony\Component\DependencyInjection\ContainerInterface; -use Symfony\Component\HttpFoundation\Exception\BadRequestException; -use Symfony\Component\HttpFoundation\RedirectResponse; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Controller\ArgumentResolver; -use Symfony\Component\HttpKernel\Controller\ControllerResolver; -use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; -use Symfony\Component\Routing\Exception\ResourceNotFoundException; -use Symfony\Component\Routing\Matcher\UrlMatcher; -use Symfony\Component\Routing\RequestContext; -use Symfony\Component\Routing\RouteCollection; -use Throwable; - -class Application -{ - public UrlMatcher $urlMatcher { - set(UrlMatcher $value) { - $this->urlMatcher = $value; - } - } - - public ControllerResolver $controllerResolver { - set(ControllerResolver $value) { - $this->controllerResolver = $value; - } - } - - private bool $isApiContext = false; - - private bool $isAdminContext = false; - - public string $routingContext = 'public' { - set { - $this->routingContext = $value; - } - } - - public function __construct( - private readonly ?ContainerInterface $container = null, - ) { - } - - /** - * @throws Exception - */ - public function run(?RouteCollection $routeCollection = null, ?Request $request = null): void - { - $currentLanguage = $this->setLanguage(); - $this->initializeTranslation($currentLanguage); - Strings::init($currentLanguage); - $request ??= Request::createFromGlobals(); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - // Autoload routes if not provided - if ($routeCollection === null) { - $routeCollection = $this->loadRoutes(); - } - - $this->handleRequest($routeCollection, $request, $requestContext); - } - - public function setApiContext(bool $isApiContext): void - { - $this->isApiContext = $isApiContext; - } - - public function setAdminContext(bool $isAdminContext): void - { - $this->isAdminContext = $isAdminContext; - } - - private function setLanguage(): string - { - if (!is_null($this->container)) { - $configuration = $this->container->get(id: 'phpmyfaq.configuration'); - $language = $this->container->get(id: 'phpmyfaq.language'); - - // Set container in configuration for lazy loading of services like translation provider - $configuration->setContainer($this->container); - - $detect = (bool) $configuration->get(item: 'main.languageDetection'); - $configLang = $configuration->get(item: 'main.language'); - - $currentLanguage = $detect - ? $language->setLanguageWithDetection($configLang) - : $language->setLanguageFromConfiguration($configLang); - - require PMF_TRANSLATION_DIR . '/language_en.php'; - if (Language::isASupportedLanguage($currentLanguage)) { - require PMF_TRANSLATION_DIR . '/language_' . strtolower($currentLanguage) . '.php'; - } - - $configuration->setLanguage($language); - - return $currentLanguage; - } - - return 'en'; - } - - /** - * @throws Exception - */ - private function initializeTranslation(string $currentLanguage): void - { - try { - Translation::create() - ->setTranslationsDir(PMF_TRANSLATION_DIR) - ->setDefaultLanguage(defaultLanguage: 'en') - ->setCurrentLanguage($currentLanguage) - ->setMultiByteLanguage(); - } catch (Exception $exception) { - throw new Exception($exception->getMessage()); - } - } - - private function handleRequest( - RouteCollection $routeCollection, - Request $request, - RequestContext $requestContext, - ): void { - $urlMatcher = new UrlMatcher($routeCollection, $requestContext); - $this->urlMatcher = $urlMatcher; - $controllerResolver = new ControllerResolver(); - $this->controllerResolver = $controllerResolver; - $argumentResolver = new ArgumentResolver(); - $response = new Response(); - - try { - $this->urlMatcher->setContext($requestContext); - $request->attributes->add($this->urlMatcher->match($request->getPathInfo())); - $controller = $this->controllerResolver->getController($request); - $arguments = $argumentResolver->getArguments($request, $controller); - $response->setStatusCode(Response::HTTP_OK); - $response = call_user_func_array($controller, $arguments); - } catch (ResourceNotFoundException $exception) { - // For API requests, return RFC 7807 JSON response - if ($this->isApiContext) { - $response = $this->createProblemDetailsResponse( - request: $request, - status: Response::HTTP_NOT_FOUND, - throwable: $exception, - defaultDetail: 'The requested resource was not found.', - ); - } else { - // For web requests, forward to the PageNotFoundController - try { - $request->attributes->set('_route', 'public.404'); - $request->attributes->set('_controller', PageNotFoundController::class . '::index'); - $controller = $this->controllerResolver->getController($request); - $arguments = $argumentResolver->getArguments($request, $controller); - $response = call_user_func_array($controller, $arguments); - } catch (Throwable) { - // Fallback if the controller fails - $message = Environment::isDebugMode() - ? $this->formatExceptionMessage( - template: 'Not Found: :message at line :line at :file', - throwable: $exception, - ) - : 'Not Found'; - $response = new Response(content: $message, status: Response::HTTP_NOT_FOUND); - } - } - } catch (UnauthorizedHttpException $exception) { - if ($this->isApiContext) { - $response = $this->createProblemDetailsResponse( - request: $request, - status: Response::HTTP_UNAUTHORIZED, - throwable: $exception, - defaultDetail: 'Unauthorized access.', - ); - } else { - $response = new RedirectResponse(url: './login'); - } - } catch (ForbiddenException $exception) { - if ($this->isApiContext) { - $response = $this->createProblemDetailsResponse( - request: $request, - status: Response::HTTP_FORBIDDEN, - throwable: $exception, - defaultDetail: 'Access to this resource is forbidden.', - ); - } else { - $message = Environment::isDebugMode() - ? $this->formatExceptionMessage( - template: 'An error occurred: :message at line :line at :file', - throwable: $exception, - ) - : 'Forbidden'; - $response = new Response(content: $message, status: Response::HTTP_FORBIDDEN); - } - } catch (BadRequestException $exception) { - if ($this->isApiContext) { - $response = $this->createProblemDetailsResponse( - request: $request, - status: Response::HTTP_BAD_REQUEST, - throwable: $exception, - defaultDetail: 'The request could not be understood or was missing required parameters.', - ); - } else { - $message = Environment::isDebugMode() - ? $this->formatExceptionMessage( - template: 'An error occurred: :message at line :line at :file', - throwable: $exception, - ) - : 'Bad Request'; - $response = new Response(content: $message, status: Response::HTTP_BAD_REQUEST); - } - } catch (Throwable $exception) { - // Log the error for debugging - error_log(sprintf( - 'Unhandled exception in Application: %s at %s:%d', - $exception->getMessage(), - $exception->getFile(), - $exception->getLine(), - )); - - if ($this->isApiContext) { - $response = $this->createProblemDetailsResponse( - request: $request, - status: Response::HTTP_INTERNAL_SERVER_ERROR, - throwable: $exception, - defaultDetail: 'An unexpected error occurred while processing your request.', - ); - } else { - $message = Environment::isDebugMode() - ? $this->formatExceptionMessage( - template: 'Internal Server Error: :message at line :line at :file', - throwable: $exception, - ) - : 'Internal Server Error'; - $response = new Response(content: $message, status: Response::HTTP_INTERNAL_SERVER_ERROR); - } - } - - $response->send(); - } - - /** - * Load routes using the RouteCollectionBuilder. - * - * @return RouteCollection The loaded routes - */ - private function loadRoutes(): RouteCollection - { - $configuration = $this->container?->get(id: 'phpmyfaq.configuration'); - - // Determine if caching is enabled via environment variable - $cacheEnabled = filter_var(Environment::get('ROUTING_CACHE_ENABLED', 'true'), FILTER_VALIDATE_BOOLEAN); - $cacheDir = Environment::get('ROUTING_CACHE_DIR', PMF_ROOT_DIR . '/cache/routes'); - - // Use appropriate context based on flags - $context = $this->routingContext; - if ($this->isAdminContext && $this->isApiContext) { - $context = 'admin-api'; - } elseif ($this->isAdminContext) { - $context = 'admin'; - } elseif ($this->isApiContext) { - $context = 'api'; - } - - // Load routes with caching if enabled (disabled automatically in debug mode) - if ($cacheEnabled && !Environment::isDebugMode()) { - $cacheManager = new RouteCacheManager($cacheDir, Environment::isDebugMode()); - return $cacheManager->getRoutes($context, static function () use ($configuration, $context) { - $builder = new RouteCollectionBuilder($configuration); - return $builder->build($context); - }); - } - - // Load routes without caching (routes are always loaded from controller attributes) - $builder = new RouteCollectionBuilder($configuration); - return $builder->build($context); - } - - /** - * Formats an exception message from a template with named placeholders. - */ - private function formatExceptionMessage(string $template, Throwable $throwable): string - { - return strtr($template, [ - ':message' => $throwable->getMessage(), - ':line' => (string) $throwable->getLine(), - ':file' => $throwable->getFile(), - ]); - } - - /** - * Creates a ProblemDetails response for API errors. - */ - private function createProblemDetailsResponse( - Request $request, - int $status, - Throwable $throwable, - string $defaultDetail, - ): Response { - $configuration = $this->container->get(id: 'phpmyfaq.configuration'); - $baseUrl = rtrim($configuration->getDefaultUrl(), '/'); - - $type = match ($status) { - Response::HTTP_BAD_REQUEST => $baseUrl . '/problems/bad-request', - Response::HTTP_UNAUTHORIZED => $baseUrl . '/problems/unauthorized', - Response::HTTP_FORBIDDEN => $baseUrl . '/problems/forbidden', - Response::HTTP_NOT_FOUND => $baseUrl . '/problems/not-found', - Response::HTTP_CONFLICT => $baseUrl . '/problems/conflict', - Response::HTTP_UNPROCESSABLE_ENTITY => $baseUrl . '/problems/validation-error', - Response::HTTP_TOO_MANY_REQUESTS => $baseUrl . '/problems/rate-limited', - Response::HTTP_INTERNAL_SERVER_ERROR => $baseUrl . '/problems/internal-server-error', - default => $baseUrl . '/problems/http-error', - }; - - $title = match ($status) { - Response::HTTP_BAD_REQUEST => 'Bad Request', - Response::HTTP_UNAUTHORIZED => 'Unauthorized', - Response::HTTP_FORBIDDEN => 'Forbidden', - Response::HTTP_NOT_FOUND => 'Resource not found', - Response::HTTP_CONFLICT => 'Conflict', - Response::HTTP_UNPROCESSABLE_ENTITY => 'Validation failed', - Response::HTTP_TOO_MANY_REQUESTS => 'Too many requests', - Response::HTTP_INTERNAL_SERVER_ERROR => 'Internal Server Error', - default => 'HTTP error', - }; - - $detail = Environment::isDebugMode() - ? $throwable->getMessage() . ' at line ' . $throwable->getLine() . ' in ' . $throwable->getFile() - : $defaultDetail; - - $problemDetails = new ProblemDetails( - type: $type, - title: $title, - status: $status, - detail: $detail, - instance: $request->getPathInfo(), - ); - - $response = new Response( - content: json_encode($problemDetails->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), - status: $status, - ); - $response->headers->set('Content-Type', 'application/problem+json'); - - return $response; - } -} diff --git a/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php b/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php index 353f0e1b71..4932f30ea1 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php @@ -3,14 +3,14 @@ /** * Abstract Controller for phpMyFAQ * - * This Source Code Form is subject to the terms of the Mozilla protected License, + * This Source Code Form is subject to the terms of the Mozilla Public License, * v. 2.0. If a copy of the MPL was not distributed with this file, You can * obtain one at https://mozilla.org/MPL/2.0/. * * @package phpMyFAQ * @author Thorsten Rinne * @copyright 2023-2026 phpMyFAQ Team - * @license https://www.mozilla.org/MPL/2.0/ Mozilla protected License Version 2.0 + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 * @link https://www.phpmyfaq.de * @since 2023-10-24 */ @@ -33,6 +33,7 @@ use phpMyFAQ\User\CurrentUser; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -54,7 +55,7 @@ #[OA\License(name: 'Mozilla Public Licence 2.0', url: 'https://www.mozilla.org/MPL/2.0/')] abstract class AbstractController { - protected ?ContainerBuilder $container = null; + protected ?ContainerInterface $container = null; protected ?Configuration $configuration = null; @@ -62,6 +63,8 @@ abstract class AbstractController protected ?SessionInterface $session = null; + private bool $containerInitialized = false; + /** @var ExtensionInterface[] */ private array $twigExtensions = []; @@ -69,19 +72,47 @@ abstract class AbstractController private array $twigFilters = []; /** - * Check if the FAQ should be secured. + * Creates a fallback container for controllers instantiated outside the Kernel. + * When using the Kernel, setContainer() is called by the ControllerContainerListener + * before the controller method runs, overriding this container. * * @throws \Exception */ public function __construct() { $this->container = $this->createContainer(); + $this->initializeFromContainer(); + } + + /** + * Sets the shared DI container from the Kernel. + * Called by ControllerContainerListener on kernel.controller event. + */ + public function setContainer(ContainerInterface $container): void + { + $this->container = $container; + $this->initializeFromContainer(); + } + + /** + * Initializes configuration, user, and session from the container. + */ + protected function initializeFromContainer(): void + { + if ($this->container === null) { + return; + } + $this->configuration = $this->container->get(id: 'phpmyfaq.configuration'); $this->currentUser = $this->container->get(id: 'phpmyfaq.user.current_user'); $this->session = $this->container->get(id: 'session'); TwigWrapper::setTemplateSetName($this->configuration->getTemplateSet()); - $this->isSecured(); + + if (!$this->containerInitialized) { + $this->containerInitialized = true; + $this->isSecured(); + } } /** diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php new file mode 100644 index 0000000000..2e8e3c889f --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php @@ -0,0 +1,144 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\EventListener; + +use phpMyFAQ\Api\ProblemDetails; +use phpMyFAQ\Configuration; +use phpMyFAQ\Controller\Exception\ForbiddenException; +use phpMyFAQ\Environment; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; + +readonly class ApiExceptionListener +{ + public function __construct( + private ?Configuration $configuration = null, + ) { + } + + public function onKernelException(ExceptionEvent $event): void + { + $request = $event->getRequest(); + $pathInfo = $request->getPathInfo(); + + // Only handle API requests + if (!str_starts_with($pathInfo, '/api/') && !$request->attributes->get('_api_context', false)) { + return; + } + + $throwable = $event->getThrowable(); + + [$status, $defaultDetail] = match (true) { + $throwable instanceof ResourceNotFoundException => [ + Response::HTTP_NOT_FOUND, + 'The requested resource was not found.', + ], + $throwable instanceof UnauthorizedHttpException => [ + Response::HTTP_UNAUTHORIZED, + 'Unauthorized access.', + ], + $throwable instanceof ForbiddenException => [ + Response::HTTP_FORBIDDEN, + 'Access to this resource is forbidden.', + ], + $throwable instanceof BadRequestException => [ + Response::HTTP_BAD_REQUEST, + 'The request could not be understood or was missing required parameters.', + ], + default => [ + Response::HTTP_INTERNAL_SERVER_ERROR, + 'An unexpected error occurred while processing your request.', + ], + }; + + if ($status === Response::HTTP_INTERNAL_SERVER_ERROR) { + error_log(sprintf( + 'Unhandled exception in API: %s at %s:%d', + $throwable->getMessage(), + $throwable->getFile(), + $throwable->getLine(), + )); + } + + $response = $this->createProblemDetailsResponse($request, $status, $throwable, $defaultDetail); + $event->setResponse($response); + } + + private function createProblemDetailsResponse( + \Symfony\Component\HttpFoundation\Request $request, + int $status, + \Throwable $throwable, + string $defaultDetail, + ): Response { + $baseUrl = ''; + if ($this->configuration !== null) { + $baseUrl = rtrim($this->configuration->getDefaultUrl(), '/'); + } + + $type = match ($status) { + Response::HTTP_BAD_REQUEST => $baseUrl . '/problems/bad-request', + Response::HTTP_UNAUTHORIZED => $baseUrl . '/problems/unauthorized', + Response::HTTP_FORBIDDEN => $baseUrl . '/problems/forbidden', + Response::HTTP_NOT_FOUND => $baseUrl . '/problems/not-found', + Response::HTTP_CONFLICT => $baseUrl . '/problems/conflict', + Response::HTTP_UNPROCESSABLE_ENTITY => $baseUrl . '/problems/validation-error', + Response::HTTP_TOO_MANY_REQUESTS => $baseUrl . '/problems/rate-limited', + Response::HTTP_INTERNAL_SERVER_ERROR => $baseUrl . '/problems/internal-server-error', + default => $baseUrl . '/problems/http-error', + }; + + $title = match ($status) { + Response::HTTP_BAD_REQUEST => 'Bad Request', + Response::HTTP_UNAUTHORIZED => 'Unauthorized', + Response::HTTP_FORBIDDEN => 'Forbidden', + Response::HTTP_NOT_FOUND => 'Resource not found', + Response::HTTP_CONFLICT => 'Conflict', + Response::HTTP_UNPROCESSABLE_ENTITY => 'Validation failed', + Response::HTTP_TOO_MANY_REQUESTS => 'Too many requests', + Response::HTTP_INTERNAL_SERVER_ERROR => 'Internal Server Error', + default => 'HTTP error', + }; + + $detail = Environment::isDebugMode() + ? $throwable->getMessage() . ' at line ' . $throwable->getLine() . ' in ' . $throwable->getFile() + : $defaultDetail; + + $problemDetails = new ProblemDetails( + type: $type, + title: $title, + status: $status, + detail: $detail, + instance: $request->getPathInfo(), + ); + + $response = new Response( + content: json_encode($problemDetails->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), + status: $status, + ); + $response->headers->set('Content-Type', 'application/problem+json'); + + return $response; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/ControllerContainerListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/ControllerContainerListener.php new file mode 100644 index 0000000000..f0d96a6fd9 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/EventListener/ControllerContainerListener.php @@ -0,0 +1,49 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\EventListener; + +use phpMyFAQ\Controller\AbstractController; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpKernel\Event\ControllerEvent; + +class ControllerContainerListener +{ + public function __construct( + private readonly ContainerInterface $container, + ) { + } + + public function onKernelController(ControllerEvent $event): void + { + $controller = $event->getController(); + + // Handle array-style callables [object, method] + if (is_array($controller)) { + $controller = $controller[0]; + } + + if ($controller instanceof AbstractController) { + $controller->setContainer($this->container); + } + } +} diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php new file mode 100644 index 0000000000..c4d80d76d8 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php @@ -0,0 +1,104 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\EventListener; + +use phpMyFAQ\Configuration; +use phpMyFAQ\Core\Exception; +use phpMyFAQ\Language; +use phpMyFAQ\Strings; +use phpMyFAQ\Translation; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpKernel\Event\RequestEvent; + +class LanguageListener +{ + private bool $initialized = false; + + public function __construct( + private readonly ContainerInterface $container, + ) { + } + + /** + * @throws Exception + */ + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest() || $this->initialized) { + return; + } + + $this->initialized = true; + + $currentLanguage = $this->detectLanguage(); + $this->initializeTranslation($currentLanguage); + } + + private function detectLanguage(): string + { + if (!$this->container->has('phpmyfaq.configuration') || !$this->container->has('phpmyfaq.language')) { + return 'en'; + } + + /** @var Configuration $configuration */ + $configuration = $this->container->get(id: 'phpmyfaq.configuration'); + /** @var Language $language */ + $language = $this->container->get(id: 'phpmyfaq.language'); + + $configuration->setContainer($this->container); + + $detect = (bool) $configuration->get(item: 'main.languageDetection'); + $configLang = $configuration->get(item: 'main.language'); + + $currentLanguage = $detect + ? $language->setLanguageWithDetection($configLang) + : $language->setLanguageFromConfiguration($configLang); + + require PMF_TRANSLATION_DIR . '/language_en.php'; + if (Language::isASupportedLanguage($currentLanguage)) { + require PMF_TRANSLATION_DIR . '/language_' . strtolower($currentLanguage) . '.php'; + } + + $configuration->setLanguage($language); + + return $currentLanguage; + } + + /** + * @throws Exception + */ + private function initializeTranslation(string $currentLanguage): void + { + Strings::init($currentLanguage); + + try { + Translation::create() + ->setTranslationsDir(PMF_TRANSLATION_DIR) + ->setDefaultLanguage(defaultLanguage: 'en') + ->setCurrentLanguage($currentLanguage) + ->setMultiByteLanguage(); + } catch (Exception $exception) { + throw new Exception($exception->getMessage()); + } + } +} diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php new file mode 100644 index 0000000000..e8bb4b30aa --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php @@ -0,0 +1,56 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\EventListener; + +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\Routing\Matcher\UrlMatcher; +use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\RouteCollection; + +class RouterListener +{ + public function __construct( + private readonly RouteCollection $routes, + ) { + } + + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $request = $event->getRequest(); + + // Skip if already matched (e.g., by sub-request or test) + if ($request->attributes->has('_controller')) { + return; + } + + $requestContext = new RequestContext(); + $requestContext->fromRequest($request); + + $urlMatcher = new UrlMatcher($this->routes, $requestContext); + $parameters = $urlMatcher->match($request->getPathInfo()); + $request->attributes->add($parameters); + } +} diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php new file mode 100644 index 0000000000..a07ca060a7 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php @@ -0,0 +1,133 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\EventListener; + +use phpMyFAQ\Controller\Exception\ForbiddenException; +use phpMyFAQ\Controller\Frontend\PageNotFoundController; +use phpMyFAQ\Environment; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpKernel\Controller\ControllerResolver; +use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use Throwable; + +class WebExceptionListener +{ + public function onKernelException(ExceptionEvent $event): void + { + $request = $event->getRequest(); + $pathInfo = $request->getPathInfo(); + + // Skip API requests — handled by ApiExceptionListener + if (str_starts_with($pathInfo, '/api/') || $request->attributes->get('_api_context', false)) { + return; + } + + $throwable = $event->getThrowable(); + + $response = match (true) { + $throwable instanceof ResourceNotFoundException => $this->handleNotFound($event), + $throwable instanceof UnauthorizedHttpException => new RedirectResponse(url: './login'), + $throwable instanceof ForbiddenException => $this->handleErrorResponse( + 'An error occurred: :message at line :line at :file', + 'Forbidden', + Response::HTTP_FORBIDDEN, + $throwable, + ), + $throwable instanceof BadRequestException => $this->handleErrorResponse( + 'An error occurred: :message at line :line at :file', + 'Bad Request', + Response::HTTP_BAD_REQUEST, + $throwable, + ), + default => $this->handleServerError($throwable), + }; + + $event->setResponse($response); + } + + private function handleNotFound(ExceptionEvent $event): Response + { + $request = $event->getRequest(); + $throwable = $event->getThrowable(); + + try { + $request->attributes->set('_route', 'public.404'); + $request->attributes->set('_controller', PageNotFoundController::class . '::index'); + $controllerResolver = new ControllerResolver(); + $argumentResolver = new ArgumentResolver(); + $controller = $controllerResolver->getController($request); + $arguments = $argumentResolver->getArguments($request, $controller); + return call_user_func_array($controller, $arguments); + } catch (Throwable) { + return $this->handleErrorResponse( + 'Not Found: :message at line :line at :file', + 'Not Found', + Response::HTTP_NOT_FOUND, + $throwable, + ); + } + } + + private function handleServerError(Throwable $throwable): Response + { + error_log(sprintf( + 'Unhandled exception: %s at %s:%d', + $throwable->getMessage(), + $throwable->getFile(), + $throwable->getLine(), + )); + + return $this->handleErrorResponse( + 'Internal Server Error: :message at line :line at :file', + 'Internal Server Error', + Response::HTTP_INTERNAL_SERVER_ERROR, + $throwable, + ); + } + + private function handleErrorResponse( + string $debugTemplate, + string $fallbackMessage, + int $statusCode, + Throwable $throwable, + ): Response { + $message = Environment::isDebugMode() + ? $this->formatExceptionMessage($debugTemplate, $throwable) + : $fallbackMessage; + + return new Response(content: $message, status: $statusCode); + } + + private function formatExceptionMessage(string $template, Throwable $throwable): string + { + return strtr($template, [ + ':message' => $throwable->getMessage(), + ':line' => (string) $throwable->getLine(), + ':file' => $throwable->getFile(), + ]); + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Kernel.php b/phpmyfaq/src/phpMyFAQ/Kernel.php new file mode 100644 index 0000000000..12803e4712 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Kernel.php @@ -0,0 +1,193 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ; + +use phpMyFAQ\EventListener\ApiExceptionListener; +use phpMyFAQ\EventListener\ControllerContainerListener; +use phpMyFAQ\EventListener\LanguageListener; +use phpMyFAQ\EventListener\RouterListener; +use phpMyFAQ\EventListener\WebExceptionListener; +use phpMyFAQ\Form\FormsServiceProvider; +use phpMyFAQ\Routing\RouteCacheManager; +use phpMyFAQ\Routing\RouteCollectionBuilder; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpKernel\Controller\ControllerResolver; +use Symfony\Component\HttpKernel\HttpKernel; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Routing\RouteCollection; + +class Kernel implements HttpKernelInterface +{ + private ?ContainerBuilder $container = null; + + private ?HttpKernel $httpKernel = null; + + private bool $booted = false; + + private ?RouteCollection $routes = null; + + public function __construct( + private readonly string $routingContext = 'public', + private readonly bool $debug = false, + ) { + } + + /** + * Boots the Kernel: builds the DI container, loads routes, registers listeners, and creates the HttpKernel. + */ + public function boot(): void + { + if ($this->booted) { + return; + } + + $this->container = $this->buildContainer(); + $this->routes = $this->loadRoutes(); + $this->httpKernel = $this->createHttpKernel(); + $this->booted = true; + } + + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + if (!$this->booted) { + $this->boot(); + } + + // Mark API context on the request for exception listeners + if ($this->routingContext === 'api' || $this->routingContext === 'admin-api') { + $request->attributes->set('_api_context', true); + } + + return $this->httpKernel->handle($request, $type, $catch); + } + + public function getContainer(): ContainerInterface + { + if (!$this->booted) { + $this->boot(); + } + + return $this->container; + } + + public function getRoutingContext(): string + { + return $this->routingContext; + } + + public function isDebug(): bool + { + return $this->debug; + } + + private function buildContainer(): ContainerBuilder + { + $containerBuilder = new ContainerBuilder(); + $phpFileLoader = new PhpFileLoader($containerBuilder, new FileLocator(PMF_SRC_DIR)); + + try { + $phpFileLoader->load(resource: 'services.php'); + } catch (\Exception $exception) { + error_log('Kernel: Failed to load services.php: ' . $exception->getMessage()); + } + + // Register Forms services + FormsServiceProvider::register($containerBuilder); + + // Register kernel-level services + $containerBuilder->set('kernel', $this); + + return $containerBuilder; + } + + private function loadRoutes(): RouteCollection + { + $configuration = $this->container?->get(id: 'phpmyfaq.configuration'); + + $cacheEnabled = filter_var(Environment::get('ROUTING_CACHE_ENABLED', 'true'), FILTER_VALIDATE_BOOLEAN); + $cacheDir = Environment::get('ROUTING_CACHE_DIR', PMF_ROOT_DIR . '/cache/routes'); + + if ($cacheEnabled && !$this->debug && !Environment::isDebugMode()) { + $cacheManager = new RouteCacheManager($cacheDir, Environment::isDebugMode()); + return $cacheManager->getRoutes($this->routingContext, function () use ($configuration) { + $builder = new RouteCollectionBuilder($configuration); + return $builder->build($this->routingContext); + }); + } + + $builder = new RouteCollectionBuilder($configuration); + return $builder->build($this->routingContext); + } + + private function createHttpKernel(): HttpKernel + { + $dispatcher = $this->container->get('phpmyfaq.event_dispatcher'); + + if (!$dispatcher instanceof EventDispatcher) { + $dispatcher = new EventDispatcher(); + } + + $this->registerEventListeners($dispatcher); + + $controllerResolver = new ControllerResolver(); + $requestStack = new RequestStack(); + $argumentResolver = new ArgumentResolver(); + + return new HttpKernel($dispatcher, $controllerResolver, $requestStack, $argumentResolver); + } + + private function registerEventListeners(EventDispatcher $dispatcher): void + { + // Router listener — matches request to route (priority 256, runs early) + $routerListener = new RouterListener($this->routes); + $dispatcher->addListener(KernelEvents::REQUEST, [$routerListener, 'onKernelRequest'], 256); + + // Language listener — detects language and initializes translations (priority 200, after router) + $languageListener = new LanguageListener($this->container); + $dispatcher->addListener(KernelEvents::REQUEST, [$languageListener, 'onKernelRequest'], 200); + + // API exception listener — converts exceptions to RFC 7807 JSON (priority 0) + $configuration = $this->container->has('phpmyfaq.configuration') + ? $this->container->get('phpmyfaq.configuration') + : null; + $apiExceptionListener = new ApiExceptionListener($configuration); + $dispatcher->addListener(KernelEvents::EXCEPTION, [$apiExceptionListener, 'onKernelException'], 0); + + // Web exception listener — handles web (non-API) exceptions (priority -10, after API listener) + $webExceptionListener = new WebExceptionListener(); + $dispatcher->addListener(KernelEvents::EXCEPTION, [$webExceptionListener, 'onKernelException'], -10); + + // Controller container listener — injects shared container into controllers + $controllerContainerListener = new ControllerContainerListener($this->container); + $dispatcher->addListener(KernelEvents::CONTROLLER, [$controllerContainerListener, 'onKernelController'], 0); + } +} diff --git a/phpunit.xml b/phpunit.xml index 30db508f2a..0f077edad3 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -21,6 +21,10 @@ ./tests/phpMyFAQ + ./tests/phpMyFAQ/Functional + + + ./tests/phpMyFAQ/Functional diff --git a/tests/phpMyFAQ/ApplicationTest.php b/tests/phpMyFAQ/ApplicationTest.php deleted file mode 100644 index 2bd89c4136..0000000000 --- a/tests/phpMyFAQ/ApplicationTest.php +++ /dev/null @@ -1,679 +0,0 @@ -container = $this->createMock(ContainerInterface::class); - $this->application = new Application($this->container); - } - - /** - * @throws Exception - */ - public function testConstructorWithContainer(): void - { - $container = $this->createStub(ContainerInterface::class); - $application = new Application($container); - $this->assertInstanceOf(Application::class, $application); - } - - public function testConstructorWithoutContainer(): void - { - $application = new Application(); - $this->assertInstanceOf(Application::class, $application); - } - - public function testSetUrlMatcher(): void - { - $urlMatcher = $this->createStub(UrlMatcher::class); - $this->application->urlMatcher = $urlMatcher; - - $reflection = new ReflectionClass(Application::class); - $property = $reflection->getProperty('urlMatcher'); - - $this->assertSame($urlMatcher, $property->getValue($this->application)); - } - - public function testSetControllerResolver(): void - { - $controllerResolver = $this->createStub(ControllerResolver::class); - $this->application->controllerResolver = $controllerResolver; - - $reflection = new ReflectionClass(Application::class); - $property = $reflection->getProperty('controllerResolver'); - - $this->assertSame($controllerResolver, $property->getValue($this->application)); - } - - /** - * @throws ReflectionException - */ - public function testSetLanguageWithContainer(): void - { - $configuration = $this->createMock(Configuration::class); - $session = $this->createStub(Session::class); - $language = new Language($configuration, $session); - - $configuration - ->expects($this->exactly(2)) - ->method('get') - ->willReturnMap([ - ['main.languageDetection', true], - ['main.language', 'en'], - ]); - - // Keine Mock-Erwartung auf Language::setLanguage() – echte Instanz wird verwendet - - // Konfiguration speichert die Language-Instanz über setLanguage() - $configuration->expects($this->once())->method('setLanguage')->with($language); - - $this->container - ->expects($this->exactly(2)) - ->method('get') - ->willReturnMap([ - ['phpmyfaq.configuration', $configuration], - ['phpmyfaq.language', $language], - ]); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('setLanguage'); - - $result = $method->invoke($this->application); - $this->assertEquals('en', $result); - } - - /** - * @throws ReflectionException - */ - public function testSetLanguageWithoutContainer(): void - { - $application = new Application(); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('setLanguage'); - - $result = $method->invoke($application); - $this->assertEquals('en', $result); - } - - /** - * @throws ReflectionException - */ - public function testInitializeTranslationSuccess(): void - { - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('initializeTranslation'); - - $this->expectNotToPerformAssertions(); - - $method->invoke($this->application, 'en'); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestSuccess(): void - { - $routeCollection = new RouteCollection(); - $routeCollection->add('test_route', new Route('/test', [ - '_controller' => function () { - return new Response('Test Response'); - }, - ])); - - $request = Request::create('/test'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - $this->expectOutputString('Test Response'); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestResourceNotFoundException(): void - { - $routeCollection = new RouteCollection(); - $request = Request::create('/nonexistent'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - $this->assertStringContainsString('Not Found', $output); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestUnauthorizedHttpExceptionForApi(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - // Set API context - $this->application->setApiContext(true); - - $routeCollection = new RouteCollection(); - $routeCollection->add('api_route', new Route('/api/test', [ - '_controller' => function () { - throw new UnauthorizedHttpException('Bearer', 'Unauthorized'); - }, - ])); - - $request = Request::create('/api/test'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - $this->assertStringContainsString('Unauthorized', $output); - $this->assertStringContainsString('/problems/unauthorized', $output); - - $data = json_decode($output, true); - $this->assertIsArray($data); - $this->assertEquals(401, $data['status']); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestUnauthorizedHttpExceptionForNonApi(): void - { - $routeCollection = new RouteCollection(); - $routeCollection->add('web_route', new Route('/test', [ - '_controller' => function () { - throw new UnauthorizedHttpException('Bearer', 'Unauthorized'); - }, - ])); - - $request = Request::create('/test'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - // RedirectResponse sendet Location Header, nicht Inhalt - $this->expectOutputString(''); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestBadRequestException(): void - { - $routeCollection = new RouteCollection(); - $routeCollection->add('bad_route', new Route('/bad', [ - '_controller' => function () { - throw new BadRequestException('Bad request'); - }, - ])); - - $request = Request::create('/bad'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - $this->assertStringContainsString('Bad Request', $output); - } - - /** - * @throws ReflectionException - */ - public function testRunMethodWithContainer(): void - { - $configuration = $this->createMock(Configuration::class); - $session = $this->createStub(Session::class); - $language = new Language($configuration, $session); - - $configuration - ->expects($this->exactly(2)) - ->method('get') - ->willReturnMap([ - ['main.languageDetection', true], - ['main.language', 'en'], - ]); - - // Keine Mock-Erwartung auf Language::setLanguage() – echte Instanz wird verwendet - - $configuration->expects($this->once())->method('setLanguage')->with($language); - - $this->container - ->expects($this->exactly(2)) - ->method('get') - ->willReturnMap([ - ['phpmyfaq.configuration', $configuration], - ['phpmyfaq.language', $language], - ]); - - $routeCollection = new RouteCollection(); - $routeCollection->add('test_route', new Route('/', [ - '_controller' => function () { - return new Response('Welcome'); - }, - ])); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $_SERVER['REQUEST_URI'] = '/'; - $_SERVER['HTTP_HOST'] = 'localhost'; - - ob_start(); - try { - $this->application->run($routeCollection); - } catch (PMFException $e) { - $this->assertInstanceOf(PMFException::class, $e); - } - ob_get_clean(); - - $this->assertTrue(true); - } - - /** - * Test für die run() Methode ohne Container - */ - public function testRunMethodWithoutContainer(): void - { - $application = new Application(); - $routeCollection = new RouteCollection(); - - $_SERVER['REQUEST_METHOD'] = 'GET'; - $_SERVER['REQUEST_URI'] = '/nonexistent'; - $_SERVER['HTTP_HOST'] = 'localhost'; - - ob_start(); - try { - $application->run($routeCollection); - } catch (PMFException $e) { - $this->assertInstanceOf(PMFException::class, $e); - } - $output = ob_get_clean(); - - $this->assertTrue(true); - } - - /** - * @throws ReflectionException - */ - public function testCreateProblemDetailsResponseFor404(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $request = Request::create('/api/nonexistent'); - $exception = new ResourceNotFoundException('Route not found'); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('createProblemDetailsResponse'); - - $response = $method->invoke( - $this->application, - $request, - Response::HTTP_NOT_FOUND, - $exception, - 'The requested resource was not found.', - ); - - $this->assertInstanceOf(Response::class, $response); - $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); - $this->assertEquals('application/problem+json', $response->headers->get('Content-Type')); - - $content = json_decode($response->getContent(), true); - $this->assertEquals('https://localhost/problems/not-found', $content['type']); - $this->assertEquals('Resource not found', $content['title']); - $this->assertEquals(404, $content['status']); - $this->assertEquals('/api/nonexistent', $content['instance']); - } - - /** - * @throws ReflectionException - */ - public function testCreateProblemDetailsResponseFor400(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://example.com'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $request = Request::create('/api/test'); - $exception = new BadRequestException('Invalid parameter'); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('createProblemDetailsResponse'); - - $response = $method->invoke( - $this->application, - $request, - Response::HTTP_BAD_REQUEST, - $exception, - 'Bad request.', - ); - - $content = json_decode($response->getContent(), true); - $this->assertEquals('https://example.com/problems/bad-request', $content['type']); - $this->assertEquals('Bad Request', $content['title']); - $this->assertEquals(400, $content['status']); - } - - /** - * @throws ReflectionException - */ - public function testCreateProblemDetailsResponseFor401(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost/'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $request = Request::create('/api/secure'); - $exception = new UnauthorizedHttpException('Bearer', 'Missing token'); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('createProblemDetailsResponse'); - - $response = $method->invoke( - $this->application, - $request, - Response::HTTP_UNAUTHORIZED, - $exception, - 'Unauthorized.', - ); - - $content = json_decode($response->getContent(), true); - $this->assertEquals('https://localhost/problems/unauthorized', $content['type']); - $this->assertEquals('Unauthorized', $content['title']); - $this->assertEquals(401, $content['status']); - } - - /** - * @throws ReflectionException - */ - public function testCreateProblemDetailsResponseFor403(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $request = Request::create('/api/admin'); - $exception = new ForbiddenException('Insufficient permissions'); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('createProblemDetailsResponse'); - - $response = $method->invoke($this->application, $request, Response::HTTP_FORBIDDEN, $exception, 'Forbidden.'); - - $content = json_decode($response->getContent(), true); - $this->assertEquals('https://localhost/problems/forbidden', $content['type']); - $this->assertEquals('Forbidden', $content['title']); - $this->assertEquals(403, $content['status']); - } - - /** - * @throws ReflectionException - */ - public function testCreateProblemDetailsResponseFor500(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $request = Request::create('/api/error'); - $exception = new \RuntimeException('Database connection failed'); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('createProblemDetailsResponse'); - - $response = $method->invoke( - $this->application, - $request, - Response::HTTP_INTERNAL_SERVER_ERROR, - $exception, - 'Internal server error.', - ); - - $content = json_decode($response->getContent(), true); - $this->assertEquals('https://localhost/problems/internal-server-error', $content['type']); - $this->assertEquals('Internal Server Error', $content['title']); - $this->assertEquals(500, $content['status']); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestResourceNotFoundExceptionForApi(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $this->application->setApiContext(true); - - $routeCollection = new RouteCollection(); - $request = Request::create('/api/nonexistent'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - $this->assertStringContainsString('Resource not found', $output); - $this->assertStringContainsString('/problems/not-found', $output); - - $data = json_decode($output, true); - $this->assertIsArray($data); - $this->assertEquals(404, $data['status']); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestBadRequestExceptionForApi(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $this->application->setApiContext(true); - - $routeCollection = new RouteCollection(); - $routeCollection->add('bad_route', new Route('/api/bad', [ - '_controller' => function () { - throw new BadRequestException('Invalid input'); - }, - ])); - - $request = Request::create('/api/bad'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - $this->assertStringContainsString('Bad Request', $output); - $this->assertStringContainsString('/problems/bad-request', $output); - - $data = json_decode($output, true); - $this->assertIsArray($data); - $this->assertEquals(400, $data['status']); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestForbiddenExceptionForApi(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $this->application->setApiContext(true); - - $routeCollection = new RouteCollection(); - $routeCollection->add('forbidden_route', new Route('/api/forbidden', [ - '_controller' => function () { - throw new ForbiddenException('Access denied'); - }, - ])); - - $request = Request::create('/api/forbidden'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - $this->assertStringContainsString('Forbidden', $output); - $this->assertStringContainsString('/problems/forbidden', $output); - - $data = json_decode($output, true); - $this->assertIsArray($data); - $this->assertEquals(403, $data['status']); - } - - /** - * @throws ReflectionException - */ - public function testHandleRequestInternalServerErrorForApi(): void - { - $configuration = $this->createMock(Configuration::class); - $configuration->method('getDefaultUrl')->willReturn('https://localhost'); - - $this->container - ->method('get') - ->with('phpmyfaq.configuration') - ->willReturn($configuration); - - $this->application->setApiContext(true); - - $routeCollection = new RouteCollection(); - $routeCollection->add('error_route', new Route('/api/error', [ - '_controller' => function () { - throw new \RuntimeException('Something went wrong'); - }, - ])); - - $request = Request::create('/api/error'); - $requestContext = new RequestContext(); - $requestContext->fromRequest($request); - - $reflection = new ReflectionClass(Application::class); - $method = $reflection->getMethod('handleRequest'); - - // Suppress error_log output - $originalErrorLog = ini_get('error_log'); - ini_set('error_log', '/dev/null'); - - ob_start(); - $method->invoke($this->application, $routeCollection, $request, $requestContext); - $output = ob_get_clean(); - - // Restore error_log - ini_set('error_log', $originalErrorLog); - - $this->assertStringContainsString('Internal Server Error', $output); - $this->assertStringContainsString('/problems/internal-server-error', $output); - - $data = json_decode($output, true); - $this->assertIsArray($data); - $this->assertEquals(500, $data['status']); - } -} diff --git a/tests/phpMyFAQ/EventListener/ApiExceptionListenerTest.php b/tests/phpMyFAQ/EventListener/ApiExceptionListenerTest.php new file mode 100644 index 0000000000..b0f4f38c06 --- /dev/null +++ b/tests/phpMyFAQ/EventListener/ApiExceptionListenerTest.php @@ -0,0 +1,144 @@ +configuration = $this->createMock(Configuration::class); + $this->configuration->method('getDefaultUrl')->willReturn('https://localhost'); + $this->listener = new ApiExceptionListener($this->configuration); + } + + private function createEvent(Request $request, \Throwable $exception): ExceptionEvent + { + $kernel = $this->createMock(HttpKernelInterface::class); + return new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $exception); + } + + public function testIgnoresNonApiRequests(): void + { + $request = Request::create('/some-page.html'); + $event = $this->createEvent($request, new \RuntimeException('error')); + + $this->listener->onKernelException($event); + + $this->assertNull($event->getResponse()); + } + + public function testHandlesApiRequestsByPath(): void + { + $request = Request::create('/api/v3.2/version'); + $event = $this->createEvent($request, new ResourceNotFoundException('Route not found')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $this->assertNotNull($response); + $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + $this->assertEquals('application/problem+json', $response->headers->get('Content-Type')); + + $content = json_decode($response->getContent(), true); + $this->assertEquals('https://localhost/problems/not-found', $content['type']); + $this->assertEquals('Resource not found', $content['title']); + $this->assertEquals(404, $content['status']); + } + + public function testHandlesApiContextAttribute(): void + { + $request = Request::create('/admin/api/something'); + $request->attributes->set('_api_context', true); + $event = $this->createEvent($request, new ResourceNotFoundException('not found')); + + $this->listener->onKernelException($event); + + $this->assertNotNull($event->getResponse()); + $this->assertEquals(Response::HTTP_NOT_FOUND, $event->getResponse()->getStatusCode()); + } + + public function testHandlesUnauthorizedException(): void + { + $request = Request::create('/api/v3.2/secure'); + $event = $this->createEvent($request, new UnauthorizedHttpException('Bearer', 'Missing token')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $content = json_decode($response->getContent(), true); + $this->assertEquals(401, $content['status']); + $this->assertEquals('Unauthorized', $content['title']); + } + + public function testHandlesForbiddenException(): void + { + $request = Request::create('/api/v3.2/admin'); + $event = $this->createEvent($request, new ForbiddenException('Access denied')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $content = json_decode($response->getContent(), true); + $this->assertEquals(403, $content['status']); + $this->assertEquals('Forbidden', $content['title']); + } + + public function testHandlesBadRequestException(): void + { + $request = Request::create('/api/v3.2/test'); + $event = $this->createEvent($request, new BadRequestException('Invalid input')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $content = json_decode($response->getContent(), true); + $this->assertEquals(400, $content['status']); + $this->assertEquals('Bad Request', $content['title']); + } + + public function testHandlesGenericException(): void + { + $request = Request::create('/api/v3.2/error'); + $event = $this->createEvent($request, new \RuntimeException('Something went wrong')); + + // Suppress error_log output + $originalErrorLog = ini_get('error_log'); + ini_set('error_log', '/dev/null'); + + $this->listener->onKernelException($event); + + ini_set('error_log', $originalErrorLog); + + $response = $event->getResponse(); + $content = json_decode($response->getContent(), true); + $this->assertEquals(500, $content['status']); + $this->assertEquals('Internal Server Error', $content['title']); + } + + public function testWithoutConfiguration(): void + { + $listener = new ApiExceptionListener(null); + $request = Request::create('/api/v3.2/test'); + $event = $this->createEvent($request, new ResourceNotFoundException('not found')); + + $listener->onKernelException($event); + + $response = $event->getResponse(); + $content = json_decode($response->getContent(), true); + $this->assertEquals('/problems/not-found', $content['type']); + } +} diff --git a/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php b/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php new file mode 100644 index 0000000000..9beefaeda3 --- /dev/null +++ b/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php @@ -0,0 +1,81 @@ +createMock(ContainerInterface::class); + $listener = new ControllerContainerListener($container); + + // Create a concrete anonymous class extending AbstractController + // that tracks setContainer calls without triggering initializeFromContainer + $controller = new class() extends AbstractController { + public bool $containerWasSet = false; + + public function __construct() + { + // Skip parent constructor to avoid container creation in tests + } + + public function setContainer(ContainerInterface $container): void + { + $this->containerWasSet = true; + // Don't call parent::setContainer() to avoid needing full container setup + $this->container = $container; + } + + public function testAction(): Response + { + return new Response('test'); + } + }; + + $kernel = $this->createMock(HttpKernelInterface::class); + $request = Request::create('/test'); + + $event = new ControllerEvent( + $kernel, + [$controller, 'testAction'], + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + + $listener->onKernelController($event); + + $this->assertTrue($controller->containerWasSet); + } + + public function testIgnoresNonAbstractControllers(): void + { + $container = $this->createMock(ContainerInterface::class); + $listener = new ControllerContainerListener($container); + + $controller = function () { + return new Response('test'); + }; + + $kernel = $this->createMock(HttpKernelInterface::class); + $request = Request::create('/test'); + + $event = new ControllerEvent( + $kernel, + $controller, + $request, + HttpKernelInterface::MAIN_REQUEST, + ); + + // Should not throw or error + $listener->onKernelController($event); + $this->assertTrue(true); + } +} diff --git a/tests/phpMyFAQ/EventListener/RouterListenerTest.php b/tests/phpMyFAQ/EventListener/RouterListenerTest.php new file mode 100644 index 0000000000..7d9074c3ac --- /dev/null +++ b/tests/phpMyFAQ/EventListener/RouterListenerTest.php @@ -0,0 +1,79 @@ +createMock(HttpKernelInterface::class); + return new RequestEvent($kernel, $request, $type); + } + + public function testMatchesRoute(): void + { + $routes = new RouteCollection(); + $routes->add('test_route', new Route('/test', [ + '_controller' => function () { + return new Response('OK'); + }, + ])); + + $listener = new RouterListener($routes); + $request = Request::create('/test'); + $event = $this->createEvent($request); + + $listener->onKernelRequest($event); + + $this->assertTrue($request->attributes->has('_controller')); + $this->assertEquals('test_route', $request->attributes->get('_route')); + } + + public function testSkipsSubRequests(): void + { + $routes = new RouteCollection(); + $listener = new RouterListener($routes); + + $request = Request::create('/test'); + $event = $this->createEvent($request, HttpKernelInterface::SUB_REQUEST); + + $listener->onKernelRequest($event); + + $this->assertFalse($request->attributes->has('_controller')); + } + + public function testSkipsAlreadyMatchedRequests(): void + { + $routes = new RouteCollection(); + $listener = new RouterListener($routes); + + $request = Request::create('/test'); + $request->attributes->set('_controller', 'SomeController::action'); + $event = $this->createEvent($request); + + $listener->onKernelRequest($event); + + $this->assertEquals('SomeController::action', $request->attributes->get('_controller')); + } + + public function testThrowsOnNoMatch(): void + { + $routes = new RouteCollection(); + $listener = new RouterListener($routes); + + $request = Request::create('/nonexistent'); + $event = $this->createEvent($request); + + $this->expectException(ResourceNotFoundException::class); + $listener->onKernelRequest($event); + } +} diff --git a/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php b/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php new file mode 100644 index 0000000000..5bfd1d1fe0 --- /dev/null +++ b/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php @@ -0,0 +1,118 @@ +listener = new WebExceptionListener(); + } + + private function createEvent(Request $request, \Throwable $exception): ExceptionEvent + { + $kernel = $this->createMock(HttpKernelInterface::class); + return new ExceptionEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, $exception); + } + + public function testIgnoresApiRequests(): void + { + $request = Request::create('/api/v3.2/version'); + $event = $this->createEvent($request, new \RuntimeException('error')); + + $this->listener->onKernelException($event); + + $this->assertNull($event->getResponse()); + } + + public function testIgnoresApiContextAttribute(): void + { + $request = Request::create('/admin/api/something'); + $request->attributes->set('_api_context', true); + $event = $this->createEvent($request, new \RuntimeException('error')); + + $this->listener->onKernelException($event); + + $this->assertNull($event->getResponse()); + } + + public function testHandlesResourceNotFoundException(): void + { + $request = Request::create('/nonexistent-page.html'); + $event = $this->createEvent($request, new ResourceNotFoundException('not found')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $this->assertNotNull($response); + // Either PageNotFoundController handles it (404) or fallback (404) + $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + } + + public function testHandlesUnauthorizedHttpException(): void + { + $request = Request::create('/secure-page.html'); + $event = $this->createEvent($request, new UnauthorizedHttpException('Bearer', 'Not logged in')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $this->assertNotNull($response); + $this->assertEquals(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertEquals('./login', $response->headers->get('Location')); + } + + public function testHandlesForbiddenException(): void + { + $request = Request::create('/admin/settings.html'); + $event = $this->createEvent($request, new ForbiddenException('No permission')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $this->assertNotNull($response); + $this->assertEquals(Response::HTTP_FORBIDDEN, $response->getStatusCode()); + } + + public function testHandlesBadRequestException(): void + { + $request = Request::create('/page.html'); + $event = $this->createEvent($request, new BadRequestException('Invalid')); + + $this->listener->onKernelException($event); + + $response = $event->getResponse(); + $this->assertNotNull($response); + $this->assertEquals(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + } + + public function testHandlesGenericException(): void + { + $request = Request::create('/page.html'); + $event = $this->createEvent($request, new \RuntimeException('Server error')); + + // Suppress error_log output + $originalErrorLog = ini_get('error_log'); + ini_set('error_log', '/dev/null'); + + $this->listener->onKernelException($event); + + ini_set('error_log', $originalErrorLog); + + $response = $event->getResponse(); + $this->assertNotNull($response); + $this->assertEquals(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + } +} diff --git a/tests/phpMyFAQ/Functional/KernelRoutingTest.php b/tests/phpMyFAQ/Functional/KernelRoutingTest.php new file mode 100644 index 0000000000..ce696f46ec --- /dev/null +++ b/tests/phpMyFAQ/Functional/KernelRoutingTest.php @@ -0,0 +1,197 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Functional; + +use phpMyFAQ\EventListener\ApiExceptionListener; +use phpMyFAQ\EventListener\RouterListener; +use phpMyFAQ\EventListener\WebExceptionListener; +use PHPUnit\Framework\TestCase; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver; +use Symfony\Component\HttpKernel\Controller\ControllerResolver; +use Symfony\Component\HttpKernel\HttpKernel; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; + +class KernelRoutingTest extends TestCase +{ + private function createKernelStack(RouteCollection $routes, bool $isApi = false): HttpKernel + { + $dispatcher = new EventDispatcher(); + + // Register router listener + $routerListener = new RouterListener($routes); + $dispatcher->addListener(KernelEvents::REQUEST, [$routerListener, 'onKernelRequest'], 256); + + // Register exception listeners + $apiListener = new ApiExceptionListener(null); + $dispatcher->addListener(KernelEvents::EXCEPTION, [$apiListener, 'onKernelException'], 0); + + $webListener = new WebExceptionListener(); + $dispatcher->addListener(KernelEvents::EXCEPTION, [$webListener, 'onKernelException'], -10); + + return new HttpKernel( + $dispatcher, + new ControllerResolver(), + new RequestStack(), + new ArgumentResolver(), + ); + } + + public function testSuccessfulRouteReturnsOk(): void + { + $routes = new RouteCollection(); + $routes->add('test_route', new Route('/test', [ + '_controller' => function () { + return new Response('Hello World'); + }, + ])); + + $kernel = $this->createKernelStack($routes); + $request = Request::create('/test'); + $response = $kernel->handle($request); + + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + $this->assertEquals('Hello World', $response->getContent()); + } + + public function testNotFoundReturns404ForWebRequest(): void + { + $routes = new RouteCollection(); + $kernel = $this->createKernelStack($routes); + $request = Request::create('/nonexistent'); + $response = $kernel->handle($request); + + $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + } + + public function testNotFoundReturns404JsonForApiRequest(): void + { + $routes = new RouteCollection(); + $kernel = $this->createKernelStack($routes, isApi: true); + $request = Request::create('/api/v3.2/nonexistent'); + $response = $kernel->handle($request); + + $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + $this->assertEquals('application/problem+json', $response->headers->get('Content-Type')); + + $content = json_decode($response->getContent(), true); + $this->assertIsArray($content); + $this->assertEquals(404, $content['status']); + $this->assertEquals('Resource not found', $content['title']); + } + + public function testControllerExceptionHandledByApiListener(): void + { + $routes = new RouteCollection(); + $routes->add('api_error', new Route('/api/v3.2/error', [ + '_controller' => function () { + throw new \RuntimeException('Test error'); + }, + ])); + + // Suppress error_log output + $originalErrorLog = ini_get('error_log'); + ini_set('error_log', '/dev/null'); + + $kernel = $this->createKernelStack($routes, isApi: true); + $request = Request::create('/api/v3.2/error'); + $response = $kernel->handle($request); + + ini_set('error_log', $originalErrorLog); + + $this->assertEquals(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + $this->assertEquals('application/problem+json', $response->headers->get('Content-Type')); + + $content = json_decode($response->getContent(), true); + $this->assertEquals(500, $content['status']); + $this->assertEquals('Internal Server Error', $content['title']); + } + + public function testControllerExceptionHandledByWebListener(): void + { + $routes = new RouteCollection(); + $routes->add('web_error', new Route('/error-page', [ + '_controller' => function () { + throw new \RuntimeException('Test web error'); + }, + ])); + + // Suppress error_log output + $originalErrorLog = ini_get('error_log'); + ini_set('error_log', '/dev/null'); + + $kernel = $this->createKernelStack($routes); + $request = Request::create('/error-page'); + $response = $kernel->handle($request); + + ini_set('error_log', $originalErrorLog); + + $this->assertEquals(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); + } + + public function testMultipleRoutesResolveCorrectly(): void + { + $routes = new RouteCollection(); + $routes->add('route_a', new Route('/page-a', [ + '_controller' => function () { + return new Response('Page A'); + }, + ])); + $routes->add('route_b', new Route('/page-b', [ + '_controller' => function () { + return new Response('Page B'); + }, + ])); + + $kernel = $this->createKernelStack($routes); + + $responseA = $kernel->handle(Request::create('/page-a')); + $this->assertEquals('Page A', $responseA->getContent()); + + $responseB = $kernel->handle(Request::create('/page-b')); + $this->assertEquals('Page B', $responseB->getContent()); + } + + public function testRouteWithParameters(): void + { + $routes = new RouteCollection(); + $routes->add('param_route', new Route('/items/{id}', [ + '_controller' => function (Request $request) { + $id = $request->attributes->get('id'); + return new Response(sprintf('Item %s', $id)); + }, + ], requirements: ['id' => '\d+'])); + + $kernel = $this->createKernelStack($routes); + + $response = $kernel->handle(Request::create('/items/42')); + $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); + $this->assertEquals('Item 42', $response->getContent()); + } +} diff --git a/tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php b/tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php new file mode 100644 index 0000000000..c8df02ea77 --- /dev/null +++ b/tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php @@ -0,0 +1,33 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Functional; + +use phpMyFAQ\Kernel; + +class PhpMyFaqTestKernel extends Kernel +{ + public function __construct(string $routingContext = 'public') + { + parent::__construct( + routingContext: $routingContext, + debug: true, + ); + } +} diff --git a/tests/phpMyFAQ/Functional/WebTestCase.php b/tests/phpMyFAQ/Functional/WebTestCase.php new file mode 100644 index 0000000000..24f44da5ea --- /dev/null +++ b/tests/phpMyFAQ/Functional/WebTestCase.php @@ -0,0 +1,120 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-15 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Functional; + +use phpMyFAQ\Kernel; +use PHPUnit\Framework\TestCase; +use Symfony\Component\BrowserKit\AbstractBrowser; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\HttpKernelInterface; + +abstract class WebTestCase extends TestCase +{ + protected static ?Kernel $kernel = null; + + protected static ?HttpKernelBrowser $client = null; + + protected static function createClient(string $routingContext = 'public'): HttpKernelBrowser + { + static::$kernel = new PhpMyFaqTestKernel($routingContext); + static::$kernel->boot(); + static::$client = new HttpKernelBrowser(static::$kernel); + + return static::$client; + } + + protected static function assertResponseStatusCodeSame(int $expectedStatusCode, ?Response $response = null): void + { + $response ??= static::$client?->getResponse(); + static::assertNotNull($response, 'No response available. Did you make a request?'); + static::assertSame($expectedStatusCode, $response->getStatusCode()); + } + + protected static function assertResponseIsSuccessful(?Response $response = null): void + { + $response ??= static::$client?->getResponse(); + static::assertNotNull($response, 'No response available. Did you make a request?'); + static::assertTrue( + $response->isSuccessful(), + sprintf('Expected successful response, got %d', $response->getStatusCode()), + ); + } + + protected static function assertResponseHeaderSame( + string $headerName, + string $expectedValue, + ?Response $response = null, + ): void { + $response ??= static::$client?->getResponse(); + static::assertNotNull($response, 'No response available.'); + static::assertSame($expectedValue, $response->headers->get($headerName)); + } + + protected function tearDown(): void + { + static::$kernel = null; + static::$client = null; + } +} + +/** + * A browser that sends requests through an HttpKernelInterface + * instead of making actual HTTP requests. + */ +class HttpKernelBrowser extends AbstractBrowser +{ + private ?Response $response = null; + + public function __construct( + private readonly HttpKernelInterface $kernel, + array $server = [], + ?\Symfony\Component\BrowserKit\History $history = null, + ?\Symfony\Component\BrowserKit\CookieJar $cookieJar = null, + ) { + parent::__construct($server, $history, $cookieJar); + } + + protected function doRequest(object $request): Response + { + if (!$request instanceof Request) { + throw new \InvalidArgumentException('Expected a Symfony Request object.'); + } + + $this->response = $this->kernel->handle($request); + + return $this->response; + } + + public function getResponse(): ?Response + { + $response = $this->getInternalResponse(); + + // Try the stored response first + if ($this->response !== null) { + return $this->response; + } + + return null; + } +} diff --git a/tests/phpMyFAQ/KernelTest.php b/tests/phpMyFAQ/KernelTest.php new file mode 100644 index 0000000000..727002a0fe --- /dev/null +++ b/tests/phpMyFAQ/KernelTest.php @@ -0,0 +1,40 @@ +assertInstanceOf(HttpKernelInterface::class, $kernel); + } + + public function testKernelRoutingContext(): void + { + $kernel = new Kernel(routingContext: 'admin', debug: false); + $this->assertEquals('admin', $kernel->getRoutingContext()); + } + + public function testKernelDebugMode(): void + { + $kernel = new Kernel(routingContext: 'public', debug: true); + $this->assertTrue($kernel->isDebug()); + } + + public function testKernelNonDebugMode(): void + { + $kernel = new Kernel(routingContext: 'public', debug: false); + $this->assertFalse($kernel->isDebug()); + } + + public function testKernelDefaultParameters(): void + { + $kernel = new Kernel(); + $this->assertEquals('public', $kernel->getRoutingContext()); + $this->assertFalse($kernel->isDebug()); + } +} From 82586c16116c8949b98026d53ecc7b474a21a92e Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Tue, 17 Feb 2026 06:03:16 +0100 Subject: [PATCH 2/5] fix: corrected review notes --- .../Controller/AbstractController.php | 8 +- .../AbstractAdministrationController.php | 5 +- .../AbstractAdministrationApiController.php | 5 +- .../Controller/Api/AbstractApiController.php | 25 ++-- .../Controller/Api/AttachmentController.php | 11 +- .../Controller/Api/CategoryController.php | 23 ++- .../Controller/Api/CommentController.php | 24 +++- .../phpMyFAQ/Controller/Api/FaqController.php | 131 +++++++++++------- .../Controller/Api/GlossaryController.php | 29 +++- .../Controller/Api/GroupController.php | 14 +- .../Controller/Api/NewsController.php | 10 +- .../Controller/Api/OpenQuestionController.php | 25 +++- .../Controller/Api/QuestionController.php | 19 +-- .../Controller/Api/SearchController.php | 26 +++- .../phpMyFAQ/Controller/Api/TagController.php | 29 +++- .../ContainerControllerResolver.php | 60 ++++++++ .../Frontend/Api/VotingController.php | 12 ++ .../EventListener/ApiExceptionListener.php | 3 +- .../EventListener/LanguageListener.php | 2 +- .../phpMyFAQ/EventListener/RouterListener.php | 17 ++- .../EventListener/WebExceptionListener.php | 9 +- phpmyfaq/src/phpMyFAQ/Kernel.php | 12 +- phpmyfaq/src/phpMyFAQ/User/UserSession.php | 11 +- .../Controller/AbstractControllerTest.php | 51 +++++++ .../Frontend/Api/VotingControllerTest.php | 11 +- .../ControllerContainerListenerTest.php | 14 +- .../EventListener/RouterListenerTest.php | 46 +++++- .../WebExceptionListenerTest.php | 2 +- .../phpMyFAQ/Functional/KernelRoutingTest.php | 46 ++++-- .../Functional/PhpMyFaqTestKernel.php | 5 +- tests/phpMyFAQ/Functional/WebTestCase.php | 17 +-- 31 files changed, 513 insertions(+), 189 deletions(-) create mode 100644 phpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.php diff --git a/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php b/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php index 4932f30ea1..d65964a08f 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php @@ -63,8 +63,6 @@ abstract class AbstractController protected ?SessionInterface $session = null; - private bool $containerInitialized = false; - /** @var ExtensionInterface[] */ private array $twigExtensions = []; @@ -108,11 +106,7 @@ protected function initializeFromContainer(): void $this->session = $this->container->get(id: 'session'); TwigWrapper::setTemplateSetName($this->configuration->getTemplateSet()); - - if (!$this->containerInitialized) { - $this->containerInitialized = true; - $this->isSecured(); - } + $this->isSecured(); } /** diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/AbstractAdministrationController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/AbstractAdministrationController.php index a36397b5d3..a61fb3a829 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/AbstractAdministrationController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/AbstractAdministrationController.php @@ -39,9 +39,10 @@ abstract class AbstractAdministrationController extends AbstractController { protected ?AdminLog $adminLog = null; - public function __construct() + #[\Override] + protected function initializeFromContainer(): void { - parent::__construct(); + parent::initializeFromContainer(); $this->adminLog = $this->container->get(id: 'phpmyfaq.admin.admin-log'); } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AbstractAdministrationApiController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AbstractAdministrationApiController.php index dc6b599188..c11879a60b 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AbstractAdministrationApiController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AbstractAdministrationApiController.php @@ -24,9 +24,10 @@ class AbstractAdministrationApiController extends AbstractController { protected ?AdminLog $adminLog = null; - public function __construct() + #[\Override] + protected function initializeFromContainer(): void { - parent::__construct(); + parent::initializeFromContainer(); $this->adminLog = $this->container->get(id: 'phpmyfaq.admin.admin-log'); } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/AbstractApiController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/AbstractApiController.php index 64be2d7933..8f26c80e3c 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/AbstractApiController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/AbstractApiController.php @@ -43,18 +43,13 @@ abstract class AbstractApiController extends AbstractController protected const int MAX_PER_PAGE = 100; /** - * Constructor - * - * Verifies that API access is enabled before allowing any API operations. - * - * @throws UnauthorizedHttpException If API is not enabled - * @throws Exception + * Initializes API controller and verifies API access is enabled. */ - public function __construct() + #[\Override] + protected function initializeFromContainer(): void { - parent::__construct(); + parent::initializeFromContainer(); - // Verify API is enabled if (!$this->isApiEnabled()) { throw new UnauthorizedHttpException(challenge: 'API is not enabled'); } @@ -70,11 +65,11 @@ public function __construct() * @return PaginationRequest */ protected function getPaginationRequest( + Request $request, int $defaultPerPage = self::DEFAULT_PER_PAGE, ?int $maxPerPage = null, ): PaginationRequest { $maxPerPage ??= self::MAX_PER_PAGE; - $request = Request::createFromGlobals(); return PaginationRequest::fromRequest($request, $defaultPerPage, $maxPerPage); } @@ -90,12 +85,11 @@ protected function getPaginationRequest( * @return SortRequest */ protected function getSortRequest( + Request $request, array $allowedFields, ?string $defaultField = null, string $defaultOrder = 'asc', ): SortRequest { - $request = Request::createFromGlobals(); - return SortRequest::fromRequest($request, $allowedFields, $defaultField, $defaultOrder); } @@ -115,10 +109,8 @@ protected function getSortRequest( * 'created_from' => 'date', * ] */ - protected function getFilterRequest(array $allowedFilters): FilterRequest + protected function getFilterRequest(Request $request, array $allowedFilters): FilterRequest { - $request = Request::createFromGlobals(); - return FilterRequest::fromRequest($request, $allowedFilters); } @@ -134,6 +126,7 @@ protected function getFilterRequest(array $allowedFilters): FilterRequest * @return JsonResponse */ protected function paginatedResponse( + Request $request, array $data, int $total, PaginationRequest $pagination, @@ -141,8 +134,6 @@ protected function paginatedResponse( ?FilterRequest $filters = null, int $status = Response::HTTP_OK, ): JsonResponse { - $request = Request::createFromGlobals(); - // Build base URL for pagination links $baseUrl = $request->getPathInfo(); if ($request->getQueryString()) { diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/AttachmentController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/AttachmentController.php index 49b3f62ff0..c8aebf8104 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/AttachmentController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/AttachmentController.php @@ -140,8 +140,9 @@ public function list(Request $request): JsonResponse $faqId = (int) Filter::filterVar($request->attributes->get(key: 'faqId'), FILTER_VALIDATE_INT); // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id', 'filename', 'mime_type', 'filesize', 'created'], defaultField: 'id', defaultOrder: 'asc', @@ -162,7 +163,13 @@ public function list(Request $request): JsonResponse $total = AttachmentFactory::countByRecordId($this->configuration, $faqId); // Return paginated response with envelope - return $this->paginatedResponse(data: $attachments, total: $total, pagination: $pagination, sort: $sort); + return $this->paginatedResponse( + $request, + data: $attachments, + total: $total, + pagination: $pagination, + sort: $sort, + ); } catch (AttachmentException) { return $this->errorResponse( message: 'Failed to fetch attachments', diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/CategoryController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/CategoryController.php index 77a75f7b69..5d7ac6fe10 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/CategoryController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/CategoryController.php @@ -35,6 +35,18 @@ final class CategoryController extends AbstractApiController { + private readonly Language $language; + + public function __construct(?Language $language = null) + { + parent::__construct(); + $resolvedLanguage = $language ?? $this->container?->get(id: 'phpmyfaq.language'); + if (!$resolvedLanguage instanceof Language) { + throw new \RuntimeException('Language service "phpmyfaq.language" is not available.'); + } + $this->language = $resolvedLanguage; + } + /** * @throws \Exception */ @@ -126,11 +138,10 @@ enum: ['id', 'name', 'parent_id', 'active'], }'), )] #[Route(path: 'v3.2/categories', name: 'api.categories.list', methods: ['GET'])] - public function list(): JsonResponse + public function list(?Request $request = null): JsonResponse { - /** @var Language $language */ - $language = $this->container->get(id: 'phpmyfaq.language'); - $currentLanguage = $language->setLanguageByAcceptLanguage(); + $request ??= Request::createFromGlobals(); + $currentLanguage = $this->language->setLanguageByAcceptLanguage(); [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); @@ -142,8 +153,9 @@ public function list(): JsonResponse $onlyActive = (bool) $this->configuration->get('api.onlyActiveCategories'); // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id', 'name', 'parent_id', 'active'], defaultField: 'id', defaultOrder: 'asc', @@ -162,6 +174,7 @@ public function list(): JsonResponse $total = $category->countCategories(activeOnly: $onlyActive); return $this->paginatedResponse( + $request, data: array_values($categories), total: $total, pagination: $pagination, diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/CommentController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/CommentController.php index a24e58703a..a7ae983c8d 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/CommentController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/CommentController.php @@ -30,6 +30,18 @@ final class CommentController extends AbstractApiController { + private readonly Comments $comments; + + public function __construct(?Comments $comments = null) + { + parent::__construct(); + $resolvedComments = $comments ?? $this->container?->get(id: 'phpmyfaq.comments'); + if (!$resolvedComments instanceof Comments) { + throw new \RuntimeException('Comments service "phpmyfaq.comments" is not available.'); + } + $this->comments = $resolvedComments; + } + /** * @throws Exception */ @@ -133,19 +145,17 @@ public function list(Request $request): JsonResponse { $recordId = (int) Filter::filterVar($request->attributes->get(key: 'recordId'), FILTER_VALIDATE_INT); - /** @var Comments $comments */ - $comments = $this->container->get(id: 'phpmyfaq.comments'); - // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id_comment', 'id', 'usr', 'email', 'datum'], defaultField: 'id_comment', defaultOrder: 'asc', ); // Get paginated comments - $result = $comments->getCommentsDataPaginated( + $result = $this->comments->getCommentsDataPaginated( referenceId: $recordId, type: CommentType::FAQ, limit: $pagination->limit, @@ -155,8 +165,8 @@ public function list(Request $request): JsonResponse ); // Get total count - $total = $comments->countComments($recordId, CommentType::FAQ); + $total = $this->comments->countComments($recordId, CommentType::FAQ); - return $this->paginatedResponse(data: $result, total: $total, pagination: $pagination, sort: $sort); + return $this->paginatedResponse($request, data: $result, total: $total, pagination: $pagination, sort: $sort); } } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/FaqController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/FaqController.php index d0aa4e8cbf..7f49327aeb 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/FaqController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/FaqController.php @@ -24,7 +24,11 @@ use OpenApi\Attributes as OA; use phpMyFAQ\Category; use phpMyFAQ\Entity\FaqEntity; +use phpMyFAQ\Faq; +use phpMyFAQ\Faq\MetaData as FaqMetaData; +use phpMyFAQ\Faq\Statistics as FaqStatistics; use phpMyFAQ\Filter; +use phpMyFAQ\Tags; use phpMyFAQ\User\CurrentUser; use stdClass; use Symfony\Component\HttpFoundation\JsonResponse; @@ -34,6 +38,38 @@ final class FaqController extends AbstractApiController { + private readonly Faq $faq; + private readonly Tags $tags; + private readonly FaqStatistics $faqStatistics; + private readonly FaqMetaData $faqMetaData; + + public function __construct( + ?Faq $faq = null, + ?Tags $tags = null, + ?FaqStatistics $faqStatistics = null, + ?FaqMetaData $faqMetaData = null, + ) { + parent::__construct(); + $resolvedFaq = $faq ?? $this->container?->get(id: 'phpmyfaq.faq'); + $resolvedTags = $tags ?? $this->container?->get(id: 'phpmyfaq.tags'); + $resolvedFaqStatistics = $faqStatistics ?? $this->container?->get(id: 'phpmyfaq.faq.statistics'); + $resolvedFaqMetaData = $faqMetaData ?? $this->container?->get(id: 'phpmyfaq.faq.metadata'); + + if ( + !$resolvedFaq instanceof Faq + || !$resolvedTags instanceof Tags + || !$resolvedFaqStatistics instanceof FaqStatistics + || !$resolvedFaqMetaData instanceof FaqMetaData + ) { + throw new \RuntimeException('FAQ-related services are not available in the container.'); + } + + $this->faq = $resolvedFaq; + $this->tags = $resolvedTags; + $this->faqStatistics = $resolvedFaqStatistics; + $this->faqMetaData = $resolvedFaqMetaData; + } + /** * @throws \phpMyFAQ\Core\Exception|Exception */ @@ -77,14 +113,13 @@ public function getByCategoryId(Request $request): JsonResponse { [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->setUser($currentUser); - $faq->setGroups($currentGroups); + $this->faq->setUser($currentUser); + $this->faq->setGroups($currentGroups); $categoryId = (int) Filter::filterVar($request->attributes->get(key: 'categoryId'), FILTER_VALIDATE_INT); try { - $result = $faq->getAllAvailableFaqsByCategoryId($categoryId); + $result = $this->faq->getAllAvailableFaqsByCategoryId($categoryId); return $this->json($result, Response::HTTP_OK); } catch (Exception|CommonMarkException $exception) { return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR); @@ -150,15 +185,14 @@ public function getById(Request $request): JsonResponse { [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->setUser($currentUser); - $faq->setGroups($currentGroups); + $this->faq->setUser($currentUser); + $this->faq->setGroups($currentGroups); $faqId = (int) Filter::filterVar($request->attributes->get(key: 'faqId'), FILTER_VALIDATE_INT); $categoryId = (int) Filter::filterVar($request->attributes->get(key: 'categoryId'), FILTER_VALIDATE_INT); $onlyActive = (bool) $this->configuration->get('api.onlyActiveFaqs'); - $result = $faq->getFaqByIdAndCategoryId($faqId, $categoryId); + $result = $this->faq->getFaqByIdAndCategoryId($faqId, $categoryId); if ( (is_countable($result) ? count($result) : 0) === 0 @@ -216,17 +250,15 @@ public function getByTagId(Request $request): JsonResponse { [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->setUser($currentUser); - $faq->setGroups($currentGroups); + $this->faq->setUser($currentUser); + $this->faq->setGroups($currentGroups); $tagId = (int) Filter::filterVar($request->attributes->get(key: 'tagId'), FILTER_VALIDATE_INT); - $tags = $this->container->get(id: 'phpmyfaq.tags'); - $recordIds = $tags->getFaqsByTagId($tagId); + $recordIds = $this->tags->getFaqsByTagId($tagId); try { - $result = $faq->getFaqsByIds($recordIds); + $result = $this->faq->getFaqsByIds($recordIds); return $this->json($result, Response::HTTP_OK); } catch (Exception $exception) { return $this->json(['error' => $exception->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR); @@ -268,11 +300,10 @@ public function getPopular(): JsonResponse { [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faqStatistics = $this->container->get(id: 'phpmyfaq.faq.statistics'); - $faqStatistics->setUser($currentUser); - $faqStatistics->setGroups($currentGroups); + $this->faqStatistics->setUser($currentUser); + $this->faqStatistics->setGroups($currentGroups); - $result = array_values($faqStatistics->getTopTenData()); + $result = array_values($this->faqStatistics->getTopTenData()); if ((is_countable($result) ? count($result) : 0) === 0) { $this->json($result, Response::HTTP_NOT_FOUND); @@ -317,11 +348,10 @@ public function getLatest(): JsonResponse { [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faqStatistics = $this->container->get(id: 'phpmyfaq.faq.statistics'); - $faqStatistics->setUser($currentUser); - $faqStatistics->setGroups($currentGroups); + $this->faqStatistics->setUser($currentUser); + $this->faqStatistics->setGroups($currentGroups); - $result = array_values($faqStatistics->getLatestData()); + $result = array_values($this->faqStatistics->getLatestData()); if ((is_countable($result) ? count($result) : 0) === 0) { return $this->json($result, Response::HTTP_NOT_FOUND); @@ -365,11 +395,10 @@ public function getTrending(): JsonResponse { [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faqStatistics = $this->container->get(id: 'phpmyfaq.faq.statistics'); - $faqStatistics->setUser($currentUser); - $faqStatistics->setGroups($currentGroups); + $this->faqStatistics->setUser($currentUser); + $this->faqStatistics->setGroups($currentGroups); - $result = array_values($faqStatistics->getTrendingData()); + $result = array_values($this->faqStatistics->getTrendingData()); if ((is_countable($result) ? count($result) : 0) === 0) { $this->json($result, Response::HTTP_NOT_FOUND); @@ -418,11 +447,10 @@ public function getSticky(): JsonResponse { [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->setUser($currentUser); - $faq->setGroups($currentGroups); + $this->faq->setUser($currentUser); + $this->faq->setGroups($currentGroups); - $result = array_values($faq->getStickyFaqsData()); + $result = array_values($this->faq->getStickyFaqsData()); if ((is_countable($result) ? count($result) : 0) === 0) { return $this->json($result, Response::HTTP_NOT_FOUND); @@ -531,17 +559,18 @@ enum: ['id', 'title', 'author', 'updated', 'created'], }', ))] #[Route(path: 'v3.2/faqs', name: 'api.faqs.list', methods: ['GET'])] - public function list(): JsonResponse + public function list(?Request $request = null): JsonResponse { + $request ??= Request::createFromGlobals(); [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->setUser($currentUser); - $faq->setGroups($currentGroups); + $this->faq->setUser($currentUser); + $this->faq->setGroups($currentGroups); // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id', 'title', 'author', 'updated', 'created'], defaultField: 'id', defaultOrder: 'asc', @@ -550,8 +579,8 @@ public function list(): JsonResponse $onlyActive = (bool) $this->configuration->get('api.onlyActiveFaqs'); $ignoreOrphanedFaqs = (bool) $this->configuration->get('api.ignoreOrphanedFaqs'); - // Get all FAQs (this populates $faq->faqRecords) - $faq->getAllFaqs( + // Get all FAQs (this populates $this->faq->faqRecords) + $this->faq->getAllFaqs( FAQ_SORTING_TYPE_CATID_FAQID, [ 'lang' => $this->configuration->getLanguage()->getLanguage(), @@ -561,7 +590,7 @@ public function list(): JsonResponse $sort->getOrderSql(), ); - $allFaqs = $faq->faqRecords; + $allFaqs = $this->faq->faqRecords; $total = is_countable($allFaqs) ? count($allFaqs) : 0; // Apply sorting if needed (basic client-side sorting) @@ -579,6 +608,7 @@ public function list(): JsonResponse $result = array_slice($allFaqs, $pagination->offset, $pagination->limit); return $this->paginatedResponse( + $request, data: array_values($result), total: $total, pagination: $pagination, @@ -683,9 +713,8 @@ public function create(Request $request): JsonResponse $category->setGroups($currentGroups); $category->setLanguage($currentLanguage); - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->setUser($currentUser); - $faq->setGroups($currentGroups); + $this->faq->setUser($currentUser); + $this->faq->setGroups($currentGroups); $languageCode = Filter::filterVar($data->language, FILTER_SANITIZE_SPECIAL_CHARS); $categoryId = Filter::filterVar($data->{'category-id'}, FILTER_VALIDATE_INT); @@ -718,7 +747,7 @@ public function create(Request $request): JsonResponse $categoryId = $categoryIdFound; } - if ($faq->hasTitleAHash($question)) { + if ($this->faq->hasTitleAHash($question)) { $result = [ 'stored' => false, 'error' => 'It is not allowed, that the question title contains a hash.', @@ -743,10 +772,13 @@ public function create(Request $request): JsonResponse ->setComment(comment: false) ->setNotes(notes: ''); - $faqEntity = $faq->create($faqData); + $faqEntity = $this->faq->create($faqData); - $faqMetaData = $this->container->get(id: 'phpmyfaq.faq.metadata'); - $faqMetaData->setFaqId($faqEntity->getId())->setFaqLanguage($languageCode)->setCategories($categories)->save(); + $this->faqMetaData + ->setFaqId($faqEntity->getId()) + ->setFaqLanguage($languageCode) + ->setCategories($categories) + ->save(); return $this->json(['stored' => true], Response::HTTP_CREATED); } @@ -842,9 +874,8 @@ public function update(Request $request): JsonResponse $category->setGroups($currentGroups); $category->setLanguage($currentLanguage); - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->setUser($currentUser); - $faq->setGroups($currentGroups); + $this->faq->setUser($currentUser); + $this->faq->setGroups($currentGroups); $faqId = Filter::filterVar($data->{'faq-id'}, FILTER_VALIDATE_INT); $languageCode = Filter::filterVar($data->language, FILTER_SANITIZE_SPECIAL_CHARS); @@ -856,7 +887,7 @@ public function update(Request $request): JsonResponse $isActive = Filter::filterVar($data->{'is-active'}, FILTER_VALIDATE_BOOLEAN); $isSticky = Filter::filterVar($data->{'is-sticky'}, FILTER_VALIDATE_BOOLEAN); - if ($faq->hasTitleAHash($question)) { + if ($this->faq->hasTitleAHash($question)) { $result = [ 'stored' => false, 'error' => 'It is not allowed, that the question title contains a hash.', @@ -882,7 +913,7 @@ public function update(Request $request): JsonResponse ->setComment(comment: false) ->setNotes(notes: ''); - $faq->update($faqEntity); + $this->faq->update($faqEntity); return $this->json(['stored' => true], Response::HTTP_OK); } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php index 3991847963..6fdf03b26e 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php @@ -21,12 +21,29 @@ use Exception; use OpenApi\Attributes as OA; +use phpMyFAQ\Glossary; +use phpMyFAQ\Language; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; final class GlossaryController extends AbstractApiController { + private readonly Glossary $glossary; + private readonly Language $language; + + public function __construct(?Glossary $glossary = null, ?Language $language = null) + { + parent::__construct(); + $resolvedGlossary = $glossary ?? $this->container?->get(id: 'phpmyfaq.glossary'); + $resolvedLanguage = $language ?? $this->container?->get(id: 'phpmyfaq.language'); + if (!$resolvedGlossary instanceof Glossary || !$resolvedLanguage instanceof Language) { + throw new \RuntimeException('Glossary services are not available in the container.'); + } + $this->glossary = $resolvedGlossary; + $this->language = $resolvedLanguage; + } + /** * @throws Exception */ @@ -114,24 +131,23 @@ final class GlossaryController extends AbstractApiController #[Route(path: 'v3.2/glossary', name: 'api.glossary.list', methods: ['GET'])] public function list(Request $request): JsonResponse { - $glossary = $this->container->get(id: 'phpmyfaq.glossary'); - $language = $this->container->get(id: 'phpmyfaq.language'); - $currentLanguage = $language->setLanguageByAcceptLanguage(); + $currentLanguage = $this->language->setLanguageByAcceptLanguage(); if ($currentLanguage !== false) { - $glossary->setLanguage($currentLanguage); + $this->glossary->setLanguage($currentLanguage); } // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id', 'item', 'definition'], defaultField: 'item', defaultOrder: 'asc', ); // Get all glossary items - $allItems = $glossary->fetchAll(); + $allItems = $this->glossary->fetchAll(); $total = is_countable($allItems) ? count($allItems) : 0; // Apply sorting if needed @@ -149,6 +165,7 @@ public function list(Request $request): JsonResponse $result = array_slice($allItems, $pagination->offset, $pagination->limit); return $this->paginatedResponse( + $request, data: array_values($result), total: $total, pagination: $pagination, diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/GroupController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/GroupController.php index a1f944e7c7..b75b7d9a7b 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/GroupController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/GroupController.php @@ -22,6 +22,7 @@ use OpenApi\Attributes as OA; use phpMyFAQ\Permission\MediumPermission; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; final class GroupController extends AbstractApiController { @@ -108,16 +109,22 @@ final class GroupController extends AbstractApiController ))] #[OA\Response(response: 401, description: 'If the user is not authenticated.')] #[Route(path: 'v3.2/groups', name: 'api.groups', methods: ['GET'])] - public function list(): JsonResponse + public function list(?Request $request = null): JsonResponse { $this->userIsAuthenticated(); + $request ??= Request::createFromGlobals(); $mediumPermission = new MediumPermission($this->configuration); $allGroups = $mediumPermission->getAllGroups($this->currentUser); // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); - $sort = $this->getSortRequest(allowedFields: ['group-id'], defaultField: 'group-id', defaultOrder: 'asc'); + $pagination = $this->getPaginationRequest($request); + $sort = $this->getSortRequest( + $request, + allowedFields: ['group-id'], + defaultField: 'group-id', + defaultOrder: 'asc', + ); $total = is_countable($allGroups) ? count($allGroups) : 0; @@ -132,6 +139,7 @@ public function list(): JsonResponse $result = array_slice($allGroups, $pagination->offset, $pagination->limit); return $this->paginatedResponse( + $request, data: array_values($result), total: $total, pagination: $pagination, diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php index 28ba3ba3fc..08cae7f51d 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php @@ -22,6 +22,7 @@ use OpenApi\Attributes as OA; use phpMyFAQ\News; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; final class NewsController extends AbstractApiController @@ -116,11 +117,14 @@ enum: ['id', 'datum', 'header', 'author_name'], }'), )] #[Route('/api/v3.2/news', name: 'api_news_list', methods: ['GET'])] - public function list(): JsonResponse + public function list(?Request $request = null): JsonResponse { + $request ??= Request::createFromGlobals(); + // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id', 'datum', 'header', 'author_name'], defaultField: 'datum', defaultOrder: 'desc', @@ -140,6 +144,6 @@ public function list(): JsonResponse // Get total count $total = $news->countLatestData(); - return $this->paginatedResponse(data: $data, total: $total, pagination: $pagination, sort: $sort); + return $this->paginatedResponse($request, data: $data, total: $total, pagination: $pagination, sort: $sort); } } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/OpenQuestionController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/OpenQuestionController.php index a3c39af6c5..c6c756d72b 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/OpenQuestionController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/OpenQuestionController.php @@ -22,10 +22,23 @@ use OpenApi\Attributes as OA; use phpMyFAQ\Question; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; final class OpenQuestionController extends AbstractApiController { + private readonly Question $question; + + public function __construct(?Question $question = null) + { + parent::__construct(); + $resolvedQuestion = $question ?? $this->container?->get(id: 'phpmyfaq.question'); + if (!$resolvedQuestion instanceof Question) { + throw new \RuntimeException('Question service "phpmyfaq.question" is not available.'); + } + $this->question = $resolvedQuestion; + } + /** * @throws \Exception */ @@ -118,23 +131,22 @@ enum: ['id', 'username', 'created', 'categoryId'], }', ))] #[Route('/api/v3.2/open-questions', name: 'api_open_questions', methods: ['GET'])] - public function list(): JsonResponse + public function list(?Request $request = null): JsonResponse { - /** @var Question $question */ - $question = $this->container?->get(id: 'phpmyfaq.question'); - + $request ??= Request::createFromGlobals(); $onlyPublic = (bool) $this->configuration->get('api.onlyPublicQuestions'); // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id', 'username', 'created', 'categoryId'], defaultField: 'id', defaultOrder: 'asc', ); // Get all open questions - $allQuestions = $question->getAll($onlyPublic); + $allQuestions = $this->question->getAll($onlyPublic); $total = is_countable($allQuestions) ? count($allQuestions) : 0; // Apply sorting if needed @@ -152,6 +164,7 @@ public function list(): JsonResponse $result = array_slice($allQuestions, $pagination->offset, $pagination->limit); return $this->paginatedResponse( + $request, data: array_values($result), total: $total, pagination: $pagination, diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/QuestionController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/QuestionController.php index a3486525f0..49a5b436ee 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/QuestionController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/QuestionController.php @@ -21,26 +21,28 @@ use OpenApi\Attributes as OA; use phpMyFAQ\Category; -use phpMyFAQ\Controller\AbstractController; use phpMyFAQ\Core\Exception; use phpMyFAQ\Entity\QuestionEntity; use phpMyFAQ\Filter; +use phpMyFAQ\Notification; use phpMyFAQ\Question; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Routing\Attribute\Route; -final class QuestionController extends AbstractController +final class QuestionController extends AbstractApiController { - public function __construct() + private readonly Notification $notification; + + public function __construct(?Notification $notification = null) { parent::__construct(); - - if (!$this->isApiEnabled()) { - throw new UnauthorizedHttpException(challenge: 'API is not enabled'); + $resolvedNotification = $notification ?? $this->container?->get(id: 'phpmyfaq.notification'); + if (!$resolvedNotification instanceof Notification) { + throw new \RuntimeException('Notification service "phpmyfaq.notification" is not available.'); } + $this->notification = $resolvedNotification; } /** @@ -119,8 +121,7 @@ public function create(Request $request): JsonResponse $categories = $category->getAllCategories(); - $notification = $this->container->get('phpmyfaq.notification'); - $notification->sendQuestionSuccessMail($questionEntity, $categories); + $this->notification->sendQuestionSuccessMail($questionEntity, $categories); return $this->json(['stored' => true], Response::HTTP_CREATED); } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php index 3b5cf56f66..4857aeab2f 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php @@ -25,6 +25,7 @@ use phpMyFAQ\Faq\Permission; use phpMyFAQ\Filter; use phpMyFAQ\Link\Util\TitleSlugifier; +use phpMyFAQ\Search; use phpMyFAQ\Search\SearchResultSet; use phpMyFAQ\Utils; use Symfony\Component\HttpFoundation\JsonResponse; @@ -34,6 +35,18 @@ final class SearchController extends AbstractApiController { + private readonly Search $search; + + public function __construct(?Search $search = null) + { + parent::__construct(); + $resolvedSearch = $search ?? $this->container?->get(id: 'phpmyfaq.search'); + if (!$resolvedSearch instanceof Search) { + throw new \RuntimeException('Search service "phpmyfaq.search" is not available.'); + } + $this->search = $resolvedSearch; + } + /** * @throws Exception */ @@ -129,19 +142,19 @@ final class SearchController extends AbstractApiController #[Route(path: 'v3.2/search', name: 'api.search', methods: ['GET'])] public function search(Request $request): JsonResponse { - $search = $this->container->get(id: 'phpmyfaq.search'); - $search->setCategory(new Category($this->configuration)); + $this->search->setCategory(new Category($this->configuration)); $faqPermission = new Permission($this->configuration); $searchResultSet = new SearchResultSet($this->currentUser, $faqPermission, $this->configuration); $searchString = Filter::filterVar($request->query->get(key: 'q'), FILTER_SANITIZE_SPECIAL_CHARS); - $searchResults = $search->search(searchTerm: $searchString, allLanguages: false); + $searchResults = $this->search->search(searchTerm: $searchString, allLanguages: false); $searchResultSet->reviewResultSet($searchResults); // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['id', 'question', 'category_id'], defaultField: 'id', defaultOrder: 'asc', @@ -180,6 +193,7 @@ public function search(Request $request): JsonResponse $result = array_slice($allResults, $pagination->offset, $pagination->limit); return $this->paginatedResponse( + $request, data: array_values($result), total: $total, pagination: $pagination, @@ -187,7 +201,7 @@ public function search(Request $request): JsonResponse ); } - return $this->paginatedResponse(data: [], total: 0, pagination: $pagination, sort: $sort); + return $this->paginatedResponse($request, data: [], total: 0, pagination: $pagination, sort: $sort); } /** @@ -226,7 +240,7 @@ public function search(Request $request): JsonResponse #[Route(path: 'v3.2/searches/popular', name: 'api.search.popular', methods: ['GET'])] public function popular(): JsonResponse { - $result = $this->container->get(id: 'phpmyfaq.search')->getMostPopularSearches(numResults: 7, withLang: true); + $result = $this->search->getMostPopularSearches(numResults: 7, withLang: true); if ((is_countable($result) ? count($result) : 0) === 0) { return $this->json([], Response::HTTP_NOT_FOUND); diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/TagController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/TagController.php index 0cf5019251..1855b6e887 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/TagController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/TagController.php @@ -20,12 +20,26 @@ namespace phpMyFAQ\Controller\Api; use OpenApi\Attributes as OA; +use phpMyFAQ\Tags; use phpMyFAQ\User\CurrentUser; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; final class TagController extends AbstractApiController { + private readonly Tags $tags; + + public function __construct(?Tags $tags = null) + { + parent::__construct(); + $resolvedTags = $tags ?? $this->container?->get(id: 'phpmyfaq.tags'); + if (!$resolvedTags instanceof Tags) { + throw new \RuntimeException('Tags service "phpmyfaq.tags" is not available.'); + } + $this->tags = $resolvedTags; + } + /** * @throws \Exception */ @@ -106,23 +120,25 @@ final class TagController extends AbstractApiController }', ))] #[Route('/api/v3.2/tags', name: 'api.tags', methods: ['GET'])] - public function list(): JsonResponse + public function list(?Request $request = null): JsonResponse { - $tags = $this->container->get(id: 'phpmyfaq.tags'); + $request ??= Request::createFromGlobals(); + [$currentUser, $currentGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser); - $tags->setUser($currentUser); - $tags->setGroups($currentGroups); + $this->tags->setUser($currentUser); + $this->tags->setGroups($currentGroups); // Get pagination and sorting parameters - $pagination = $this->getPaginationRequest(); + $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( + $request, allowedFields: ['tagId', 'tagName', 'tagFrequency'], defaultField: 'tagFrequency', defaultOrder: 'desc', ); // Get all tags (we'll use a high limit to get all tags) - $allTags = $tags->getPopularTagsAsArray(limit: 1000); + $allTags = $this->tags->getPopularTagsAsArray(limit: 1000); $total = is_countable($allTags) ? count($allTags) : 0; // Apply sorting if needed @@ -140,6 +156,7 @@ public function list(): JsonResponse $result = array_slice($allTags, $pagination->offset, $pagination->limit); return $this->paginatedResponse( + $request, data: array_values($result), total: $total, pagination: $pagination, diff --git a/phpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.php b/phpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.php new file mode 100644 index 0000000000..ea79f89dbb --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.php @@ -0,0 +1,60 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-16 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Controller; + +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Controller\ControllerResolver; + +class ContainerControllerResolver extends ControllerResolver +{ + public function __construct( + private readonly ContainerInterface $container, + ) { + parent::__construct(); + } + + #[\Override] + public function getController(Request $request): callable|false + { + $controller = parent::getController($request); + + if ($controller === false) { + return false; + } + + // Handle array-style callables [object, method] + if (is_array($controller) && isset($controller[0]) && is_object($controller[0])) { + $controllerClass = $controller[0]::class; + + if ($this->container->has($controllerClass)) { + $controller[0] = $this->container->get($controllerClass); + } + + return $controller; + } + + return $controller; + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php index 42c90fa0e3..881f96e3e4 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php @@ -51,10 +51,22 @@ public function create(Request $request): JsonResponse throw new Exception('Missing vote value'); } + if (!isset($data->id)) { + throw new Exception('Missing FAQ ID'); + } + $faqId = Filter::filterVar($data->id ?? null, FILTER_VALIDATE_INT, 0); $vote = Filter::filterVar($data->value, FILTER_VALIDATE_INT); $userIp = Filter::filterVar($request->server->get('REMOTE_ADDR'), FILTER_VALIDATE_IP) ?? ''; + if ($faqId <= 0) { + throw new Exception('Missing FAQ ID'); + } + + if (!isset($vote) || $vote < 1 || $vote > 5) { + throw new Exception('Invalid vote value'); + } + if (isset($vote) && $rating->check($faqId, $userIp) && $vote > 0 && $vote < 6) { $session->userTracking('save_voting', $faqId); diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php index 2e8e3c889f..f7cfecc257 100644 --- a/phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php +++ b/phpmyfaq/src/phpMyFAQ/EventListener/ApiExceptionListener.php @@ -28,6 +28,7 @@ use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Routing\Exception\ResourceNotFoundException; @@ -51,7 +52,7 @@ public function onKernelException(ExceptionEvent $event): void $throwable = $event->getThrowable(); [$status, $defaultDetail] = match (true) { - $throwable instanceof ResourceNotFoundException => [ + $throwable instanceof ResourceNotFoundException, $throwable instanceof NotFoundHttpException => [ Response::HTTP_NOT_FOUND, 'The requested resource was not found.', ], diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php index c4d80d76d8..6567933714 100644 --- a/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php +++ b/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php @@ -98,7 +98,7 @@ private function initializeTranslation(string $currentLanguage): void ->setCurrentLanguage($currentLanguage) ->setMultiByteLanguage(); } catch (Exception $exception) { - throw new Exception($exception->getMessage()); + throw $exception; } } } diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php index e8bb4b30aa..f088b3550d 100644 --- a/phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php +++ b/phpmyfaq/src/phpMyFAQ/EventListener/RouterListener.php @@ -22,6 +22,10 @@ namespace phpMyFAQ\EventListener; use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Routing\Exception\MethodNotAllowedException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Symfony\Component\Routing\Matcher\UrlMatcher; use Symfony\Component\Routing\RequestContext; use Symfony\Component\Routing\RouteCollection; @@ -50,7 +54,18 @@ public function onKernelRequest(RequestEvent $event): void $requestContext->fromRequest($request); $urlMatcher = new UrlMatcher($this->routes, $requestContext); - $parameters = $urlMatcher->match($request->getPathInfo()); + try { + $parameters = $urlMatcher->match($request->getPathInfo()); + } catch (ResourceNotFoundException $exception) { + throw new NotFoundHttpException($exception->getMessage(), $exception); + } catch (MethodNotAllowedException $exception) { + throw new MethodNotAllowedHttpException( + $exception->getAllowedMethods(), + $exception->getMessage(), + $exception, + ); + } + $request->attributes->add($parameters); } } diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php index a07ca060a7..57f2a656f5 100644 --- a/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php +++ b/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php @@ -30,6 +30,7 @@ use Symfony\Component\HttpKernel\Controller\ArgumentResolver; use Symfony\Component\HttpKernel\Controller\ControllerResolver; use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Throwable; @@ -40,6 +41,8 @@ public function onKernelException(ExceptionEvent $event): void { $request = $event->getRequest(); $pathInfo = $request->getPathInfo(); + $baseUrl = '/' . ltrim(rtrim($request->getBaseUrl(), '/'), '/'); + $loginPath = $baseUrl === '/' ? '/login' : $baseUrl . '/login'; // Skip API requests — handled by ApiExceptionListener if (str_starts_with($pathInfo, '/api/') || $request->attributes->get('_api_context', false)) { @@ -49,8 +52,10 @@ public function onKernelException(ExceptionEvent $event): void $throwable = $event->getThrowable(); $response = match (true) { - $throwable instanceof ResourceNotFoundException => $this->handleNotFound($event), - $throwable instanceof UnauthorizedHttpException => new RedirectResponse(url: './login'), + $throwable instanceof ResourceNotFoundException, + $throwable instanceof NotFoundHttpException, + => $this->handleNotFound($event), + $throwable instanceof UnauthorizedHttpException => new RedirectResponse(url: $loginPath), $throwable instanceof ForbiddenException => $this->handleErrorResponse( 'An error occurred: :message at line :line at :file', 'Forbidden', diff --git a/phpmyfaq/src/phpMyFAQ/Kernel.php b/phpmyfaq/src/phpMyFAQ/Kernel.php index 12803e4712..bd59f444c2 100644 --- a/phpmyfaq/src/phpMyFAQ/Kernel.php +++ b/phpmyfaq/src/phpMyFAQ/Kernel.php @@ -22,6 +22,7 @@ namespace phpMyFAQ; +use phpMyFAQ\Controller\ContainerControllerResolver; use phpMyFAQ\EventListener\ApiExceptionListener; use phpMyFAQ\EventListener\ControllerContainerListener; use phpMyFAQ\EventListener\LanguageListener; @@ -39,7 +40,6 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Controller\ArgumentResolver; -use Symfony\Component\HttpKernel\Controller\ControllerResolver; use Symfony\Component\HttpKernel\HttpKernel; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\KernelEvents; @@ -116,8 +116,12 @@ private function buildContainer(): ContainerBuilder try { $phpFileLoader->load(resource: 'services.php'); - } catch (\Exception $exception) { - error_log('Kernel: Failed to load services.php: ' . $exception->getMessage()); + } catch (\Throwable $exception) { + throw new \RuntimeException( + 'Kernel boot failed while loading "services.php"; cannot resolve "phpmyfaq.event_dispatcher".', + 0, + $exception, + ); } // Register Forms services @@ -158,7 +162,7 @@ private function createHttpKernel(): HttpKernel $this->registerEventListeners($dispatcher); - $controllerResolver = new ControllerResolver(); + $controllerResolver = new ContainerControllerResolver($this->container); $requestStack = new RequestStack(); $argumentResolver = new ArgumentResolver(); diff --git a/phpmyfaq/src/phpMyFAQ/User/UserSession.php b/phpmyfaq/src/phpMyFAQ/User/UserSession.php index e45f8a4754..67d433767a 100644 --- a/phpmyfaq/src/phpMyFAQ/User/UserSession.php +++ b/phpmyfaq/src/phpMyFAQ/User/UserSession.php @@ -150,8 +150,9 @@ public function userTracking(string $action, int|string|null $data = null): void $this->setCurrentSessionId(0); } + $userAgent = (string) $request->headers->get('user-agent'); foreach ($this->getBotIgnoreList() as $bot) { - if (!Strings::strstr($request->headers->get('user-agent'), $bot)) { + if (!Strings::strstr($userAgent, $bot)) { continue; } @@ -169,6 +170,14 @@ public function userTracking(string $action, int|string|null $data = null): void // clean up as well $remoteAddress = preg_replace('([^0-9a-z:.]+)i', '', (string) $remoteAddress); + if ( + !is_string($remoteAddress) + || $remoteAddress === '' + || filter_var($remoteAddress, FILTER_VALIDATE_IP) === false + ) { + $remoteAddress = '127.0.0.1'; + } + // Anonymize IP address $remoteAddress = IpUtils::anonymize($remoteAddress); diff --git a/tests/phpMyFAQ/Controller/AbstractControllerTest.php b/tests/phpMyFAQ/Controller/AbstractControllerTest.php index 53f75bbcf1..84d7f01127 100644 --- a/tests/phpMyFAQ/Controller/AbstractControllerTest.php +++ b/tests/phpMyFAQ/Controller/AbstractControllerTest.php @@ -11,8 +11,10 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException; use Twig\Extension\ExtensionInterface; use Twig\TwigFilter; @@ -452,6 +454,55 @@ public function testIsSecuredSucceedsWhenLoginNotRequired(): void $this->assertTrue(true); } + public function testSetContainerReEvaluatesIsSecuredWhenContainerChanges(): void + { + $controller = new class() extends AbstractController {}; + + $session = $this->createMock(SessionInterface::class); + + $firstConfiguration = $this->createMock(Configuration::class); + $firstConfiguration->expects($this->once())->method('getTemplateSet')->willReturn('default'); + + $firstCurrentUser = $this->createMock(CurrentUser::class); + $firstCurrentUser->expects($this->once())->method('isLoggedIn')->willReturn(true); + + $firstContainer = $this->createMock(ContainerInterface::class); + $firstContainer + ->method('get') + ->willReturnCallback(static function (string $id) use ($firstConfiguration, $firstCurrentUser, $session) { + return match ($id) { + 'phpmyfaq.configuration' => $firstConfiguration, + 'phpmyfaq.user.current_user' => $firstCurrentUser, + 'session' => $session, + default => throw new \InvalidArgumentException(sprintf('Unexpected service id "%s".', $id)), + }; + }); + + $controller->setContainer($firstContainer); + + $secondConfiguration = $this->createMock(Configuration::class); + $secondConfiguration->expects($this->once())->method('getTemplateSet')->willReturn('default'); + $secondConfiguration->expects($this->once())->method('get')->with('security.enableLoginOnly')->willReturn(true); + + $secondCurrentUser = $this->createMock(CurrentUser::class); + $secondCurrentUser->expects($this->once())->method('isLoggedIn')->willReturn(false); + + $secondContainer = $this->createMock(ContainerInterface::class); + $secondContainer + ->method('get') + ->willReturnCallback(static function (string $id) use ($secondConfiguration, $secondCurrentUser, $session) { + return match ($id) { + 'phpmyfaq.configuration' => $secondConfiguration, + 'phpmyfaq.user.current_user' => $secondCurrentUser, + 'session' => $session, + default => throw new \InvalidArgumentException(sprintf('Unexpected service id "%s".', $id)), + }; + }); + + $this->expectException(UnauthorizedHttpException::class); + $controller->setContainer($secondContainer); + } + public function testCreateContainerReturnsContainerBuilder(): void { $container = $this->abstractController->createContainerPublic(); diff --git a/tests/phpMyFAQ/Controller/Frontend/Api/VotingControllerTest.php b/tests/phpMyFAQ/Controller/Frontend/Api/VotingControllerTest.php index e8da5d80c5..383068d9e6 100644 --- a/tests/phpMyFAQ/Controller/Frontend/Api/VotingControllerTest.php +++ b/tests/phpMyFAQ/Controller/Frontend/Api/VotingControllerTest.php @@ -141,7 +141,7 @@ public function testCreateWithZeroVoteValueThrowsException(): void /** * @throws Exception */ - public function testCreateWithValidVoteValueThrowsException(): void + public function testCreateWithValidVoteValueReturnsJsonResponseOrThrowsException(): void { $requestData = json_encode([ 'id' => 1, @@ -153,7 +153,12 @@ public function testCreateWithValidVoteValueThrowsException(): void $controller = new VotingController(); - $this->expectException(\Exception::class); - $controller->create($request); + try { + $response = $controller->create($request); + $this->assertNotNull($response); + $this->assertContains($response->getStatusCode(), [200, 400]); + } catch (\Exception $exception) { + $this->assertNotEmpty($exception->getMessage()); + } } } diff --git a/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php b/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php index 9beefaeda3..e51cbca713 100644 --- a/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php +++ b/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php @@ -43,12 +43,7 @@ public function testAction(): Response $kernel = $this->createMock(HttpKernelInterface::class); $request = Request::create('/test'); - $event = new ControllerEvent( - $kernel, - [$controller, 'testAction'], - $request, - HttpKernelInterface::MAIN_REQUEST, - ); + $event = new ControllerEvent($kernel, [$controller, 'testAction'], $request, HttpKernelInterface::MAIN_REQUEST); $listener->onKernelController($event); @@ -67,12 +62,7 @@ public function testIgnoresNonAbstractControllers(): void $kernel = $this->createMock(HttpKernelInterface::class); $request = Request::create('/test'); - $event = new ControllerEvent( - $kernel, - $controller, - $request, - HttpKernelInterface::MAIN_REQUEST, - ); + $event = new ControllerEvent($kernel, $controller, $request, HttpKernelInterface::MAIN_REQUEST); // Should not throw or error $listener->onKernelController($event); diff --git a/tests/phpMyFAQ/EventListener/RouterListenerTest.php b/tests/phpMyFAQ/EventListener/RouterListenerTest.php index 7d9074c3ac..69d7897ed7 100644 --- a/tests/phpMyFAQ/EventListener/RouterListenerTest.php +++ b/tests/phpMyFAQ/EventListener/RouterListenerTest.php @@ -6,7 +6,10 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Routing\Exception\MethodNotAllowedException; use Symfony\Component\Routing\Exception\ResourceNotFoundException; use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; @@ -65,7 +68,7 @@ public function testSkipsAlreadyMatchedRequests(): void $this->assertEquals('SomeController::action', $request->attributes->get('_controller')); } - public function testThrowsOnNoMatch(): void + public function testThrowsNotFoundHttpExceptionOnNoMatch(): void { $routes = new RouteCollection(); $listener = new RouterListener($routes); @@ -73,7 +76,44 @@ public function testThrowsOnNoMatch(): void $request = Request::create('/nonexistent'); $event = $this->createEvent($request); - $this->expectException(ResourceNotFoundException::class); - $listener->onKernelRequest($event); + try { + $listener->onKernelRequest($event); + $this->fail('Expected NotFoundHttpException was not thrown.'); + } catch (NotFoundHttpException $exception) { + $this->assertInstanceOf(ResourceNotFoundException::class, $exception->getPrevious()); + } + } + + public function testThrowsMethodNotAllowedHttpExceptionWhenMethodIsNotAllowed(): void + { + $routes = new RouteCollection(); + $routes->add( + 'test_route', + new Route( + '/test', + [ + '_controller' => static function () { + return new Response('OK'); + }, + ], + [], + [], + '', + [], + ['GET'], + ), + ); + + $listener = new RouterListener($routes); + $request = Request::create('/test', 'POST'); + $event = $this->createEvent($request); + + try { + $listener->onKernelRequest($event); + $this->fail('Expected MethodNotAllowedHttpException was not thrown.'); + } catch (MethodNotAllowedHttpException $exception) { + $this->assertStringContainsString('GET', $exception->getHeaders()['Allow'] ?? ''); + $this->assertInstanceOf(MethodNotAllowedException::class, $exception->getPrevious()); + } } } diff --git a/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php b/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php index 5bfd1d1fe0..3a446469e7 100644 --- a/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php +++ b/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php @@ -71,7 +71,7 @@ public function testHandlesUnauthorizedHttpException(): void $response = $event->getResponse(); $this->assertNotNull($response); $this->assertEquals(Response::HTTP_FOUND, $response->getStatusCode()); - $this->assertEquals('./login', $response->headers->get('Location')); + $this->assertEquals('/login', $response->headers->get('Location')); } public function testHandlesForbiddenException(): void diff --git a/tests/phpMyFAQ/Functional/KernelRoutingTest.php b/tests/phpMyFAQ/Functional/KernelRoutingTest.php index ce696f46ec..918a6b53e9 100644 --- a/tests/phpMyFAQ/Functional/KernelRoutingTest.php +++ b/tests/phpMyFAQ/Functional/KernelRoutingTest.php @@ -40,7 +40,7 @@ class KernelRoutingTest extends TestCase { - private function createKernelStack(RouteCollection $routes, bool $isApi = false): HttpKernel + private function createKernelStack(RouteCollection $routes, bool $isApi = false): HttpKernelInterface { $dispatcher = new EventDispatcher(); @@ -55,12 +55,25 @@ private function createKernelStack(RouteCollection $routes, bool $isApi = false) $webListener = new WebExceptionListener(); $dispatcher->addListener(KernelEvents::EXCEPTION, [$webListener, 'onKernelException'], -10); - return new HttpKernel( - $dispatcher, - new ControllerResolver(), - new RequestStack(), - new ArgumentResolver(), - ); + $kernel = new HttpKernel($dispatcher, new ControllerResolver(), new RequestStack(), new ArgumentResolver()); + + if (!$isApi) { + return $kernel; + } + + return new class($kernel) implements HttpKernelInterface { + public function __construct( + private readonly HttpKernelInterface $kernel, + ) { + } + + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + $request->attributes->set('_api_context', true); + + return $this->kernel->handle($request, $type, $catch); + } + }; } public function testSuccessfulRouteReturnsOk(): void @@ -181,12 +194,19 @@ public function testMultipleRoutesResolveCorrectly(): void public function testRouteWithParameters(): void { $routes = new RouteCollection(); - $routes->add('param_route', new Route('/items/{id}', [ - '_controller' => function (Request $request) { - $id = $request->attributes->get('id'); - return new Response(sprintf('Item %s', $id)); - }, - ], requirements: ['id' => '\d+'])); + $routes->add( + 'param_route', + new Route( + '/items/{id}', + [ + '_controller' => function (Request $request) { + $id = $request->attributes->get('id'); + return new Response(sprintf('Item %s', $id)); + }, + ], + requirements: ['id' => '\d+'], + ), + ); $kernel = $this->createKernelStack($routes); diff --git a/tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php b/tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php index c8df02ea77..962ddba813 100644 --- a/tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php +++ b/tests/phpMyFAQ/Functional/PhpMyFaqTestKernel.php @@ -25,9 +25,6 @@ class PhpMyFaqTestKernel extends Kernel { public function __construct(string $routingContext = 'public') { - parent::__construct( - routingContext: $routingContext, - debug: true, - ); + parent::__construct(routingContext: $routingContext, debug: true); } } diff --git a/tests/phpMyFAQ/Functional/WebTestCase.php b/tests/phpMyFAQ/Functional/WebTestCase.php index 24f44da5ea..9f24842fd6 100644 --- a/tests/phpMyFAQ/Functional/WebTestCase.php +++ b/tests/phpMyFAQ/Functional/WebTestCase.php @@ -55,10 +55,10 @@ protected static function assertResponseIsSuccessful(?Response $response = null) { $response ??= static::$client?->getResponse(); static::assertNotNull($response, 'No response available. Did you make a request?'); - static::assertTrue( - $response->isSuccessful(), - sprintf('Expected successful response, got %d', $response->getStatusCode()), - ); + static::assertTrue($response->isSuccessful(), sprintf( + 'Expected successful response, got %d', + $response->getStatusCode(), + )); } protected static function assertResponseHeaderSame( @@ -108,13 +108,6 @@ protected function doRequest(object $request): Response public function getResponse(): ?Response { - $response = $this->getInternalResponse(); - - // Try the stored response first - if ($this->response !== null) { - return $this->response; - } - - return null; + return $this->response; } } From 37c9a1114e2e10d22248f26a7512c5ccae8a2b22 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Tue, 17 Feb 2026 18:43:55 +0100 Subject: [PATCH 3/5] fix: migrated more controllers and corrected review notes --- composer.lock | 63 +++-- .../phpMyFAQ/Category/CategoryRepository.php | 4 +- .../Administration/Api/AdminLogController.php | 2 +- .../Api/AttachmentController.php | 3 +- .../Administration/Api/CategoryController.php | 18 +- .../Api/ElasticsearchController.php | 33 +-- .../Controller/Api/CategoryController.php | 12 +- .../Controller/Api/CommentController.php | 12 +- .../phpMyFAQ/Controller/Api/FaqController.php | 31 +-- .../Controller/Api/GlossaryController.php | 16 +- .../Controller/Api/GroupController.php | 2 +- .../Controller/Api/NewsController.php | 2 +- .../Controller/Api/OpenQuestionController.php | 14 +- .../Controller/Api/QuestionController.php | 12 +- .../Controller/Api/SearchController.php | 12 +- .../phpMyFAQ/Controller/Api/TagController.php | 14 +- .../ContainerControllerResolver.php | 26 +- .../Frontend/AbstractFrontController.php | 27 +- .../Frontend/Api/AutoCompleteController.php | 30 +- .../Frontend/Api/CaptchaController.php | 9 +- .../Frontend/Api/CommentController.php | 65 +++-- .../Frontend/Api/ContactController.php | 27 +- .../Controller/Frontend/Api/FaqController.php | 55 ++-- .../Frontend/Api/PushController.php | 28 +- .../Frontend/Api/QuestionController.php | 43 ++- .../Frontend/Api/UserController.php | 26 +- .../Frontend/Api/VotingController.php | 31 ++- .../phpMyFAQ/Controller/SitemapController.php | 15 +- .../EventListener/LanguageListener.php | 4 +- .../EventListener/WebExceptionListener.php | 16 ++ phpmyfaq/src/phpMyFAQ/Faq.php | 6 +- .../Instance/Search/Elasticsearch.php | 132 ++++++--- phpmyfaq/src/phpMyFAQ/Kernel.php | 2 +- .../phpMyFAQ/Search/Search/Elasticsearch.php | 2 +- .../src/phpMyFAQ/Search/Search/OpenSearch.php | 2 +- phpmyfaq/src/phpMyFAQ/User.php | 4 +- phpmyfaq/src/services.php | 125 +++++++++ .../Controller/AbstractControllerTest.php | 6 +- .../Api/CategoryControllerTest.php | 25 +- .../Api/ElasticsearchControllerTest.php | 19 +- .../Controller/Api/CategoryControllerTest.php | 18 +- .../Controller/Api/CommentControllerTest.php | 23 +- .../Controller/Api/FaqControllerTest.php | 256 +++++++++++++++--- .../Controller/Api/GlossaryControllerTest.php | 31 ++- .../Api/OpenQuestionControllerTest.php | 13 +- .../Controller/Api/QuestionControllerTest.php | 19 +- .../Controller/Api/SearchControllerTest.php | 31 ++- .../Controller/Api/TagControllerTest.php | 15 +- .../Api/AutoCompleteControllerTest.php | 43 ++- .../Frontend/Api/CommentControllerTest.php | 55 +++- .../Frontend/Api/ContactControllerTest.php | 24 +- .../Frontend/Api/FaqControllerTest.php | 49 +++- .../Frontend/Api/QuestionControllerTest.php | 41 ++- .../Frontend/Api/UserControllerTest.php | 26 +- .../Frontend/Api/VotingControllerTest.php | 26 +- .../Controller/SitemapControllerTest.php | 8 +- .../ApiExceptionListenerTest.php | 2 + .../ControllerContainerListenerTest.php | 2 + .../EventListener/RouterListenerTest.php | 2 + .../WebExceptionListenerTest.php | 2 + 60 files changed, 1152 insertions(+), 509 deletions(-) diff --git a/composer.lock b/composer.lock index d71ee3909f..a1418e0f35 100644 --- a/composer.lock +++ b/composer.lock @@ -107,16 +107,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.33", + "version": "3.369.35", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "27a14b3822c253cb98465c2e43f4e68b153a63f4" + "reference": "0f3e296342fe965271b5dd0bded4a18bdab8aba5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/27a14b3822c253cb98465c2e43f4e68b153a63f4", - "reference": "27a14b3822c253cb98465c2e43f4e68b153a63f4", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0f3e296342fe965271b5dd0bded4a18bdab8aba5", + "reference": "0f3e296342fe965271b5dd0bded4a18bdab8aba5", "shasum": "" }, "require": { @@ -198,9 +198,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.369.33" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.35" }, - "time": "2026-02-12T19:07:01+00:00" + "time": "2026-02-16T19:15:41+00:00" }, { "name": "bacon/bacon-qr-code", @@ -7011,16 +7011,16 @@ "packages-dev": [ { "name": "carthage-software/mago", - "version": "1.8.0", + "version": "1.9.0", "source": { "type": "git", "url": "https://github.com/carthage-software/mago.git", - "reference": "8d58c5c129d7259f42e8de596cf17c41b49c293d" + "reference": "16d2b042a3fc47b1a4efb8fa545519b4ab596d4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/carthage-software/mago/zipball/8d58c5c129d7259f42e8de596cf17c41b49c293d", - "reference": "8d58c5c129d7259f42e8de596cf17c41b49c293d", + "url": "https://api.github.com/repos/carthage-software/mago/zipball/16d2b042a3fc47b1a4efb8fa545519b4ab596d4d", + "reference": "16d2b042a3fc47b1a4efb8fa545519b4ab596d4d", "shasum": "" }, "require": { @@ -7038,6 +7038,9 @@ "class": "Mago\\MagoPlugin" }, "autoload": { + "files": [ + "composer/functions.php" + ], "psr-4": { "Mago\\": "composer/" } @@ -7058,7 +7061,7 @@ ], "support": { "issues": "https://github.com/carthage-software/mago/issues", - "source": "https://github.com/carthage-software/mago/tree/1.8.0" + "source": "https://github.com/carthage-software/mago/tree/1.9.0" }, "funding": [ { @@ -7066,7 +7069,7 @@ "type": "github" } ], - "time": "2026-02-12T05:55:55+00:00" + "time": "2026-02-17T01:40:44+00:00" }, { "name": "doctrine/deprecations", @@ -8037,16 +8040,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.11", + "version": "12.5.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "9b518cb40f9474572c9f0178e96ff3dc1cf02bf1" + "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9b518cb40f9474572c9f0178e96ff3dc1cf02bf1", - "reference": "9b518cb40f9474572c9f0178e96ff3dc1cf02bf1", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/418e06b3b46b0d54bad749ff4907fc7dfb530199", + "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199", "shasum": "" }, "require": { @@ -8115,7 +8118,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.11" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.12" }, "funding": [ { @@ -8139,20 +8142,20 @@ "type": "tidelift" } ], - "time": "2026-02-10T12:32:02+00:00" + "time": "2026-02-16T08:34:36+00:00" }, { "name": "radebatz/type-info-extras", - "version": "1.0.5", + "version": "1.0.6", "source": { "type": "git", "url": "https://github.com/DerManoMann/type-info-extras.git", - "reference": "217e249a35dbdbd9537f99de622cc080c3f8fb2c" + "reference": "577ac42f3a819b6c0b94821df0c40575811e0c1b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/DerManoMann/type-info-extras/zipball/217e249a35dbdbd9537f99de622cc080c3f8fb2c", - "reference": "217e249a35dbdbd9537f99de622cc080c3f8fb2c", + "url": "https://api.github.com/repos/DerManoMann/type-info-extras/zipball/577ac42f3a819b6c0b94821df0c40575811e0c1b", + "reference": "577ac42f3a819b6c0b94821df0c40575811e0c1b", "shasum": "" }, "require": { @@ -8199,9 +8202,9 @@ ], "support": { "issues": "https://github.com/DerManoMann/type-info-extras/issues", - "source": "https://github.com/DerManoMann/type-info-extras/tree/1.0.5" + "source": "https://github.com/DerManoMann/type-info-extras/tree/1.0.6" }, - "time": "2026-02-07T00:19:33+00:00" + "time": "2026-02-15T21:52:16+00:00" }, { "name": "rector/rector", @@ -9625,16 +9628,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.2", + "version": "2.1.4", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" + "reference": "b39f1870fc7c3e9e4a26106df5053354b9260a33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/b39f1870fc7c3e9e4a26106df5053354b9260a33", + "reference": "b39f1870fc7c3e9e4a26106df5053354b9260a33", "shasum": "" }, "require": { @@ -9681,9 +9684,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.2" + "source": "https://github.com/webmozarts/assert/tree/2.1.4" }, - "time": "2026-01-13T14:02:24+00:00" + "time": "2026-02-17T12:17:51+00:00" }, { "name": "zircote/swagger-php", diff --git a/phpmyfaq/src/phpMyFAQ/Category/CategoryRepository.php b/phpmyfaq/src/phpMyFAQ/Category/CategoryRepository.php index 1431b54313..9091d678d5 100644 --- a/phpmyfaq/src/phpMyFAQ/Category/CategoryRepository.php +++ b/phpmyfaq/src/phpMyFAQ/Category/CategoryRepository.php @@ -387,7 +387,9 @@ public function create(CategoryEntity $categoryEntity): ?int private function getTenantQuotaEnforcer(): TenantQuotaEnforcer { - return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver($this->configuration->getDb()); + return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver( + $this->configuration->getDb(), + ); } public function update(CategoryEntity $categoryEntity): bool diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AdminLogController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AdminLogController.php index 1a5a0b2156..788c04053c 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AdminLogController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AdminLogController.php @@ -49,7 +49,7 @@ public function delete(Request $request): JsonResponse return $this->json(['error' => Translation::get(key: 'msgNoPermission')], Response::HTTP_UNAUTHORIZED); } - if ($this->container->get(id: 'phpmyfaq.admin.admin-log')->delete()) { + if ($this->adminLog->delete()) { return $this->json(['success' => Translation::get(key: 'ad_adminlog_delete_success')], Response::HTTP_OK); } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AttachmentController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AttachmentController.php index 0538acdaa0..ca134d8635 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AttachmentController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/AttachmentController.php @@ -152,9 +152,8 @@ public function upload(Request $request): JsonResponse } if (!empty($uploadedFiles)) { - $adminLog = $this->container->get(id: 'phpmyfaq.admin.admin-log'); $attachmentIds = array_column($uploadedFiles, 'attachmentId'); - $adminLog->log( + $this->adminLog->log( $this->currentUser, AdminLogType::ATTACHMENT_ADD->value . ':' . implode(',', $attachmentIds), ); diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/CategoryController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/CategoryController.php index 0aaebce6b9..3c1053d3ac 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/CategoryController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/CategoryController.php @@ -20,6 +20,8 @@ namespace phpMyFAQ\Controller\Administration\Api; use phpMyFAQ\Category; +use phpMyFAQ\Category\Image; +use phpMyFAQ\Category\Order; use phpMyFAQ\Category\Permission; use phpMyFAQ\Category\Relation; use phpMyFAQ\Core\Exception; @@ -36,6 +38,14 @@ final class CategoryController extends AbstractAdministrationApiController { + public function __construct( + private readonly Image $categoryImage, + private readonly Order $categoryOrder, + private readonly Permission $categoryPermission, + ) { + parent::__construct(); + } + /** * @throws Exception * @throws \Exception @@ -59,13 +69,9 @@ public function delete(Request $request): JsonResponse $categoryRelation = new Relation($this->configuration, $category); - $categoryImage = $this->container->get(id: 'phpmyfaq.category.image'); - $categoryImage->setFileName($category->getCategoryData((int) $data->categoryId)->getImage()); + $this->categoryImage->setFileName($category->getCategoryData((int) $data->categoryId)->getImage()); - $categoryOrder = $this->container->get(id: 'phpmyfaq.category.order'); - $categoryOrder->remove((int) $data->categoryId); - - $categoryPermission = $this->container->get(id: 'phpmyfaq.category.permission'); + $this->categoryOrder->remove((int) $data->categoryId); if ( ( diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ElasticsearchController.php b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ElasticsearchController.php index c113df1ad9..1aa04d93a5 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ElasticsearchController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ElasticsearchController.php @@ -34,6 +34,14 @@ final class ElasticsearchController extends AbstractController { + public function __construct( + private readonly Elasticsearch $elasticsearch, + private readonly Faq $faq, + private readonly CustomPage $customPage, + ) { + parent::__construct(); + } + /** * @throws \Exception */ @@ -42,11 +50,8 @@ public function create(): JsonResponse { $this->userHasPermission(PermissionType::CONFIGURATION_EDIT); - /** @var Elasticsearch $elasticsearch */ - $elasticsearch = $this->container->get(id: 'phpmyfaq.instance.elasticsearch'); - try { - $elasticsearch->createIndex(); + $this->elasticsearch->createIndex(); return $this->json(['success' => Translation::get( 'msgAdminElasticsearchCreateIndex_success', )], Response::HTTP_OK); @@ -63,11 +68,8 @@ public function drop(): JsonResponse { $this->userHasPermission(PermissionType::CONFIGURATION_EDIT); - /** @var Elasticsearch $elasticsearch */ - $elasticsearch = $this->container->get(id: 'phpmyfaq.instance.elasticsearch'); - try { - $elasticsearch->dropIndex(); + $this->elasticsearch->dropIndex(); return $this->json(['success' => Translation::get( 'msgAdminElasticsearchDropIndex_success', )], Response::HTTP_OK); @@ -84,25 +86,18 @@ public function import(): JsonResponse { $this->userHasPermission(PermissionType::CONFIGURATION_EDIT); - /** @var Elasticsearch $elasticsearch */ - $elasticsearch = $this->container->get(id: 'phpmyfaq.instance.elasticsearch'); - - /** @var Faq $faq */ - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faq->getAllFaqs(); + $this->faq->getAllFaqs(); // Index FAQs - $bulkIndexResult = $elasticsearch->bulkIndex($faq->faqRecords); + $bulkIndexResult = $this->elasticsearch->bulkIndex($this->faq->faqRecords); if (!isset($bulkIndexResult['success'])) { return $this->json(['error' => $bulkIndexResult], Response::HTTP_BAD_REQUEST); } // Index custom pages - /** @var CustomPage $customPage */ - $customPage = $this->container->get(id: 'phpmyfaq.custom-page'); - $pages = $customPage->getAllPages(); + $pages = $this->customPage->getAllPages(); - $bulkIndexPagesResult = $elasticsearch->bulkIndexCustomPages($pages); + $bulkIndexPagesResult = $this->elasticsearch->bulkIndexCustomPages($pages); if (!isset($bulkIndexPagesResult['success'])) { return $this->json([ 'error' => 'FAQs indexed but custom pages failed: ' . json_encode($bulkIndexPagesResult), diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/CategoryController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/CategoryController.php index 5d7ac6fe10..e83f16b24d 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/CategoryController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/CategoryController.php @@ -35,16 +35,10 @@ final class CategoryController extends AbstractApiController { - private readonly Language $language; - - public function __construct(?Language $language = null) - { + public function __construct( + private readonly Language $language, + ) { parent::__construct(); - $resolvedLanguage = $language ?? $this->container?->get(id: 'phpmyfaq.language'); - if (!$resolvedLanguage instanceof Language) { - throw new \RuntimeException('Language service "phpmyfaq.language" is not available.'); - } - $this->language = $resolvedLanguage; } /** diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/CommentController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/CommentController.php index a7ae983c8d..d1b04b01cf 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/CommentController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/CommentController.php @@ -30,16 +30,10 @@ final class CommentController extends AbstractApiController { - private readonly Comments $comments; - - public function __construct(?Comments $comments = null) - { + public function __construct( + private readonly Comments $comments, + ) { parent::__construct(); - $resolvedComments = $comments ?? $this->container?->get(id: 'phpmyfaq.comments'); - if (!$resolvedComments instanceof Comments) { - throw new \RuntimeException('Comments service "phpmyfaq.comments" is not available.'); - } - $this->comments = $resolvedComments; } /** diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/FaqController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/FaqController.php index 7f49327aeb..aac7a06e15 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/FaqController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/FaqController.php @@ -38,36 +38,13 @@ final class FaqController extends AbstractApiController { - private readonly Faq $faq; - private readonly Tags $tags; - private readonly FaqStatistics $faqStatistics; - private readonly FaqMetaData $faqMetaData; - public function __construct( - ?Faq $faq = null, - ?Tags $tags = null, - ?FaqStatistics $faqStatistics = null, - ?FaqMetaData $faqMetaData = null, + private readonly Faq $faq, + private readonly Tags $tags, + private readonly FaqStatistics $faqStatistics, + private readonly FaqMetaData $faqMetaData, ) { parent::__construct(); - $resolvedFaq = $faq ?? $this->container?->get(id: 'phpmyfaq.faq'); - $resolvedTags = $tags ?? $this->container?->get(id: 'phpmyfaq.tags'); - $resolvedFaqStatistics = $faqStatistics ?? $this->container?->get(id: 'phpmyfaq.faq.statistics'); - $resolvedFaqMetaData = $faqMetaData ?? $this->container?->get(id: 'phpmyfaq.faq.metadata'); - - if ( - !$resolvedFaq instanceof Faq - || !$resolvedTags instanceof Tags - || !$resolvedFaqStatistics instanceof FaqStatistics - || !$resolvedFaqMetaData instanceof FaqMetaData - ) { - throw new \RuntimeException('FAQ-related services are not available in the container.'); - } - - $this->faq = $resolvedFaq; - $this->tags = $resolvedTags; - $this->faqStatistics = $resolvedFaqStatistics; - $this->faqMetaData = $resolvedFaqMetaData; } /** diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php index 6fdf03b26e..7fc979796d 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php @@ -29,19 +29,11 @@ final class GlossaryController extends AbstractApiController { - private readonly Glossary $glossary; - private readonly Language $language; - - public function __construct(?Glossary $glossary = null, ?Language $language = null) - { + public function __construct( + private readonly Glossary $glossary, + private readonly Language $language, + ) { parent::__construct(); - $resolvedGlossary = $glossary ?? $this->container?->get(id: 'phpmyfaq.glossary'); - $resolvedLanguage = $language ?? $this->container?->get(id: 'phpmyfaq.language'); - if (!$resolvedGlossary instanceof Glossary || !$resolvedLanguage instanceof Language) { - throw new \RuntimeException('Glossary services are not available in the container.'); - } - $this->glossary = $resolvedGlossary; - $this->language = $resolvedLanguage; } /** diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/GroupController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/GroupController.php index b75b7d9a7b..2970a3319a 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/GroupController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/GroupController.php @@ -108,7 +108,7 @@ final class GroupController extends AbstractApiController }', ))] #[OA\Response(response: 401, description: 'If the user is not authenticated.')] - #[Route(path: 'v3.2/groups', name: 'api.groups', methods: ['GET'])] + #[Route(path: 'v3.2/groups', name: 'api.groups.list', methods: ['GET'])] public function list(?Request $request = null): JsonResponse { $this->userIsAuthenticated(); diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php index 08cae7f51d..80207661a7 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php @@ -116,7 +116,7 @@ enum: ['id', 'datum', 'header', 'author_name'], } }'), )] - #[Route('/api/v3.2/news', name: 'api_news_list', methods: ['GET'])] + #[Route('/api/v3.2/news', name: 'api.news.list', methods: ['GET'])] public function list(?Request $request = null): JsonResponse { $request ??= Request::createFromGlobals(); diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/OpenQuestionController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/OpenQuestionController.php index c6c756d72b..17d8066377 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/OpenQuestionController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/OpenQuestionController.php @@ -27,16 +27,10 @@ final class OpenQuestionController extends AbstractApiController { - private readonly Question $question; - - public function __construct(?Question $question = null) - { + public function __construct( + private readonly Question $question, + ) { parent::__construct(); - $resolvedQuestion = $question ?? $this->container?->get(id: 'phpmyfaq.question'); - if (!$resolvedQuestion instanceof Question) { - throw new \RuntimeException('Question service "phpmyfaq.question" is not available.'); - } - $this->question = $resolvedQuestion; } /** @@ -130,7 +124,7 @@ enum: ['id', 'username', 'created', 'categoryId'], } }', ))] - #[Route('/api/v3.2/open-questions', name: 'api_open_questions', methods: ['GET'])] + #[Route(path: 'v3.2/open-questions', name: 'api.open-questions.list', methods: ['GET'])] public function list(?Request $request = null): JsonResponse { $request ??= Request::createFromGlobals(); diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/QuestionController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/QuestionController.php index 49a5b436ee..3d188fcb43 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/QuestionController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/QuestionController.php @@ -33,16 +33,10 @@ final class QuestionController extends AbstractApiController { - private readonly Notification $notification; - - public function __construct(?Notification $notification = null) - { + public function __construct( + private readonly Notification $notification, + ) { parent::__construct(); - $resolvedNotification = $notification ?? $this->container?->get(id: 'phpmyfaq.notification'); - if (!$resolvedNotification instanceof Notification) { - throw new \RuntimeException('Notification service "phpmyfaq.notification" is not available.'); - } - $this->notification = $resolvedNotification; } /** diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php index 4857aeab2f..7bbee6f64a 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/SearchController.php @@ -35,16 +35,10 @@ final class SearchController extends AbstractApiController { - private readonly Search $search; - - public function __construct(?Search $search = null) - { + public function __construct( + private readonly Search $search, + ) { parent::__construct(); - $resolvedSearch = $search ?? $this->container?->get(id: 'phpmyfaq.search'); - if (!$resolvedSearch instanceof Search) { - throw new \RuntimeException('Search service "phpmyfaq.search" is not available.'); - } - $this->search = $resolvedSearch; } /** diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/TagController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/TagController.php index 1855b6e887..0015c7838f 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/TagController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/TagController.php @@ -28,16 +28,10 @@ final class TagController extends AbstractApiController { - private readonly Tags $tags; - - public function __construct(?Tags $tags = null) - { + public function __construct( + private readonly Tags $tags, + ) { parent::__construct(); - $resolvedTags = $tags ?? $this->container?->get(id: 'phpmyfaq.tags'); - if (!$resolvedTags instanceof Tags) { - throw new \RuntimeException('Tags service "phpmyfaq.tags" is not available.'); - } - $this->tags = $resolvedTags; } /** @@ -119,7 +113,7 @@ public function __construct(?Tags $tags = null) } }', ))] - #[Route('/api/v3.2/tags', name: 'api.tags', methods: ['GET'])] + #[Route('/api/v3.2/tags', name: 'api.tags.list', methods: ['GET'])] public function list(?Request $request = null): JsonResponse { $request ??= Request::createFromGlobals(); diff --git a/phpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.php b/phpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.php index ea79f89dbb..11890570be 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/ContainerControllerResolver.php @@ -38,23 +38,19 @@ public function __construct( #[\Override] public function getController(Request $request): callable|false { - $controller = parent::getController($request); - - if ($controller === false) { - return false; - } - - // Handle array-style callables [object, method] - if (is_array($controller) && isset($controller[0]) && is_object($controller[0])) { - $controllerClass = $controller[0]::class; - - if ($this->container->has($controllerClass)) { - $controller[0] = $this->container->get($controllerClass); + $controllerAttr = $request->attributes->get('_controller'); + + // If the controller is in ClassName::method format and registered in the container, + // resolve it from the container BEFORE parent tries to instantiate with `new`. + if (is_string($controllerAttr) && str_contains($controllerAttr, '::')) { + [$class, $method] = explode('::', $controllerAttr, 2); + if (class_exists($class) && $this->container->has($class)) { + $instance = $this->container->get($class); + return [$instance, $method]; } - - return $controller; } - return $controller; + // Fall back to default resolution for unregistered controllers + return parent::getController($request); } } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php index 62c8625e49..996e5be137 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php @@ -25,6 +25,7 @@ use phpMyFAQ\Enums\PermissionType; use phpMyFAQ\Environment; use phpMyFAQ\Helper\LanguageHelper; +use phpMyFAQ\Seo; use phpMyFAQ\Session\Token; use phpMyFAQ\System; use phpMyFAQ\Translation; @@ -36,6 +37,19 @@ abstract class AbstractFrontController extends AbstractController { + protected ?System $faqSystem = null; + + protected ?Seo $seo = null; + + #[\Override] + protected function initializeFromContainer(): void + { + parent::initializeFromContainer(); + + $this->faqSystem = $this->container->get(id: 'phpmyfaq.system'); + $this->seo = $this->container->get(id: 'phpmyfaq.seo'); + } + /** * @return string[] * @throws Exception @@ -43,8 +57,6 @@ abstract class AbstractFrontController extends AbstractController */ protected function getHeader(Request $request): array { - $faqSystem = $this->container->get(id: 'phpmyfaq.system'); - $seo = $this->container->get(id: 'phpmyfaq.seo'); $action = $request->query->get(key: 'action', default: 'index'); $isUserHasAdminRights = $this->currentUser->perm->hasPermission( @@ -53,9 +65,8 @@ protected function getHeader(Request $request): array ); // Get flash messages - $session = $this->container->get('session'); - $successMessages = $session->getFlashBag()->get('success'); - $errorMessages = $session->getFlashBag()->get('error'); + $successMessages = $this->session->getFlashBag()->get('success'); + $errorMessages = $this->session->getFlashBag()->get('error'); return [ ...$this->getUserDropdown(), @@ -71,14 +82,14 @@ protected function getHeader(Request $request): array : Translation::get(key: 'msgLoginUser'), 'isUserLoggedIn' => $this->currentUser->isLoggedIn(), 'isUserHasAdminRights' => $isUserHasAdminRights || $this->currentUser->isSuperAdmin(), - 'baseHref' => $faqSystem->getSystemUri($this->configuration), + 'baseHref' => $this->faqSystem->getSystemUri($this->configuration), 'customCss' => $this->configuration->getCustomCss(), 'version' => $this->configuration->getVersion(), 'header' => str_replace('"', '', $this->configuration->getTitle()), 'metaDescription' => $metaDescription ?? $this->configuration->get('seo.description'), 'metaPublisher' => $this->configuration->get('main.metaPublisher'), 'metaLanguage' => Translation::get(key: 'metaLanguage'), - 'metaRobots' => $seo->getMetaRobots($action), + 'metaRobots' => $this->seo->getMetaRobots($action), 'phpmyfaqVersion' => $this->configuration->getVersion(), 'stylesheet' => Translation::get(key: 'direction') == 'rtl' ? 'style.rtl' : 'style', 'currentPageUrl' => $request->getSchemeAndHttpHost() . $request->getRequestUri(), @@ -160,7 +171,7 @@ private function getTopNavigation(Request $request): array { $templateVars = []; if ($this->currentUser->isLoggedIn() && $this->currentUser->getUserId() > 0) { - $csrfLogoutToken = Token::getInstance($this->container->get('session'))->getTokenString('logout'); + $csrfLogoutToken = Token::getInstance($this->session)->getTokenString('logout'); if ( $this->currentUser->perm->hasPermission( diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/AutoCompleteController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/AutoCompleteController.php index 2d77a58f1e..005b6e70a4 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/AutoCompleteController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/AutoCompleteController.php @@ -21,7 +21,11 @@ use phpMyFAQ\Category; use phpMyFAQ\Controller\AbstractController; +use phpMyFAQ\Faq\Permission; use phpMyFAQ\Filter; +use phpMyFAQ\Helper\SearchHelper; +use phpMyFAQ\Language\Plurals; +use phpMyFAQ\Search; use phpMyFAQ\Search\SearchResultSet; use phpMyFAQ\User\CurrentUser; use Symfony\Component\HttpFoundation\JsonResponse; @@ -31,6 +35,15 @@ final class AutoCompleteController extends AbstractController { + public function __construct( + private readonly Permission $faqPermission, + private readonly Search $faqSearch, + private readonly SearchHelper $faqSearchHelper, + private readonly Plurals $plurals, + ) { + parent::__construct(); + } + /** * @throws \Exception */ @@ -51,21 +64,18 @@ public function search(Request $request): JsonResponse $category->transform(categoryId: 0); $category->buildCategoryTree(); - $faqPermission = $this->container->get(id: 'phpmyfaq.faq.permission'); - $faqSearch = $this->container->get(id: 'phpmyfaq.search'); - $searchResultSet = new SearchResultSet($this->currentUser, $faqPermission, $this->configuration); + $searchResultSet = new SearchResultSet($this->currentUser, $this->faqPermission, $this->configuration); - $faqSearch->setCategory($category); + $this->faqSearch->setCategory($category); - $searchResult = $faqSearch->autoComplete($searchString); + $searchResult = $this->faqSearch->autoComplete($searchString); $searchResultSet->reviewResultSet($searchResult); - $faqSearchHelper = $this->container->get(id: 'phpmyfaq.helper.search'); - $faqSearchHelper->setSearchTerm($searchString); - $faqSearchHelper->setCategory($category); - $faqSearchHelper->setPlurals($this->container->get(id: 'phpmyfaq.language.plurals')); + $this->faqSearchHelper->setSearchTerm($searchString); + $this->faqSearchHelper->setCategory($category); + $this->faqSearchHelper->setPlurals($this->plurals); - return $this->json($faqSearchHelper->createAutoCompleteResult($searchResultSet), Response::HTTP_OK); + return $this->json($this->faqSearchHelper->createAutoCompleteResult($searchResultSet), Response::HTTP_OK); } } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/CaptchaController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/CaptchaController.php index 239e99e626..bc62f2a8e6 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/CaptchaController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/CaptchaController.php @@ -19,12 +19,19 @@ namespace phpMyFAQ\Controller\Frontend\Api; +use phpMyFAQ\Captcha\Captcha; use phpMyFAQ\Controller\AbstractController; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; final class CaptchaController extends AbstractController { + public function __construct( + private readonly Captcha $captcha, + ) { + parent::__construct(); + } + /** * @throws \JsonException|\Exception */ @@ -36,7 +43,7 @@ public function renderImage(): Response $response->headers->set('Content-Type', 'image/jpeg'); // Set image content - $response->setContent($this->container->get(id: 'phpmyfaq.captcha')->getCaptchaImage()); + $response->setContent($this->captcha->getCaptchaImage()); return $response; } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/CommentController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/CommentController.php index ecbb761cb4..0a12bc5d95 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/CommentController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/CommentController.php @@ -19,14 +19,23 @@ namespace phpMyFAQ\Controller\Frontend\Api; +use phpMyFAQ\Comments; use phpMyFAQ\Controller\AbstractController; use phpMyFAQ\Core\Exception; use phpMyFAQ\Entity\Comment; use phpMyFAQ\Enums\PermissionType; +use phpMyFAQ\Faq; use phpMyFAQ\Filter; +use phpMyFAQ\Language; +use phpMyFAQ\News; +use phpMyFAQ\Notification; +use phpMyFAQ\Service\Gravatar; use phpMyFAQ\Session\Token; +use phpMyFAQ\StopWords; use phpMyFAQ\Translation; +use phpMyFAQ\User; use phpMyFAQ\User\CurrentUser; +use phpMyFAQ\User\UserSession; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -35,6 +44,20 @@ final class CommentController extends AbstractController { + public function __construct( + private readonly Faq $faq, + private readonly Comments $comments, + private readonly StopWords $stopWords, + private readonly UserSession $userSession, + private readonly Language $language, + private readonly User $user, + private readonly Notification $notification, + private readonly News $news, + private readonly Gravatar $gravatar, + ) { + parent::__construct(); + } + /** * @throws Exception * @throws \JsonException @@ -43,16 +66,11 @@ final class CommentController extends AbstractController #[Route(path: 'comment/create', name: 'api.private.comment', methods: ['POST'])] public function create(Request $request): JsonResponse { - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $comment = $this->container->get(id: 'phpmyfaq.comments'); - $stopWords = $this->container->get(id: 'phpmyfaq.stop-words'); - $session = $this->container->get(id: 'phpmyfaq.user.session'); - $session->setCurrentUser($this->currentUser); + $this->userSession->setCurrentUser($this->currentUser); - $language = $this->container->get(id: 'phpmyfaq.language'); $languageCode = $this->configuration->get(item: 'main.languageDetection') - ? $language->setLanguageWithDetection($this->configuration->get(item: 'main.language')) - : $language->setLanguageFromConfiguration($this->configuration->get(item: 'main.language')); + ? $this->language->setLanguageWithDetection($this->configuration->get(item: 'main.language')) + : $this->language->setLanguageFromConfiguration($this->configuration->get(item: 'main.language')); if (!$this->isCommentAllowed($this->currentUser)) { return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')], Response::HTTP_FORBIDDEN); @@ -127,8 +145,7 @@ public function create(Request $request): JsonResponse // Check display name and e-mail address for not logged-in users if (!$this->currentUser->isLoggedIn()) { - $user = $this->container->get(id: 'phpmyfaq.user'); - if ($user->checkDisplayName($username) && $user->checkMailAddress($email)) { + if ($this->user->checkDisplayName($username) && $this->user->checkMailAddress($email)) { $this->configuration->getLogger()->error(message: 'Name and email already used by registered user.'); return $this->json(['error' => Translation::get(key: 'errSaveComment')], Response::HTTP_CONFLICT); } @@ -138,11 +155,11 @@ public function create(Request $request): JsonResponse $username !== '' && $email !== '' && $commentText !== '' - && $stopWords->checkBannedWord($commentText) - && $comment->isCommentAllowed($commentId, $languageCode, $type) - && $faq->isActive($commentId, $languageCode, $type) + && $this->stopWords->checkBannedWord($commentText) + && $this->comments->isCommentAllowed($commentId, $languageCode, $type) + && $this->faq->isActive($commentId, $languageCode, $type) ) { - $session->userTracking(action: 'save_comment', data: $commentId); + $this->userSession->userTracking(action: 'save_comment', data: $commentId); $commentEntity = new Comment(); $commentEntity ->setRecordId((int) $commentId) @@ -156,19 +173,19 @@ public function create(Request $request): JsonResponse ) // Already sanitized with HTML support // Plain text with line breaks ->setDate((string) $request->server->get(key: 'REQUEST_TIME')); - if ($comment->create($commentEntity)) { - $notification = $this->container->get(id: 'phpmyfaq.notification'); + if ($this->comments->create($commentEntity)) { if ('faq' === $type) { - $faq->getFaq($commentId); - $notification->sendFaqCommentNotification($faq, $commentEntity); + $this->faq->getFaq($commentId); + $this->notification->sendFaqCommentNotification($this->faq, $commentEntity); } else { - $news = $this->container->get(id: 'phpmyfaq.news'); - $newsData = $news->get($commentId); - $notification->sendNewsCommentNotification($newsData, $commentEntity); + $newsData = $this->news->get($commentId); + $this->notification->sendNewsCommentNotification($newsData, $commentEntity); } - $gravatar = $this->container->get(id: 'phpmyfaq.services.gravatar'); - $gravatarUrl = $gravatar->getImageUrl($commentEntity->getEmail(), ['size' => 50, 'default' => 'mm']); + $gravatarUrl = $this->gravatar->getImageUrl($commentEntity->getEmail(), [ + 'size' => 50, + 'default' => 'mm', + ]); return $this->json([ 'success' => Translation::get(key: 'msgCommentThanks'), @@ -182,7 +199,7 @@ public function create(Request $request): JsonResponse ], Response::HTTP_OK); } - $session->userTracking(action: 'error_save_comment', data: $commentId); + $this->userSession->userTracking(action: 'error_save_comment', data: $commentId); return $this->json(['error' => Translation::get(key: 'errSaveComment')], Response::HTTP_BAD_REQUEST); } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/ContactController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/ContactController.php index f3dabb832b..d0482b991f 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/ContactController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/ContactController.php @@ -22,6 +22,8 @@ use phpMyFAQ\Controller\AbstractController; use phpMyFAQ\Core\Exception; use phpMyFAQ\Filter; +use phpMyFAQ\Mail; +use phpMyFAQ\StopWords; use phpMyFAQ\Translation; use phpMyFAQ\Utils; use Symfony\Component\HttpFoundation\JsonResponse; @@ -32,6 +34,13 @@ final class ContactController extends AbstractController { + public function __construct( + private readonly StopWords $stopWords, + private readonly Mail $mailer, + ) { + parent::__construct(); + } + /** * @throws Exception * @throws \JsonException @@ -71,9 +80,7 @@ public function create(Request $request): JsonResponse throw new Exception('Invalid captcha'); } - $stopWords = $this->container->get(id: 'phpmyfaq.stop-words'); - - if ($author !== '' && $author !== '0' && $email !== '' && $stopWords->checkBannedWord($question)) { + if ($author !== '' && $author !== '0' && $email !== '' && $this->stopWords->checkBannedWord($question)) { $question = sprintf( '%s: %s
%s: %s

%s', Translation::get(key: 'msgNewContentName'), @@ -83,18 +90,16 @@ public function create(Request $request): JsonResponse $question, ); - $mailer = $this->container->get(id: 'phpmyfaq.mail'); try { - $mailer->setReplyTo($email, $author); - $mailer->addTo($this->configuration->getAdminEmail()); - $mailer->setReplyTo($this->configuration->getNoReplyEmail()); - $mailer->subject = Utils::resolveMarkers( + $this->mailer->setReplyTo($email, $author); + $this->mailer->addTo($this->configuration->getAdminEmail()); + $this->mailer->setReplyTo($this->configuration->getNoReplyEmail()); + $this->mailer->subject = Utils::resolveMarkers( text: 'Feedback: %sitename%', configuration: $this->configuration, ); - $mailer->message = $question; - $mailer->send(); - unset($mailer); + $this->mailer->message = $question; + $this->mailer->send(); return $this->json(['success' => Translation::get(key: 'msgMailContact')], Response::HTTP_OK); } catch (Exception|TransportExceptionInterface $e) { diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/FaqController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/FaqController.php index 05f46fd2dc..5eed46674d 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/FaqController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/FaqController.php @@ -25,11 +25,19 @@ use phpMyFAQ\Core\Exception; use phpMyFAQ\Entity\FaqEntity; use phpMyFAQ\Enums\PermissionType; +use phpMyFAQ\Faq; use phpMyFAQ\Faq\MetaData; use phpMyFAQ\Faq\Permission as FaqPermission; use phpMyFAQ\Filter; +use phpMyFAQ\Helper\CategoryHelper; +use phpMyFAQ\Helper\FaqHelper; +use phpMyFAQ\Language; +use phpMyFAQ\Notification; +use phpMyFAQ\Question; +use phpMyFAQ\StopWords; use phpMyFAQ\Translation; use phpMyFAQ\User\CurrentUser; +use phpMyFAQ\User\UserSession; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -38,26 +46,33 @@ final class FaqController extends AbstractController { + public function __construct( + private readonly Faq $faq, + private readonly FaqHelper $faqHelper, + private readonly Question $question, + private readonly StopWords $stopWords, + private readonly UserSession $userSession, + private readonly Language $language, + private readonly CategoryHelper $categoryHelper, + private readonly Notification $notification, + ) { + parent::__construct(); + } + /** * @throws Exception|\JsonException|\Exception */ #[Route(path: 'faq/create', name: 'api.private.faq.create', methods: ['POST'])] public function create(Request $request): JsonResponse { - $faq = $this->container->get(id: 'phpmyfaq.faq'); - $faqHelper = $this->container->get(id: 'phpmyfaq.helper.faq'); - $question = $this->container->get(id: 'phpmyfaq.question'); - $stopWords = $this->container->get(id: 'phpmyfaq.stop-words'); - $session = $this->container->get(id: 'phpmyfaq.user.session'); - $session->setCurrentUser($this->currentUser); + $this->userSession->setCurrentUser($this->currentUser); $categoryPermission = new CategoryPermission($this->configuration); $faqPermission = new FaqPermission($this->configuration); - $language = $this->container->get(id: 'phpmyfaq.language'); $languageCode = $this->configuration->get(item: 'main.languageDetection') - ? $language->setLanguageWithDetection($this->configuration->get(item: 'main.language')) - : $language->setLanguageFromConfiguration($this->configuration->get(item: 'main.language')); + ? $this->language->setLanguageWithDetection($this->configuration->get(item: 'main.language')) + : $this->language->setLanguageFromConfiguration($this->configuration->get(item: 'main.language')); if (!$this->isAddingFaqsAllowed($this->currentUser)) { return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')], Response::HTTP_FORBIDDEN); @@ -123,15 +138,15 @@ public function create(Request $request): JsonResponse && $email !== '0' && $questionText !== '' && $questionText !== '0' - && $stopWords->checkBannedWord(strip_tags($questionText)) + && $this->stopWords->checkBannedWord(strip_tags($questionText)) ) { if ($answer !== '' && $answer !== '0') { - $stopWords->checkBannedWord(strip_tags($answer)); + $this->stopWords->checkBannedWord(strip_tags($answer)); } else { $answer = ''; } - $session->userTracking('save_new_entry', 0); + $this->userSession->userTracking('save_new_entry', 0); $autoActivate = $this->configuration->get(item: 'records.defaultActivation'); @@ -148,15 +163,15 @@ public function create(Request $request): JsonResponse ->setComment(true) ->setNotes(''); - $faq->create($faqEntity); + $this->faq->create($faqEntity); $recordId = $faqEntity->getId(); $openQuestionId = Filter::filterVar($data->openQuestionID, FILTER_VALIDATE_INT); if ($openQuestionId) { if ($this->configuration->get(item: 'records.enableDeleteQuestion')) { - $question->delete($openQuestionId); + $this->question->delete($openQuestionId); } else { // adds this faq record id to the related open question - $question->updateQuestionAnswer((int) $openQuestionId, (int) $recordId, (int) $categories[0]); + $this->question->updateQuestionAnswer((int) $openQuestionId, (int) $recordId, (int) $categories[0]); } } @@ -168,10 +183,9 @@ public function create(Request $request): JsonResponse ->save(); // Let the admin and the category owners to be informed by email of this new entry - $categoryHelper = $this->container->get(id: 'phpmyfaq.helper.category-helper'); - $categoryHelper->setCategory($category)->setConfiguration($this->configuration); + $this->categoryHelper->setCategory($category)->setConfiguration($this->configuration); - $moderators = $categoryHelper->getModerators($categories); + $moderators = $this->categoryHelper->getModerators($categories); // Add user and group permissions $permissions = $categoryPermission->getAll($categories); @@ -183,15 +197,14 @@ public function create(Request $request): JsonResponse } try { - $notification = $this->container->get(id: 'phpmyfaq.notification'); - $notification->sendNewFaqAdded($moderators, $faqEntity); + $this->notification->sendNewFaqAdded($moderators, $faqEntity); } catch (Exception|TransportExceptionInterface $e) { $this->configuration->getLogger()->info('Notification could not be sent: ', [$e->getMessage()]); } if ($this->configuration->get(item: 'records.defaultActivation')) { $link = [ - 'link' => $faqHelper->createFaqUrl($faqEntity, $categories[0]), + 'link' => $this->faqHelper->createFaqUrl($faqEntity, $categories[0]), 'info' => Translation::get(key: 'msgRedirect'), ]; } else { diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php index 94be5e6f37..129c04fcd7 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PushController.php @@ -31,18 +31,22 @@ final class PushController extends AbstractController { + public function __construct( + private readonly WebPushService $webPushService, + private readonly PushSubscriptionRepository $pushSubscriptionRepository, + ) { + parent::__construct(); + } + /** * Returns the VAPID public key and whether push is enabled. */ #[Route(path: 'push/vapid-public-key', name: 'api.public.push.vapid-public-key', methods: ['GET'])] public function getVapidPublicKey(): JsonResponse { - /** @var WebPushService $webPushService */ - $webPushService = $this->container->get('phpmyfaq.push.web-push-service'); - return $this->json([ - 'enabled' => $webPushService->isEnabled(), - 'vapidPublicKey' => $webPushService->getVapidPublicKey(), + 'enabled' => $this->webPushService->isEnabled(), + 'vapidPublicKey' => $this->webPushService->getVapidPublicKey(), ], Response::HTTP_OK); } @@ -78,10 +82,7 @@ public function subscribe(Request $request): JsonResponse ->setAuthToken($authToken) ->setContentEncoding($contentEncoding); - /** @var PushSubscriptionRepository $repository */ - $repository = $this->container->get('phpmyfaq.push.subscription-repository'); - - if ($repository->save($entity)) { + if ($this->pushSubscriptionRepository->save($entity)) { return $this->json(['success' => true], Response::HTTP_CREATED); } @@ -108,12 +109,10 @@ public function unsubscribe(Request $request): JsonResponse return $this->json(['error' => 'Missing endpoint'], Response::HTTP_BAD_REQUEST); } - /** @var PushSubscriptionRepository $repository */ - $repository = $this->container->get('phpmyfaq.push.subscription-repository'); $endpointHash = hash('sha256', $endpoint); $userId = $this->currentUser->getUserId(); - if ($repository->deleteByEndpointHashAndUserId($endpointHash, $userId)) { + if ($this->pushSubscriptionRepository->deleteByEndpointHashAndUserId($endpointHash, $userId)) { return $this->json(['success' => true], Response::HTTP_OK); } @@ -128,11 +127,8 @@ public function status(): JsonResponse { $this->userIsAuthenticated(); - /** @var PushSubscriptionRepository $repository */ - $repository = $this->container->get('phpmyfaq.push.subscription-repository'); - return $this->json([ - 'subscribed' => $repository->hasSubscription($this->currentUser->getUserId()), + 'subscribed' => $this->pushSubscriptionRepository->hasSubscription($this->currentUser->getUserId()), ], Response::HTTP_OK); } } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/QuestionController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/QuestionController.php index c415f005c4..59324e5466 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/QuestionController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/QuestionController.php @@ -26,7 +26,12 @@ use phpMyFAQ\Enums\PermissionType; use phpMyFAQ\Faq\Permission; use phpMyFAQ\Filter; +use phpMyFAQ\Helper\QuestionHelper; +use phpMyFAQ\Notification; +use phpMyFAQ\Question; +use phpMyFAQ\Search; use phpMyFAQ\Search\SearchResultSet; +use phpMyFAQ\StopWords; use phpMyFAQ\Translation; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -35,6 +40,16 @@ final class QuestionController extends AbstractController { + public function __construct( + private readonly StopWords $stopWords, + private readonly QuestionHelper $questionHelper, + private readonly Search $search, + private readonly Question $question, + private readonly Notification $notification, + ) { + parent::__construct(); + } + /** * @throws Exception * @throws \JsonException @@ -47,11 +62,9 @@ public function create(Request $request): JsonResponse return $this->json(['error' => Translation::get(key: 'ad_msg_noauth')], Response::HTTP_FORBIDDEN); } - $stopWords = $this->container->get(id: 'phpmyfaq.stop-words'); $category = new Category($this->configuration); - $questionHelper = $this->container->get(id: 'phpmyfaq.helper.question'); - $questionHelper->setConfiguration($this->configuration)->setCategory($category); + $this->questionHelper->setConfiguration($this->configuration)->setCategory($category); $categories = $category->getAllCategories(); @@ -107,7 +120,12 @@ public function create(Request $request): JsonResponse } // Check if all necessary fields are provided and not empty - if ($author !== '' && $email !== '' && $userQuestion !== '' && $stopWords->checkBannedWord($userQuestion)) { + if ( + $author !== '' + && $email !== '' + && $userQuestion !== '' + && $this->stopWords->checkBannedWord($userQuestion) + ) { if ($selectedCategory === false) { $selectedCategory = $category->getAllCategoryIds()[0]; } @@ -125,16 +143,15 @@ public function create(Request $request): JsonResponse // Save the question immediately if smart answering is disabled if (false === (bool) $save) { - $cleanQuestion = $stopWords->clean($userQuestion); + $cleanQuestion = $this->stopWords->clean($userQuestion); - $faqSearch = $this->container->get(id: 'phpmyfaq.search'); - $faqSearch->setCategory(new Category($this->configuration)); - $faqSearch->setCategoryId((int) $selectedCategory); + $this->search->setCategory(new Category($this->configuration)); + $this->search->setCategoryId((int) $selectedCategory); $faqPermission = new Permission($this->configuration); $searchResultSet = new SearchResultSet($this->currentUser, $faqPermission, $this->configuration); - $searchResult = array_merge(...array_map(static fn($word) => $faqSearch->search( + $searchResult = array_merge(...array_map(fn($word) => $this->search->search( $word, allLanguages: false, ), array_filter($cleanQuestion))); @@ -142,15 +159,13 @@ public function create(Request $request): JsonResponse $searchResultSet->reviewResultSet($searchResult); if ($searchResultSet->getNumberOfResults() > 0) { - $smartAnswer = $questionHelper->generateSmartAnswer($searchResultSet); + $smartAnswer = $this->questionHelper->generateSmartAnswer($searchResultSet); return $this->json(['result' => $smartAnswer], Response::HTTP_OK); } } - $question = $this->container->get(id: 'phpmyfaq.question'); - $question->add($questionEntity); - $notification = $this->container->get(id: 'phpmyfaq.notification'); - $notification->sendQuestionSuccessMail($questionEntity, $categories); + $this->question->add($questionEntity); + $this->notification->sendQuestionSuccessMail($questionEntity, $categories); return $this->json(['success' => Translation::get(key: 'msgAskThx4Mail')], Response::HTTP_OK); } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/UserController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/UserController.php index 48035a918c..76d541fe47 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/UserController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/UserController.php @@ -22,7 +22,9 @@ use phpMyFAQ\Controller\AbstractController; use phpMyFAQ\Core\Exception; use phpMyFAQ\Filter; +use phpMyFAQ\Mail; use phpMyFAQ\Session\Token; +use phpMyFAQ\StopWords; use phpMyFAQ\Translation; use phpMyFAQ\User\TwoFactor; use RobThree\Auth\TwoFactorAuthException; @@ -37,6 +39,13 @@ final class UserController extends AbstractController { + public function __construct( + private readonly StopWords $stopWords, + private readonly Mail $mailer, + ) { + parent::__construct(); + } + /** * @throws \Exception */ @@ -229,7 +238,6 @@ public function requestUserRemoval(Request $request): JsonResponse )], Response::HTTP_BAD_REQUEST); } - $stopWords = $this->container->get(id: 'phpmyfaq.stop-words'); if ( $author !== '' && $author !== '0' @@ -237,7 +245,7 @@ public function requestUserRemoval(Request $request): JsonResponse && $email !== '0' && $question !== '' && $question !== '0' - && $stopWords->checkBannedWord($question) + && $this->stopWords->checkBannedWord($question) ) { $question = sprintf( '%s %s
%s %s
%s %s

%s', @@ -250,15 +258,13 @@ public function requestUserRemoval(Request $request): JsonResponse $question, ); - $mailer = $this->container->get(id: 'phpmyfaq.mail'); try { - $mailer->setReplyTo($email, $author); - $mailer->addTo($this->configuration->getAdminEmail()); - $mailer->setReplyTo($this->configuration->getNoReplyEmail()); - $mailer->subject = $this->configuration->getTitle() . ': Remove User Request'; - $mailer->message = $question; - $mailer->send(); - unset($mailer); + $this->mailer->setReplyTo($email, $author); + $this->mailer->addTo($this->configuration->getAdminEmail()); + $this->mailer->setReplyTo($this->configuration->getNoReplyEmail()); + $this->mailer->subject = $this->configuration->getTitle() . ': Remove User Request'; + $this->mailer->message = $question; + $this->mailer->send(); return $this->json(['success' => Translation::get(key: 'msgMailContact')], Response::HTTP_OK); } catch (Exception|TransportExceptionInterface $exception) { diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php index 881f96e3e4..b1001d1e67 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php @@ -23,7 +23,9 @@ use phpMyFAQ\Controller\AbstractController; use phpMyFAQ\Entity\Vote; use phpMyFAQ\Filter; +use phpMyFAQ\Rating; use phpMyFAQ\Translation; +use phpMyFAQ\User\UserSession; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -31,15 +33,20 @@ final class VotingController extends AbstractController { + public function __construct( + private readonly Rating $rating, + private readonly UserSession $userSession, + ) { + parent::__construct(); + } + /** * @throws Exception */ #[Route(path: 'voting', name: 'api.private.voting', methods: ['POST'])] public function create(Request $request): JsonResponse { - $rating = $this->container->get(id: 'phpmyfaq.rating'); - $session = $this->container->get(id: 'phpmyfaq.user.session'); - $session->setCurrentUser($this->currentUser); + $this->userSession->setCurrentUser($this->currentUser); $data = json_decode($request->getContent()); @@ -67,30 +74,30 @@ public function create(Request $request): JsonResponse throw new Exception('Invalid vote value'); } - if (isset($vote) && $rating->check($faqId, $userIp) && $vote > 0 && $vote < 6) { - $session->userTracking('save_voting', $faqId); + if (isset($vote) && $this->rating->check($faqId, $userIp) && $vote > 0 && $vote < 6) { + $this->userSession->userTracking('save_voting', $faqId); $votingData = new Vote(); $votingData->setFaqId($faqId)->setVote($vote)->setIp($userIp); - if ($rating->getNumberOfVotings($faqId) === 0) { - $rating->create($votingData); + if ($this->rating->getNumberOfVotings($faqId) === 0) { + $this->rating->create($votingData); } else { - $rating->update($votingData); + $this->rating->update($votingData); } return $this->json([ 'success' => Translation::get(key: 'msgVoteThanks'), - 'rating' => $rating->get($faqId), + 'rating' => $this->rating->get($faqId), ], Response::HTTP_OK); } - if (!$rating->check($faqId, $userIp)) { - $session->userTracking('error_save_voting', $faqId); + if (!$this->rating->check($faqId, $userIp)) { + $this->userSession->userTracking('error_save_voting', $faqId); return $this->json(['error' => Translation::get(key: 'err_VoteTooMuch')], Response::HTTP_BAD_REQUEST); } - $session->userTracking('error_save_voting', $faqId); + $this->userSession->userTracking('error_save_voting', $faqId); return $this->json(['error' => Translation::get(key: 'err_noVote')], Response::HTTP_BAD_REQUEST); } } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/SitemapController.php b/phpmyfaq/src/phpMyFAQ/Controller/SitemapController.php index 4a3a7eea8c..efac3d15e9 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/SitemapController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/SitemapController.php @@ -20,6 +20,8 @@ namespace phpMyFAQ\Controller; use phpMyFAQ\Core\Exception; +use phpMyFAQ\CustomPage; +use phpMyFAQ\Faq\Statistics as FaqStatistics; use phpMyFAQ\Twig\TemplateException; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Response; @@ -27,6 +29,13 @@ final class SitemapController extends AbstractController { + public function __construct( + private readonly FaqStatistics $faqStatistics, + private readonly CustomPage $customPage, + ) { + parent::__construct(); + } + private const int PMF_SITEMAP_GOOGLE_MAX_URLS = 50000; /** @@ -87,8 +96,7 @@ private function generateSitemapXml(): ?string return null; } - $faqStatistics = $this->container->get(id: 'phpmyfaq.faq.statistics'); - $items = $faqStatistics->getTopTenData(self::PMF_SITEMAP_GOOGLE_MAX_URLS - 1); + $items = $this->faqStatistics->getTopTenData(self::PMF_SITEMAP_GOOGLE_MAX_URLS - 1); $urls = []; foreach ($items as $item) { @@ -100,8 +108,7 @@ private function generateSitemapXml(): ?string } // Add custom pages to sitemap - $customPage = $this->container->get(id: 'phpmyfaq.custom-page'); - $pages = $customPage->getAllPages(); + $pages = $this->customPage->getAllPages(); foreach ($pages as $page) { if ($page['active'] !== 'y') { diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php index 6567933714..919b6b314b 100644 --- a/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php +++ b/phpmyfaq/src/phpMyFAQ/EventListener/LanguageListener.php @@ -74,9 +74,9 @@ private function detectLanguage(): string ? $language->setLanguageWithDetection($configLang) : $language->setLanguageFromConfiguration($configLang); - require PMF_TRANSLATION_DIR . '/language_en.php'; + require_once PMF_TRANSLATION_DIR . '/language_en.php'; if (Language::isASupportedLanguage($currentLanguage)) { - require PMF_TRANSLATION_DIR . '/language_' . strtolower($currentLanguage) . '.php'; + require_once PMF_TRANSLATION_DIR . '/language_' . strtolower($currentLanguage) . '.php'; } $configuration->setLanguage($language); diff --git a/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php b/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php index 57f2a656f5..7cd3cd0163 100644 --- a/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php +++ b/phpmyfaq/src/phpMyFAQ/EventListener/WebExceptionListener.php @@ -21,9 +21,11 @@ namespace phpMyFAQ\EventListener; +use phpMyFAQ\Controller\AbstractController; use phpMyFAQ\Controller\Exception\ForbiddenException; use phpMyFAQ\Controller\Frontend\PageNotFoundController; use phpMyFAQ\Environment; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Response; @@ -37,6 +39,11 @@ class WebExceptionListener { + public function __construct( + private readonly ?ContainerInterface $container = null, + ) { + } + public function onKernelException(ExceptionEvent $event): void { $request = $event->getRequest(); @@ -85,6 +92,15 @@ private function handleNotFound(ExceptionEvent $event): Response $controllerResolver = new ControllerResolver(); $argumentResolver = new ArgumentResolver(); $controller = $controllerResolver->getController($request); + + if (is_array($controller) && $controller[0] instanceof AbstractController) { + if ($this->container instanceof ContainerInterface) { + $controller[0]->setContainer($this->container); + } else { + throw new \RuntimeException('Container is required to render the styled 404 page.'); + } + } + $arguments = $argumentResolver->getArguments($request, $controller); return call_user_func_array($controller, $arguments); } catch (Throwable) { diff --git a/phpmyfaq/src/phpMyFAQ/Faq.php b/phpmyfaq/src/phpMyFAQ/Faq.php index d5e6a15979..26d5f8e72b 100755 --- a/phpmyfaq/src/phpMyFAQ/Faq.php +++ b/phpmyfaq/src/phpMyFAQ/Faq.php @@ -447,7 +447,7 @@ public function renderFaqsByCategoryId(int $categoryId, string $orderBy = 'id', $pagination = new Pagination( baseUrl: $baseUrl, total: $num, - perPage: $this->configuration->get(item: 'records.numberOfRecordsPerPage'), + perPage: (int) $this->configuration->get(item: 'records.numberOfRecordsPerPage'), urlConfig: new UrlConfig(pageParamName: 'seite', rewriteUrl: $rewriteUrl), ); $output .= $pagination->render(); @@ -963,7 +963,9 @@ public function create(FaqEntity $faqEntity): FaqEntity private function getTenantQuotaEnforcer(): TenantQuotaEnforcer { - return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver($this->configuration->getDb()); + return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver( + $this->configuration->getDb(), + ); } /** diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Search/Elasticsearch.php b/phpmyfaq/src/phpMyFAQ/Instance/Search/Elasticsearch.php index e496b10925..bebf05bf5a 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Search/Elasticsearch.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Search/Elasticsearch.php @@ -43,39 +43,7 @@ class Elasticsearch * Elasticsearch mapping * @var array */ - private array $mappings = [ - '_source' => [ - 'enabled' => true, - ], - 'properties' => [ - 'question' => [ - 'type' => 'search_as_you_type', - 'analyzer' => 'autocomplete', - 'search_analyzer' => PMF_ELASTICSEARCH_TOKENIZER, - ], - 'answer' => [ - 'type' => 'search_as_you_type', - 'analyzer' => 'autocomplete', - 'search_analyzer' => PMF_ELASTICSEARCH_TOKENIZER, - ], - 'keywords' => [ - 'type' => 'search_as_you_type', - 'analyzer' => 'autocomplete', - 'search_analyzer' => PMF_ELASTICSEARCH_TOKENIZER, - ], - 'categories' => [ - 'type' => 'search_as_you_type', - 'analyzer' => 'autocomplete', - 'search_analyzer' => PMF_ELASTICSEARCH_TOKENIZER, - ], - 'content_type' => [ - 'type' => 'keyword', - ], - 'slug' => [ - 'type' => 'keyword', - ], - ], - ]; + private array $mappings = []; /** * Elasticsearch constructor. @@ -85,6 +53,7 @@ public function __construct( ) { $this->client = $configuration->getElasticsearch(); $this->elasticsearchConfiguration = $configuration->getElasticsearchConfig(); + $this->mappings = $this->buildMappings(); } /** @@ -109,12 +78,14 @@ public function createIndex(): bool */ private function getParams(): array { + $tokenizer = $this->getTokenizer(); + return [ 'index' => $this->elasticsearchConfiguration->getIndex(), 'body' => [ 'settings' => [ - 'number_of_shards' => PMF_ELASTICSEARCH_NUMBER_SHARDS, - 'number_of_replicas' => PMF_ELASTICSEARCH_NUMBER_REPLICAS, + 'number_of_shards' => $this->getNumberOfShards(), + 'number_of_replicas' => $this->getNumberOfReplicas(), 'analysis' => [ 'filter' => [ 'autocomplete_filter' => [ @@ -124,13 +95,13 @@ private function getParams(): array ], 'Language_stemmer' => [ 'type' => 'stemmer', - 'name' => PMF_ELASTICSEARCH_STEMMING_LANGUAGE[$this->configuration->getDefaultLanguage()], + 'name' => $this->getStemmingLanguage(), ], ], 'analyzer' => [ 'autocomplete' => [ 'type' => 'custom', - 'tokenizer' => PMF_ELASTICSEARCH_TOKENIZER, + 'tokenizer' => $tokenizer, 'filter' => [ 'lowercase', 'autocomplete_filter', @@ -144,6 +115,93 @@ private function getParams(): array ]; } + /** + * @return array + */ + private function buildMappings(): array + { + $tokenizer = $this->getTokenizer(); + + return [ + '_source' => [ + 'enabled' => true, + ], + 'properties' => [ + 'question' => [ + 'type' => 'search_as_you_type', + 'analyzer' => 'autocomplete', + 'search_analyzer' => $tokenizer, + ], + 'answer' => [ + 'type' => 'search_as_you_type', + 'analyzer' => 'autocomplete', + 'search_analyzer' => $tokenizer, + ], + 'keywords' => [ + 'type' => 'search_as_you_type', + 'analyzer' => 'autocomplete', + 'search_analyzer' => $tokenizer, + ], + 'categories' => [ + 'type' => 'search_as_you_type', + 'analyzer' => 'autocomplete', + 'search_analyzer' => $tokenizer, + ], + 'content_type' => [ + 'type' => 'keyword', + ], + 'slug' => [ + 'type' => 'keyword', + ], + ], + ]; + } + + private function getTokenizer(): string + { + if (defined('PMF_ELASTICSEARCH_TOKENIZER')) { + return (string) constant('PMF_ELASTICSEARCH_TOKENIZER'); + } + + return 'standard'; + } + + private function getNumberOfShards(): int + { + if (defined('PMF_ELASTICSEARCH_NUMBER_SHARDS')) { + return (int) constant('PMF_ELASTICSEARCH_NUMBER_SHARDS'); + } + + return 2; + } + + private function getNumberOfReplicas(): int + { + if (defined('PMF_ELASTICSEARCH_NUMBER_REPLICAS')) { + return (int) constant('PMF_ELASTICSEARCH_NUMBER_REPLICAS'); + } + + return 0; + } + + private function getStemmingLanguage(): string + { + if (!defined('PMF_ELASTICSEARCH_STEMMING_LANGUAGE')) { + return 'english'; + } + + $defaultLanguage = $this->configuration->getDefaultLanguage(); + $stemmingLanguages = constant('PMF_ELASTICSEARCH_STEMMING_LANGUAGE'); + + if (!is_array($stemmingLanguages)) { + return 'english'; + } + + $stemmer = $stemmingLanguages[$defaultLanguage] ?? 'english'; + + return is_string($stemmer) ? $stemmer : 'english'; + } + /** * Puts phpMyFAQ Elasticsearch mapping into index. */ diff --git a/phpmyfaq/src/phpMyFAQ/Kernel.php b/phpmyfaq/src/phpMyFAQ/Kernel.php index bd59f444c2..3574192604 100644 --- a/phpmyfaq/src/phpMyFAQ/Kernel.php +++ b/phpmyfaq/src/phpMyFAQ/Kernel.php @@ -187,7 +187,7 @@ private function registerEventListeners(EventDispatcher $dispatcher): void $dispatcher->addListener(KernelEvents::EXCEPTION, [$apiExceptionListener, 'onKernelException'], 0); // Web exception listener — handles web (non-API) exceptions (priority -10, after API listener) - $webExceptionListener = new WebExceptionListener(); + $webExceptionListener = new WebExceptionListener($this->container); $dispatcher->addListener(KernelEvents::EXCEPTION, [$webExceptionListener, 'onKernelException'], -10); // Controller container listener — injects shared container into controllers diff --git a/phpmyfaq/src/phpMyFAQ/Search/Search/Elasticsearch.php b/phpmyfaq/src/phpMyFAQ/Search/Search/Elasticsearch.php index 6732e6e178..ae8f6b6c7f 100644 --- a/phpmyfaq/src/phpMyFAQ/Search/Search/Elasticsearch.php +++ b/phpmyfaq/src/phpMyFAQ/Search/Search/Elasticsearch.php @@ -41,7 +41,7 @@ class Elasticsearch extends AbstractSearch implements SearchInterface private string $language = ''; - /**@var int[] */ + /** @var int[] */ private array $categoryIds = []; /** diff --git a/phpmyfaq/src/phpMyFAQ/Search/Search/OpenSearch.php b/phpmyfaq/src/phpMyFAQ/Search/Search/OpenSearch.php index a17ea90eee..1f872708a9 100644 --- a/phpmyfaq/src/phpMyFAQ/Search/Search/OpenSearch.php +++ b/phpmyfaq/src/phpMyFAQ/Search/Search/OpenSearch.php @@ -39,7 +39,7 @@ class OpenSearch extends AbstractSearch implements SearchInterface private string $language = ''; - /**@var int[] */ + /** @var int[] */ private array $categoryIds = []; /** diff --git a/phpmyfaq/src/phpMyFAQ/User.php b/phpmyfaq/src/phpMyFAQ/User.php index 64c8ca1825..1a5775e9c8 100644 --- a/phpmyfaq/src/phpMyFAQ/User.php +++ b/phpmyfaq/src/phpMyFAQ/User.php @@ -440,7 +440,9 @@ public function createUser(string $login, string $pass = '', string $domain = '' private function getTenantQuotaEnforcer(): TenantQuotaEnforcer { - return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver($this->configuration->getDb()); + return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver( + $this->configuration->getDb(), + ); } /** diff --git a/phpmyfaq/src/services.php b/phpmyfaq/src/services.php index 101fd55d18..d7d5fc42e0 100644 --- a/phpmyfaq/src/services.php +++ b/phpmyfaq/src/services.php @@ -47,6 +47,26 @@ use phpMyFAQ\Category\Order; use phpMyFAQ\Category\Permission; use phpMyFAQ\Command\CreateHashesCommand; +use phpMyFAQ\Controller\Administration\Api\CategoryController as AdminApiCategoryController; +use phpMyFAQ\Controller\Administration\Api\ElasticsearchController as AdminApiElasticsearchController; +use phpMyFAQ\Controller\Api\CategoryController as ApiCategoryController; +use phpMyFAQ\Controller\Api\CommentController as ApiCommentController; +use phpMyFAQ\Controller\Api\FaqController as ApiFaqController; +use phpMyFAQ\Controller\Api\GlossaryController as ApiGlossaryController; +use phpMyFAQ\Controller\Api\OpenQuestionController as ApiOpenQuestionController; +use phpMyFAQ\Controller\Api\QuestionController as ApiQuestionController; +use phpMyFAQ\Controller\Api\SearchController as ApiSearchController; +use phpMyFAQ\Controller\Api\TagController as ApiTagController; +use phpMyFAQ\Controller\Frontend\Api\AutoCompleteController as FrontendApiAutoCompleteController; +use phpMyFAQ\Controller\Frontend\Api\CaptchaController as FrontendApiCaptchaController; +use phpMyFAQ\Controller\Frontend\Api\CommentController as FrontendApiCommentController; +use phpMyFAQ\Controller\Frontend\Api\ContactController as FrontendApiContactController; +use phpMyFAQ\Controller\Frontend\Api\FaqController as FrontendApiFaqController; +use phpMyFAQ\Controller\Frontend\Api\PushController as FrontendApiPushController; +use phpMyFAQ\Controller\Frontend\Api\QuestionController as FrontendApiQuestionController; +use phpMyFAQ\Controller\Frontend\Api\UserController as FrontendApiUserController; +use phpMyFAQ\Controller\Frontend\Api\VotingController as FrontendApiVotingController; +use phpMyFAQ\Controller\SitemapController as RootSitemapController; use phpMyFAQ\Comment\CommentsRepository; use phpMyFAQ\Comments; use phpMyFAQ\Configuration; @@ -587,4 +607,109 @@ service('phpmyfaq.system'), service('filesystem'), ]); + + // ========== Controller services (constructor injection) ========== + + // Batch 1: Controller/Api/ + $services->set(ApiCategoryController::class)->args([ + service('phpmyfaq.language'), + ]); + $services->set(ApiCommentController::class)->args([ + service('phpmyfaq.comments'), + ]); + $services->set(ApiFaqController::class)->args([ + service('phpmyfaq.faq'), + service('phpmyfaq.tags'), + service('phpmyfaq.faq.statistics'), + service('phpmyfaq.faq.metadata'), + ]); + $services->set(ApiGlossaryController::class)->args([ + service('phpmyfaq.glossary'), + service('phpmyfaq.language'), + ]); + $services->set(ApiOpenQuestionController::class)->args([ + service('phpmyfaq.question'), + ]); + $services->set(ApiSearchController::class)->args([ + service('phpmyfaq.search'), + ]); + $services->set(ApiTagController::class)->args([ + service('phpmyfaq.tags'), + ]); + $services->set(ApiQuestionController::class)->args([ + service('phpmyfaq.notification'), + ]); + + // Batch 2: Controller/Frontend/Api/ + $services->set(FrontendApiAutoCompleteController::class)->args([ + service('phpmyfaq.faq.permission'), + service('phpmyfaq.search'), + service('phpmyfaq.helper.search'), + service('phpmyfaq.language.plurals'), + ]); + $services->set(FrontendApiCaptchaController::class)->args([ + service('phpmyfaq.captcha'), + ]); + $services->set(FrontendApiCommentController::class)->args([ + service('phpmyfaq.faq'), + service('phpmyfaq.comments'), + service('phpmyfaq.stop-words'), + service('phpmyfaq.user.session'), + service('phpmyfaq.language'), + service('phpmyfaq.user'), + service('phpmyfaq.notification'), + service('phpmyfaq.news'), + service('phpmyfaq.services.gravatar'), + ]); + $services->set(FrontendApiContactController::class)->args([ + service('phpmyfaq.stop-words'), + service('phpmyfaq.mail'), + ]); + $services->set(FrontendApiFaqController::class)->args([ + service('phpmyfaq.faq'), + service('phpmyfaq.helper.faq'), + service('phpmyfaq.question'), + service('phpmyfaq.stop-words'), + service('phpmyfaq.user.session'), + service('phpmyfaq.language'), + service('phpmyfaq.helper.category-helper'), + service('phpmyfaq.notification'), + ]); + $services->set(FrontendApiPushController::class)->args([ + service('phpmyfaq.push.web-push-service'), + service('phpmyfaq.push.subscription-repository'), + ]); + $services->set(FrontendApiQuestionController::class)->args([ + service('phpmyfaq.stop-words'), + service('phpmyfaq.helper.question'), + service('phpmyfaq.search'), + service('phpmyfaq.question'), + service('phpmyfaq.notification'), + ]); + $services->set(FrontendApiUserController::class)->args([ + service('phpmyfaq.stop-words'), + service('phpmyfaq.mail'), + ]); + $services->set(FrontendApiVotingController::class)->args([ + service('phpmyfaq.rating'), + service('phpmyfaq.user.session'), + ]); + + // Batch 3: Controller/Administration/Api/ + $services->set(AdminApiCategoryController::class)->args([ + service('phpmyfaq.category.image'), + service('phpmyfaq.category.order'), + service('phpmyfaq.category.permission'), + ]); + $services->set(AdminApiElasticsearchController::class)->args([ + service('phpmyfaq.instance.elasticsearch'), + service('phpmyfaq.faq'), + service('phpmyfaq.custom-page'), + ]); + + // Batch 6: Root controllers + $services->set(RootSitemapController::class)->args([ + service('phpmyfaq.faq.statistics'), + service('phpmyfaq.custom-page'), + ]); }; diff --git a/tests/phpMyFAQ/Controller/AbstractControllerTest.php b/tests/phpMyFAQ/Controller/AbstractControllerTest.php index 84d7f01127..b5e09f9310 100644 --- a/tests/phpMyFAQ/Controller/AbstractControllerTest.php +++ b/tests/phpMyFAQ/Controller/AbstractControllerTest.php @@ -456,7 +456,11 @@ public function testIsSecuredSucceedsWhenLoginNotRequired(): void public function testSetContainerReEvaluatesIsSecuredWhenContainerChanges(): void { - $controller = new class() extends AbstractController {}; + $controller = new class() extends AbstractController { + public function __construct() + { + } + }; $session = $this->createMock(SessionInterface::class); diff --git a/tests/phpMyFAQ/Controller/Administration/Api/CategoryControllerTest.php b/tests/phpMyFAQ/Controller/Administration/Api/CategoryControllerTest.php index e00bbd02f0..9e326b5a8e 100644 --- a/tests/phpMyFAQ/Controller/Administration/Api/CategoryControllerTest.php +++ b/tests/phpMyFAQ/Controller/Administration/Api/CategoryControllerTest.php @@ -4,6 +4,9 @@ namespace phpMyFAQ\Controller\Administration\Api; +use phpMyFAQ\Category\Image; +use phpMyFAQ\Category\Order; +use phpMyFAQ\Category\Permission; use phpMyFAQ\Configuration; use phpMyFAQ\Core\Exception; use phpMyFAQ\Strings; @@ -16,6 +19,9 @@ class CategoryControllerTest extends TestCase { private Configuration $configuration; + private Image $categoryImage; + private Order $categoryOrder; + private Permission $categoryPermission; /** * @throws Exception @@ -33,6 +39,9 @@ protected function setUp(): void ->setMultiByteLanguage(); $this->configuration = Configuration::getConfigurationInstance(); + $this->categoryImage = $this->createStub(Image::class); + $this->categoryOrder = $this->createStub(Order::class); + $this->categoryPermission = $this->createStub(Permission::class); } /** @@ -42,7 +51,7 @@ public function testDeleteRequiresAuthentication(): void { $requestData = json_encode(['csrfToken' => 'test-token', 'categoryId' => 1]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new CategoryController(); + $controller = new CategoryController($this->categoryImage, $this->categoryOrder, $this->categoryPermission); $this->expectException(\Exception::class); $controller->delete($request); @@ -54,7 +63,7 @@ public function testDeleteRequiresAuthentication(): void public function testPermissionsRequiresAuthentication(): void { $request = new Request(); - $controller = new CategoryController(); + $controller = new CategoryController($this->categoryImage, $this->categoryOrder, $this->categoryPermission); $this->expectException(\Exception::class); $controller->permissions($request); @@ -66,7 +75,7 @@ public function testPermissionsRequiresAuthentication(): void public function testTranslationsRequiresAuthentication(): void { $request = new Request(); - $controller = new CategoryController(); + $controller = new CategoryController($this->categoryImage, $this->categoryOrder, $this->categoryPermission); $this->expectException(\Exception::class); $controller->translations($request); @@ -79,7 +88,7 @@ public function testUpdateOrderRequiresAuthentication(): void { $requestData = json_encode(['csrfToken' => 'test-token', 'categoryId' => 1]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new CategoryController(); + $controller = new CategoryController($this->categoryImage, $this->categoryOrder, $this->categoryPermission); $this->expectException(\Exception::class); $controller->updateOrder($request); @@ -91,7 +100,7 @@ public function testUpdateOrderRequiresAuthentication(): void public function testDeleteWithInvalidJsonThrowsException(): void { $request = new Request([], [], [], [], [], [], 'invalid json'); - $controller = new CategoryController(); + $controller = new CategoryController($this->categoryImage, $this->categoryOrder, $this->categoryPermission); $this->expectException(\Exception::class); $controller->delete($request); @@ -103,7 +112,7 @@ public function testDeleteWithInvalidJsonThrowsException(): void public function testUpdateOrderWithInvalidJsonThrowsException(): void { $request = new Request([], [], [], [], [], [], 'invalid json'); - $controller = new CategoryController(); + $controller = new CategoryController($this->categoryImage, $this->categoryOrder, $this->categoryPermission); $this->expectException(\Exception::class); $controller->updateOrder($request); @@ -116,7 +125,7 @@ public function testDeleteWithMissingCsrfTokenThrowsException(): void { $requestData = json_encode(['categoryId' => 1]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new CategoryController(); + $controller = new CategoryController($this->categoryImage, $this->categoryOrder, $this->categoryPermission); $this->expectException(\Exception::class); $controller->delete($request); @@ -129,7 +138,7 @@ public function testUpdateOrderWithMissingCsrfTokenThrowsException(): void { $requestData = json_encode(['categoryId' => 1]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new CategoryController(); + $controller = new CategoryController($this->categoryImage, $this->categoryOrder, $this->categoryPermission); $this->expectException(\Exception::class); $controller->updateOrder($request); diff --git a/tests/phpMyFAQ/Controller/Administration/Api/ElasticsearchControllerTest.php b/tests/phpMyFAQ/Controller/Administration/Api/ElasticsearchControllerTest.php index 2967208159..50fadba19a 100644 --- a/tests/phpMyFAQ/Controller/Administration/Api/ElasticsearchControllerTest.php +++ b/tests/phpMyFAQ/Controller/Administration/Api/ElasticsearchControllerTest.php @@ -6,6 +6,9 @@ use phpMyFAQ\Configuration; use phpMyFAQ\Core\Exception; +use phpMyFAQ\CustomPage; +use phpMyFAQ\Faq; +use phpMyFAQ\Instance\Search\Elasticsearch; use phpMyFAQ\Strings; use phpMyFAQ\Translation; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; @@ -16,6 +19,9 @@ class ElasticsearchControllerTest extends TestCase { private Configuration $configuration; + private Elasticsearch $elasticsearch; + private Faq $faq; + private CustomPage $customPage; /** * @throws Exception @@ -33,6 +39,9 @@ protected function setUp(): void ->setMultiByteLanguage(); $this->configuration = Configuration::getConfigurationInstance(); + $this->elasticsearch = $this->createStub(Elasticsearch::class); + $this->faq = $this->createStub(Faq::class); + $this->customPage = $this->createStub(CustomPage::class); } /** @@ -41,7 +50,7 @@ protected function setUp(): void public function testCreateRequiresAuthentication(): void { $request = new Request(); - $controller = new ElasticsearchController(); + $controller = new ElasticsearchController($this->elasticsearch, $this->faq, $this->customPage); $this->expectException(\Exception::class); $controller->create(); @@ -53,7 +62,7 @@ public function testCreateRequiresAuthentication(): void public function testDropRequiresAuthentication(): void { $request = new Request(); - $controller = new ElasticsearchController(); + $controller = new ElasticsearchController($this->elasticsearch, $this->faq, $this->customPage); $this->expectException(\Exception::class); $controller->drop(); @@ -65,7 +74,7 @@ public function testDropRequiresAuthentication(): void public function testImportRequiresAuthentication(): void { $request = new Request(); - $controller = new ElasticsearchController(); + $controller = new ElasticsearchController($this->elasticsearch, $this->faq, $this->customPage); $this->expectException(\Exception::class); $controller->import(); @@ -77,7 +86,7 @@ public function testImportRequiresAuthentication(): void public function testStatisticsRequiresAuthentication(): void { $request = new Request(); - $controller = new ElasticsearchController(); + $controller = new ElasticsearchController($this->elasticsearch, $this->faq, $this->customPage); $this->expectException(\Exception::class); $controller->statistics(); @@ -89,7 +98,7 @@ public function testStatisticsRequiresAuthentication(): void public function testHealthcheckRequiresAuthentication(): void { $request = new Request(); - $controller = new ElasticsearchController(); + $controller = new ElasticsearchController($this->elasticsearch, $this->faq, $this->customPage); $this->expectException(\Exception::class); $controller->healthcheck(); diff --git a/tests/phpMyFAQ/Controller/Api/CategoryControllerTest.php b/tests/phpMyFAQ/Controller/Api/CategoryControllerTest.php index 431b4defea..9ef9de0e38 100644 --- a/tests/phpMyFAQ/Controller/Api/CategoryControllerTest.php +++ b/tests/phpMyFAQ/Controller/Api/CategoryControllerTest.php @@ -49,7 +49,7 @@ public function testListReturnsJsonResponse(): void $language->setLanguageWithDetection('language_en.php'); $this->configuration->setLanguage($language); - $controller = new CategoryController(); + $controller = new CategoryController($this->createStub(Language::class)); $response = $controller->list(); $this->assertInstanceOf(JsonResponse::class, $response); @@ -64,7 +64,7 @@ public function testListReturnsValidStatusCode(): void $language->setLanguageWithDetection('language_en.php'); $this->configuration->setLanguage($language); - $controller = new CategoryController(); + $controller = new CategoryController($this->createStub(Language::class)); $response = $controller->list(); $this->assertContains($response->getStatusCode(), [Response::HTTP_OK, Response::HTTP_NOT_FOUND]); @@ -84,7 +84,7 @@ public function testCreateRequiresValidToken(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new CategoryController(); + $controller = new CategoryController($this->createStub(Language::class)); $this->expectException(\Exception::class); $controller->create($request); @@ -95,7 +95,7 @@ public function testCreateWithInvalidJsonThrowsException(): void $requestData = 'invalid json'; $request = new Request([], [], [], [], [], [], $requestData); - $controller = new CategoryController(); + $controller = new CategoryController($this->createStub(Language::class)); $this->expectException(\Exception::class); $controller->create($request); @@ -108,7 +108,7 @@ public function testCreateWithMissingRequiredFieldsThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new CategoryController(); + $controller = new CategoryController($this->createStub(Language::class)); $this->expectException(\Exception::class); $controller->create($request); @@ -123,7 +123,7 @@ public function testListResponseContainsJsonData(): void $language->setLanguageWithDetection('language_en.php'); $this->configuration->setLanguage($language); - $controller = new CategoryController(); + $controller = new CategoryController($this->createStub(Language::class)); $response = $controller->list(); $this->assertJson($response->getContent()); @@ -138,7 +138,7 @@ public function testListReturnsArrayData(): void $language->setLanguageWithDetection('language_en.php'); $this->configuration->setLanguage($language); - $controller = new CategoryController(); + $controller = new CategoryController($this->createStub(Language::class)); $response = $controller->list(); $data = json_decode($response->getContent(), true); @@ -154,7 +154,7 @@ public function testListResponseContentIsNotNull(): void $language->setLanguageWithDetection('language_en.php'); $this->configuration->setLanguage($language); - $controller = new CategoryController(); + $controller = new CategoryController($this->createStub(Language::class)); $response = $controller->list(); $this->assertNotNull($response->getContent()); @@ -169,7 +169,7 @@ public function testListReturnsEmptyArrayOn404(): void $language->setLanguageWithDetection('language_en.php'); $this->configuration->setLanguage($language); - $controller = new CategoryController(); + $controller = new CategoryController($this->createStub(Language::class)); $response = $controller->list(); if ($response->getStatusCode() === Response::HTTP_NOT_FOUND) { diff --git a/tests/phpMyFAQ/Controller/Api/CommentControllerTest.php b/tests/phpMyFAQ/Controller/Api/CommentControllerTest.php index ba14e3d605..9ad00832fd 100644 --- a/tests/phpMyFAQ/Controller/Api/CommentControllerTest.php +++ b/tests/phpMyFAQ/Controller/Api/CommentControllerTest.php @@ -5,6 +5,7 @@ namespace phpMyFAQ\Controller\Api; use Exception; +use phpMyFAQ\Comments; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\JsonResponse; @@ -22,7 +23,7 @@ public function testListReturnsJsonResponse(): void $request = new Request(); $request->attributes->set('recordId', '1'); - $controller = new CommentController(); + $controller = new CommentController($this->createStub(Comments::class)); $response = $controller->list($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -36,7 +37,7 @@ public function testListReturnsValidStatusCode(): void $request = new Request(); $request->attributes->set('recordId', '1'); - $controller = new CommentController(); + $controller = new CommentController($this->createStub(Comments::class)); $response = $controller->list($request); $this->assertContains($response->getStatusCode(), [Response::HTTP_OK, Response::HTTP_NOT_FOUND]); @@ -50,7 +51,7 @@ public function testListWithNonExistentRecordId(): void $request = new Request(); $request->attributes->set('recordId', '999999'); - $controller = new CommentController(); + $controller = new CommentController($this->createStub(Comments::class)); $response = $controller->list($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -64,7 +65,7 @@ public function testListReturnsJsonData(): void $request = new Request(); $request->attributes->set('recordId', '1'); - $controller = new CommentController(); + $controller = new CommentController($this->createStub(Comments::class)); $response = $controller->list($request); $this->assertJson($response->getContent()); @@ -78,7 +79,7 @@ public function testListReturnsArrayData(): void $request = new Request(); $request->attributes->set('recordId', '1'); - $controller = new CommentController(); + $controller = new CommentController($this->createStub(Comments::class)); $response = $controller->list($request); $data = json_decode($response->getContent(), true); @@ -93,7 +94,7 @@ public function testListWithInvalidRecordId(): void $request = new Request(); $request->attributes->set('recordId', 'invalid'); - $controller = new CommentController(); + $controller = new CommentController($this->createStub(Comments::class)); $response = $controller->list($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -107,7 +108,7 @@ public function testListWithZeroRecordId(): void $request = new Request(); $request->attributes->set('recordId', '0'); - $controller = new CommentController(); + $controller = new CommentController($this->createStub(Comments::class)); $response = $controller->list($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -121,7 +122,7 @@ public function testListResponseContentIsNotNull(): void $request = new Request(); $request->attributes->set('recordId', '1'); - $controller = new CommentController(); + $controller = new CommentController($this->createStub(Comments::class)); $response = $controller->list($request); $this->assertNotNull($response->getContent()); @@ -135,7 +136,7 @@ public function testListReturnsEmptyArrayOn404(): void $request = new Request(); $request->attributes->set('recordId', '999999'); - $controller = new CommentController(); + $controller = new CommentController($this->createStub(Comments::class)); $response = $controller->list($request); $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); @@ -157,7 +158,7 @@ public function testListWithNegativeRecordId(): void $request = new Request(); $request->attributes->set('recordId', '-1'); - $controller = new CommentController(); + $controller = new CommentController($this->createStub(Comments::class)); $response = $controller->list($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -171,7 +172,7 @@ public function testListWithLargeRecordId(): void $request = new Request(); $request->attributes->set('recordId', '999999999'); - $controller = new CommentController(); + $controller = new CommentController($this->createStub(Comments::class)); $response = $controller->list($request); $this->assertInstanceOf(JsonResponse::class, $response); diff --git a/tests/phpMyFAQ/Controller/Api/FaqControllerTest.php b/tests/phpMyFAQ/Controller/Api/FaqControllerTest.php index bbc8e80aac..9376a74207 100644 --- a/tests/phpMyFAQ/Controller/Api/FaqControllerTest.php +++ b/tests/phpMyFAQ/Controller/Api/FaqControllerTest.php @@ -6,8 +6,12 @@ use phpMyFAQ\Configuration; use phpMyFAQ\Core\Exception; +use phpMyFAQ\Faq; +use phpMyFAQ\Faq\MetaData as FaqMetaData; +use phpMyFAQ\Faq\Statistics as FaqStatistics; use phpMyFAQ\Language; use phpMyFAQ\Strings; +use phpMyFAQ\Tags; use phpMyFAQ\Translation; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; @@ -49,7 +53,12 @@ protected function setUp(): void $request = new Request(); $request->attributes->set('categoryId', '1'); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getByCategoryId($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -63,7 +72,12 @@ protected function setUp(): void $request->attributes->set('faqId', '1'); $request->attributes->set('categoryId', '1'); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getById($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -76,7 +90,12 @@ protected function setUp(): void $request = new Request(); $request->attributes->set('tagId', '1'); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getByTagId($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -86,7 +105,12 @@ protected function setUp(): void * @throws Exception */ public function testGetPopularReturnsJsonResponse(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getPopular(); $this->assertInstanceOf(JsonResponse::class, $response); @@ -96,7 +120,12 @@ protected function setUp(): void * @throws Exception */ public function testGetLatestReturnsJsonResponse(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getLatest(); $this->assertInstanceOf(JsonResponse::class, $response); @@ -106,7 +135,12 @@ protected function setUp(): void * @throws Exception */ public function testGetTrendingReturnsJsonResponse(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getTrending(); $this->assertInstanceOf(JsonResponse::class, $response); @@ -116,7 +150,12 @@ protected function setUp(): void * @throws Exception */ public function testGetStickyReturnsJsonResponse(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getSticky(); $this->assertInstanceOf(JsonResponse::class, $response); @@ -126,7 +165,12 @@ protected function setUp(): void * @throws Exception */ public function testListReturnsJsonResponse(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->list(); $this->assertInstanceOf(JsonResponse::class, $response); @@ -150,7 +194,12 @@ protected function setUp(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $this->expectException(\Exception::class); $controller->create($request); @@ -175,7 +224,12 @@ protected function setUp(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $this->expectException(\Exception::class); $controller->update($request); @@ -189,7 +243,12 @@ public function testGetByCategoryIdReturnsValidStatusCode(): void $request = new Request(); $request->attributes->set('categoryId', '1'); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getByCategoryId($request); $this->assertContains($response->getStatusCode(), [200, 500]); @@ -204,7 +263,12 @@ public function testGetByIdReturnsValidStatusCode(): void $request->attributes->set('faqId', '1'); $request->attributes->set('categoryId', '1'); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getById($request); $this->assertContains($response->getStatusCode(), [200, 404]); @@ -219,7 +283,12 @@ public function testGetByIdWithNonExistentFaq(): void $request->attributes->set('faqId', '999999'); $request->attributes->set('categoryId', '999999'); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getById($request); $this->assertEquals(404, $response->getStatusCode()); @@ -233,7 +302,12 @@ public function testGetByTagIdReturnsValidStatusCode(): void $request = new Request(); $request->attributes->set('tagId', '1'); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getByTagId($request); $this->assertContains($response->getStatusCode(), [200, 500]); @@ -244,7 +318,12 @@ public function testGetByTagIdReturnsValidStatusCode(): void */ public function testGetPopularReturnsValidStatusCode(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getPopular(); $this->assertContains($response->getStatusCode(), [200, 404]); @@ -255,7 +334,12 @@ public function testGetPopularReturnsValidStatusCode(): void */ public function testGetLatestReturnsValidStatusCode(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getLatest(); $this->assertContains($response->getStatusCode(), [200, 404]); @@ -266,7 +350,12 @@ public function testGetLatestReturnsValidStatusCode(): void */ public function testGetTrendingReturnsValidStatusCode(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getTrending(); $this->assertContains($response->getStatusCode(), [200, 404]); @@ -277,7 +366,12 @@ public function testGetTrendingReturnsValidStatusCode(): void */ public function testGetStickyReturnsValidStatusCode(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getSticky(); $this->assertContains($response->getStatusCode(), [200, 404]); @@ -288,7 +382,12 @@ public function testGetStickyReturnsValidStatusCode(): void */ public function testListReturnsValidStatusCode(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->list(); $this->assertContains($response->getStatusCode(), [200, 404]); @@ -302,7 +401,12 @@ public function testGetByCategoryIdReturnsJsonData(): void $request = new Request(); $request->attributes->set('categoryId', '1'); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getByCategoryId($request); $this->assertJson($response->getContent()); @@ -317,7 +421,12 @@ public function testGetByIdReturnsJsonData(): void $request->attributes->set('faqId', '1'); $request->attributes->set('categoryId', '1'); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getById($request); $this->assertJson($response->getContent()); @@ -328,7 +437,12 @@ public function testGetByIdReturnsJsonData(): void */ public function testListReturnsArrayData(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->list(); $data = json_decode($response->getContent(), true); @@ -344,7 +458,12 @@ public function testCreateWithInvalidJsonThrowsException(): void $requestData = 'invalid json'; $request = new Request([], [], [], [], [], [], $requestData); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $this->expectException(\Exception::class); $controller->create($request); @@ -359,7 +478,12 @@ public function testUpdateWithInvalidJsonThrowsException(): void $requestData = 'invalid json'; $request = new Request([], [], [], [], [], [], $requestData); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $this->expectException(\Exception::class); $controller->update($request); @@ -373,7 +497,12 @@ public function testGetByCategoryIdResponseHeaders(): void $request = new Request(); $request->attributes->set('categoryId', '1'); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getByCategoryId($request); $this->assertTrue($response->headers->has('Content-Type')); @@ -389,7 +518,12 @@ public function testGetByIdResponseHeaders(): void $request->attributes->set('faqId', '1'); $request->attributes->set('categoryId', '1'); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getById($request); $this->assertTrue($response->headers->has('Content-Type')); @@ -400,7 +534,12 @@ public function testGetByIdResponseHeaders(): void */ public function testGetPopularResponseIsNotEmpty(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getPopular(); $content = $response->getContent(); @@ -413,7 +552,12 @@ public function testGetPopularResponseIsNotEmpty(): void */ public function testGetLatestResponseIsNotEmpty(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getLatest(); $content = $response->getContent(); @@ -426,7 +570,12 @@ public function testGetLatestResponseIsNotEmpty(): void */ public function testGetTrendingResponseIsNotEmpty(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getTrending(); $content = $response->getContent(); @@ -439,7 +588,12 @@ public function testGetTrendingResponseIsNotEmpty(): void */ public function testGetStickyResponseIsNotEmpty(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getSticky(); $content = $response->getContent(); @@ -452,7 +606,12 @@ public function testGetStickyResponseIsNotEmpty(): void */ public function testListResponseIsNotEmpty(): void { - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->list(); $content = $response->getContent(); @@ -471,7 +630,12 @@ public function testGetByCategoryIdWithMultipleCategories(): void $request = new Request(); $request->attributes->set('categoryId', $categoryId); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getByCategoryId($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -495,7 +659,12 @@ public function testGetByIdWithMultipleFaqs(): void $request->attributes->set('faqId', $faq['faqId']); $request->attributes->set('categoryId', $faq['categoryId']); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getById($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -513,7 +682,12 @@ public function testGetByTagIdWithMultipleTags(): void $request = new Request(); $request->attributes->set('tagId', $tagId); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getByTagId($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -529,7 +703,12 @@ public function testGetByCategoryIdJsonStructure(): void $request = new Request(); $request->attributes->set('categoryId', '1'); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getByCategoryId($request); $data = json_decode($response->getContent(), true); @@ -549,7 +728,12 @@ public function testGetByIdJsonStructure(): void $request->attributes->set('faqId', '1'); $request->attributes->set('categoryId', '1'); - $controller = new FaqController(); + $controller = new FaqController( + $this->createStub(Faq::class), + $this->createStub(Tags::class), + $this->createStub(FaqStatistics::class), + $this->createStub(FaqMetaData::class), + ); $response = $controller->getById($request); $content = $response->getContent(); diff --git a/tests/phpMyFAQ/Controller/Api/GlossaryControllerTest.php b/tests/phpMyFAQ/Controller/Api/GlossaryControllerTest.php index 55e2af026b..4c3db62d7c 100644 --- a/tests/phpMyFAQ/Controller/Api/GlossaryControllerTest.php +++ b/tests/phpMyFAQ/Controller/Api/GlossaryControllerTest.php @@ -3,6 +3,7 @@ namespace phpMyFAQ\Controller\Api; use phpMyFAQ\Configuration; +use phpMyFAQ\Glossary; use phpMyFAQ\Language; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Exception; @@ -30,7 +31,10 @@ protected function setUp(): void public function testListReturnsGlossaryItems(): void { - $glossaryController = new GlossaryController(); + $glossaryController = new GlossaryController( + $this->createStub(Glossary::class), + $this->createStub(Language::class), + ); $request = Request::create( '/api/v3.2/glossary', @@ -55,7 +59,10 @@ public function testListReturnsGlossaryItems(): void public function testListHandlesAcceptLanguageHeader(): void { - $glossaryController = new GlossaryController(); + $glossaryController = new GlossaryController( + $this->createStub(Glossary::class), + $this->createStub(Language::class), + ); $request = Request::create( '/api/v3.2/glossary', @@ -76,7 +83,10 @@ public function testListHandlesAcceptLanguageHeader(): void public function testListWithoutAcceptLanguageHeader(): void { - $glossaryController = new GlossaryController(); + $glossaryController = new GlossaryController( + $this->createStub(Glossary::class), + $this->createStub(Language::class), + ); $request = Request::create('/api/v3.2/glossary', 'GET'); @@ -88,7 +98,10 @@ public function testListWithoutAcceptLanguageHeader(): void public function testListReturnsJsonData(): void { - $glossaryController = new GlossaryController(); + $glossaryController = new GlossaryController( + $this->createStub(Glossary::class), + $this->createStub(Language::class), + ); $request = Request::create('/api/v3.2/glossary', 'GET'); @@ -99,7 +112,10 @@ public function testListReturnsJsonData(): void public function testListResponseContentIsNotNull(): void { - $glossaryController = new GlossaryController(); + $glossaryController = new GlossaryController( + $this->createStub(Glossary::class), + $this->createStub(Language::class), + ); $request = Request::create('/api/v3.2/glossary', 'GET'); @@ -110,7 +126,10 @@ public function testListResponseContentIsNotNull(): void public function testListReturnsEmptyArrayOn404(): void { - $glossaryController = new GlossaryController(); + $glossaryController = new GlossaryController( + $this->createStub(Glossary::class), + $this->createStub(Language::class), + ); $request = Request::create('/api/v3.2/glossary', 'GET'); diff --git a/tests/phpMyFAQ/Controller/Api/OpenQuestionControllerTest.php b/tests/phpMyFAQ/Controller/Api/OpenQuestionControllerTest.php index 9a0796fe79..bd65256785 100644 --- a/tests/phpMyFAQ/Controller/Api/OpenQuestionControllerTest.php +++ b/tests/phpMyFAQ/Controller/Api/OpenQuestionControllerTest.php @@ -5,6 +5,7 @@ namespace phpMyFAQ\Controller\Api; use Exception; +use phpMyFAQ\Question; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\JsonResponse; @@ -18,7 +19,7 @@ class OpenQuestionControllerTest extends TestCase */ public function testListReturnsJsonResponse(): void { - $controller = new OpenQuestionController(); + $controller = new OpenQuestionController($this->createStub(Question::class)); $response = $controller->list(); $this->assertInstanceOf(JsonResponse::class, $response); @@ -29,7 +30,7 @@ public function testListReturnsJsonResponse(): void */ public function testListReturnsValidStatusCode(): void { - $controller = new OpenQuestionController(); + $controller = new OpenQuestionController($this->createStub(Question::class)); $response = $controller->list(); $this->assertContains($response->getStatusCode(), [Response::HTTP_OK, Response::HTTP_NOT_FOUND]); @@ -40,7 +41,7 @@ public function testListReturnsValidStatusCode(): void */ public function testListReturnsJsonData(): void { - $controller = new OpenQuestionController(); + $controller = new OpenQuestionController($this->createStub(Question::class)); $response = $controller->list(); $this->assertJson($response->getContent()); @@ -51,7 +52,7 @@ public function testListReturnsJsonData(): void */ public function testListReturnsArrayData(): void { - $controller = new OpenQuestionController(); + $controller = new OpenQuestionController($this->createStub(Question::class)); $response = $controller->list(); $data = json_decode($response->getContent(), true); @@ -63,7 +64,7 @@ public function testListReturnsArrayData(): void */ public function testListResponseContentIsNotNull(): void { - $controller = new OpenQuestionController(); + $controller = new OpenQuestionController($this->createStub(Question::class)); $response = $controller->list(); $this->assertNotNull($response->getContent()); @@ -74,7 +75,7 @@ public function testListResponseContentIsNotNull(): void */ public function testListReturnsEmptyArrayOn404(): void { - $controller = new OpenQuestionController(); + $controller = new OpenQuestionController($this->createStub(Question::class)); $response = $controller->list(); $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); diff --git a/tests/phpMyFAQ/Controller/Api/QuestionControllerTest.php b/tests/phpMyFAQ/Controller/Api/QuestionControllerTest.php index dd0f425052..cdcdcd7d69 100644 --- a/tests/phpMyFAQ/Controller/Api/QuestionControllerTest.php +++ b/tests/phpMyFAQ/Controller/Api/QuestionControllerTest.php @@ -6,6 +6,7 @@ use phpMyFAQ\Configuration; use phpMyFAQ\Core\Exception; +use phpMyFAQ\Notification; use phpMyFAQ\Strings; use phpMyFAQ\Translation; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; @@ -46,7 +47,7 @@ public function testCreateReturnsJsonResponse(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = new QuestionController($this->createStub(Notification::class)); $this->expectException(\Exception::class); $controller->create($request); @@ -62,7 +63,7 @@ public function testCreateRequiresValidToken(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = new QuestionController($this->createStub(Notification::class)); $this->expectException(\Exception::class); $controller->create($request); @@ -75,7 +76,7 @@ public function testCreateRequiresAllRequiredFields(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = new QuestionController($this->createStub(Notification::class)); $this->expectException(\Exception::class); $controller->create($request); @@ -86,7 +87,7 @@ public function testCreateWithInvalidJsonThrowsException(): void $requestData = 'invalid json'; $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = new QuestionController($this->createStub(Notification::class)); $this->expectException(\Exception::class); $controller->create($request); @@ -101,7 +102,7 @@ public function testCreateWithMissingCategoryId(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = new QuestionController($this->createStub(Notification::class)); $this->expectException(\Exception::class); $controller->create($request); @@ -116,7 +117,7 @@ public function testCreateWithMissingQuestion(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = new QuestionController($this->createStub(Notification::class)); $this->expectException(\Exception::class); $controller->create($request); @@ -131,7 +132,7 @@ public function testCreateWithMissingAuthor(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = new QuestionController($this->createStub(Notification::class)); $this->expectException(\Exception::class); $controller->create($request); @@ -146,7 +147,7 @@ public function testCreateWithMissingEmail(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = new QuestionController($this->createStub(Notification::class)); $this->expectException(\Exception::class); $controller->create($request); @@ -162,7 +163,7 @@ public function testCreateWithInvalidCategoryId(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = new QuestionController($this->createStub(Notification::class)); $this->expectException(\Exception::class); $controller->create($request); diff --git a/tests/phpMyFAQ/Controller/Api/SearchControllerTest.php b/tests/phpMyFAQ/Controller/Api/SearchControllerTest.php index dcb0f85948..ad2224c0db 100644 --- a/tests/phpMyFAQ/Controller/Api/SearchControllerTest.php +++ b/tests/phpMyFAQ/Controller/Api/SearchControllerTest.php @@ -4,6 +4,7 @@ namespace phpMyFAQ\Controller\Api; +use phpMyFAQ\Search; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\JsonResponse; @@ -16,7 +17,7 @@ class SearchControllerTest extends TestCase public function testSearchReturnsJsonResponse(): void { $request = new Request(['q' => 'test']); - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->search($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -25,7 +26,7 @@ public function testSearchReturnsJsonResponse(): void public function testSearchReturnsValidStatusCode(): void { $request = new Request(['q' => 'test']); - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->search($request); $this->assertContains($response->getStatusCode(), [ @@ -37,7 +38,7 @@ public function testSearchReturnsValidStatusCode(): void public function testPopularReturnsJsonResponse(): void { - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->popular(); $this->assertInstanceOf(JsonResponse::class, $response); @@ -45,7 +46,7 @@ public function testPopularReturnsJsonResponse(): void public function testPopularReturnsValidStatusCode(): void { - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->popular(); $this->assertContains($response->getStatusCode(), [Response::HTTP_OK, Response::HTTP_NOT_FOUND]); @@ -54,7 +55,7 @@ public function testPopularReturnsValidStatusCode(): void public function testSearchWithEmptyQuery(): void { $request = new Request(['q' => '']); - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->search($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -63,7 +64,7 @@ public function testSearchWithEmptyQuery(): void public function testSearchReturnsJsonData(): void { $request = new Request(['q' => 'test']); - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->search($request); $this->assertJson($response->getContent()); @@ -72,7 +73,7 @@ public function testSearchReturnsJsonData(): void public function testSearchReturnsArrayData(): void { $request = new Request(['q' => 'test']); - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->search($request); $data = json_decode($response->getContent(), true); @@ -81,7 +82,7 @@ public function testSearchReturnsArrayData(): void public function testPopularReturnsJsonData(): void { - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->popular(); $this->assertJson($response->getContent()); @@ -89,7 +90,7 @@ public function testPopularReturnsJsonData(): void public function testPopularReturnsArrayData(): void { - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->popular(); $data = json_decode($response->getContent(), true); @@ -99,7 +100,7 @@ public function testPopularReturnsArrayData(): void public function testSearchWithSpecialCharacters(): void { $request = new Request(['q' => '@#$%^&*()']); - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->search($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -108,7 +109,7 @@ public function testSearchWithSpecialCharacters(): void public function testSearchWithUnicodeCharacters(): void { $request = new Request(['q' => '日本語テスト']); - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->search($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -118,7 +119,7 @@ public function testSearchWithUnicodeCharacters(): void public function testSearchWithLongQuery(): void { $request = new Request(['q' => str_repeat('a', 1000)]); - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->search($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -127,7 +128,7 @@ public function testSearchWithLongQuery(): void public function testSearchWithWhitespaceQuery(): void { $request = new Request(['q' => ' ']); - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->search($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -136,7 +137,7 @@ public function testSearchWithWhitespaceQuery(): void public function testSearchResponseContentIsNotNull(): void { $request = new Request(['q' => 'test']); - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->search($request); $this->assertNotNull($response->getContent()); @@ -144,7 +145,7 @@ public function testSearchResponseContentIsNotNull(): void public function testPopularResponseContentIsNotNull(): void { - $controller = new SearchController(); + $controller = new SearchController($this->createStub(Search::class)); $response = $controller->popular(); $this->assertNotNull($response->getContent()); diff --git a/tests/phpMyFAQ/Controller/Api/TagControllerTest.php b/tests/phpMyFAQ/Controller/Api/TagControllerTest.php index 7cce1aa543..06305211c4 100644 --- a/tests/phpMyFAQ/Controller/Api/TagControllerTest.php +++ b/tests/phpMyFAQ/Controller/Api/TagControllerTest.php @@ -6,6 +6,7 @@ use phpMyFAQ\Configuration; use phpMyFAQ\Language; +use phpMyFAQ\Tags; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\TestCase; @@ -31,7 +32,7 @@ protected function setUp(): void public function testListReturnsJsonResponse(): void { - $controller = new TagController(); + $controller = new TagController($this->createStub(Tags::class)); $response = $controller->list(); $this->assertInstanceOf(JsonResponse::class, $response); @@ -39,7 +40,7 @@ public function testListReturnsJsonResponse(): void public function testListReturnsValidStatusCode(): void { - $controller = new TagController(); + $controller = new TagController($this->createStub(Tags::class)); $response = $controller->list(); $this->assertContains($response->getStatusCode(), [Response::HTTP_OK, Response::HTTP_NOT_FOUND]); @@ -47,7 +48,7 @@ public function testListReturnsValidStatusCode(): void public function testListReturnsJsonData(): void { - $controller = new TagController(); + $controller = new TagController($this->createStub(Tags::class)); $response = $controller->list(); $this->assertJson($response->getContent()); @@ -55,7 +56,7 @@ public function testListReturnsJsonData(): void public function testListReturnsArrayData(): void { - $controller = new TagController(); + $controller = new TagController($this->createStub(Tags::class)); $response = $controller->list(); $data = json_decode($response->getContent(), true); @@ -64,7 +65,7 @@ public function testListReturnsArrayData(): void public function testListResponseContentIsNotNull(): void { - $controller = new TagController(); + $controller = new TagController($this->createStub(Tags::class)); $response = $controller->list(); $this->assertNotNull($response->getContent()); @@ -72,7 +73,7 @@ public function testListResponseContentIsNotNull(): void public function testListReturnsCorrectStructureWhenTagsExist(): void { - $controller = new TagController(); + $controller = new TagController($this->createStub(Tags::class)); $response = $controller->list(); $data = json_decode($response->getContent(), true); @@ -99,7 +100,7 @@ public function testListReturnsCorrectStructureWhenTagsExist(): void public function testListReturnsEmptyArrayOn404(): void { - $controller = new TagController(); + $controller = new TagController($this->createStub(Tags::class)); $response = $controller->list(); $this->assertEquals(Response::HTTP_OK, $response->getStatusCode()); diff --git a/tests/phpMyFAQ/Controller/Frontend/Api/AutoCompleteControllerTest.php b/tests/phpMyFAQ/Controller/Frontend/Api/AutoCompleteControllerTest.php index 056504e271..b47ead6739 100644 --- a/tests/phpMyFAQ/Controller/Frontend/Api/AutoCompleteControllerTest.php +++ b/tests/phpMyFAQ/Controller/Frontend/Api/AutoCompleteControllerTest.php @@ -6,6 +6,10 @@ use phpMyFAQ\Configuration; use phpMyFAQ\Core\Exception; +use phpMyFAQ\Faq\Permission; +use phpMyFAQ\Helper\SearchHelper; +use phpMyFAQ\Language\Plurals; +use phpMyFAQ\Search; use phpMyFAQ\Strings; use phpMyFAQ\Translation; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; @@ -18,6 +22,10 @@ class AutoCompleteControllerTest extends TestCase { private Configuration $configuration; + private Permission $faqPermission; + private Search $faqSearch; + private SearchHelper $faqSearchHelper; + private Plurals $plurals; /** * @throws Exception @@ -35,6 +43,21 @@ protected function setUp(): void ->setMultiByteLanguage(); $this->configuration = Configuration::getConfigurationInstance(); + + $this->faqPermission = $this->createStub(Permission::class); + $this->faqSearch = $this->createStub(Search::class); + $this->faqSearchHelper = $this->createStub(SearchHelper::class); + $this->plurals = $this->createStub(Plurals::class); + } + + private function createController(): AutoCompleteController + { + return new AutoCompleteController( + $this->faqPermission, + $this->faqSearch, + $this->faqSearchHelper, + $this->plurals, + ); } /** @@ -44,7 +67,7 @@ public function testSearchReturnsJsonResponse(): void { $request = new Request(['search' => 'test']); - $controller = new AutoCompleteController(); + $controller = $this->createController(); $response = $controller->search($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -57,7 +80,7 @@ public function testSearchWithValidQueryReturnsOkOrNotFound(): void { $request = new Request(['search' => 'test']); - $controller = new AutoCompleteController(); + $controller = $this->createController(); $response = $controller->search($request); $this->assertContains($response->getStatusCode(), [Response::HTTP_OK, Response::HTTP_NOT_FOUND]); @@ -70,7 +93,7 @@ public function testSearchWithoutQueryReturnsNotFound(): void { $request = new Request(); - $controller = new AutoCompleteController(); + $controller = $this->createController(); $response = $controller->search($request); $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); @@ -83,7 +106,7 @@ public function testSearchReturnsValidJsonContent(): void { $request = new Request(['search' => 'test']); - $controller = new AutoCompleteController(); + $controller = $this->createController(); $response = $controller->search($request); $this->assertJson($response->getContent()); @@ -96,7 +119,7 @@ public function testSearchReturnsArrayData(): void { $request = new Request(['search' => 'test']); - $controller = new AutoCompleteController(); + $controller = $this->createController(); $response = $controller->search($request); $data = json_decode($response->getContent(), true); @@ -110,7 +133,7 @@ public function testSearchResponseHasCorrectContentType(): void { $request = new Request(['search' => 'test']); - $controller = new AutoCompleteController(); + $controller = $this->createController(); $response = $controller->search($request); $this->assertTrue($response->headers->has('Content-Type')); @@ -124,7 +147,7 @@ public function testSearchWithEmptyStringReturnsNotFound(): void { $request = new Request(['search' => '']); - $controller = new AutoCompleteController(); + $controller = $this->createController(); $response = $controller->search($request); $this->assertEquals(Response::HTTP_NOT_FOUND, $response->getStatusCode()); @@ -139,7 +162,7 @@ public function testSearchWithMultipleQueries(): void foreach ($queries as $query) { $request = new Request(['search' => $query]); - $controller = new AutoCompleteController(); + $controller = $this->createController(); $response = $controller->search($request); $this->assertInstanceOf(JsonResponse::class, $response); @@ -154,7 +177,7 @@ public function testSearchResponseIsNotEmpty(): void { $request = new Request(['search' => 'test']); - $controller = new AutoCompleteController(); + $controller = $this->createController(); $response = $controller->search($request); $content = $response->getContent(); @@ -169,7 +192,7 @@ public function testSearchWithSpecialCharacters(): void { $request = new Request(['search' => '']); - $controller = new AutoCompleteController(); + $controller = $this->createController(); $response = $controller->search($request); $this->assertInstanceOf(JsonResponse::class, $response); diff --git a/tests/phpMyFAQ/Controller/Frontend/Api/CommentControllerTest.php b/tests/phpMyFAQ/Controller/Frontend/Api/CommentControllerTest.php index 1b421fbcf5..33ea931e7b 100644 --- a/tests/phpMyFAQ/Controller/Frontend/Api/CommentControllerTest.php +++ b/tests/phpMyFAQ/Controller/Frontend/Api/CommentControllerTest.php @@ -4,10 +4,19 @@ namespace phpMyFAQ\Controller\Frontend\Api; +use phpMyFAQ\Comments; use phpMyFAQ\Configuration; use phpMyFAQ\Core\Exception; +use phpMyFAQ\Faq; +use phpMyFAQ\Language; +use phpMyFAQ\News; +use phpMyFAQ\Notification; +use phpMyFAQ\Service\Gravatar; +use phpMyFAQ\StopWords; use phpMyFAQ\Strings; use phpMyFAQ\Translation; +use phpMyFAQ\User; +use phpMyFAQ\User\UserSession; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; @@ -16,6 +25,15 @@ class CommentControllerTest extends TestCase { private Configuration $configuration; + private Faq $faq; + private Comments $comments; + private StopWords $stopWords; + private UserSession $userSession; + private Language $language; + private User $user; + private Notification $notification; + private News $news; + private Gravatar $gravatar; /** * @throws Exception @@ -33,6 +51,31 @@ protected function setUp(): void ->setMultiByteLanguage(); $this->configuration = Configuration::getConfigurationInstance(); + + $this->faq = $this->createStub(Faq::class); + $this->comments = $this->createStub(Comments::class); + $this->stopWords = $this->createStub(StopWords::class); + $this->userSession = $this->createStub(UserSession::class); + $this->language = $this->createStub(Language::class); + $this->user = $this->createStub(User::class); + $this->notification = $this->createStub(Notification::class); + $this->news = $this->createStub(News::class); + $this->gravatar = $this->createStub(Gravatar::class); + } + + private function createController(): CommentController + { + return new CommentController( + $this->faq, + $this->comments, + $this->stopWords, + $this->userSession, + $this->language, + $this->user, + $this->notification, + $this->news, + $this->gravatar, + ); } /** @@ -44,7 +87,7 @@ public function testCreateWithInvalidJsonThrowsException(): void $requestData = 'invalid json'; $request = new Request([], [], [], [], [], [], $requestData); - $controller = new CommentController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -65,7 +108,7 @@ public function testCreateWithMissingCsrfTokenReturnsUnauthorized(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new CommentController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -87,7 +130,7 @@ public function testCreateWithEmptyCommentTextReturnsBadRequest(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new CommentController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -108,7 +151,7 @@ public function testCreateWithMissingUserThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new CommentController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -130,7 +173,7 @@ public function testCreateWithInvalidEmailThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new CommentController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -152,7 +195,7 @@ public function testCreateWithNewsTypeThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new CommentController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); diff --git a/tests/phpMyFAQ/Controller/Frontend/Api/ContactControllerTest.php b/tests/phpMyFAQ/Controller/Frontend/Api/ContactControllerTest.php index c7c29d1672..1f477c10ce 100644 --- a/tests/phpMyFAQ/Controller/Frontend/Api/ContactControllerTest.php +++ b/tests/phpMyFAQ/Controller/Frontend/Api/ContactControllerTest.php @@ -6,6 +6,8 @@ use phpMyFAQ\Configuration; use phpMyFAQ\Core\Exception; +use phpMyFAQ\Mail; +use phpMyFAQ\StopWords; use phpMyFAQ\Strings; use phpMyFAQ\Translation; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; @@ -16,6 +18,8 @@ class ContactControllerTest extends TestCase { private Configuration $configuration; + private StopWords $stopWords; + private Mail $mailer; /** * @throws Exception @@ -33,6 +37,14 @@ protected function setUp(): void ->setMultiByteLanguage(); $this->configuration = Configuration::getConfigurationInstance(); + + $this->stopWords = $this->createStub(StopWords::class); + $this->mailer = $this->createStub(Mail::class); + } + + private function createController(): ContactController + { + return new ContactController($this->stopWords, $this->mailer); } /** @@ -44,7 +56,7 @@ public function testCreateWithInvalidJsonThrowsException(): void $requestData = 'invalid json'; $request = new Request([], [], [], [], [], [], $requestData); - $controller = new ContactController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -62,7 +74,7 @@ public function testCreateWithMissingNameThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new ContactController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -81,7 +93,7 @@ public function testCreateWithInvalidEmailThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new ContactController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -100,7 +112,7 @@ public function testCreateWithEmptyQuestionThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new ContactController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -118,7 +130,7 @@ public function testCreateWithMissingQuestionThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new ContactController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -137,7 +149,7 @@ public function testCreateWithValidDataRequiresCaptcha(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new ContactController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); diff --git a/tests/phpMyFAQ/Controller/Frontend/Api/FaqControllerTest.php b/tests/phpMyFAQ/Controller/Frontend/Api/FaqControllerTest.php index b32308d7ca..c8f348b80d 100644 --- a/tests/phpMyFAQ/Controller/Frontend/Api/FaqControllerTest.php +++ b/tests/phpMyFAQ/Controller/Frontend/Api/FaqControllerTest.php @@ -6,8 +6,16 @@ use phpMyFAQ\Configuration; use phpMyFAQ\Core\Exception; +use phpMyFAQ\Faq; +use phpMyFAQ\Helper\CategoryHelper; +use phpMyFAQ\Helper\FaqHelper; +use phpMyFAQ\Language; +use phpMyFAQ\Notification; +use phpMyFAQ\Question; +use phpMyFAQ\StopWords; use phpMyFAQ\Strings; use phpMyFAQ\Translation; +use phpMyFAQ\User\UserSession; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; @@ -17,6 +25,14 @@ class FaqControllerTest extends TestCase { private Configuration $configuration; + private Faq $faq; + private FaqHelper $faqHelper; + private Question $question; + private StopWords $stopWords; + private UserSession $userSession; + private Language $language; + private CategoryHelper $categoryHelper; + private Notification $notification; /** * @throws Exception @@ -34,6 +50,29 @@ protected function setUp(): void ->setMultiByteLanguage(); $this->configuration = Configuration::getConfigurationInstance(); + + $this->faq = $this->createStub(Faq::class); + $this->faqHelper = $this->createStub(FaqHelper::class); + $this->question = $this->createStub(Question::class); + $this->stopWords = $this->createStub(StopWords::class); + $this->userSession = $this->createStub(UserSession::class); + $this->language = $this->createStub(Language::class); + $this->categoryHelper = $this->createStub(CategoryHelper::class); + $this->notification = $this->createStub(Notification::class); + } + + private function createController(): FaqController + { + return new FaqController( + $this->faq, + $this->faqHelper, + $this->question, + $this->stopWords, + $this->userSession, + $this->language, + $this->categoryHelper, + $this->notification, + ); } /** @@ -44,7 +83,7 @@ public function testCreateWithInvalidJsonThrowsException(): void $requestData = 'invalid json'; $request = new Request([], [], [], [], [], [], $requestData); - $controller = new FaqController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -64,7 +103,7 @@ public function testCreateWithMissingNameThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new FaqController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -85,7 +124,7 @@ public function testCreateWithInvalidEmailThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new FaqController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -106,7 +145,7 @@ public function testCreateWithEmptyQuestionThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new FaqController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -126,7 +165,7 @@ public function testCreateWithMissingAnswerThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new FaqController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); diff --git a/tests/phpMyFAQ/Controller/Frontend/Api/QuestionControllerTest.php b/tests/phpMyFAQ/Controller/Frontend/Api/QuestionControllerTest.php index e91ecd33e9..37e391e4a4 100644 --- a/tests/phpMyFAQ/Controller/Frontend/Api/QuestionControllerTest.php +++ b/tests/phpMyFAQ/Controller/Frontend/Api/QuestionControllerTest.php @@ -6,6 +6,11 @@ use phpMyFAQ\Configuration; use phpMyFAQ\Core\Exception; +use phpMyFAQ\Helper\QuestionHelper; +use phpMyFAQ\Notification; +use phpMyFAQ\Question; +use phpMyFAQ\Search; +use phpMyFAQ\StopWords; use phpMyFAQ\Strings; use phpMyFAQ\Translation; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; @@ -16,6 +21,11 @@ class QuestionControllerTest extends TestCase { private Configuration $configuration; + private StopWords $stopWords; + private QuestionHelper $questionHelper; + private Search $search; + private Question $question; + private Notification $notification; /** * @throws Exception @@ -33,6 +43,23 @@ protected function setUp(): void ->setMultiByteLanguage(); $this->configuration = Configuration::getConfigurationInstance(); + + $this->stopWords = $this->createStub(StopWords::class); + $this->questionHelper = $this->createStub(QuestionHelper::class); + $this->search = $this->createStub(Search::class); + $this->question = $this->createStub(Question::class); + $this->notification = $this->createStub(Notification::class); + } + + private function createController(): QuestionController + { + return new QuestionController( + $this->stopWords, + $this->questionHelper, + $this->search, + $this->question, + $this->notification, + ); } /** @@ -44,7 +71,7 @@ public function testCreateWithInvalidJsonThrowsException(): void $requestData = 'invalid json'; $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -63,7 +90,7 @@ public function testCreateWithMissingNameThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -83,7 +110,7 @@ public function testCreateWithInvalidEmailThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -103,7 +130,7 @@ public function testCreateWithEmptyQuestionThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -122,7 +149,7 @@ public function testCreateWithMissingLanguageThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -143,7 +170,7 @@ public function testCreateWithCategoryThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -164,7 +191,7 @@ public function testCreateWithSaveParameterThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new QuestionController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); diff --git a/tests/phpMyFAQ/Controller/Frontend/Api/UserControllerTest.php b/tests/phpMyFAQ/Controller/Frontend/Api/UserControllerTest.php index 1c5fd7fa53..c013a41372 100644 --- a/tests/phpMyFAQ/Controller/Frontend/Api/UserControllerTest.php +++ b/tests/phpMyFAQ/Controller/Frontend/Api/UserControllerTest.php @@ -6,6 +6,8 @@ use phpMyFAQ\Configuration; use phpMyFAQ\Core\Exception; +use phpMyFAQ\Mail; +use phpMyFAQ\StopWords; use phpMyFAQ\Strings; use phpMyFAQ\Translation; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; @@ -16,6 +18,8 @@ class UserControllerTest extends TestCase { private Configuration $configuration; + private StopWords $stopWords; + private Mail $mailer; /** * @throws Exception @@ -33,6 +37,14 @@ protected function setUp(): void ->setMultiByteLanguage(); $this->configuration = Configuration::getConfigurationInstance(); + + $this->stopWords = $this->createStub(StopWords::class); + $this->mailer = $this->createStub(Mail::class); + } + + private function createController(): UserController + { + return new UserController($this->stopWords, $this->mailer); } /** @@ -49,7 +61,7 @@ public function testUpdateDataRequiresAuthentication(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new UserController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->updateData($request); @@ -63,7 +75,7 @@ public function testUpdateDataWithInvalidJsonThrowsException(): void $requestData = 'invalid json'; $request = new Request([], [], [], [], [], [], $requestData); - $controller = new UserController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->updateData($request); @@ -75,7 +87,7 @@ public function testUpdateDataWithInvalidJsonThrowsException(): void public function testExportUserDataRequiresAuthentication(): void { $request = new Request(); - $controller = new UserController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->exportUserData($request); @@ -96,7 +108,7 @@ public function testRequestUserRemovalRequiresValidToken(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new UserController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->requestUserRemoval($request); @@ -110,7 +122,7 @@ public function testRequestUserRemovalWithInvalidJsonThrowsException(): void $requestData = 'invalid json'; $request = new Request([], [], [], [], [], [], $requestData); - $controller = new UserController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->requestUserRemoval($request); @@ -126,7 +138,7 @@ public function testRemoveTwofactorConfigRequiresAuthentication(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new UserController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->removeTwofactorConfig($request); @@ -140,7 +152,7 @@ public function testRemoveTwofactorConfigWithInvalidJsonThrowsException(): void $requestData = 'invalid json'; $request = new Request([], [], [], [], [], [], $requestData); - $controller = new UserController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->removeTwofactorConfig($request); diff --git a/tests/phpMyFAQ/Controller/Frontend/Api/VotingControllerTest.php b/tests/phpMyFAQ/Controller/Frontend/Api/VotingControllerTest.php index 383068d9e6..9139aceb3f 100644 --- a/tests/phpMyFAQ/Controller/Frontend/Api/VotingControllerTest.php +++ b/tests/phpMyFAQ/Controller/Frontend/Api/VotingControllerTest.php @@ -6,8 +6,10 @@ use phpMyFAQ\Configuration; use phpMyFAQ\Core\Exception; +use phpMyFAQ\Rating; use phpMyFAQ\Strings; use phpMyFAQ\Translation; +use phpMyFAQ\User\UserSession; use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; @@ -16,6 +18,8 @@ class VotingControllerTest extends TestCase { private Configuration $configuration; + private Rating $rating; + private UserSession $userSession; /** * @throws Exception @@ -33,6 +37,14 @@ protected function setUp(): void ->setMultiByteLanguage(); $this->configuration = Configuration::getConfigurationInstance(); + + $this->rating = $this->createStub(Rating::class); + $this->userSession = $this->createStub(UserSession::class); + } + + private function createController(): VotingController + { + return new VotingController($this->rating, $this->userSession); } /** @@ -43,7 +55,7 @@ public function testCreateWithInvalidJsonThrowsException(): void $requestData = 'invalid json'; $request = new Request([], [], [], [], [], [], $requestData); - $controller = new VotingController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -59,7 +71,7 @@ public function testCreateWithMissingIdThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new VotingController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -75,7 +87,7 @@ public function testCreateWithMissingValueThrowsException(): void ]); $request = new Request([], [], [], [], [], [], $requestData); - $controller = new VotingController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -94,7 +106,7 @@ public function testCreateWithInvalidVoteValueThrowsException(): void $request = new Request([], [], [], [], [], [], $requestData); $request->server->set('REMOTE_ADDR', '127.0.0.1'); - $controller = new VotingController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -113,7 +125,7 @@ public function testCreateWithNegativeVoteValueThrowsException(): void $request = new Request([], [], [], [], [], [], $requestData); $request->server->set('REMOTE_ADDR', '127.0.0.1'); - $controller = new VotingController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -132,7 +144,7 @@ public function testCreateWithZeroVoteValueThrowsException(): void $request = new Request([], [], [], [], [], [], $requestData); $request->server->set('REMOTE_ADDR', '127.0.0.1'); - $controller = new VotingController(); + $controller = $this->createController(); $this->expectException(\Exception::class); $controller->create($request); @@ -151,7 +163,7 @@ public function testCreateWithValidVoteValueReturnsJsonResponseOrThrowsException $request = new Request([], [], [], [], [], [], $requestData); $request->server->set('REMOTE_ADDR', '127.0.0.1'); - $controller = new VotingController(); + $controller = $this->createController(); try { $response = $controller->create($request); diff --git a/tests/phpMyFAQ/Controller/SitemapControllerTest.php b/tests/phpMyFAQ/Controller/SitemapControllerTest.php index 5d7d15956c..b04f450ae3 100644 --- a/tests/phpMyFAQ/Controller/SitemapControllerTest.php +++ b/tests/phpMyFAQ/Controller/SitemapControllerTest.php @@ -2,6 +2,8 @@ namespace phpMyFAQ\Controller; +use phpMyFAQ\CustomPage; +use phpMyFAQ\Faq\Statistics as FaqStatistics; use phpMyFAQ\Strings; use phpMyFAQ\Translation; use phpMyFAQ\Twig\TemplateException; @@ -15,6 +17,8 @@ class SitemapControllerTest extends TestCase { private Environment $twig; + private FaqStatistics $faqStatistics; + private CustomPage $customPage; private SitemapController $controller; /** @@ -34,7 +38,9 @@ protected function setUp(): void ->setMultiByteLanguage(); $this->twig = $this->createStub(Environment::class); - $this->controller = new SitemapController(); + $this->faqStatistics = $this->createStub(FaqStatistics::class); + $this->customPage = $this->createStub(CustomPage::class); + $this->controller = new SitemapController($this->faqStatistics, $this->customPage); } /** diff --git a/tests/phpMyFAQ/EventListener/ApiExceptionListenerTest.php b/tests/phpMyFAQ/EventListener/ApiExceptionListenerTest.php index b0f4f38c06..b43f89cc3d 100644 --- a/tests/phpMyFAQ/EventListener/ApiExceptionListenerTest.php +++ b/tests/phpMyFAQ/EventListener/ApiExceptionListenerTest.php @@ -4,6 +4,7 @@ use phpMyFAQ\Configuration; use phpMyFAQ\Controller\Exception\ForbiddenException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\Request; @@ -13,6 +14,7 @@ use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Routing\Exception\ResourceNotFoundException; +#[AllowMockObjectsWithoutExpectations] class ApiExceptionListenerTest extends TestCase { private ApiExceptionListener $listener; diff --git a/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php b/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php index e51cbca713..3073daef17 100644 --- a/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php +++ b/tests/phpMyFAQ/EventListener/ControllerContainerListenerTest.php @@ -3,6 +3,7 @@ namespace phpMyFAQ\EventListener; use phpMyFAQ\Controller\AbstractController; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\Request; @@ -10,6 +11,7 @@ use Symfony\Component\HttpKernel\Event\ControllerEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; +#[AllowMockObjectsWithoutExpectations] class ControllerContainerListenerTest extends TestCase { public function testInjectsContainerIntoAbstractController(): void diff --git a/tests/phpMyFAQ/EventListener/RouterListenerTest.php b/tests/phpMyFAQ/EventListener/RouterListenerTest.php index 69d7897ed7..a002fdbe03 100644 --- a/tests/phpMyFAQ/EventListener/RouterListenerTest.php +++ b/tests/phpMyFAQ/EventListener/RouterListenerTest.php @@ -2,6 +2,7 @@ namespace phpMyFAQ\EventListener; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -14,6 +15,7 @@ use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; +#[AllowMockObjectsWithoutExpectations] class RouterListenerTest extends TestCase { private function createEvent(Request $request, int $type = HttpKernelInterface::MAIN_REQUEST): RequestEvent diff --git a/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php b/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php index 3a446469e7..0d3028194c 100644 --- a/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php +++ b/tests/phpMyFAQ/EventListener/WebExceptionListenerTest.php @@ -3,6 +3,7 @@ namespace phpMyFAQ\EventListener; use phpMyFAQ\Controller\Exception\ForbiddenException; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\Request; @@ -12,6 +13,7 @@ use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Routing\Exception\ResourceNotFoundException; +#[AllowMockObjectsWithoutExpectations] class WebExceptionListenerTest extends TestCase { private WebExceptionListener $listener; From 06ffb6e54e85d6e87a142888cc55140a5bd276e6 Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Tue, 17 Feb 2026 19:08:39 +0100 Subject: [PATCH 4/5] fix: corrected review notes --- .../Controller/Api/GlossaryController.php | 2 +- .../Controller/Api/NewsController.php | 4 +--- .../Frontend/AbstractFrontController.php | 2 +- .../Frontend/Api/ContactController.php | 1 - .../Controller/Frontend/Api/FaqController.php | 6 ++++- .../Frontend/Api/UserController.php | 1 - .../Frontend/Api/VotingController.php | 9 ++------ .../Instance/Search/Elasticsearch.php | 22 ++++++++++++++----- .../Controller/Api/NewsControllerTest.php | 18 ++++++++++----- 9 files changed, 39 insertions(+), 26 deletions(-) diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php index 7fc979796d..071de6d40a 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/GlossaryController.php @@ -125,7 +125,7 @@ public function list(Request $request): JsonResponse { $currentLanguage = $this->language->setLanguageByAcceptLanguage(); - if ($currentLanguage !== false) { + if ($currentLanguage !== '') { $this->glossary->setLanguage($currentLanguage); } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php b/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php index 80207661a7..97c294c6d8 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Api/NewsController.php @@ -117,10 +117,8 @@ enum: ['id', 'datum', 'header', 'author_name'], }'), )] #[Route('/api/v3.2/news', name: 'api.news.list', methods: ['GET'])] - public function list(?Request $request = null): JsonResponse + public function list(Request $request): JsonResponse { - $request ??= Request::createFromGlobals(); - // Get pagination and sorting parameters $pagination = $this->getPaginationRequest($request); $sort = $this->getSortRequest( diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php index 996e5be137..738aff62df 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php @@ -86,7 +86,7 @@ protected function getHeader(Request $request): array 'customCss' => $this->configuration->getCustomCss(), 'version' => $this->configuration->getVersion(), 'header' => str_replace('"', '', $this->configuration->getTitle()), - 'metaDescription' => $metaDescription ?? $this->configuration->get('seo.description'), + 'metaDescription' => $this->configuration->get('seo.description'), 'metaPublisher' => $this->configuration->get('main.metaPublisher'), 'metaLanguage' => Translation::get(key: 'metaLanguage'), 'metaRobots' => $this->seo->getMetaRobots($action), diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/ContactController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/ContactController.php index d0482b991f..339433cd33 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/ContactController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/ContactController.php @@ -93,7 +93,6 @@ public function create(Request $request): JsonResponse try { $this->mailer->setReplyTo($email, $author); $this->mailer->addTo($this->configuration->getAdminEmail()); - $this->mailer->setReplyTo($this->configuration->getNoReplyEmail()); $this->mailer->subject = Utils::resolveMarkers( text: 'Feedback: %sitename%', configuration: $this->configuration, diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/FaqController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/FaqController.php index 5eed46674d..b76b974277 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/FaqController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/FaqController.php @@ -141,7 +141,11 @@ public function create(Request $request): JsonResponse && $this->stopWords->checkBannedWord(strip_tags($questionText)) ) { if ($answer !== '' && $answer !== '0') { - $this->stopWords->checkBannedWord(strip_tags($answer)); + if (!$this->stopWords->checkBannedWord(strip_tags($answer))) { + return $this->json(['error' => Translation::get( + key: 'errSaveEntries', + )], Response::HTTP_BAD_REQUEST); + } } else { $answer = ''; } diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/UserController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/UserController.php index 76d541fe47..be6e6c3688 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/UserController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/UserController.php @@ -261,7 +261,6 @@ public function requestUserRemoval(Request $request): JsonResponse try { $this->mailer->setReplyTo($email, $author); $this->mailer->addTo($this->configuration->getAdminEmail()); - $this->mailer->setReplyTo($this->configuration->getNoReplyEmail()); $this->mailer->subject = $this->configuration->getTitle() . ': Remove User Request'; $this->mailer->message = $question; $this->mailer->send(); diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php index b1001d1e67..260b9b9e1f 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php @@ -74,7 +74,7 @@ public function create(Request $request): JsonResponse throw new Exception('Invalid vote value'); } - if (isset($vote) && $this->rating->check($faqId, $userIp) && $vote > 0 && $vote < 6) { + if ($this->rating->check($faqId, $userIp)) { $this->userSession->userTracking('save_voting', $faqId); $votingData = new Vote(); @@ -90,14 +90,9 @@ public function create(Request $request): JsonResponse 'success' => Translation::get(key: 'msgVoteThanks'), 'rating' => $this->rating->get($faqId), ], Response::HTTP_OK); - } - - if (!$this->rating->check($faqId, $userIp)) { + } else { $this->userSession->userTracking('error_save_voting', $faqId); return $this->json(['error' => Translation::get(key: 'err_VoteTooMuch')], Response::HTTP_BAD_REQUEST); } - - $this->userSession->userTracking('error_save_voting', $faqId); - return $this->json(['error' => Translation::get(key: 'err_noVote')], Response::HTTP_BAD_REQUEST); } } diff --git a/phpmyfaq/src/phpMyFAQ/Instance/Search/Elasticsearch.php b/phpmyfaq/src/phpMyFAQ/Instance/Search/Elasticsearch.php index bebf05bf5a..7122e5d3b9 100644 --- a/phpmyfaq/src/phpMyFAQ/Instance/Search/Elasticsearch.php +++ b/phpmyfaq/src/phpMyFAQ/Instance/Search/Elasticsearch.php @@ -120,7 +120,7 @@ private function getParams(): array */ private function buildMappings(): array { - $tokenizer = $this->getTokenizer(); + $searchAnalyzer = $this->getSearchAnalyzer(); return [ '_source' => [ @@ -130,22 +130,22 @@ private function buildMappings(): array 'question' => [ 'type' => 'search_as_you_type', 'analyzer' => 'autocomplete', - 'search_analyzer' => $tokenizer, + 'search_analyzer' => $searchAnalyzer, ], 'answer' => [ 'type' => 'search_as_you_type', 'analyzer' => 'autocomplete', - 'search_analyzer' => $tokenizer, + 'search_analyzer' => $searchAnalyzer, ], 'keywords' => [ 'type' => 'search_as_you_type', 'analyzer' => 'autocomplete', - 'search_analyzer' => $tokenizer, + 'search_analyzer' => $searchAnalyzer, ], 'categories' => [ 'type' => 'search_as_you_type', 'analyzer' => 'autocomplete', - 'search_analyzer' => $tokenizer, + 'search_analyzer' => $searchAnalyzer, ], 'content_type' => [ 'type' => 'keyword', @@ -157,6 +157,18 @@ private function buildMappings(): array ]; } + private function getSearchAnalyzer(): string + { + if (defined('PMF_ELASTICSEARCH_SEARCH_ANALYZER')) { + $searchAnalyzer = constant('PMF_ELASTICSEARCH_SEARCH_ANALYZER'); + if (is_string($searchAnalyzer) && $searchAnalyzer !== '') { + return $searchAnalyzer; + } + } + + return 'standard'; + } + private function getTokenizer(): string { if (defined('PMF_ELASTICSEARCH_TOKENIZER')) { diff --git a/tests/phpMyFAQ/Controller/Api/NewsControllerTest.php b/tests/phpMyFAQ/Controller/Api/NewsControllerTest.php index 11b0c8a34b..0c900d1f34 100644 --- a/tests/phpMyFAQ/Controller/Api/NewsControllerTest.php +++ b/tests/phpMyFAQ/Controller/Api/NewsControllerTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\MockObject\Exception as MockException; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Session; @@ -29,10 +30,15 @@ protected function setUp(): void $this->configuration->setLanguage($language); } + private function createRequest(): Request + { + return Request::create('/api/v3.2/news', 'GET'); + } + public function testListReturnsJsonResponse(): void { $controller = new NewsController(); - $response = $controller->list(); + $response = $controller->list($this->createRequest()); $this->assertInstanceOf(JsonResponse::class, $response); } @@ -40,7 +46,7 @@ public function testListReturnsJsonResponse(): void public function testListReturnsValidStatusCode(): void { $controller = new NewsController(); - $response = $controller->list(); + $response = $controller->list($this->createRequest()); $this->assertContains($response->getStatusCode(), [Response::HTTP_OK, Response::HTTP_NOT_FOUND]); } @@ -48,7 +54,7 @@ public function testListReturnsValidStatusCode(): void public function testListReturnsJsonData(): void { $controller = new NewsController(); - $response = $controller->list(); + $response = $controller->list($this->createRequest()); $this->assertJson($response->getContent()); } @@ -56,7 +62,7 @@ public function testListReturnsJsonData(): void public function testListReturnsArrayData(): void { $controller = new NewsController(); - $response = $controller->list(); + $response = $controller->list($this->createRequest()); $data = json_decode($response->getContent(), true); $this->assertIsArray($data); @@ -65,7 +71,7 @@ public function testListReturnsArrayData(): void public function testListResponseContentIsNotNull(): void { $controller = new NewsController(); - $response = $controller->list(); + $response = $controller->list($this->createRequest()); $this->assertNotNull($response->getContent()); } @@ -73,7 +79,7 @@ public function testListResponseContentIsNotNull(): void public function testListReturnsEmptyArrayOn404(): void { $controller = new NewsController(); - $response = $controller->list(); + $response = $controller->list($this->createRequest()); if ($response->getStatusCode() === Response::HTTP_NOT_FOUND) { $this->assertEquals([], json_decode($response->getContent(), true)); From 82917abb5c1e22093be596ededd6523cda755ada Mon Sep 17 00:00:00 2001 From: Thorsten Rinne Date: Tue, 17 Feb 2026 20:22:43 +0100 Subject: [PATCH 5/5] fix: corrected review notes --- .../Frontend/AbstractFrontController.php | 16 ++++++++++++++-- .../Controller/Frontend/Api/VotingController.php | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php index 738aff62df..604fec9a3d 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/AbstractFrontController.php @@ -46,6 +46,10 @@ protected function initializeFromContainer(): void { parent::initializeFromContainer(); + if ($this->container === null) { + return; + } + $this->faqSystem = $this->container->get(id: 'phpmyfaq.system'); $this->seo = $this->container->get(id: 'phpmyfaq.seo'); } @@ -58,6 +62,14 @@ protected function initializeFromContainer(): void protected function getHeader(Request $request): array { $action = $request->query->get(key: 'action', default: 'index'); + $faqSystem = $this->faqSystem; + $seo = $this->seo; + + if ($faqSystem === null || $seo === null) { + throw new \LogicException( + 'Front controller dependencies are not initialized. Ensure initializeFromContainer() is called before getHeader().', + ); + } $isUserHasAdminRights = $this->currentUser->perm->hasPermission( $this->currentUser->getUserId(), @@ -82,14 +94,14 @@ protected function getHeader(Request $request): array : Translation::get(key: 'msgLoginUser'), 'isUserLoggedIn' => $this->currentUser->isLoggedIn(), 'isUserHasAdminRights' => $isUserHasAdminRights || $this->currentUser->isSuperAdmin(), - 'baseHref' => $this->faqSystem->getSystemUri($this->configuration), + 'baseHref' => $faqSystem->getSystemUri($this->configuration), 'customCss' => $this->configuration->getCustomCss(), 'version' => $this->configuration->getVersion(), 'header' => str_replace('"', '', $this->configuration->getTitle()), 'metaDescription' => $this->configuration->get('seo.description'), 'metaPublisher' => $this->configuration->get('main.metaPublisher'), 'metaLanguage' => Translation::get(key: 'metaLanguage'), - 'metaRobots' => $this->seo->getMetaRobots($action), + 'metaRobots' => $seo->getMetaRobots($action), 'phpmyfaqVersion' => $this->configuration->getVersion(), 'stylesheet' => Translation::get(key: 'direction') == 'rtl' ? 'style.rtl' : 'style', 'currentPageUrl' => $request->getSchemeAndHttpHost() . $request->getRequestUri(), diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php index 260b9b9e1f..450db8f927 100644 --- a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/VotingController.php @@ -43,7 +43,7 @@ public function __construct( /** * @throws Exception */ - #[Route(path: 'voting', name: 'api.private.voting', methods: ['POST'])] + #[Route(path: 'voting', name: 'public.voting.create', methods: ['POST'])] public function create(Request $request): JsonResponse { $this->userSession->setCurrentUser($this->currentUser);