[TASK] Add functional test for route enhanced link handling 61/58461/7
authorOliver Hader <oliver@typo3.org>
Sat, 29 Sep 2018 15:44:42 +0000 (17:44 +0200)
committerGeorg Ringer <georg.ringer@gmail.com>
Sun, 30 Sep 2018 06:59:23 +0000 (08:59 +0200)
* adds test cases for resolving and generating enhanced links
* fixes locale issues when dealing with persisted mappers
* removes PersistedAliasMapper.valueFieldName and uses TCA
  'uid' field per default, which is required for proper
  language based resolving as well
* introduces language based resolving to PersistedAliasMapper
  and PersistedPatternMapper

Resolves: #86444
Releases: master
Change-Id: Ib9c9ea8efc25a8a5adb727f8402a2b6b1e6a1602
Reviewed-on: https://review.typo3.org/58461
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Benni Mack <benni@typo3.org>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Georg Ringer <georg.ringer@gmail.com>
Tested-by: Georg Ringer <georg.ringer@gmail.com>
12 files changed:
typo3/sysext/core/Classes/Routing/Aspect/PersistedAliasMapper.php
typo3/sysext/core/Classes/Routing/Aspect/PersistedPatternMapper.php
typo3/sysext/core/Classes/Routing/Aspect/StaticValueMapper.php
typo3/sysext/core/Documentation/Changelog/master/Feature-86365-RoutingEnhancersAndAspects.rst
typo3/sysext/frontend/Tests/Functional/SiteHandling/AbstractTestCase.php
typo3/sysext/frontend/Tests/Functional/SiteHandling/EnhancerLinkGeneratorTest.php [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/EnhancerSiteRequestTest.php [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/LinkGenerator.typoscript
typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/LinkGeneratorController.php [deleted file]
typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/LinkHandlingController.php [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/LinkRequest.typoscript [new file with mode: 0644]
typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/SlugScenario.yaml

index fd2db34..a6e505c 100644 (file)
@@ -16,10 +16,13 @@ namespace TYPO3\CMS\Core\Routing\Aspect;
  * The TYPO3 project - inspiring people to share!
  */
 
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Context\LanguageAspectFactory;
 use TYPO3\CMS\Core\Database\ConnectionPool;
 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
 use TYPO3\CMS\Core\Site\SiteLanguageAwareTrait;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Frontend\Page\PageRepository;
 
 /**
  * Classic usage when using a "URL segment" (e.g. slug) field within a database table.
@@ -38,7 +41,6 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
  *           type: PersistedAliasMapper
  *           tableName: 'tx_events2_domain_model_event'
  *           routeFieldName: 'path_segment'
- *           valueFieldName: 'uid'
  *           routeValuePrefix: '/'
  */
 class PersistedAliasMapper implements StaticMappableAspectInterface
@@ -63,11 +65,6 @@ class PersistedAliasMapper implements StaticMappableAspectInterface
     /**
      * @var string
      */
-    protected $valueFieldName;
-
-    /**
-     * @var string
-     */
     protected $routeValuePrefix;
 
     /**
@@ -76,6 +73,16 @@ class PersistedAliasMapper implements StaticMappableAspectInterface
     protected $persistenceDelegate;
 
     /**
+     * @var string[]
+     */
+    protected $persistenceFieldNames;
+
+    /**
+     * @var string|null
+     */
+    protected $languageParentFieldName;
+
+    /**
      * @param array $settings
      * @throws \InvalidArgumentException
      */
@@ -83,27 +90,33 @@ class PersistedAliasMapper implements StaticMappableAspectInterface
     {
         $tableName = $settings['tableName'] ?? null;
         $routeFieldName = $settings['routeFieldName'] ?? null;
-        $valueFieldName = $settings['valueFieldName'] ?? null;
         $routeValuePrefix = $settings['routeValuePrefix'] ?? '';
 
         if (!is_string($tableName)) {
-            throw new \InvalidArgumentException('tableName must be string', 1537277133);
+            throw new \InvalidArgumentException(
+                'tableName must be string',
+                1537277133
+            );
         }
         if (!is_string($routeFieldName)) {
-            throw new \InvalidArgumentException('routeFieldName name must be string', 1537277134);
-        }
-        if (!is_string($valueFieldName)) {
-            throw new \InvalidArgumentException('valueFieldName name must be string', 1537277135);
+            throw new \InvalidArgumentException(
+                'routeFieldName name must be string',
+                1537277134
+            );
         }
         if (!is_string($routeValuePrefix) || strlen($routeValuePrefix) > 1) {
-            throw new \InvalidArgumentException('$routeValuePrefix name must be string with one character', 1537277136);
+            throw new \InvalidArgumentException(
+                '$routeValuePrefix must be string with one character',
+                1537277136
+            );
         }
 
         $this->settings = $settings;
         $this->tableName = $tableName;
         $this->routeFieldName = $routeFieldName;
-        $this->valueFieldName = $valueFieldName;
         $this->routeValuePrefix = $routeValuePrefix;
+        $this->persistenceFieldNames = $this->buildPersistenceFieldNames();
+        $this->languageParentFieldName = $GLOBALS['TCA'][$this->tableName]['ctrl']['transOrigPointerField'] ?? null;
     }
 
     /**
@@ -112,14 +125,15 @@ class PersistedAliasMapper implements StaticMappableAspectInterface
     public function generate(string $value): ?string
     {
         $result = $this->getPersistenceDelegate()->generate([
-            $this->valueFieldName => $value
+            'uid' => $value
         ]);
-        $value = null;
-        if (isset($result[$this->routeFieldName])) {
-            $value = (string)$result[$this->routeFieldName];
+        $result = $this->resolveOverlay($result);
+        if (!isset($result[$this->routeFieldName])) {
+            return null;
         }
-        $result = $this->purgeRouteValuePrefix($value);
-        return $result ? (string)$result : null;
+        return $this->purgeRouteValuePrefix(
+            (string)$result[$this->routeFieldName]
+        );
     }
 
     /**
@@ -131,8 +145,27 @@ class PersistedAliasMapper implements StaticMappableAspectInterface
         $result = $this->getPersistenceDelegate()->resolve([
             $this->routeFieldName => $value
         ]);
-        $result = $result[$this->valueFieldName] ?? null;
-        return $result ? (string)$result : null;
+        if ($result[$this->languageParentFieldName] ?? null > 0) {
+            return (string)$result[$this->languageParentFieldName];
+        }
+        if (isset($result['uid'])) {
+            return (string)$result['uid'];
+        }
+        return null;
+    }
+
+    /**
+     * @return string[]
+     */
+    protected function buildPersistenceFieldNames(): array
+    {
+        return array_filter([
+            'uid',
+            'pid',
+            $this->routeFieldName,
+            $GLOBALS['TCA'][$this->tableName]['ctrl']['languageField'] ?? null,
+            $GLOBALS['TCA'][$this->tableName]['ctrl']['transOrigPointerField'] ?? null,
+        ]);
     }
 
     /**
@@ -161,12 +194,12 @@ class PersistedAliasMapper implements StaticMappableAspectInterface
         // @todo Restrictions (Hidden? Workspace?)
 
         $resolveModifier = function (QueryBuilder $queryBuilder, array $values) {
-            return $queryBuilder->select($this->valueFieldName)->where(
+            return $queryBuilder->select(...$this->persistenceFieldNames)->where(
                 ...$this->createFieldConstraints($queryBuilder, $values)
             );
         };
         $generateModifier = function (QueryBuilder $queryBuilder, array $values) {
-            return $queryBuilder->select($this->routeFieldName)->where(
+            return $queryBuilder->select(...$this->persistenceFieldNames)->where(
                 ...$this->createFieldConstraints($queryBuilder, $values)
             );
         };
@@ -189,9 +222,47 @@ class PersistedAliasMapper implements StaticMappableAspectInterface
         foreach ($values as $fieldName => $fieldValue) {
             $constraints[] = $queryBuilder->expr()->eq(
                 $fieldName,
-                $queryBuilder->createNamedParameter($fieldValue, \PDO::PARAM_STR)
+                $queryBuilder->createNamedParameter(
+                    $fieldValue,
+                    \PDO::PARAM_STR
+                )
             );
         }
         return $constraints;
     }
+
+    /**
+     * @param array|null $record
+     * @return array|null
+     */
+    protected function resolveOverlay(?array $record): ?array
+    {
+        $languageId = $this->siteLanguage->getLanguageId();
+        if ($record === null || $languageId === 0) {
+            return $record;
+        }
+
+        $pageRepository = $this->createPageRepository();
+        if ($this->tableName === 'pages') {
+            return $pageRepository->getPageOverlay($record, $languageId);
+        }
+        return $pageRepository
+            ->getRecordOverlay($this->tableName, $record, $languageId) ?: null;
+    }
+
+    /**
+     * @return PageRepository
+     */
+    protected function createPageRepository(): PageRepository
+    {
+        $context = clone GeneralUtility::makeInstance(Context::class);
+        $context->setAspect(
+            'language',
+            LanguageAspectFactory::createFromSiteLanguage($this->siteLanguage)
+        );
+        return GeneralUtility::makeInstance(
+            PageRepository::class,
+            $context
+        );
+    }
 }
index e2b4ff7..dd1fab3 100644 (file)
@@ -16,10 +16,13 @@ namespace TYPO3\CMS\Core\Routing\Aspect;
  * The TYPO3 project - inspiring people to share!
  */
 
+use TYPO3\CMS\Core\Context\Context;
+use TYPO3\CMS\Core\Context\LanguageAspectFactory;
 use TYPO3\CMS\Core\Database\ConnectionPool;
 use TYPO3\CMS\Core\Database\Query\QueryBuilder;
 use TYPO3\CMS\Core\Site\SiteLanguageAwareTrait;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Frontend\Page\PageRepository;
 
 /**
  * Very useful for building an a path segment from a combined value of the database.
@@ -75,14 +78,14 @@ class PersistedPatternMapper implements StaticMappableAspectInterface
     protected $routeFieldResultNames;
 
     /**
-     * @var string
+     * @var PersistenceDelegate
      */
-    protected $valueFieldName = 'uid';
+    protected $persistenceDelegate;
 
     /**
-     * @var PersistenceDelegate
+     * @var string|null
      */
-    protected $persistenceDelegate;
+    protected $languageParentFieldName;
 
     /**
      * @param array $settings
@@ -115,6 +118,7 @@ class PersistedPatternMapper implements StaticMappableAspectInterface
         $this->routeFieldPattern = $routeFieldPattern;
         $this->routeFieldResult = $routeFieldResult;
         $this->routeFieldResultNames = $routeFieldResultNames['fieldName'] ?? [];
+        $this->languageParentFieldName = $GLOBALS['TCA'][$this->tableName]['ctrl']['transOrigPointerField'] ?? null;
     }
 
     /**
@@ -123,8 +127,9 @@ class PersistedPatternMapper implements StaticMappableAspectInterface
     public function generate(string $value): ?string
     {
         $result = $this->getPersistenceDelegate()->generate([
-            $this->valueFieldName => $value
+            'uid' => $value
         ]);
+        $result = $this->resolveOverlay($result);
         return $this->createRouteResult($result);
     }
 
@@ -138,8 +143,13 @@ class PersistedPatternMapper implements StaticMappableAspectInterface
         }
         $values = $this->filterNamesKeys($matches);
         $result = $this->getPersistenceDelegate()->resolve($values);
-        $result = $result[$this->valueFieldName] ?? null;
-        return $result ? (string)$result : null;
+        if ($result[$this->languageParentFieldName] ?? null > 0) {
+            return (string)$result[$this->languageParentFieldName];
+        }
+        if (isset($result['uid'])) {
+            return (string)$result['uid'];
+        }
+        return null;
     }
 
     /**
@@ -154,8 +164,11 @@ class PersistedPatternMapper implements StaticMappableAspectInterface
         }
         $substitutes = [];
         foreach ($this->routeFieldResultNames as $fieldName) {
+            if (!isset($result[$fieldName])) {
+                return null;
+            }
             $routeFieldName = '{' . $fieldName . '}';
-            $substitutes[$routeFieldName] = ($result[$fieldName] ?? null) ?: 'empty';
+            $substitutes[$routeFieldName] = $result[$fieldName];
         }
         return str_replace(
             array_keys($substitutes),
@@ -193,8 +206,8 @@ class PersistedPatternMapper implements StaticMappableAspectInterface
         // @todo Restrictions (Hidden? Workspace?)
 
         $resolveModifier = function (QueryBuilder $queryBuilder, array $values) {
-            return $queryBuilder->select($this->valueFieldName)->where(
-                ...$this->createFieldConstraints($queryBuilder, $values)
+            return $queryBuilder->select('*')->where(
+                ...$this->createFieldConstraints($queryBuilder, $values, true)
             );
         };
         $generateModifier = function (QueryBuilder $queryBuilder, array $values) {
@@ -213,12 +226,23 @@ class PersistedPatternMapper implements StaticMappableAspectInterface
     /**
      * @param QueryBuilder $queryBuilder
      * @param array $values
+     * @param bool $resolveExpansion
      * @return array
      */
-    protected function createFieldConstraints(QueryBuilder $queryBuilder, array $values): array
-    {
+    protected function createFieldConstraints(
+        QueryBuilder $queryBuilder,
+        array $values,
+        bool $resolveExpansion = false
+    ): array {
+        $languageExpansion = $this->languageParentFieldName
+            && $resolveExpansion
+            && isset($values['uid']);
+
         $constraints = [];
         foreach ($values as $fieldName => $fieldValue) {
+            if ($languageExpansion && $fieldName === 'uid') {
+                continue;
+            }
             $constraints[] = $queryBuilder->expr()->eq(
                 $fieldName,
                 $queryBuilder->createNamedParameter(
@@ -227,6 +251,53 @@ class PersistedPatternMapper implements StaticMappableAspectInterface
                 )
             );
         }
+        // If requested, either match uid or language parent field value
+        if ($languageExpansion) {
+            $idParameter = $queryBuilder->createNamedParameter(
+                $values['uid'],
+                \PDO::PARAM_INT
+            );
+            $constraints[] = $queryBuilder->expr()->orX(
+                $queryBuilder->expr()->eq('uid', $idParameter),
+                $queryBuilder->expr()->eq($this->languageParentFieldName, $idParameter)
+            );
+        }
+
         return $constraints;
     }
+
+    /**
+     * @param array|null $record
+     * @return array|null
+     */
+    protected function resolveOverlay(?array $record): ?array
+    {
+        $languageId = $this->siteLanguage->getLanguageId();
+        if ($record === null || $languageId === 0) {
+            return $record;
+        }
+
+        $pageRepository = $this->createPageRepository();
+        if ($this->tableName === 'pages') {
+            return $pageRepository->getPageOverlay($record, $languageId);
+        }
+        return $pageRepository
+            ->getRecordOverlay($this->tableName, $record, $languageId) ?: null;
+    }
+
+    /**
+     * @return PageRepository
+     */
+    protected function createPageRepository(): PageRepository
+    {
+        $context = clone GeneralUtility::makeInstance(Context::class);
+        $context->setAspect(
+            'language',
+            LanguageAspectFactory::createFromSiteLanguage($this->siteLanguage)
+        );
+        return GeneralUtility::makeInstance(
+            PageRepository::class,
+            $context
+        );
+    }
 }
index 93f8e8b..9e1ff5b 100644 (file)
@@ -113,7 +113,7 @@ class StaticValueMapper implements StaticMappableAspectInterface, \Countable
     {
         $locale = $this->siteLanguage->getLocale();
         foreach ($this->localeMap as $item) {
-            $pattern = '#' . $item['locale'] . '#i';
+            $pattern = '#^' . $item['locale'] . '#i';
             if (preg_match($pattern, $locale)) {
                 return array_map('strval', $item['map']);
             }
index 6451c32..0aae390 100644 (file)
@@ -392,13 +392,11 @@ can be used to build the URL:
             type: PersistedAliasMapper
             tableName: 'tx_news_domain_model_news'
             routeFieldName: 'path_segment'
-            valueFieldName: 'uid'
             routeValuePrefix: '/'
 
 The PersistedAliasMapper looks up (via a so-called delegate pattern under the hood) to map the given value to a
 a URL. The property `tableName` points to the database table, property `routeFieldName` is the field which will be
-used within the route path, and the `valueFieldName` is the argument that is used within the Extbase plugin for
-example.
+used within the route path for example.
 
 The special `routeValuePrefix` is used for TCA type `slug` fields where the prefix `/` is within all fields of the
 field names, which should be removed in the case above.
index e31fbe7..424ce50 100644 (file)
@@ -18,7 +18,7 @@ namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling;
 use TYPO3\CMS\Core\Configuration\SiteConfiguration;
 use TYPO3\CMS\Core\Utility\ArrayUtility;
 use TYPO3\CMS\Frontend\Page\CacheHashCalculator;
-use TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\Fixtures\LinkGeneratorController;
+use TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\Fixtures\LinkHandlingController;
 use TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\Fixtures\PhpError;
 use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\Internal\ArrayValueInstruction;
 use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
@@ -181,14 +181,12 @@ abstract class AbstractTestCase extends FunctionalTestCase
         array $languages = [],
         array $errorHandling = []
     ) {
-        $configuration = [
-            'site' => $site,
-        ];
+        $configuration = $site;
         if (!empty($languages)) {
-            $configuration['site']['languages'] = $languages;
+            $configuration['languages'] = $languages;
         }
         if (!empty($errorHandling)) {
-            $configuration['site']['errorHandling'] = $errorHandling;
+            $configuration['errorHandling'] = $errorHandling;
         }
 
         $siteConfiguration = new SiteConfiguration(
@@ -203,6 +201,26 @@ abstract class AbstractTestCase extends FunctionalTestCase
     }
 
     /**
+     * @param string $identifier
+     * @param array $overrides
+     */
+    protected function mergeSiteConfiguration(
+        string $identifier,
+        array $overrides
+    ) {
+        $siteConfiguration = new SiteConfiguration(
+            $this->instancePath . '/typo3conf/sites/'
+        );
+        $configuration = $siteConfiguration->load($identifier);
+        $configuration = array_merge($configuration, $overrides);
+        try {
+            $siteConfiguration->write($identifier, $configuration);
+        } catch (\Exception $exception) {
+            $this->markTestSkipped($exception->getMessage());
+        }
+    }
+
+    /**
      * @param int $rootPageId
      * @param string $base
      * @return array
@@ -364,7 +382,7 @@ abstract class AbstractTestCase extends FunctionalTestCase
      */
     protected function createTypoLinkUrlInstruction(array $typoScript): ArrayValueInstruction
     {
-        return (new ArrayValueInstruction(LinkGeneratorController::class))
+        return (new ArrayValueInstruction(LinkHandlingController::class))
             ->withArray([
                 '10' => 'TEXT',
                 '10.' => [
@@ -382,7 +400,7 @@ abstract class AbstractTestCase extends FunctionalTestCase
      */
     protected function createHierarchicalMenuProcessorInstruction(array $typoScript): ArrayValueInstruction
     {
-        return (new ArrayValueInstruction(LinkGeneratorController::class))
+        return (new ArrayValueInstruction(LinkHandlingController::class))
             ->withArray([
                 '10' => 'FLUIDTEMPLATE',
                 '10.' => [
@@ -404,7 +422,7 @@ abstract class AbstractTestCase extends FunctionalTestCase
      */
     protected function createLanguageMenuProcessorInstruction(array $typoScript): ArrayValueInstruction
     {
-        return (new ArrayValueInstruction(LinkGeneratorController::class))
+        return (new ArrayValueInstruction(LinkHandlingController::class))
             ->withArray([
                 '10' => 'FLUIDTEMPLATE',
                 '10.' => [
@@ -454,4 +472,18 @@ abstract class AbstractTestCase extends FunctionalTestCase
             $menu
         );
     }
+
+    /**
+     * @param array $keys
+     * @param mixed $payload
+     * @return array
+     */
+    protected function populateToKeys(array $keys, $payload): array
+    {
+        $result = [];
+        foreach ($keys as $key) {
+            $result[$key] = $payload;
+        }
+        return $result;
+    }
 }
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/EnhancerLinkGeneratorTest.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/EnhancerLinkGeneratorTest.php
new file mode 100644 (file)
index 0000000..cbeb0cd
--- /dev/null
@@ -0,0 +1,484 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Core\Bootstrap;
+use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataHandlerFactory;
+use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataHandlerWriter;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequestContext;
+
+/**
+ * Test case for frontend requests having site handling configured using enhancers.
+ */
+class EnhancerLinkGeneratorTest extends AbstractTestCase
+{
+    /**
+     * @var string
+     */
+    private $siteTitle = 'A Company that Manufactures Everything Inc';
+
+    /**
+     * @var InternalRequestContext
+     */
+    private $internalRequestContext;
+
+    public static function setUpBeforeClass()
+    {
+        parent::setUpBeforeClass();
+        static::initializeDatabaseSnapshot();
+    }
+
+    public static function tearDownAfterClass()
+    {
+        static::destroyDatabaseSnapshot();
+        parent::tearDownAfterClass();
+    }
+
+    protected function setUp()
+    {
+        parent::setUp();
+
+        // these settings are forwarded to the frontend sub-request as well
+        $this->internalRequestContext = (new InternalRequestContext())
+            ->withGlobalSettings(['TYPO3_CONF_VARS' => static::TYPO3_CONF_VARS]);
+
+        $this->writeSiteConfiguration(
+            'acme-com',
+            $this->buildSiteConfiguration(1000, 'https://acme.com/'),
+            [
+                $this->buildDefaultLanguageConfiguration('EN', 'https://acme.us/'),
+                $this->buildLanguageConfiguration('FR', 'https://acme.fr/', ['EN']),
+                $this->buildLanguageConfiguration('FR-CA', 'https://acme.ca/', ['FR', 'EN']),
+            ]
+        );
+
+        $this->withDatabaseSnapshot(function () {
+            $this->setUpDatabase();
+        });
+    }
+
+    protected function setUpDatabase()
+    {
+        $backendUser = $this->setUpBackendUserFromFixture(1);
+        Bootstrap::initializeLanguageObject();
+
+        $scenarioFile = __DIR__ . '/Fixtures/SlugScenario.yaml';
+        $factory = DataHandlerFactory::fromYamlFile($scenarioFile);
+        $writer = DataHandlerWriter::withBackendUser($backendUser);
+        $writer->invokeFactory($factory);
+        static::failIfArrayIsNotEmpty(
+            $writer->getErrors()
+        );
+
+        $this->setUpFrontendRootPage(
+            1000,
+            [
+                'typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/LinkGenerator.typoscript',
+            ],
+            [
+                'title' => 'ACME Root',
+                'sitetitle' => $this->siteTitle,
+            ]
+        );
+    }
+
+    protected function tearDown()
+    {
+        unset($this->internalRequestContext);
+        parent::tearDown();
+    }
+
+    /**
+     * @param array $aspect
+     * @param array $languages
+     * @param array $enhancers
+     * @param string $variableName
+     * @param string $templateSuffix
+     * @return array
+     */
+    protected function createDataSet(
+        array $aspect,
+        array $languages,
+        array $enhancers,
+        string $variableName = 'value',
+        string $templateSuffix = ''
+    ): array {
+        $dataSet = [];
+        foreach ($enhancers as $enhancer) {
+            foreach ($languages as $languageId => $expectation) {
+                $dataSet[] = [
+                    array_merge(
+                        $enhancer['enhancer'],
+                        ['aspects' => [$variableName => $aspect]]
+                    ),
+                    $enhancer['parameters'],
+                    $languageId,
+                    $expectation,
+                ];
+            }
+        }
+        return $this->keysFromTemplate(
+            $dataSet,
+            'enhancer:%1$s, lang:%3$d' . $templateSuffix,
+            function (array $items) {
+                array_splice(
+                    $items,
+                    0,
+                    1,
+                    $items[0]['type']
+                );
+                return $items;
+            }
+        );
+    }
+
+    protected function getEnhancers(array $options = []): array
+    {
+        $options = array_merge(['name' => 'enhance', 'value' => 100], $options);
+        return [
+            [
+                'parameters' => sprintf('&value=%s', $options['value']),
+                'enhancer' => [
+                    'type' => 'Simple',
+                    'routePath' => sprintf('/%s/{value}', $options['name']),
+                    '_arguments' => [],
+                ],
+            ],
+            [
+                'parameters' => sprintf('&testing[value]=%s', $options['value']),
+                'enhancer' => [
+                    'type' => 'Plugin',
+                    'routePath' => sprintf('/%s/{value}', $options['name']),
+                    'namespace' => 'testing',
+                    '_arguments' => [],
+                ],
+            ],
+            [
+                'parameters' => sprintf('&tx_testing_link[value]=%s&tx_testing_link[controller]=Link&tx_testing_link[action]=index', $options['value']),
+                'enhancer' => [
+                    'type' => 'Extbase',
+                    'routes' => [
+                        [
+                            'routePath' => sprintf('/%s/{value}', $options['name']),
+                            '_controller' => 'Link::index',
+                            '_arguments' => [],
+                        ],
+                    ],
+                    'extension' => 'testing',
+                    'plugin' => 'link',
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @return array
+     */
+    public function localeModifierDataProvider(): array
+    {
+        $aspect = [
+            'type' => 'LocaleModifier',
+            'default' => 'enhance',
+            'localeMap' => [
+                [
+                    'locale' => 'fr_FR',
+                    'value' => 'augmenter'
+                ]
+            ],
+        ];
+
+        $languages = [
+            '0' => 'https://acme.us/welcome/enhance/100?cHash=',
+            '1' => 'https://acme.fr/bienvenue/augmenter/100?cHash=',
+        ];
+
+        return $this->createDataSet(
+            $aspect,
+            $languages,
+            $this->getEnhancers(['name' => '{enhance_name}']),
+            'enhance_name'
+        );
+    }
+
+    /**
+     * @param array $enhancer
+     * @param string $additionalParameters
+     * @param int $targetLanguageId
+     * @param string $expectation
+     *
+     * @test
+     * @dataProvider localeModifierDataProvider
+     */
+    public function localeModifierIsApplied(array $enhancer, string $additionalParameters, int $targetLanguageId, string $expectation)
+    {
+        $this->mergeSiteConfiguration('acme-com', [
+            'routeEnhancers' => ['Enhancer' => $enhancer]
+        ]);
+
+        $response = $this->executeFrontendRequest(
+            (new InternalRequest('https://acme.us/'))
+                ->withPageId(1100)
+                ->withInstructions([
+                    $this->createTypoLinkUrlInstruction([
+                        'parameter' => 1100,
+                        'language' => $targetLanguageId,
+                        'additionalParams' => $additionalParameters,
+                        'forceAbsoluteUrl' => 1,
+                    ])
+                ]),
+            $this->internalRequestContext
+        );
+
+        static::assertStringStartsWith($expectation, (string)$response->getBody());
+    }
+
+    /**
+     * @return array
+     */
+    public function persistedAliasMapperDataProvider(): array
+    {
+        $aspect = [
+            'type' => 'PersistedAliasMapper',
+            'tableName' => 'pages',
+            'routeFieldName' => 'slug',
+            'routeValuePrefix' => '/',
+        ];
+
+        $languages = [
+            '0' => 'https://acme.us/welcome/enhance/welcome',
+            '1' => 'https://acme.fr/bienvenue/enhance/bienvenue',
+        ];
+
+        return $this->createDataSet(
+            $aspect,
+            $languages,
+            $this->getEnhancers(['value' => 1100])
+        );
+    }
+
+    /**
+     * @param array $enhancer
+     * @param string $additionalParameters
+     * @param int $targetLanguageId
+     * @param string $expectation
+     *
+     * @test
+     * @dataProvider persistedAliasMapperDataProvider
+     */
+    public function persistedAliasMapperIsApplied(array $enhancer, string $additionalParameters, int $targetLanguageId, string $expectation)
+    {
+        $this->mergeSiteConfiguration('acme-com', [
+            'routeEnhancers' => ['Enhancer' => $enhancer]
+        ]);
+
+        $response = $this->executeFrontendRequest(
+            (new InternalRequest('https://acme.us/'))
+                ->withPageId(1100)
+                ->withInstructions([
+                    $this->createTypoLinkUrlInstruction([
+                        'parameter' => 1100,
+                        'language' => $targetLanguageId,
+                        'additionalParams' => $additionalParameters,
+                        'forceAbsoluteUrl' => 1,
+                    ])
+                ]),
+            $this->internalRequestContext
+        );
+
+        static::assertSame($expectation, (string)$response->getBody());
+    }
+
+    /**
+     * @return array
+     */
+    public function persistedPatternMapperDataProvider(): array
+    {
+        $aspect = [
+            'type' => 'PersistedPatternMapper',
+            'tableName' => 'pages',
+            'routeFieldPattern' => '^(?P<subtitle>.+)-(?P<uid>\d+)$',
+            'routeFieldResult' => '{subtitle}-{uid}',
+        ];
+
+        $languages = [
+            '0' => 'https://acme.us/welcome/enhance/hello-and-welcome-1100',
+            '1' => 'https://acme.fr/bienvenue/enhance/salut-et-bienvenue-1100',
+        ];
+
+        return $this->createDataSet(
+            $aspect,
+            $languages,
+            $this->getEnhancers(['value' => 1100])
+        );
+    }
+
+    /**
+     * @param array $enhancer
+     * @param string $additionalParameters
+     * @param int $targetLanguageId
+     * @param string $expectation
+     *
+     * @test
+     * @dataProvider persistedPatternMapperDataProvider
+     */
+    public function persistedPatternMapperIsApplied(array $enhancer, string $additionalParameters, int $targetLanguageId, string $expectation)
+    {
+        $this->mergeSiteConfiguration('acme-com', [
+            'routeEnhancers' => ['Enhancer' => $enhancer]
+        ]);
+
+        $response = $this->executeFrontendRequest(
+            (new InternalRequest('https://acme.us/'))
+                ->withPageId(1100)
+                ->withInstructions([
+                    $this->createTypoLinkUrlInstruction([
+                        'parameter' => 1100,
+                        'language' => $targetLanguageId,
+                        'additionalParams' => $additionalParameters,
+                        'forceAbsoluteUrl' => 1,
+                    ])
+                ]),
+            $this->internalRequestContext
+        );
+
+        static::assertSame($expectation, (string)$response->getBody());
+    }
+
+    /**
+     * @return array
+     */
+    public function staticValueMapperDataProvider(): array
+    {
+        $aspect = [
+            'type' => 'StaticValueMapper',
+            'map' => [
+                'hundred' => 100,
+            ],
+            'localeMap' => [
+                [
+                    'locale' => 'fr_FR',
+                    'map' => [
+                        'cent' => 100,
+                    ],
+                ]
+            ],
+        ];
+
+        $languages = [
+            '0' => 'https://acme.us/welcome/enhance/hundred',
+            '1' => 'https://acme.fr/bienvenue/enhance/cent',
+        ];
+
+        return $this->createDataSet($aspect, $languages, $this->getEnhancers());
+    }
+
+    /**
+     * @param array $enhancer
+     * @param string $additionalParameters
+     * @param int $targetLanguageId
+     * @param string $expectation
+     *
+     * @test
+     * @dataProvider staticValueMapperDataProvider
+     */
+    public function staticValueMapperIsApplied(array $enhancer, string $additionalParameters, int $targetLanguageId, string $expectation)
+    {
+        $this->mergeSiteConfiguration('acme-com', [
+            'routeEnhancers' => ['Enhancer' => $enhancer]
+        ]);
+
+        $response = $this->executeFrontendRequest(
+            (new InternalRequest('https://acme.us/'))
+                ->withPageId(1100)
+                ->withInstructions([
+                    $this->createTypoLinkUrlInstruction([
+                        'parameter' => 1100,
+                        'language' => $targetLanguageId,
+                        'additionalParams' => $additionalParameters,
+                        'forceAbsoluteUrl' => 1,
+                    ])
+                ]),
+            $this->internalRequestContext
+        );
+
+        static::assertStringStartsWith($expectation, (string)$response->getBody());
+    }
+
+    /**
+     * @return array
+     */
+    public function staticRangeMapperDataProvider(): array
+    {
+        $aspect = [
+            'type' => 'StaticRangeMapper',
+            'start' => '1',
+            'end' => '100',
+        ];
+
+        $dataSet = [];
+        foreach (range(10, 100, 30) as $value) {
+            $languages = [
+                '0' => sprintf('https://acme.us/welcome/enhance/%s', $value),
+                '1' => sprintf('https://acme.fr/bienvenue/enhance/%s', $value),
+            ];
+
+            $dataSet = array_merge(
+                $dataSet,
+                $this->createDataSet(
+                    $aspect,
+                    $languages,
+                    $this->getEnhancers(['value' => $value]),
+                    'value',
+                    sprintf(', value:%d', $value)
+                )
+            );
+        }
+        return $dataSet;
+    }
+
+    /**
+     * @param array $enhancer
+     * @param string $additionalParameters
+     * @param int $targetLanguageId
+     * @param string $expectation
+     *
+     * @test
+     * @dataProvider staticRangeMapperDataProvider
+     */
+    public function staticRangeMapperIsApplied(array $enhancer, string $additionalParameters, int $targetLanguageId, string $expectation)
+    {
+        $this->mergeSiteConfiguration('acme-com', [
+            'routeEnhancers' => ['Enhancer' => $enhancer]
+        ]);
+
+        $response = $this->executeFrontendRequest(
+            (new InternalRequest('https://acme.us/'))
+                ->withPageId(1100)
+                ->withInstructions([
+                    $this->createTypoLinkUrlInstruction([
+                        'parameter' => 1100,
+                        'language' => $targetLanguageId,
+                        'additionalParams' => $additionalParameters,
+                        'forceAbsoluteUrl' => 1,
+                    ])
+                ]),
+            $this->internalRequestContext
+        );
+
+        static::assertStringStartsWith($expectation, (string)$response->getBody());
+    }
+}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/EnhancerSiteRequestTest.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/EnhancerSiteRequestTest.php
new file mode 100644 (file)
index 0000000..71f657f
--- /dev/null
@@ -0,0 +1,514 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Core\Bootstrap;
+use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataHandlerFactory;
+use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataHandlerWriter;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequestContext;
+
+/**
+ * Test case for frontend requests having site handling configured using enhancers.
+ */
+class EnhancerSiteRequestTest extends AbstractTestCase
+{
+    /**
+     * @var string
+     */
+    private $siteTitle = 'A Company that Manufactures Everything Inc';
+
+    /**
+     * @var InternalRequestContext
+     */
+    private $internalRequestContext;
+
+    public static function setUpBeforeClass()
+    {
+        parent::setUpBeforeClass();
+        static::initializeDatabaseSnapshot();
+    }
+
+    public static function tearDownAfterClass()
+    {
+        static::destroyDatabaseSnapshot();
+        parent::tearDownAfterClass();
+    }
+
+    protected function setUp()
+    {
+        parent::setUp();
+
+        // these settings are forwarded to the frontend sub-request as well
+        $this->internalRequestContext = (new InternalRequestContext())
+            ->withGlobalSettings(['TYPO3_CONF_VARS' => static::TYPO3_CONF_VARS]);
+
+        $this->writeSiteConfiguration(
+            'acme-com',
+            $this->buildSiteConfiguration(1000, 'https://acme.com/'),
+            [
+                $this->buildDefaultLanguageConfiguration('EN', 'https://acme.us/'),
+                $this->buildLanguageConfiguration('FR', 'https://acme.fr/', ['EN']),
+                $this->buildLanguageConfiguration('FR-CA', 'https://acme.ca/', ['FR', 'EN']),
+            ]
+        );
+
+        $this->withDatabaseSnapshot(function () {
+            $this->setUpDatabase();
+        });
+    }
+
+    protected function setUpDatabase()
+    {
+        $backendUser = $this->setUpBackendUserFromFixture(1);
+        Bootstrap::initializeLanguageObject();
+
+        $scenarioFile = __DIR__ . '/Fixtures/SlugScenario.yaml';
+        $factory = DataHandlerFactory::fromYamlFile($scenarioFile);
+        $writer = DataHandlerWriter::withBackendUser($backendUser);
+        $writer->invokeFactory($factory);
+        static::failIfArrayIsNotEmpty(
+            $writer->getErrors()
+        );
+
+        $this->setUpFrontendRootPage(
+            1000,
+            [
+                'typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/LinkRequest.typoscript',
+            ],
+            [
+                'title' => 'ACME Root',
+                'sitetitle' => $this->siteTitle,
+            ]
+        );
+    }
+
+    protected function tearDown()
+    {
+        unset($this->internalRequestContext);
+        parent::tearDown();
+    }
+
+    /**
+     * @param array $aspect
+     * @param array $enhancerLanguageUris
+     * @param array $enhancers
+     * @param string $variableName
+     * @param string $templateSuffix
+     * @return array
+     */
+    protected function createDataSet(
+        array $aspect,
+        array $enhancerLanguageUris,
+        array $enhancers,
+        string $variableName = 'value',
+        string $templateSuffix = ''
+    ): array {
+        $dataSet = [];
+        foreach ($enhancers as $enhancer) {
+            $enhancerType = $enhancer['enhancer']['type'];
+            foreach ($enhancerLanguageUris[$enhancerType] as $languageId => $uri) {
+                $expectation = $enhancer['arguments'];
+                $expectation['staticArguments'] = $expectation['staticArguments'] ?? [];
+                $expectation['dynamicArguments'] = $expectation['dynamicArguments'] ?? [];
+                $expectation['queryArguments'] = $expectation['queryArguments'] ?? [];
+                if (preg_match('#\?cHash=([a-z0-9]+)#i', $uri, $matches)) {
+                    $expectation['dynamicArguments']['cHash'] = $matches[1];
+                    $expectation['queryArguments']['cHash'] = $matches[1];
+                }
+                $dataSet[] = [
+                    array_merge(
+                        $enhancer['enhancer'],
+                        ['aspects' => [$variableName => $aspect]]
+                    ),
+                    $uri,
+                    $languageId,
+                    $expectation,
+                ];
+            }
+        }
+        return $this->keysFromTemplate(
+            $dataSet,
+            'enhancer:%1$s, lang:%3$d' . $templateSuffix,
+            function (array $items) {
+                array_splice(
+                    $items,
+                    0,
+                    1,
+                    $items[0]['type']
+                );
+                return $items;
+            }
+        );
+    }
+
+    /**
+     * @param array $options
+     * @param bool $isStatic
+     * @return array
+     */
+    protected function getEnhancers(array $options = [], bool $isStatic = false): array
+    {
+        $inArguments = $isStatic ? 'staticArguments' : 'dynamicArguments';
+        $options = array_merge(['name' => 'enhance', 'value' => 100], $options);
+        return [
+            [
+                'arguments' => [
+                    $inArguments => [
+                        'value' => (string)$options['value'],
+                    ],
+                ],
+                'enhancer' => [
+                    'type' => 'Simple',
+                    'routePath' => sprintf('/%s/{value}', $options['name']),
+                    '_arguments' => [],
+                ],
+            ],
+            [
+                'arguments' => [
+                    $inArguments => [
+                        'testing' => [
+                            'value' => (string)$options['value'],
+                        ],
+                    ],
+                ],
+                'enhancer' => [
+                    'type' => 'Plugin',
+                    'routePath' => sprintf('/%s/{value}', $options['name']),
+                    'namespace' => 'testing',
+                    '_arguments' => [],
+                ],
+            ],
+            [
+                'arguments' => array_merge_recursive([
+                    $inArguments => [
+                        'tx_testing_link' => [
+                            'value' => (string)$options['value'],
+                        ],
+                    ],
+                ], [
+                    'staticArguments' => [
+                        'tx_testing_link' => [
+                            'controller' => 'Link',
+                            'action' => 'index',
+                        ],
+                    ],
+                ]),
+                'enhancer' => [
+                    'type' => 'Extbase',
+                    'routes' => [
+                        [
+                            'routePath' => sprintf('/%s/{value}', $options['name']),
+                            '_controller' => 'Link::index',
+                            '_arguments' => [],
+                        ],
+                    ],
+                    'extension' => 'testing',
+                    'plugin' => 'link',
+                ],
+            ],
+        ];
+    }
+
+    /**
+     * @return array
+     */
+    public function localeModifierDataProvider(): array
+    {
+        $aspect = [
+            'type' => 'LocaleModifier',
+            'default' => 'enhance',
+            'localeMap' => [
+                [
+                    'locale' => 'fr_FR',
+                    'value' => 'augmenter'
+                ]
+            ],
+        ];
+
+        $enhancerLanguageUris = [
+            'Simple' => [
+                '0' => 'https://acme.us/welcome/enhance/100?cHash=46227b4ce096dc78a4e71463326c9020',
+                '1' => 'https://acme.fr/bienvenue/augmenter/100?cHash=46227b4ce096dc78a4e71463326c9020',
+            ],
+            'Plugin' => [
+                '0' => 'https://acme.us/welcome/enhance/100?cHash=e24d3d2d5503baba670d827c3b9470c8',
+                '1' => 'https://acme.fr/bienvenue/augmenter/100?cHash=e24d3d2d5503baba670d827c3b9470c8',
+            ],
+            'Extbase' => [
+                '0' => 'https://acme.us/welcome/enhance/100?cHash=eef21771ab3c3dac3514b4479eedd5ff',
+                '1' => 'https://acme.fr/bienvenue/augmenter/100?cHash=eef21771ab3c3dac3514b4479eedd5ff',
+            ]
+        ];
+
+        return $this->createDataSet(
+            $aspect,
+            $enhancerLanguageUris,
+            $this->getEnhancers(['name' => '{enhance_name}']),
+            'enhance_name'
+        );
+    }
+
+    /**
+     * @param array $enhancer
+     * @param string $targetUri
+     * @param int $expectedLanguageId
+     * @param string $expectation
+     *
+     * @test
+     * @dataProvider localeModifierDataProvider
+     */
+    public function localeModifierIsApplied(array $enhancer, string $targetUri, int $expectedLanguageId, array $expectation)
+    {
+        $this->assertPageArgumentsEquals(
+            $enhancer,
+            $targetUri,
+            $expectedLanguageId,
+            $expectation
+        );
+    }
+
+    /**
+     * @return array
+     */
+    public function persistedAliasMapperDataProvider(): array
+    {
+        $aspect = [
+            'type' => 'PersistedAliasMapper',
+            'tableName' => 'pages',
+            'routeFieldName' => 'slug',
+            'routeValuePrefix' => '/',
+        ];
+
+        $enhancerLanguageUris = $this->populateToKeys(
+            ['Simple', 'Plugin', 'Extbase'],
+            [
+                '0' => 'https://acme.us/welcome/enhance/welcome',
+                '1' => 'https://acme.fr/bienvenue/enhance/bienvenue',
+            ]
+        );
+
+        return $this->createDataSet(
+            $aspect,
+            $enhancerLanguageUris,
+            $this->getEnhancers(['value' => 1100], true)
+        );
+    }
+
+    /**
+     * @param array $enhancer
+     * @param string $targetUri
+     * @param int $expectedLanguageId
+     * @param string $expectation
+     *
+     * @test
+     * @dataProvider persistedAliasMapperDataProvider
+     */
+    public function persistedAliasMapperIsApplied(array $enhancer, string $targetUri, int $expectedLanguageId, array $expectation)
+    {
+        $this->assertPageArgumentsEquals(
+            $enhancer,
+            $targetUri,
+            $expectedLanguageId,
+            $expectation
+        );
+    }
+
+    /**
+     * @return array
+     */
+    public function persistedPatternMapperDataProvider(): array
+    {
+        $aspect = [
+            'type' => 'PersistedPatternMapper',
+            'tableName' => 'pages',
+            'routeFieldPattern' => '^(?P<subtitle>.+)-(?P<uid>\d+)$',
+            'routeFieldResult' => '{subtitle}-{uid}',
+        ];
+
+        $enhancerLanguageUris = $this->populateToKeys(
+            ['Simple', 'Plugin', 'Extbase'],
+            [
+                '0' => 'https://acme.us/welcome/enhance/hello-and-welcome-1100',
+                '1' => 'https://acme.fr/bienvenue/enhance/salut-et-bienvenue-1100',
+            ]
+        );
+
+        return $this->createDataSet(
+            $aspect,
+            $enhancerLanguageUris,
+            $this->getEnhancers(['value' => 1100], true)
+        );
+    }
+
+    /**
+     * @param array $enhancer
+     * @param string $targetUri
+     * @param int $expectedLanguageId
+     * @param string $expectation
+     *
+     * @test
+     * @dataProvider persistedPatternMapperDataProvider
+     */
+    public function persistedPatternMapperIsApplied(array $enhancer, string $targetUri, int $expectedLanguageId, array $expectation)
+    {
+        $this->assertPageArgumentsEquals(
+            $enhancer,
+            $targetUri,
+            $expectedLanguageId,
+            $expectation
+        );
+    }
+
+    /**
+     * @return array
+     */
+    public function staticValueMapperDataProvider(): array
+    {
+        $aspect = [
+            'type' => 'StaticValueMapper',
+            'map' => [
+                'hundred' => 100,
+            ],
+            'localeMap' => [
+                [
+                    'locale' => 'fr_FR',
+                    'map' => [
+                        'cent' => 100,
+                    ],
+                ]
+            ],
+        ];
+
+        $enhancerLanguageUris = $this->populateToKeys(
+            ['Simple', 'Plugin', 'Extbase'],
+            [
+                '0' => 'https://acme.us/welcome/enhance/hundred',
+                '1' => 'https://acme.fr/bienvenue/enhance/cent',
+            ]
+        );
+
+        return $this->createDataSet(
+            $aspect,
+            $enhancerLanguageUris,
+            $this->getEnhancers([], true)
+        );
+    }
+
+    /**
+     * @param array $enhancer
+     * @param string $targetUri
+     * @param int $expectedLanguageId
+     * @param string $expectation
+     *
+     * @test
+     * @dataProvider staticValueMapperDataProvider
+     */
+    public function staticValueMapperIsApplied(array $enhancer, string $targetUri, int $expectedLanguageId, array $expectation)
+    {
+        $this->assertPageArgumentsEquals(
+            $enhancer,
+            $targetUri,
+            $expectedLanguageId,
+            $expectation
+        );
+    }
+
+    /**
+     * @return array
+     */
+    public function staticRangeMapperDataProvider(): array
+    {
+        $aspect = [
+            'type' => 'StaticRangeMapper',
+            'start' => '1',
+            'end' => '100',
+        ];
+
+        $dataSet = [];
+        foreach (range(10, 100, 30) as $value) {
+            $enhancerLanguageUris = $this->populateToKeys(
+                ['Simple', 'Plugin', 'Extbase'],
+                [
+                    '0' => sprintf('https://acme.us/welcome/enhance/%s', $value),
+                    '1' => sprintf('https://acme.fr/bienvenue/enhance/%s', $value),
+                ]
+            );
+
+            $dataSet = array_merge(
+                $dataSet,
+                $this->createDataSet(
+                    $aspect,
+                    $enhancerLanguageUris,
+                    $this->getEnhancers(['value' => $value], true),
+                    'value',
+                    sprintf(', value:%d', $value)
+                )
+            );
+        }
+        return $dataSet;
+    }
+
+    /**
+     * @param array $enhancer
+     * @param string $targetUri
+     * @param int $expectedLanguageId
+     * @param string $expectation
+     *
+     * @test
+     * @dataProvider staticRangeMapperDataProvider
+     */
+    public function staticRangeMapperIsApplied(array $enhancer, string $targetUri, int $expectedLanguageId, array $expectation)
+    {
+        $this->assertPageArgumentsEquals(
+            $enhancer,
+            $targetUri,
+            $expectedLanguageId,
+            $expectation
+        );
+    }
+
+    /**
+     * @param array $enhancer
+     * @param string $targetUri
+     * @param int $expectedLanguageId
+     * @param array $expectation
+     */
+    protected function assertPageArgumentsEquals(array $enhancer, string $targetUri, int $expectedLanguageId, array $expectation)
+    {
+        $this->mergeSiteConfiguration('acme-com', [
+            'routeEnhancers' => ['Enhancer' => $enhancer]
+        ]);
+
+        $allParameters = array_replace_recursive(
+            $expectation['dynamicArguments'],
+            $expectation['staticArguments']
+        );
+        $expectation['pageId'] = 1100;
+        $expectation['languageId'] = $expectedLanguageId;
+        $expectation['requestQueryParams'] = $allParameters;
+        $expectation['_GET'] = $allParameters;
+
+        $response = $this->executeFrontendRequest(
+            new InternalRequest($targetUri),
+            $this->internalRequestContext,
+            true
+        );
+
+        $pageArguments = json_decode((string)$response->getBody(), true);
+        static::assertEquals($expectation, $pageArguments);
+    }
+}
index 743527a..983737d 100644 (file)
@@ -15,5 +15,5 @@ config {
 page = PAGE
 page {
   10 = USER
-  10.userFunc = TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\Fixtures\LinkGeneratorController->mainAction
+  10.userFunc = TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\Fixtures\LinkHandlingController->mainAction
 }
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/LinkGeneratorController.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/LinkGeneratorController.php
deleted file mode 100644 (file)
index 33d1d90..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-<?php
-declare(strict_types = 1);
-namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\Fixtures;
-
-/*
- * This file is part of the TYPO3 CMS project.
- *
- * It is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License, either version 2
- * of the License, or any later version.
- *
- * For the full copyright and license information, please read the
- * LICENSE.txt file that was distributed with this source code.
- *
- * The TYPO3 project - inspiring people to share!
- */
-
-use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
-use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\Internal\ArrayValueInstruction;
-use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\RequestBootstrap;
-
-/**
- * Test case for frontend requests having site handling configured
- */
-class LinkGeneratorController
-{
-    /**
-     * @var ContentObjectRenderer
-     */
-    public $cObj;
-
-    public function mainAction(): string
-    {
-        $instruction = RequestBootstrap::getInternalRequest()
-            ->getInstruction(LinkGeneratorController::class);
-        if (!$instruction instanceof ArrayValueInstruction) {
-            return '';
-        }
-        return $this->cObj->cObjGet($instruction->getArray());
-    }
-}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/LinkHandlingController.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/LinkHandlingController.php
new file mode 100644 (file)
index 0000000..14ac057
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\Fixtures;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\ServerRequestInterface;
+use TYPO3\CMS\Core\Routing\PageArguments;
+use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
+use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\Internal\ArrayValueInstruction;
+use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\RequestBootstrap;
+
+/**
+ * Test case for frontend requests having site handling configured
+ */
+class LinkHandlingController
+{
+    /**
+     * @var ContentObjectRenderer
+     */
+    public $cObj;
+
+    /**
+     * @return string
+     */
+    public function mainAction(): string
+    {
+        $instruction = RequestBootstrap::getInternalRequest()
+            ->getInstruction(LinkHandlingController::class);
+        if (!$instruction instanceof ArrayValueInstruction) {
+            return '';
+        }
+        return $this->cObj->cObjGet($instruction->getArray());
+    }
+
+    /**
+     * @return string
+     */
+    public function dumpPageArgumentsAction(): string
+    {
+        /** @var ServerRequestInterface $request */
+        $request = $GLOBALS['TYPO3_REQUEST'];
+        /** @var PageArguments $pageArguments */
+        $pageArguments = $request->getAttribute('routing');
+        /** @var SiteLanguage $language */
+        $language = $request->getAttribute('language');
+        return json_encode([
+            'pageId' => $pageArguments->getPageId(),
+            'languageId' => $language->getLanguageId(),
+            'dynamicArguments' => $pageArguments->getDynamicArguments(),
+            'staticArguments' => $pageArguments->getStaticArguments(),
+            'queryArguments' => $pageArguments->getQueryArguments(),
+            'requestQueryParams' => $request->getQueryParams(),
+            '_GET' => $_GET,
+        ]);
+    }
+}
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/LinkRequest.typoscript b/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/LinkRequest.typoscript
new file mode 100644 (file)
index 0000000..20f11b4
--- /dev/null
@@ -0,0 +1,19 @@
+config {
+  no_cache = 1
+  debug = 0
+  xhtml_cleaning = 0
+  admPanel = 0
+  disableAllHeaderCode = 1
+  sendCacheHeaders = 0
+  sys_language_uid = 0
+  sys_language_mode = ignore
+  sys_language_overlay = 1
+  additionalHeaders.10.header = Content-Type: application/json; charset=utf-8
+  additionalHeaders.10.replace = 1
+}
+
+page = PAGE
+page {
+  10 = USER
+  10.userFunc = TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\Fixtures\LinkHandlingController->dumpPageArgumentsAction
+}
index cacf4a9..cdb2710 100644 (file)
@@ -53,10 +53,10 @@ entities:
   page:
     - self: {id: *idAcmeRootPage, title: 'ACME Inc', type: *pageShortcut, shortcut: 'first', root: true, alias: 'acme-root', slug: '/'}
       children:
-        - self: {id: *idAcmeFirstPage, title: 'EN: Welcome', alias: 'acme-first', slug: '/welcome'}
+        - self: {id: *idAcmeFirstPage, title: 'EN: Welcome', alias: 'acme-first', slug: '/welcome', subtitle: 'hello-and-welcome'}
           languageVariants:
-            - self: {id: 1101, title: 'FR: Welcome', language: 1, slug: '/bienvenue'}
-            - self: {id: 1102, title: 'FR-CA: Welcome', language: 2, slug: '/bienvenue'}
+            - self: {id: 1101, title: 'FR: Welcome', language: 1, slug: '/bienvenue', subtitle: 'salut-et-bienvenue'}
+            - self: {id: 1102, title: 'FR-CA: Welcome', language: 2, slug: '/bienvenue', subtitle: 'salut-et-bienvenue'}
           versionVariants:
             - version: {title: 'EN: Welcome to ACME Inc', workspace: 1, slug: '/welcome'}
           entities: