diff --git a/components/com_contact/src/Service/Router.php b/components/com_contact/src/Service/Router.php index bc02d57c9cf51..51f9c5a7d0ed0 100644 --- a/components/com_contact/src/Service/Router.php +++ b/components/com_contact/src/Service/Router.php @@ -20,7 +20,9 @@ use Joomla\CMS\Component\Router\Rules\NomenuRules; use Joomla\CMS\Component\Router\Rules\PreprocessRules; use Joomla\CMS\Component\Router\Rules\StandardRules; +use Joomla\CMS\Factory; use Joomla\CMS\Menu\AbstractMenu; +use Joomla\CMS\Router\SiteRouter; use Joomla\Database\DatabaseInterface; use Joomla\Database\ParameterType; @@ -207,7 +209,7 @@ public function getCategoryId($segment, $query) // We haven't found a matching category, but maybe we turned off IDs? foreach ($category->getChildren() as $child) { if ($child->id == (int) $segment) { - $this->app->getRouter()->setTainted(); + Factory::getContainer()->get(SiteRouter::class)->setTainted(); return $child->id; } @@ -216,7 +218,7 @@ public function getCategoryId($segment, $query) foreach ($category->getChildren() as $child) { if ($child->id == (int) $segment) { if ($child->id . '-' . $child->alias != $segment) { - $this->app->getRouter()->setTainted(); + Factory::getContainer()->get(SiteRouter::class)->setTainted(); } return $child->id; @@ -273,7 +275,7 @@ public function getContactId($segment, $query) return $id; } - $this->app->getRouter()->setTainted(); + Factory::getContainer()->get(SiteRouter::class)->setTainted(); } $id = (int) $segment; @@ -288,7 +290,7 @@ public function getContactId($segment, $query) $alias = $this->db->loadResult(); if ($alias && $id . '-' . $alias != $segment) { - $this->app->getRouter()->setTainted(); + Factory::getContainer()->get(SiteRouter::class)->setTainted(); } } diff --git a/components/com_content/src/Service/Router.php b/components/com_content/src/Service/Router.php index 177f1d4ff091a..7ad4c94bd4347 100644 --- a/components/com_content/src/Service/Router.php +++ b/components/com_content/src/Service/Router.php @@ -20,7 +20,9 @@ use Joomla\CMS\Component\Router\Rules\NomenuRules; use Joomla\CMS\Component\Router\Rules\PreprocessRules; use Joomla\CMS\Component\Router\Rules\StandardRules; +use Joomla\CMS\Factory; use Joomla\CMS\Menu\AbstractMenu; +use Joomla\CMS\Router\SiteRouter; use Joomla\Database\DatabaseInterface; use Joomla\Database\ParameterType; @@ -208,7 +210,7 @@ public function getCategoryId($segment, $query) // We haven't found a matching category, but maybe we turned off IDs? foreach ($category->getChildren() as $child) { if ($child->id == (int) $segment) { - $this->app->getRouter()->setTainted(); + Factory::getContainer()->get(SiteRouter::class)->setTainted(); return $child->id; } @@ -217,7 +219,7 @@ public function getCategoryId($segment, $query) foreach ($category->getChildren() as $child) { if ($child->id == (int) $segment) { if ($child->id . '-' . $child->alias != $segment) { - $this->app->getRouter()->setTainted(); + Factory::getContainer()->get(SiteRouter::class)->setTainted(); } return $child->id; @@ -274,7 +276,7 @@ public function getArticleId($segment, $query) return $id; } - $this->app->getRouter()->setTainted(); + Factory::getContainer()->get(SiteRouter::class)->setTainted(); } $id = (int) $segment; @@ -289,7 +291,7 @@ public function getArticleId($segment, $query) $alias = $this->db->loadResult(); if ($alias && $id . '-' . $alias != $segment) { - $this->app->getRouter()->setTainted(); + Factory::getContainer()->get(SiteRouter::class)->setTainted(); } } diff --git a/components/com_newsfeeds/src/Service/Router.php b/components/com_newsfeeds/src/Service/Router.php index 1cc90077c2801..3f13403f6320b 100644 --- a/components/com_newsfeeds/src/Service/Router.php +++ b/components/com_newsfeeds/src/Service/Router.php @@ -20,7 +20,9 @@ use Joomla\CMS\Component\Router\Rules\NomenuRules; use Joomla\CMS\Component\Router\Rules\PreprocessRules; use Joomla\CMS\Component\Router\Rules\StandardRules; +use Joomla\CMS\Factory; use Joomla\CMS\Menu\AbstractMenu; +use Joomla\CMS\Router\SiteRouter; use Joomla\Database\DatabaseInterface; use Joomla\Database\ParameterType; @@ -188,7 +190,7 @@ public function getCategoryId($segment, $query) // We haven't found a matching category, but maybe we turned off IDs? foreach ($category->getChildren() as $child) { if ($child->id == (int) $segment) { - $this->app->getRouter()->setTainted(); + Factory::getContainer()->get(SiteRouter::class)->setTainted(); return $child->id; } @@ -197,7 +199,7 @@ public function getCategoryId($segment, $query) foreach ($category->getChildren() as $child) { if ($child->id == (int) $segment) { if ($child->id . '-' . $child->alias != $segment) { - $this->app->getRouter()->setTainted(); + Factory::getContainer()->get(SiteRouter::class)->setTainted(); } return $child->id; @@ -254,7 +256,7 @@ public function getNewsfeedId($segment, $query) return $id; } - $this->app->getRouter()->setTainted(); + Factory::getContainer()->get(SiteRouter::class)->setTainted(); } $id = (int) $segment; @@ -269,7 +271,7 @@ public function getNewsfeedId($segment, $query) $alias = $this->db->loadResult(); if ($alias && $id . '-' . $alias != $segment) { - $this->app->getRouter()->setTainted(); + Factory::getContainer()->get(SiteRouter::class)->setTainted(); } } diff --git a/libraries/src/Application/AdministratorApplication.php b/libraries/src/Application/AdministratorApplication.php index dfb76c8a5ea58..c7c1378e86588 100644 --- a/libraries/src/Application/AdministratorApplication.php +++ b/libraries/src/Application/AdministratorApplication.php @@ -219,10 +219,10 @@ protected function doExecute() * * @since 3.2 * - * @deprecated 4.3 will be removed in 7.0 + * @deprecated 4.3 will be removed in 8.0 + * Only available with the Behaviour - Backward Compatibility 7 plugin enabled. * Inject the router or load it from the dependency injection container - * Example: - * Factory::getContainer()->get(AdministratorRouter::class); + * Example: Factory::getContainer()->get(\Joomla\CMS\Router\AdministratorRouter::class); */ public static function getRouter($name = 'administrator', array $options = []) { diff --git a/libraries/src/Application/CMSApplication.php b/libraries/src/Application/CMSApplication.php index 037c9442fe41c..5429ca944f02e 100644 --- a/libraries/src/Application/CMSApplication.php +++ b/libraries/src/Application/CMSApplication.php @@ -659,12 +659,28 @@ public function getPathway() * * @since 3.2 * - * @deprecated 4.3 will be removed in 7.0 + * @deprecated 4.3 will be removed in 8.0 + * Only available with the Behaviour - Backward Compatibility 7 plugin enabled. * Inject the router or load it from the dependency injection container - * Example: Factory::getContainer()->get($name); + * Example: Factory::getContainer()->get(\Joomla\CMS\Router\SiteRouter::class); */ public static function getRouter($name = null, array $options = []) { + if (!\defined('COMPAT_JOOMLA_7')) { + throw new \BadMethodCallException(\sprintf( + '%s() is only available in compatibility mode (compatibility plugin enabled). Inject the router or load it from the dependency injection container.', + __METHOD__ + )); + } + + @trigger_error( + \sprintf( + '%s() is deprecated. Inject the router or load it from the dependency injection container.', + __METHOD__ + ), + E_USER_DEPRECATED + ); + $app = Factory::getApplication(); if (!isset($name)) { @@ -1139,7 +1155,7 @@ protected function route() // Get the full request URI. $uri = clone Uri::getInstance(); - $router = static::getRouter(); + $router = $this->getContainer()->get(ucfirst($this->getName()) . 'Router'); $result = $router->parse($uri, true); $active = $this->getMenu()->getActive(); diff --git a/libraries/src/Application/CMSWebApplicationInterface.php b/libraries/src/Application/CMSWebApplicationInterface.php index cc702434eaeb2..1fd5763160126 100644 --- a/libraries/src/Application/CMSWebApplicationInterface.php +++ b/libraries/src/Application/CMSWebApplicationInterface.php @@ -12,7 +12,6 @@ use Joomla\Application\SessionAwareWebApplicationInterface; use Joomla\CMS\Document\Document; use Joomla\CMS\Menu\AbstractMenu; -use Joomla\CMS\Router\Router; // phpcs:disable PSR1.Files.SideEffects \defined('_JEXEC') or die; @@ -46,22 +45,6 @@ public function getDocument(); */ public function getMenu($name = null, $options = []); - /** - * Returns the application Router object. - * - * @param string $name The name of the application. - * @param array $options An optional associative array of configuration settings. - * - * @return Router - * - * @since 4.0.0 - * - * @deprecated 4.3 will be removed in 7.0 - * Inject the router or load it from the dependency injection container - * Example: Factory::getContainer()->get($name); - */ - public static function getRouter($name = null, array $options = []); - /** * Gets a user state. * diff --git a/libraries/src/Application/ConsoleApplication.php b/libraries/src/Application/ConsoleApplication.php index 96bc5059399a9..8dace2fde7e73 100644 --- a/libraries/src/Application/ConsoleApplication.php +++ b/libraries/src/Application/ConsoleApplication.php @@ -421,12 +421,28 @@ public function setName(string $name): void * * @throws \InvalidArgumentException * - * @deprecated 4.3 will be removed in 7.0 + * @deprecated 4.3 will be removed in 8.0 + * Only available with the Behaviour - Backward Compatibility 7 plugin enabled. * Inject the router or load it from the dependency injection container - * Example: Factory::getContainer()->get(ApiRouter::class); + * Example: Factory::getContainer()->get(\Joomla\CMS\Router\ApiRouter::class); */ public static function getRouter($name = null, array $options = []) { + if (!\defined('COMPAT_JOOMLA_7')) { + throw new \BadMethodCallException(\sprintf( + '%s() is only available in compatibility mode (compatibility plugin enabled). Inject the router or load it from the dependency injection container.', + __METHOD__ + )); + } + + @trigger_error( + \sprintf( + '%s() is deprecated. Inject the router or load it from the dependency injection container.', + __METHOD__ + ), + E_USER_DEPRECATED + ); + if (empty($name)) { throw new \InvalidArgumentException('A router name must be set in console application.'); } diff --git a/libraries/src/Application/SiteApplication.php b/libraries/src/Application/SiteApplication.php index be6c4e8f0a524..680ed5667ea04 100644 --- a/libraries/src/Application/SiteApplication.php +++ b/libraries/src/Application/SiteApplication.php @@ -383,7 +383,8 @@ public function getParams($option = null) * * @since 3.2 * - * @deprecated 4.3 will be removed in 7.0 + * @deprecated 4.3 will be removed in 8.0 + * Only available with the Behaviour - Backward Compatibility 7 plugin enabled. * Inject the router or load it from the dependency injection container * Example: Factory::getContainer()->get(SiteRouter::class); */ diff --git a/libraries/src/Router/Route.php b/libraries/src/Router/Route.php index 61ef044bd1afe..1f5056a6e005b 100644 --- a/libraries/src/Router/Route.php +++ b/libraries/src/Router/Route.php @@ -135,8 +135,13 @@ public static function link($client, $url, $xhtml = true, $tls = self::TLS_IGNOR // Get the router instance, only attempt when a client name is given. if ($client && !isset(self::$_router[$client])) { try { - self::$_router[$client] = Factory::getContainer()->get(ucfirst($client) . 'Router') ?: Factory::getApplication()::getRouter($client); + self::$_router[$client] = Factory::getContainer()->get(ucfirst($client) . 'Router') ?: null; } catch (KeyNotFoundException) { + self::$_router[$client] = null; + } + + // Legacy application routers are only available through the compatibility plugin. + if (!isset(self::$_router[$client]) && \defined('COMPAT_JOOMLA_7')) { self::$_router[$client] = Factory::getApplication()::getRouter($client); } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 19b608b070a0a..015734edc160f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5974,17 +5974,6 @@ parameters: count: 1 path: components/com_contact/src/Model/FeaturedModel.php - - - message: ''' - #^Call to deprecated method getRouter\(\) of class Joomla\\CMS\\Application\\CMSApplication\: - 4\.3 will be removed in 7\.0 - Inject the router or load it from the dependency injection container - Example\: Factory\:\:getContainer\(\)\-\>get\(\$name\);$# - ''' - identifier: staticMethod.deprecated - count: 4 - path: components/com_contact/src/Service/Router.php - - message: '#^Access to an undefined property Joomla\\Component\\Contact\\Site\\View\\Category\\HtmlView\:\:\$menu\.$#' identifier: property.notFound @@ -6278,17 +6267,6 @@ parameters: count: 1 path: components/com_content/src/Model/FormModel.php - - - message: ''' - #^Call to deprecated method getRouter\(\) of class Joomla\\CMS\\Application\\CMSApplication\: - 4\.3 will be removed in 7\.0 - Inject the router or load it from the dependency injection container - Example\: Factory\:\:getContainer\(\)\-\>get\(\$name\);$# - ''' - identifier: staticMethod.deprecated - count: 4 - path: components/com_content/src/Service/Router.php - - message: ''' #^Call to deprecated method getErrors\(\) of class Joomla\\CMS\\MVC\\Model\\BaseModel\: @@ -6595,17 +6573,6 @@ parameters: count: 1 path: components/com_newsfeeds/src/Model/NewsfeedModel.php - - - message: ''' - #^Call to deprecated method getRouter\(\) of class Joomla\\CMS\\Application\\CMSApplication\: - 4\.3 will be removed in 7\.0 - Inject the router or load it from the dependency injection container - Example\: Factory\:\:getContainer\(\)\-\>get\(\$name\);$# - ''' - identifier: staticMethod.deprecated - count: 4 - path: components/com_newsfeeds/src/Service/Router.php - - message: '#^Access to an undefined property Joomla\\Component\\Newsfeeds\\Site\\View\\Category\\HtmlView\:\:\$menu\.$#' identifier: property.notFound diff --git a/tests/Unit/Libraries/Cms/Application/ApplicationRouterCompatibilityTest.php b/tests/Unit/Libraries/Cms/Application/ApplicationRouterCompatibilityTest.php new file mode 100644 index 0000000000000..4750f679f77fe --- /dev/null +++ b/tests/Unit/Libraries/Cms/Application/ApplicationRouterCompatibilityTest.php @@ -0,0 +1,138 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Libraries\Cms\Application; + +use Joomla\CMS\Application\AdministratorApplication; +use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Application\CMSWebApplicationInterface; +use Joomla\CMS\Application\ConsoleApplication; +use Joomla\CMS\Application\SiteApplication; +use Joomla\CMS\Factory; +use Joomla\CMS\Router\Router; +use Joomla\DI\Container; +use Joomla\Tests\Unit\UnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; + +/** + * Tests the compatibility boundary for legacy application router access. + * + * @since __DEPLOY_VERSION__ + */ +#[RunTestsInSeparateProcesses] +#[PreserveGlobalState(false)] +class ApplicationRouterCompatibilityTest extends UnitTestCase +{ + /** + * @since __DEPLOY_VERSION__ + */ + public function testWebApplicationInterfaceDoesNotRequireLegacyRouterAccess(): void + { + $this->assertFalse(method_exists(CMSWebApplicationInterface::class, 'getRouter')); + } + + /** + * @since __DEPLOY_VERSION__ + */ + #[DataProvider('applicationClasses')] + public function testRouterAccessRequiresCompatibility(string $applicationClass): void + { + // The guard must run before accessing the global application or router services. + Factory::$application = null; + Factory::$container = null; + + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionMessage('compatibility'); + + $applicationClass::getRouter(); + } + + /** + * @since __DEPLOY_VERSION__ + */ + public static function applicationClasses(): array + { + return [ + 'base application' => [CMSApplication::class], + 'site application' => [SiteApplication::class], + 'administrator' => [AdministratorApplication::class], + 'console' => [ConsoleApplication::class], + ]; + } + + /** + * @since __DEPLOY_VERSION__ + */ + #[DataProvider('compatibleRouterCalls')] + public function testCompatibilityPreservesRouterSelection(string $applicationClass, array $arguments, string $client): void + { + \define('COMPAT_JOOMLA_7', true); + + $application = $this->getMockBuilder(CMSApplication::class) + ->disableOriginalConstructor() + ->onlyMethods(['doExecute', 'get', 'getName']) + ->getMock(); + $application->method('getName')->willReturn('current'); + $application->expects($this->once())->method('get')->with('sef')->willReturn(1); + + $router = new Router(); + Factory::$application = $application; + Factory::$container = new Container(); + Factory::$container->set('JRouter' . ucfirst($client), $router, true); + + $deprecations = []; + set_error_handler(static function (int $severity, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, E_USER_DEPRECATED); + + try { + $this->assertSame($router, $applicationClass::getRouter(...$arguments)); + } finally { + restore_error_handler(); + } + + $this->assertCount(1, $deprecations); + $this->assertStringContainsString('getRouter()', $deprecations[0]); + $this->assertStringContainsString('deprecated', $deprecations[0]); + } + + /** + * @since __DEPLOY_VERSION__ + */ + public static function compatibleRouterCalls(): array + { + return [ + 'base defaults to current application' => [CMSApplication::class, [], 'current'], + 'site default' => [SiteApplication::class, [], 'site'], + 'administrator default' => [AdministratorApplication::class, [], 'administrator'], + 'base explicit client' => [CMSApplication::class, ['custom', ['mode' => 0]], 'custom'], + 'site explicit client' => [SiteApplication::class, ['custom'], 'custom'], + 'administrator explicit client' => [AdministratorApplication::class, ['custom'], 'custom'], + 'console explicit client' => [ConsoleApplication::class, ['custom'], 'custom'], + ]; + } + + /** + * @since __DEPLOY_VERSION__ + */ + public function testConsoleStillRequiresAnExplicitRouterNameInCompatibilityMode(): void + { + \define('COMPAT_JOOMLA_7', true); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('A router name must be set in console application.'); + + ConsoleApplication::getRouter(); + } +} diff --git a/tests/Unit/Libraries/Cms/Router/ComponentRouterTaintTest.php b/tests/Unit/Libraries/Cms/Router/ComponentRouterTaintTest.php new file mode 100644 index 0000000000000..f746ffcaa4e53 --- /dev/null +++ b/tests/Unit/Libraries/Cms/Router/ComponentRouterTaintTest.php @@ -0,0 +1,79 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Libraries\Cms\Router; + +use Joomla\CMS\Factory; +use Joomla\CMS\Router\SiteRouter; +use Joomla\Component\Contact\Site\Service\Router as ContactRouter; +use Joomla\Component\Content\Site\Service\Router as ContentRouter; +use Joomla\Component\Newsfeeds\Site\Service\Router as NewsfeedsRouter; +use Joomla\Database\DatabaseInterface; +use Joomla\Database\QueryInterface; +use Joomla\DI\Container; +use Joomla\Tests\Unit\UnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; + +/** + * Tests that component redirects taint the shared router without compatibility mode. + * + * @since __DEPLOY_VERSION__ + */ +#[RunTestsInSeparateProcesses] +#[PreserveGlobalState(false)] +class ComponentRouterTaintTest extends UnitTestCase +{ + /** + * @since __DEPLOY_VERSION__ + */ + #[DataProvider('componentRouters')] + public function testOutdatedAliasTaintsTheSharedSiteRouter(string $routerClass, string $parseMethod): void + { + $query = $this->createStub(QueryInterface::class); + + foreach (['select', 'from', 'where', 'bind'] as $method) { + $query->method($method)->willReturnSelf(); + } + + $database = $this->createMock(DatabaseInterface::class); + $database->method('quoteName')->willReturnArgument(0); + $database->expects($this->once())->method('createQuery')->willReturn($query); + $database->expects($this->once())->method('setQuery')->with($query); + $database->expects($this->once())->method('loadResult')->willReturn('current-alias'); + + // Parsing an ID needs only the database; bypass unrelated application/menu setup. + $componentRouter = (new \ReflectionClass($routerClass))->newInstanceWithoutConstructor(); + (new \ReflectionProperty($routerClass, 'db'))->setValue($componentRouter, $database); + + $siteRouter = (new \ReflectionClass(SiteRouter::class))->newInstanceWithoutConstructor(); + Factory::$application = null; + Factory::$container = new Container(); + Factory::$container->set(SiteRouter::class, $siteRouter, true); + + $this->assertFalse(\defined('COMPAT_JOOMLA_7')); + $this->assertFalse($siteRouter->isTainted()); + $this->assertSame(42, $componentRouter->$parseMethod('42-old-alias', [])); + $this->assertTrue($siteRouter->isTainted()); + } + + /** + * @since __DEPLOY_VERSION__ + */ + public static function componentRouters(): array + { + return [ + 'article' => [ContentRouter::class, 'getArticleId'], + 'contact' => [ContactRouter::class, 'getContactId'], + 'newsfeed' => [NewsfeedsRouter::class, 'getNewsfeedId'], + ]; + } +} diff --git a/tests/Unit/Libraries/Cms/Router/RouteTest.php b/tests/Unit/Libraries/Cms/Router/RouteTest.php new file mode 100644 index 0000000000000..b306ccc885d3f --- /dev/null +++ b/tests/Unit/Libraries/Cms/Router/RouteTest.php @@ -0,0 +1,133 @@ + + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +namespace Joomla\Tests\Unit\Libraries\Cms\Router; + +use Joomla\CMS\Application\CMSApplication; +use Joomla\CMS\Factory; +use Joomla\CMS\Router\Route; +use Joomla\CMS\Router\Router; +use Joomla\CMS\Uri\Uri; +use Joomla\DI\Container; +use Joomla\Tests\Unit\UnitTestCase; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; + +/** + * Tests router service resolution and the compatibility-only legacy fallback. + * + * @since __DEPLOY_VERSION__ + */ +#[RunTestsInSeparateProcesses] +#[PreserveGlobalState(false)] +class RouteTest extends UnitTestCase +{ + /** + * @since __DEPLOY_VERSION__ + */ + #[DataProvider('compatibilityModes')] + public function testLinkUsesTheContainerRouterWithoutALegacyApplication(bool $compatibility): void + { + if ($compatibility) { + \define('COMPAT_JOOMLA_7', true); + } + + $router = $this->createMock(Router::class); + $router->expects($this->once())->method('build') + ->with('index.php?option=com_content') + ->willReturn(new Uri('/article?first=1&second=2')); + + Factory::$application = null; + Factory::$container = new Container(); + Factory::$container->set('SiteRouter', $router, true); + + $this->assertSame('/article?first=1&second=2', Route::link('site', 'index.php?option=com_content')); + } + + /** + * @since __DEPLOY_VERSION__ + */ + public static function compatibilityModes(): array + { + return [ + 'compatibility disabled' => [false], + 'compatibility enabled' => [true], + ]; + } + + /** + * @since __DEPLOY_VERSION__ + */ + #[DataProvider('missingRouterServices')] + public function testMissingContainerRouterRespectsCompatibility(bool $compatibility, string $serviceState): void + { + if ($compatibility) { + \define('COMPAT_JOOMLA_7', true); + } + + $application = $this->getMockBuilder(CMSApplication::class) + ->disableOriginalConstructor() + ->onlyMethods(['doExecute', 'get']) + ->getMock(); + $application->expects($compatibility ? $this->once() : $this->never()) + ->method('get')->with('sef')->willReturn(1); + + $router = $this->createMock(Router::class); + $router->expects($compatibility ? $this->once() : $this->never()) + ->method('build')->with('index.php?option=com_example') + ->willReturn(new Uri('/legacy-result')); + + Factory::$application = $application; + Factory::$container = new Container(); + Factory::$container->set('JRouterLegacy', $router, true); + + // A missing key exercises KeyNotFoundException; false and null exercise falsey services. + if ($serviceState !== 'missing') { + Factory::$container->set('LegacyRouter', $serviceState === 'false' ? false : null); + } + + if (!$compatibility) { + $this->expectException(\RuntimeException::class); + $this->expectExceptionCode(500); + } + + $deprecations = []; + set_error_handler(static function (int $severity, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, E_USER_DEPRECATED); + + try { + $this->assertSame('/legacy-result', Route::link('legacy', 'index.php?option=com_example')); + } finally { + restore_error_handler(); + } + + $this->assertCount(1, $deprecations); + $this->assertStringContainsString('getRouter()', $deprecations[0]); + } + + /** + * @since __DEPLOY_VERSION__ + */ + public static function missingRouterServices(): array + { + return [ + 'missing service without compatibility' => [false, 'missing'], + 'false service without compatibility' => [false, 'false'], + 'null service without compatibility' => [false, 'null'], + 'missing service with compatibility' => [true, 'missing'], + 'false service with compatibility' => [true, 'false'], + 'null service with compatibility' => [true, 'null'], + ]; + } +}