diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a64119a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +Tests export-ignore +.github export-ignore + +.gitattributes export-ignore +.gitignore export-ignore + +ecs.php export-ignore +rector.php export-ignore +phpstan-baseline.neon export-ignore +phpstan.neon export-ignore +phpunit.xml.dist export-ignore diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1e85da9..6200187 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,7 +15,7 @@ jobs: - name: Install PHP uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 coverage: none tools: composer:v2 env: @@ -29,9 +29,10 @@ jobs: strategy: matrix: php-version: - - 8.1 - 8.2 - 8.3 + - 8.4 + - 8.5 steps: - name: Checkout uses: actions/checkout@v3 @@ -55,7 +56,7 @@ jobs: - name: Install PHP uses: shivammathur/setup-php@v2 with: - php-version: "8.1" + php-version: "8.2" coverage: none tools: composer:v2 env: @@ -86,7 +87,7 @@ jobs: - name: Install PHP uses: shivammathur/setup-php@v2 with: - php-version: "8.1" + php-version: "8.2" coverage: none tools: composer:v2 env: @@ -106,12 +107,14 @@ jobs: strategy: matrix: include: - - php-version: '8.1' - typo3-version: '^12.4' - php-version: '8.2' - typo3-version: '^12.4' + typo3-version: '^13.4' - php-version: '8.3' - typo3-version: '^12.4' + typo3-version: '^13.4' + - php-version: '8.4' + typo3-version: '^13.4' + - php-version: '8.5' + typo3-version: '^13.4' steps: - uses: actions/checkout@v3 @@ -129,3 +132,36 @@ jobs: - name: PHPUnit Tests run: ./vendor/bin/phpunit --testdox + + phpstan: + runs-on: ubuntu-latest + needs: + - check-composer + strategy: + matrix: + include: + - php-version: '8.2' + typo3-version: '^13.4' + - php-version: '8.3' + typo3-version: '^13.4' + - php-version: '8.4' + typo3-version: '^13.4' + - php-version: '8.5' + typo3-version: '^13.4' + steps: + - uses: actions/checkout@v3 + + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "${{ matrix.php-version }}" + coverage: none + tools: composer:v2 + env: + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install dependencies with expected TYPO3 version + run: composer require --no-progress --no-interaction --optimize-autoloader "typo3/cms-core:${{ matrix.typo3-version }}" + + - name: PHPStan + run: ./vendor/bin/phpstan diff --git a/CHANGELOG.md b/CHANGELOG.md index f4f264a..e7b56f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 5.0.0 (2025-09-18) + +[!!!][TASK] TYPO3 13 compatibility + ## 3.0.0 (2022-06-16) [TASK] php annotation diff --git a/Classes/Domain/Model/Marker.php b/Classes/Domain/Model/Marker.php index f0634db..536a112 100644 --- a/Classes/Domain/Model/Marker.php +++ b/Classes/Domain/Model/Marker.php @@ -21,7 +21,6 @@ public function __construct( public int $uid, public string $key, public string $replacement, - // public ?int $smallestValueFromTimeFields, ) { } diff --git a/Classes/Domain/Model/MarkerCollection.php b/Classes/Domain/Model/MarkerCollection.php index 2a7b9b6..912d686 100644 --- a/Classes/Domain/Model/MarkerCollection.php +++ b/Classes/Domain/Model/MarkerCollection.php @@ -17,6 +17,9 @@ use Ramsey\Collection\AbstractArray; +/** + * @extends AbstractArray + */ class MarkerCollection extends AbstractArray { public function getType(): string @@ -41,8 +44,14 @@ public function get(string $markerKey): Marker return $this[$markerKey]; } + /** + * @return list + */ public function getMarkerKeys(): array { - return array_keys($this->data); + return array_map( + static fn (Marker $marker): string => $marker->getMarkerWithBrackets(), + array_values($this->data), + ); } } diff --git a/Classes/Hooks/ContentProcessor.php b/Classes/EventListener/ContentProcessor.php similarity index 57% rename from Classes/Hooks/ContentProcessor.php rename to Classes/EventListener/ContentProcessor.php index 3b0273c..4aee085 100644 --- a/Classes/Hooks/ContentProcessor.php +++ b/Classes/EventListener/ContentProcessor.php @@ -13,26 +13,25 @@ * The TYPO3 project - inspiring people to share! */ -namespace Sinso\Variables\Hooks; +namespace Sinso\Variables\EventListener; use Sinso\Variables\Service\VariablesService; -use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; -use TYPO3\CMS\Core\Utility\GeneralUtility; +use TYPO3\CMS\Core\Attribute\AsEventListener; use TYPO3\CMS\Frontend\Event\AfterCacheableContentIsGeneratedEvent; class ContentProcessor { - protected VariablesService $variablesService; - - public function __construct() - { - $this->variablesService = GeneralUtility::makeInstance(VariablesService::class); + public function __construct( + private readonly VariablesService $variablesService, + ) { } + #[AsEventListener()] public function __invoke(AfterCacheableContentIsGeneratedEvent $event): void { - $extensionConfiguration = GeneralUtility::makeInstance(ExtensionConfiguration::class); - $this->variablesService->initialize($extensionConfiguration, $event->getController()); - $this->variablesService->replaceMarkersInStructureAndAdjustCaching($event->getController()->content); + $this->variablesService->replaceMarkersInStructureAndAdjustCaching( + $event->getRequest(), + $event->getController()->content + ); } } diff --git a/Classes/EventListener/ModifyCacheLifetime.php b/Classes/EventListener/ModifyCacheLifetime.php index d9951f0..a77361e 100644 --- a/Classes/EventListener/ModifyCacheLifetime.php +++ b/Classes/EventListener/ModifyCacheLifetime.php @@ -5,9 +5,10 @@ namespace Sinso\Variables\EventListener; use Sinso\Variables\Service\VariablesService; +use TYPO3\CMS\Core\Attribute\AsEventListener; use TYPO3\CMS\Frontend\Event\ModifyCacheLifetimeForPageEvent; -final class ModifyCacheLifetime +final readonly class ModifyCacheLifetime { public function __construct( private VariablesService $variablesService, @@ -18,6 +19,7 @@ public function __construct( * Calculate shortest lifetime (aka duration) respecting data from * markers */ + #[AsEventListener()] public function __invoke(ModifyCacheLifetimeForPageEvent $event): void { $event->setCacheLifetime( diff --git a/Classes/Hooks/DataHandler.php b/Classes/Hooks/DataHandler.php index 394040a..2efc74e 100644 --- a/Classes/Hooks/DataHandler.php +++ b/Classes/Hooks/DataHandler.php @@ -20,6 +20,7 @@ use TYPO3\CMS\Core\Cache\CacheManager; use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\DataHandling\DataHandler as Typo3CoreDataHandler; class DataHandler { @@ -31,8 +32,9 @@ public function __construct( /** * Flushes the cache if a marker record was edited. + * @param array $params */ - public function clearCachePostProc(array $params, \TYPO3\CMS\Core\DataHandling\DataHandler $dataHandler): void + public function clearCachePostProc(array $params, Typo3CoreDataHandler $dataHandler): void { $marker = $this->getMarkerFromHook($params, $dataHandler); @@ -47,11 +49,15 @@ public function clearCachePostProc(array $params, \TYPO3\CMS\Core\DataHandling\D $this->cacheManager->flushCachesInGroupByTag('pages', $cacheTagToFlush); } - protected function getMarkerFromHook(array $params, \TYPO3\CMS\Core\DataHandling\DataHandler $dataHandler): ?Marker + /** + * @param array $params + */ + protected function getMarkerFromHook(array $params, Typo3CoreDataHandler $dataHandler): ?Marker { if ( - ($params['table'] !== 'tx_variables_marker') + (($params['table'] ?? '') !== 'tx_variables_marker') || !isset($params['uid']) + || is_numeric($params['uid']) === false ) { return null; } @@ -59,7 +65,7 @@ protected function getMarkerFromHook(array $params, \TYPO3\CMS\Core\DataHandling $marker = $dataHandler->datamap[$params['table']][$params['uid']]['marker'] ?? null; if (!$marker) { - $marker = $this->findVariableMarkerByUidEventIfHiddenOrDeleted($params['uid']); + $marker = $this->findVariableMarkerByUidEventIfHiddenOrDeleted((int) $params['uid']); } if (!$marker) { @@ -67,7 +73,7 @@ protected function getMarkerFromHook(array $params, \TYPO3\CMS\Core\DataHandling } return new Marker( - uid: $params['uid'], + uid: (int) $params['uid'], key: $marker, replacement: '', // value doesn't matter here ); @@ -77,7 +83,7 @@ protected function findVariableMarkerByUidEventIfHiddenOrDeleted(int $uid): ?str { $queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_variables_marker'); $queryBuilder->getRestrictions()->removeAll(); - return $queryBuilder + $result = $queryBuilder ->select('marker') ->from('tx_variables_marker') ->where( @@ -85,5 +91,11 @@ protected function findVariableMarkerByUidEventIfHiddenOrDeleted(int $uid): ?str ) ->executeQuery() ->fetchOne(); + + if (is_string($result) || is_null($result)) { + return $result; + } + + return null; } } diff --git a/Classes/Hooks/MarkersProcessorInterface.php b/Classes/Hooks/MarkersProcessorInterface.php index e6937e4..e689650 100644 --- a/Classes/Hooks/MarkersProcessorInterface.php +++ b/Classes/Hooks/MarkersProcessorInterface.php @@ -19,5 +19,5 @@ interface MarkersProcessorInterface { - public function postProcessMarkers(MarkerCollection $markers); + public function postProcessMarkers(MarkerCollection $markers): void; } diff --git a/Classes/Service/VariablesService.php b/Classes/Service/VariablesService.php index e7cb415..91ce70a 100644 --- a/Classes/Service/VariablesService.php +++ b/Classes/Service/VariablesService.php @@ -2,55 +2,45 @@ namespace Sinso\Variables\Service; +use Psr\Http\Message\ServerRequestInterface; use Ramsey\Collection\Set; use Sinso\Variables\Domain\Model\Marker; use Sinso\Variables\Domain\Model\MarkerCollection; use Sinso\Variables\Hooks\MarkersProcessorInterface; use Sinso\Variables\Utility\CacheKeyUtility; +use TYPO3\CMS\Core\Cache\CacheDataCollector; +use TYPO3\CMS\Core\Cache\CacheTag; use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; +use TYPO3\CMS\Core\Context\Context; use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction; +use TYPO3\CMS\Core\TypoScript\FrontendTypoScript; +use TYPO3\CMS\Core\Utility\ArrayUtility; use TYPO3\CMS\Core\Utility\GeneralUtility; -use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController; +use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer; class VariablesService { public const MAXIMUM_LOOP_COUNT = 100; + /** + * @var Set + */ protected Set $cacheTags; + /** + * @var string[] + */ protected array $usedMarkerKeys = []; - protected ?ExtensionConfiguration $extensionConfiguration = null; - protected ?TypoScriptFrontendController $typoScriptFrontendController = null; - protected ?MarkerCollection $markerCollection = null; - protected ?array $markerKeys = null; - protected ?string $markerRegexp = null; - - public function __construct() - { + public function __construct( + private readonly ExtensionConfiguration $extensionConfiguration, + private readonly Context $context, + private readonly ConnectionPool $connectionPool, + ) { $this->cacheTags = new Set('string'); } - public function initialize( - ExtensionConfiguration $extensionConfiguration = null, - TypoScriptFrontendController $typoScriptFrontendController = null, - ): void { - if (!$typoScriptFrontendController instanceof \TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController) { - $typoScriptFrontendController = $this->getTypoScriptFrontendController(); - } - - if (!$extensionConfiguration instanceof \TYPO3\CMS\Core\Configuration\ExtensionConfiguration) { - $extensionConfiguration = GeneralUtility::makeInstance(ExtensionConfiguration::class); - } - - $this->extensionConfiguration = $extensionConfiguration; - $this->typoScriptFrontendController = $typoScriptFrontendController; - $this->markerCollection = $this->getMarkers(); - $this->markerKeys = $this->markerCollection->getMarkerKeys(); - $this->markerRegexp = '/(' . implode('|', array_map('preg_quote', $this->markerKeys)) . ')/'; - } - /** * Iterates over a structure (array, object) and replaces markers in every string found. * @@ -58,13 +48,11 @@ public function initialize( * @throws \Exception */ public function replaceMarkersInStructureAndAdjustCaching( + ServerRequestInterface $request, mixed &$structure ): void { - if (!$this->markerCollection instanceof \Sinso\Variables\Domain\Model\MarkerCollection) { - throw new \Exception('Markers not initialized. Please run initialize() first.', 1726241619); - } - $this->replaceMarkersInStructure($structure); - $this->setCacheTagsInTsfe(); + $this->replaceMarkersInStructure($this->getMarkers($request), $structure); + $this->setCacheTags($request); } /** @@ -72,20 +60,22 @@ public function replaceMarkersInStructureAndAdjustCaching( * * @throws \Exception */ - protected function replaceMarkersInStructure(mixed &$structure): void - { + private function replaceMarkersInStructure( + MarkerCollection $markerCollection, + mixed &$structure + ): void { if (is_null($structure) || is_bool($structure) || is_int($structure) || is_float($structure) || $structure instanceof \UnitEnum) { return; } if (is_string($structure)) { - $this->replaceMarkersInText($structure); + $this->replaceMarkersInText($markerCollection, $structure); return; } - if (is_array($structure) || is_object($structure)) { + if (is_iterable($structure)) { foreach ($structure as &$subStructure) { - $this->replaceMarkersInStructure($subStructure); + $this->replaceMarkersInStructure($markerCollection, $subStructure); } return; } @@ -93,29 +83,35 @@ protected function replaceMarkersInStructure(mixed &$structure): void throw new \Exception(sprintf('Unsupported type "%s" in structure', gettype($structure)), 1725955598); } - protected function replaceMarkersInText(string &$text): void - { - $loops = 0; - while (preg_match($this->markerRegexp, $text) && $loops++ < self::MAXIMUM_LOOP_COUNT) { - foreach ($this->markerCollection as $marker) { - $newContent = str_replace( - $marker->getMarkerWithBrackets(), - $marker->replacement, - $text - ); + private function replaceMarkersInText( + MarkerCollection $markerCollection, + string &$text + ): void { + if ($markerCollection->isEmpty() === false) { + $markerRegexp = $this->buildMarkerRegExp($markerCollection); + $loops = 0; + + while (preg_match($markerRegexp, $text) && $loops++ < self::MAXIMUM_LOOP_COUNT) { + foreach ($markerCollection as $marker) { + $newContent = str_replace( + $marker->getMarkerWithBrackets(), + $marker->replacement, + $text + ); + + if ($newContent === $text) { + continue; + } - if ($newContent === $text) { - continue; + // Assign a cache key associated with the marker + $this->cacheTags->add( + CacheKeyUtility::getCacheKey( + $marker->getMarkerWithBrackets() + ) + ); + $this->usedMarkerKeys[] = $marker->key; + $text = $newContent; } - - // Assign a cache key associated with the marker - $this->cacheTags->add( - CacheKeyUtility::getCacheKey( - $marker->getMarkerWithBrackets() - ) - ); - $this->usedMarkerKeys[] = $marker->key; - $text = $newContent; } } @@ -130,18 +126,19 @@ protected function replaceMarkersInText(string &$text): void /** * Returns the markers available in the current root line. */ - protected function getMarkers(): MarkerCollection - { - $pids = array_map(static function ($page) { - return $page['uid']; - }, $this->typoScriptFrontendController->rootLine); - - if (!empty($GLOBALS['TYPO3_REQUEST']->getAttribute('frontend.typoscript')->getSetupArray()['plugin.']['tx_variables.']['persistence.']['storagePid'])) { - $pids[] = (int)$GLOBALS['TYPO3_REQUEST']->getAttribute('frontend.typoscript')->getSetupArray()['plugin.']['tx_variables.']['persistence.']['storagePid']; + private function getMarkers( + ServerRequestInterface $request, + ): MarkerCollection { + $pids = array_map(static fn ($page) => $page['uid'], $request->getAttribute('frontend.page.information')?->getRootLine() ?? []); + + $storagePid = $this->getStoragePidFromTypoScript($request); + if (is_int($storagePid)) { + $pids[] = $storagePid; } $table = 'tx_variables_marker'; - $rows = $this->typoScriptFrontendController->cObj->getRecords( + $contentObjectRenderer = GeneralUtility::makeInstance(ContentObjectRenderer::class); + $rows = $contentObjectRenderer->getRecords( $table, [ 'selectFields' => 'marker, replacement', @@ -173,38 +170,55 @@ protected function getMarkers(): MarkerCollection return $markers; } - protected function setCacheTagsInTsfe(): void + private function buildMarkerRegExp(MarkerCollection $markerCollection): string { - if (count($this->cacheTags) > 0) { - $this->typoScriptFrontendController->addCacheTags($this->cacheTags->toArray()); + $quotedMarkerKeys = []; + foreach ($markerCollection->getMarkerKeys() as $markerKey) { + $quotedMarkerKeys[] = preg_quote($markerKey, '/'); + } + + return '/(' . implode('|', $quotedMarkerKeys) . ')/'; + } + + private function setCacheTags( + ServerRequestInterface $request, + ): void { + if (count($this->cacheTags) === 0) { + return; + } + + $cacheCollector = $request->getAttribute('frontend.cache.collector'); + if (($cacheCollector instanceof CacheDataCollector) === false) { + return; } + + $cacheTags = array_map(fn (string $tag): CacheTag => new CacheTag($tag), $this->cacheTags->toArray()); + $cacheCollector->addCacheTags(...$cacheTags); } public function getLifetime(): int { - return $this->getNearestTimestampForMarkers($this->usedMarkerKeys) - \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Context\Context::class)->getPropertyFromAspect('date', 'timestamp'); + return $this->getNearestTimestampForMarkers() - $this->context->getPropertyFromAspect('date', 'timestamp'); } /** * Get the nearest timestamp in the future when changes for Markers should happen. * This respects starttime and endtime. * The result will be used to calculate the maximal caching duration - * - * @throws \Doctrine\DBAL\Exception */ - public function getNearestTimestampForMarkers(array $usedMarkerKeys): int + private function getNearestTimestampForMarkers(): int { // Max value possible to keep an int \TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->realPageCacheContent ($timeOutTime = $GLOBALS['EXEC_TIME'] + $cacheTimeout;) $result = PHP_INT_MAX; $tableName = 'tx_variables_marker'; - $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable($tableName)->createQueryBuilder(); + $queryBuilder = $this->connectionPool->getConnectionForTable($tableName)->createQueryBuilder(); $queryBuilder->getRestrictions()->removeAll() ->add(GeneralUtility::makeInstance(DeletedRestriction::class)); // Code heavily inspired by: // \TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController->getFirstTimeValueForRecord - $now = (int)\TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Context\Context::class)->getPropertyFromAspect('date', 'timestamp'); + $now = (int)$this->context->getPropertyFromAspect('date', 'timestamp'); $timeFields = []; $timeConditions = $queryBuilder->expr()->or(); foreach (['starttime', 'endtime'] as $field) { @@ -215,7 +229,7 @@ public function getNearestTimestampForMarkers(array $usedMarkerKeys): int . 'CASE WHEN ' . $queryBuilder->expr()->lte( $timeFields[$field], - $queryBuilder->createNamedParameter($now, \PDO::PARAM_INT) + $queryBuilder->createNamedParameter($now, Connection::PARAM_INT) ) . ' THEN NULL ELSE ' . $queryBuilder->quoteIdentifier($timeFields[$field]) . ' END' . ') AS ' . $queryBuilder->quoteIdentifier($timeFields[$field]) @@ -223,7 +237,7 @@ public function getNearestTimestampForMarkers(array $usedMarkerKeys): int $timeConditions->with( $queryBuilder->expr()->gt( $timeFields[$field], - $queryBuilder->createNamedParameter($now, \PDO::PARAM_INT) + $queryBuilder->createNamedParameter($now, Connection::PARAM_INT) ) ); } @@ -235,12 +249,12 @@ public function getNearestTimestampForMarkers(array $usedMarkerKeys): int $queryBuilder ->from($tableName) ->where( - $queryBuilder->expr()->in('marker', $queryBuilder->createNamedParameter($usedMarkerKeys, Connection::PARAM_STR_ARRAY)), + $queryBuilder->expr()->in('marker', $queryBuilder->createNamedParameter($this->usedMarkerKeys, Connection::PARAM_STR_ARRAY)), $timeConditions ); $row = $queryBuilder - ->execute() - ->fetch(); + ->executeQuery() + ->fetchAssociative(); if ($row) { foreach (array_keys($timeFields) as $timeField) { @@ -259,8 +273,25 @@ public function getNearestTimestampForMarkers(array $usedMarkerKeys): int return $result; } - public function getTypoScriptFrontendController(): TypoScriptFrontendController + private function getStoragePidFromTypoScript(ServerRequestInterface $request): ?int { - return $GLOBALS['TSFE']; + $typoScript = $request->getAttribute('frontend.typoscript'); + if (($typoScript instanceof FrontendTypoScript) === false) { + return null; + } + + $typoScriptArray = $typoScript->getSetupArray(); + $lookUpPath = 'plugin./tx_variables./persistence./storagePid'; + + if (ArrayUtility::isValidPath($typoScriptArray, $lookUpPath) === false) { + return null; + } + + $storagePid = ArrayUtility::getValueByPath($typoScriptArray, $lookUpPath); + if (is_numeric($storagePid) === false) { + return null; + } + + return (int)$storagePid; } } diff --git a/Configuration/Services.yaml b/Configuration/Services.yaml index f2115f4..b062dc8 100644 --- a/Configuration/Services.yaml +++ b/Configuration/Services.yaml @@ -10,13 +10,5 @@ services: Sinso\Variables\Hooks\DataHandler: public: true - Sinso\Variables\Hooks\ContentProcessor: - tags: - - name: event.listener - Sinso\Variables\Service\VariablesService: public: true - - Sinso\Variables\EventListener\ModifyCacheLifetime: - tags: - - name: event.listener diff --git a/Configuration/TCA/tx_variables_marker.php b/Configuration/TCA/tx_variables_marker.php index 341a9dc..c754085 100644 --- a/Configuration/TCA/tx_variables_marker.php +++ b/Configuration/TCA/tx_variables_marker.php @@ -24,7 +24,6 @@ 'starttime' => 'starttime', 'endtime' => 'endtime', ], - 'searchFields' => 'marker,replacement', 'iconfile' => 'EXT:variables/Resources/Public/Icons/tx_variables_marker.png' ], 'types' => [ diff --git a/Resources/Public/Icons/Extension.png b/Resources/Public/Icons/Extension.png index 6c3d566..df12dba 100644 Binary files a/Resources/Public/Icons/Extension.png and b/Resources/Public/Icons/Extension.png differ diff --git a/Tests/Functional/Caching/AdjustsPageCacheLifetimeTest.php b/Tests/Functional/Caching/AdjustsPageCacheLifetimeTest.php new file mode 100644 index 0000000..5a01373 --- /dev/null +++ b/Tests/Functional/Caching/AdjustsPageCacheLifetimeTest.php @@ -0,0 +1,128 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace Sinso\Variables\Tests\Functional\Caching; + +use DateTimeImmutable; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use Sinso\Variables\EventListener\ModifyCacheLifetime; +use Sinso\Variables\Service\VariablesService; +use Sinso\Variables\Tests\Functional\Frontend\AbstractProcessesMarkersTestCase; + +#[CoversClass(ModifyCacheLifetime::class)] +#[CoversClass(VariablesService::class)] +class AdjustsPageCacheLifetimeTest extends AbstractProcessesMarkersTestCase +{ + /** + * @param array $availableVariable + */ + #[DataProvider('possibleVariableLifeTimeCombinations')] + #[Test] + public function cacheLifetimeOfPageIsSetAsExpected( + DateTimeImmutable $expectedTimeStamp, + array $availableVariable, + ): void { + if ($availableVariable !== []) { + $this->insertMarkerWithData($availableVariable); + } + + $this->fetchContentForPage(1); + + $this->assertCacheTimeSampMatches($expectedTimeStamp); + } + + /** + * @return \Generator + */ + public static function possibleVariableLifeTimeCombinations(): iterable + { + yield 'No Variable Exists' => [ + 'expectedTimeStamp' => (new DateTimeImmutable())->modify('+1 day'), + 'availableVariable' => [], + ]; + yield 'Only Variable is deleted' => [ + 'expectedTimeStamp' => (new DateTimeImmutable())->modify('+1 day'), + 'availableVariable' => [ + 'deleted' => '1', + ], + ]; + yield 'Variable StartTime more in future than default life time' => [ + 'expectedTimeStamp' => (new DateTimeImmutable())->modify('+1 day'), + 'availableVariable' => [ + 'starttime' => (new DateTimeImmutable())->modify('+2 day')->format('U'), + ], + ]; + yield 'Variable EndTime more in future than default life time' => [ + 'expectedTimeStamp' => (new DateTimeImmutable())->modify('+1 day'), + 'availableVariable' => [ + 'endtime' => (new DateTimeImmutable())->modify('+2 day')->format('U'), + ], + ]; + // Currently broken. + // yield 'Variable StartTime nearer than default life time' => [ + // 'expectedTimeStamp' => (new DateTimeImmutable())->modify('+1 hour'), + // 'availableVariable' => [ + // 'starttime' => (new DateTimeImmutable())->modify('+1 hour')->format('U'), + // ], + // ]; + yield 'Variable EndTime nearer than default life time' => [ + 'expectedTimeStamp' => (new DateTimeImmutable())->modify('+1 hour'), + 'availableVariable' => [ + 'endtime' => (new DateTimeImmutable())->modify('+1 hour')->format('U'), + ], + ]; + } + + private function assertCacheTimeSampMatches(\DateTimeImmutable $dateTime): void + { + $cacheEntries = $this->getConnectionPool() + ->getConnectionForTable('cache_pages') + ->select(['expires'], 'cache_pages') + ->fetchAllAssociative(); + + self::assertCount(1, $cacheEntries); + + $actualTimeStamp = $cacheEntries[0]['expires']; + + $minimumTimeStamp = $dateTime->modify('-5 seconds')->format('U'); + $maximumTimeStamp = $dateTime->modify('+5 seconds')->format('U'); + + self::assertGreaterThanOrEqual($minimumTimeStamp, $actualTimeStamp); + self::assertLessThanOrEqual($maximumTimeStamp, $actualTimeStamp); + } + + /** + * @param array $data + */ + private function insertMarkerWithData(array $data): void + { + $this->getConnectionPool()->getConnectionForTable('tx_variables_marker') + ->insert('tx_variables_marker', array_merge([ + 'pid' => '1', + 'marker' => 'MARKER1', + 'replacement' => 'Replaced marker 1', + ], $data)); + } +} diff --git a/Tests/Functional/Caching/FlushViaDataHandlerChangesTest.php b/Tests/Functional/Caching/FlushViaDataHandlerChangesTest.php index 928c921..e7b3740 100644 --- a/Tests/Functional/Caching/FlushViaDataHandlerChangesTest.php +++ b/Tests/Functional/Caching/FlushViaDataHandlerChangesTest.php @@ -23,26 +23,37 @@ namespace Sinso\Variables\Tests\Functional\Caching; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; use Sinso\Variables\Hooks\DataHandler; use TYPO3\CMS\Core\Cache\CacheManager; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\DataHandling\DataHandler as Typo3DataHandler; -use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; -/** - * @covers \Sinso\Variables\Hooks\DataHandler - */ +#[CoversClass(DataHandler::class)] class FlushViaDataHandlerChangesTest extends FunctionalTestCase { + protected array $testExtensionsToLoad = [ + 'typo3conf/ext/variables', + ]; + + protected function setUp(): void + { + parent::setUp(); + + $this->importCSVDataSet(__DIR__ . '/../Fixtures/Frontend/Content.csv'); + } + /** - * @dataProvider possibleNoneTriggeringParams + * @param array $params */ - public function testDoesNotInteractWithCacheManagerOnUnkownData(array $params): void + #[DataProvider('possibleNoneTriggeringParams')] + #[Test] + public function doesNotInteractWithCacheManagerOnUnkownData(array $params): void { - $connectionPool = self::createMock(ConnectionPool::class); + $connectionPool = $this->get(ConnectionPool::class); $cacheManager = self::createMock(CacheManager::class); $cacheManager ->expects(self::never()) @@ -54,7 +65,7 @@ public function testDoesNotInteractWithCacheManagerOnUnkownData(array $params): } /** - * @return Generator}> + * @return \Generator}|array{params:array}> */ public static function possibleNoneTriggeringParams(): \Generator { @@ -75,9 +86,10 @@ public static function possibleNoneTriggeringParams(): \Generator ]; } - public function testFlushCachesByGroupForMarker(): void + #[Test] + public function flushCachesByGroupForMarker(): void { - $connectionPool = self::createMock(ConnectionPool::class); + $connectionPool = $this->get(ConnectionPool::class); $cacheManager = self::createMock(CacheManager::class); $cacheManager ->expects(self::once()) @@ -101,4 +113,83 @@ public function testFlushCachesByGroupForMarker(): void 'marker' => 'TEST' ], $dataHandler); } + + #[Test] + public function flushCachesByGroupForHiddenMarker(): void + { + $connectionPool = $this->get(ConnectionPool::class); + $connectionPool->getConnectionForTable('tx_variables_marker') + ->insert('tx_variables_marker', [ + 'uid' => '100', + 'hidden' => '1', + 'marker' => 'hidden_marker', + 'replacement' => 'replacement', + ]); + + $cacheManager = self::createMock(CacheManager::class); + $cacheManager + ->expects(self::once()) + ->method('flushCachesInGroupByTag') + ->with('pages', $this->stringStartsWith('tx_variables_key_hash_')) + ; + + $dataHandler = self::createStub(Typo3DataHandler::class); + + $subject = new DataHandler($connectionPool, $cacheManager); + $subject->clearCachePostProc([ + 'table' => 'tx_variables_marker', + 'uid' => '100', + 'marker' => 'TEST' + ], $dataHandler); + } + + #[Test] + public function flushCachesByGroupForDeletedMarker(): void + { + $connectionPool = $this->get(ConnectionPool::class); + $connectionPool->getConnectionForTable('tx_variables_marker') + ->insert('tx_variables_marker', [ + 'uid' => '100', + 'deleted' => '1', + 'marker' => 'hidden_marker', + 'replacement' => 'replacement', + ]); + + $cacheManager = self::createMock(CacheManager::class); + $cacheManager + ->expects(self::once()) + ->method('flushCachesInGroupByTag') + ->with('pages', $this->stringStartsWith('tx_variables_key_hash_')) + ; + + $dataHandler = self::createStub(Typo3DataHandler::class); + + $subject = new DataHandler($connectionPool, $cacheManager); + $subject->clearCachePostProc([ + 'table' => 'tx_variables_marker', + 'uid' => '100', + 'marker' => 'TEST' + ], $dataHandler); + } + + #[Test] + public function doesNotFlushCachesByGroupForUnkownMarker(): void + { + $connectionPool = $this->get(ConnectionPool::class); + + $cacheManager = self::createMock(CacheManager::class); + $cacheManager + ->expects(self::never()) + ->method('flushCachesInGroupByTag') + ; + + $dataHandler = self::createStub(Typo3DataHandler::class); + + $subject = new DataHandler($connectionPool, $cacheManager); + $subject->clearCachePostProc([ + 'table' => 'tx_variables_marker', + 'uid' => '100', + 'marker' => 'TEST' + ], $dataHandler); + } } diff --git a/Tests/Functional/Fixtures/Frontend/Content.csv b/Tests/Functional/Fixtures/Frontend/Content.csv new file mode 100644 index 0000000..d4372d0 --- /dev/null +++ b/Tests/Functional/Fixtures/Frontend/Content.csv @@ -0,0 +1,11 @@ +pages +,uid,pid,title,slug,sorting,deleted +,1,0,"Page 1",/page-1,128,0 +,2,1,"Page 2",/page-2,128,0 +,3,1,"Page 3",/page-3,128,0, +,4,1,"Page 4 (Storage)",/page-4,128,0, +tt_content +,uid,pid,hidden,sorting,deleted,starttime,endtime,colPos,sys_language_uid,CType,header,bodytext +,1,1,0,1,0,0,0,0,0,text,"Has Marker on Page 1","Some example text with marker {{MARKER1}}" +,2,2,0,1,0,0,0,0,0,text,"Has Marker on Page 2","Some example text with marker {{MARKER2}} {{MARKER1}}" +,3,3,0,1,0,0,0,0,0,text,"Has Marker on Page 3","Some example text with marker {{MARKER3}}" diff --git a/Tests/Functional/Fixtures/Frontend/Content.xml b/Tests/Functional/Fixtures/Frontend/Content.xml deleted file mode 100644 index 552ee24..0000000 --- a/Tests/Functional/Fixtures/Frontend/Content.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - 1 - 0 - Page 1 - /page-1 - 128 - 0 - - - 2 - 1 - Page 2 - /page-2 - 128 - 0 - - - 3 - 1 - Page 3 - /page-3 - 128 - 0 - - - 4 - 1 - Page 4 (Storage) - /page-4 - 128 - 0 - - - 1 - 1 - 0 - 1 - 0 - 0 - 0 - 0 - 0 - text -
Has Marker on Page 1
- Some example text with marker {{MARKER1}} -
- - 2 - 2 - 0 - 1 - 0 - 0 - 0 - 0 - 0 - text -
Has Marker on Page 2
- Some example text with marker {{MARKER2}} {{MARKER1}} -
- - 3 - 3 - 0 - 1 - 0 - 0 - 0 - 0 - 0 - text -
Has Marker on Page 3
- Some example text with marker {{MARKER3}} -
-
diff --git a/Tests/Functional/Fixtures/Frontend/Marker.csv b/Tests/Functional/Fixtures/Frontend/Marker.csv new file mode 100644 index 0000000..43183bd --- /dev/null +++ b/Tests/Functional/Fixtures/Frontend/Marker.csv @@ -0,0 +1,5 @@ +tx_variables_marker +,uid,pid,marker,replacement +,1,1,MARKER1,"Replaced marker 1 from pid 1" +,2,2,MARKER2,"Replaced marker 2 from pid 2" +,3,4,MARKER3,"Replaced marker 3 from storage pid 4" diff --git a/Tests/Functional/Fixtures/Frontend/Marker.xml b/Tests/Functional/Fixtures/Frontend/Marker.xml deleted file mode 100644 index d6c2962..0000000 --- a/Tests/Functional/Fixtures/Frontend/Marker.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - 1 - 1 - MARKER1 - Replaced marker 1 from pid 1 - - - 2 - 2 - MARKER2 - Replaced marker 2 from pid 2 - - - 3 - 4 - MARKER3 - Replaced marker 3 from storage pid 4 - - diff --git a/Tests/Functional/Frontend/AbstractProcessesMarkersTestCase.php b/Tests/Functional/Frontend/AbstractProcessesMarkersTestCase.php index 5e45150..ffe49ef 100644 --- a/Tests/Functional/Frontend/AbstractProcessesMarkersTestCase.php +++ b/Tests/Functional/Frontend/AbstractProcessesMarkersTestCase.php @@ -54,7 +54,7 @@ protected function setUp(): void parent::setUp(); - $this->importDataSet('EXT:variables/Tests/Functional/Fixtures/Frontend/Content.xml'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/Frontend/Content.csv'); $this->setUpFrontendRootPage(1, [ 'EXT:variables/Tests/Functional/Fixtures/Frontend/Rendering.typoscript', ]); @@ -65,6 +65,28 @@ protected function fetchContentForPage(int $pageUid): string $request = new InternalRequest(); $request = $request->withPageId($pageUid); - return $this->executeFrontendRequest($request)->getBody()->__toString(); + return $this->executeFrontendSubRequest($request)->getBody()->__toString(); + } + + protected function assertHasCacheForKey(string $cacheKey): void + { + $queryBuilder = $this->getConnectionPool()->getQueryBuilderForTable('cache_pages_tags'); + $queryBuilder->count('*'); + $queryBuilder->from('cache_pages_tags'); + $queryBuilder->where($queryBuilder->expr()->eq('tag', $queryBuilder->createNamedParameter($cacheKey))); + + $count = $queryBuilder->executeQuery()->fetchOne(); + self::assertSame(1, $count, 'Did not find a single cache entry for key: ' . $cacheKey); + } + + protected function assertHasNoCacheKeyStartingWithPrefix(string $cacheKeyPrefix): void + { + $queryBuilder = $this->getConnectionPool()->getQueryBuilderForTable('cache_pages_tags'); + $queryBuilder->count('*'); + $queryBuilder->from('cache_pages_tags'); + $queryBuilder->where($queryBuilder->expr()->like('tag', $queryBuilder->createNamedParameter($queryBuilder->escapeLikeWildcards($cacheKeyPrefix)))); + + $count = $queryBuilder->executeQuery()->fetchOne(); + self::assertSame(0, $count, 'Did find a cache entries for key prefix: ' . $cacheKeyPrefix); } } diff --git a/Tests/Functional/Frontend/BreaksOnInvalidHookTest.php b/Tests/Functional/Frontend/BreaksOnInvalidHookTest.php index 9d2ae96..3972943 100644 --- a/Tests/Functional/Frontend/BreaksOnInvalidHookTest.php +++ b/Tests/Functional/Frontend/BreaksOnInvalidHookTest.php @@ -23,11 +23,12 @@ namespace Sinso\Variables\Tests\Functional\Frontend; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use Sinso\Variables\Service\VariablesService; use Sinso\Variables\Tests\Functional\Fixtures\Frontend\Hook\NotImplementingInterface; -/** - * @covers \Sinso\Variables\Service\VariablesService - */ +#[CoversClass(VariablesService::class)] class BreaksOnInvalidHookTest extends AbstractProcessesMarkersTestCase { protected array $configurationToUseInTestInstance = [ @@ -40,7 +41,8 @@ class BreaksOnInvalidHookTest extends AbstractProcessesMarkersTestCase ], ]; - public function testThrowsExceptionIfConfiguredClassDoesntImplementInterface(): void + #[Test] + public function throwsExceptionIfConfiguredClassDoesntImplementInterface(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Sinso\Variables\Tests\Functional\Fixtures\Frontend\Hook\NotImplementingInterface does not implement Sinso\Variables\Hooks\MarkersProcessorInterface'); diff --git a/Tests/Functional/Frontend/ProcessesMarkersHookTest.php b/Tests/Functional/Frontend/ProcessesMarkersHookTest.php index 7e21f82..0fa7899 100644 --- a/Tests/Functional/Frontend/ProcessesMarkersHookTest.php +++ b/Tests/Functional/Frontend/ProcessesMarkersHookTest.php @@ -23,11 +23,12 @@ namespace Sinso\Variables\Tests\Functional\Frontend; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use Sinso\Variables\Service\VariablesService; use Sinso\Variables\Tests\Functional\Fixtures\Frontend\Hook\ImplementingInterface; -/** - * @covers \Sinso\Variables\Hooks\ContentProcessor - */ +#[CoversClass(VariablesService::class)] class ProcessesMarkersHookTest extends AbstractProcessesMarkersTestCase { protected array $configurationToUseInTestInstance = [ @@ -40,17 +41,16 @@ class ProcessesMarkersHookTest extends AbstractProcessesMarkersTestCase ], ]; - public function testExecutesHookClass(): void + #[Test] + public function executesHookClass(): void { - $this->importDataSet('EXT:variables/Tests/Functional/Fixtures/Frontend/Marker.xml'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/Frontend/Marker.csv'); self::assertStringContainsString( '

Some example text with marker Modified by hook

', $this->fetchContentForPage(1) ); - $pageCache = $this->getAllRecords('cache_pages_tags'); - self::assertCount(2, $pageCache); - self::assertSame('tx_variables_key_hash_b3560bb929f682dcc19c903256f98639', $pageCache[0]['tag']); + $this->assertHasCacheForKey('tx_variables_key_hash_b3560bb929f682dcc19c903256f98639'); } } diff --git a/Tests/Functional/Frontend/ProcessesMarkersTest.php b/Tests/Functional/Frontend/ProcessesMarkersTest.php index dd9dc92..38abee0 100644 --- a/Tests/Functional/Frontend/ProcessesMarkersTest.php +++ b/Tests/Functional/Frontend/ProcessesMarkersTest.php @@ -23,62 +23,61 @@ namespace Sinso\Variables\Tests\Functional\Frontend; -/** - * @covers \Sinso\Variables\Hooks\ContentProcessor - */ +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use Sinso\Variables\EventListener\ContentProcessor; + +#[CoversClass(ContentProcessor::class)] class ProcessesMarkersTest extends AbstractProcessesMarkersTestCase { - public function testNoMarkerAppliedAsNoneExist(): void + #[Test] + public function noMarkerAppliedAsNoneExist(): void { self::assertStringContainsString( '

Some example text with marker {{MARKER1}}

', $this->fetchContentForPage(1) ); - $pageCache = $this->getAllRecords('cache_pages_tags'); - self::assertCount(1, $pageCache); + $this->assertHasNoCacheKeyStartingWithPrefix('tx_variables_key_hash'); } - public function testAppliesMarkersStoredOnSamePage(): void + #[Test] + public function appliesMarkersStoredOnSamePage(): void { - $this->importDataSet('EXT:variables/Tests/Functional/Fixtures/Frontend/Marker.xml'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/Frontend/Marker.csv'); self::assertStringContainsString( '

Some example text with marker Replaced marker 1 from pid 1

', $this->fetchContentForPage(1) ); - $pageCache = $this->getAllRecords('cache_pages_tags'); - self::assertCount(2, $pageCache); - self::assertSame('tx_variables_key_hash_b3560bb929f682dcc19c903256f98639', $pageCache[0]['tag']); + $this->assertHasCacheForKey('tx_variables_key_hash_b3560bb929f682dcc19c903256f98639'); } - public function testAppliesMarkersFromRootlinePage(): void + #[Test] + public function appliesMarkersFromRootlinePage(): void { - $this->importDataSet('EXT:variables/Tests/Functional/Fixtures/Frontend/Marker.xml'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/Frontend/Marker.csv'); self::assertStringContainsString( '

Some example text with marker Replaced marker 2 from pid 2 Replaced marker 1 from pid 1

', $this->fetchContentForPage(2) ); - $pageCache = $this->getAllRecords('cache_pages_tags'); - self::assertCount(3, $pageCache); - self::assertSame('tx_variables_key_hash_b3560bb929f682dcc19c903256f98639', $pageCache[0]['tag']); - self::assertSame('tx_variables_key_hash_7324efb2ab7ff6e7ef0fe77210ff6b20', $pageCache[1]['tag']); + $this->assertHasCacheForKey('tx_variables_key_hash_b3560bb929f682dcc19c903256f98639'); + $this->assertHasCacheForKey('tx_variables_key_hash_7324efb2ab7ff6e7ef0fe77210ff6b20'); } - public function testAppliesMarkersFromConfiguredStoragePid(): void + #[Test] + public function appliesMarkersFromConfiguredStoragePid(): void { - $this->importDataSet('EXT:variables/Tests/Functional/Fixtures/Frontend/Marker.xml'); + $this->importCSVDataSet(__DIR__ . '/../Fixtures/Frontend/Marker.csv'); self::assertStringContainsString( '

Some example text with marker Replaced marker 3 from storage pid 4

', $this->fetchContentForPage(3) ); - $pageCache = $this->getAllRecords('cache_pages_tags'); - self::assertCount(2, $pageCache); - self::assertSame('tx_variables_key_hash_2328e4c0fcee8716480763f53e97ea82', $pageCache[0]['tag']); + $this->assertHasCacheForKey('tx_variables_key_hash_2328e4c0fcee8716480763f53e97ea82'); } } diff --git a/Tests/Unit/Domain/Model/MarkerCollectionTest.php b/Tests/Unit/Domain/Model/MarkerCollectionTest.php new file mode 100644 index 0000000..3701d1c --- /dev/null +++ b/Tests/Unit/Domain/Model/MarkerCollectionTest.php @@ -0,0 +1,81 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace Sinso\Variables\Tests\Unit\Domain\Model; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use RuntimeException; +use Sinso\Variables\Domain\Model\Marker; +use Sinso\Variables\Domain\Model\MarkerCollection; + +#[CoversClass(MarkerCollection::class)] +final class MarkerCollectionTest extends TestCase +{ + #[Test] + public function returnsType(): void + { + $subject = new MarkerCollection(); + + self::assertSame(\Sinso\Variables\Domain\Model\Marker::class, $subject->getType()); + } + + #[Test] + public function throwsExceptionWhenRequestingUnavailableMarker(): void + { + $subject = new MarkerCollection(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Marker not found'); + $this->expectExceptionCode(6494338102); + + $subject->get('{{unavailable}}'); + } + + #[Test] + public function returnsAddedMarker(): void + { + $marker = new Marker(0, 'key', 'replacement'); + + $subject = new MarkerCollection(); + + $subject->add($marker); + + self::assertSame($marker, $subject->get('{{key}}')); + } + + #[Test] + public function returnsMarkerKeys(): void + { + $subject = new MarkerCollection(); + + $subject->add(new Marker(0, 'key_1', 'replacement_1')); + $subject->add(new Marker(0, 'key_2', 'replacement_2')); + + self::assertSame([ + '{{key_1}}', + '{{key_2}}', + ], $subject->getMarkerKeys()); + } +} diff --git a/Tests/Unit/Domain/Model/MarkerTest.php b/Tests/Unit/Domain/Model/MarkerTest.php new file mode 100644 index 0000000..5d122a6 --- /dev/null +++ b/Tests/Unit/Domain/Model/MarkerTest.php @@ -0,0 +1,65 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace Sinso\Variables\Tests\Unit\Domain\Model; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Sinso\Variables\Domain\Model\Marker; + +#[CoversClass(Marker::class)] +final class MarkerTest extends TestCase +{ + #[Test] + public function exposesUid(): void + { + $subject = new Marker(10, '', ''); + + self::assertSame(10, $subject->uid); + } + + #[Test] + public function exposesKey(): void + { + $subject = new Marker(0, 'key', ''); + + self::assertSame('key', $subject->key); + } + + #[Test] + public function exposesReplacement(): void + { + $subject = new Marker(0, '', 'replacement'); + + self::assertSame('replacement', $subject->replacement); + } + + #[Test] + public function returnsMarkerWithBrackets(): void + { + $subject = new Marker(0, 'key', ''); + + self::assertSame('{{key}}', $subject->getMarkerWithBrackets()); + } +} diff --git a/Tests/Unit/Service/VariablesServiceTest.php b/Tests/Unit/Service/VariablesServiceTest.php new file mode 100644 index 0000000..85b29e8 --- /dev/null +++ b/Tests/Unit/Service/VariablesServiceTest.php @@ -0,0 +1,74 @@ + + */ + public static function markerKeyDataProvider(): iterable + { + yield 'plain key' => ['key', 'replacement', 'before {{key}} after', 'before replacement after']; + yield 'key containing a slash' => ['foo/bar', 'replacement', 'before {{foo/bar}} after', 'before replacement after']; + yield 'key containing a regular expression special character' => ['foo.bar', 'replacement', 'before {{foo.bar}} after', 'before replacement after']; + } + + #[Test] + #[DataProvider('markerKeyDataProvider')] + public function replacesMarkerRegardlessOfSpecialCharactersInKey( + string $markerKey, + string $replacement, + string $text, + string $expected, + ): void { + $markerCollection = new MarkerCollection(); + $markerCollection->add(new Marker(1, $markerKey, $replacement)); + + $this->replaceMarkersInStructure($markerCollection, $text); + + self::assertSame($expected, $text); + } + + private function replaceMarkersInStructure(MarkerCollection $markerCollection, mixed &$structure): void + { + $extensionConfiguration = $this->createMock(ExtensionConfiguration::class); + $extensionConfiguration->method('get')->willReturn(false); + + $subject = new VariablesService( + $extensionConfiguration, + $this->createMock(Context::class), + $this->createMock(ConnectionPool::class), + ); + + $reflectionMethod = new \ReflectionMethod($subject, 'replaceMarkersInStructure'); + $reflectionMethod->invokeArgs($subject, [$markerCollection, &$structure]); + } +} diff --git a/Tests/Unit/Utility/CacheKeyUtilityTest.php b/Tests/Unit/Utility/CacheKeyUtilityTest.php new file mode 100644 index 0000000..65664b0 --- /dev/null +++ b/Tests/Unit/Utility/CacheKeyUtilityTest.php @@ -0,0 +1,59 @@ + + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301, USA. + */ + +namespace Sinso\Variables\Tests\Unit\Utility; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Sinso\Variables\Utility\CacheKeyUtility; + +#[CoversClass(CacheKeyUtility::class)] +final class CacheKeyUtilityTest extends TestCase +{ + #[DataProvider('possibleCacheKeys')] + #[Test] + public function returnsCacheKey(string $cacheKey): void + { + self::assertSame('tx_variables_key_hash_3d70412c7e9ea2d96fa23d4f1f1f0a1c', CacheKeyUtility::getCacheKey($cacheKey)); + } + + /** + * @return \Generator + */ + public static function possibleCacheKeys(): iterable + { + yield 'Simple Cache Key' => [ + 'cacheKey' => 'some_key', + ]; + + yield 'Cache Key with leading white space' => [ + 'cacheKey' => ' some_key', + ]; + + yield 'Cache Key with trailing white space' => [ + 'cacheKey' => 'some_key ', + ]; + } +} diff --git a/composer.json b/composer.json index 7caa296..4719a48 100644 --- a/composer.json +++ b/composer.json @@ -14,14 +14,17 @@ } }, "require": { - "php": ">= 8.1 < 8.4", + "php": ">= 8.2 < 8.6", "ramsey/collection": "^2.0", - "typo3/cms-core": "^12.4" + "typo3/cms-core": "^13.4" }, "require-dev": { - "rector/rector": "^0.12.23", - "typo3/testing-framework": "^7.0", - "symplify/easy-coding-standard": "^12.3" + "rector/rector": "^2.6", + "typo3/testing-framework": "^8.0", + "symplify/easy-coding-standard": "^12.3", + "phpstan/extension-installer": "^1.4", + "saschaegerer/phpstan-typo3": "^2.1", + "ssch/typo3-rector": "^3.16" }, "extra": { "typo3/cms": { @@ -30,15 +33,11 @@ } }, "config": { + "lock": false, "allow-plugins": { "typo3/cms-composer-installers": true, - "typo3/class-alias-loader": true + "typo3/class-alias-loader": true, + "phpstan/extension-installer": true } - }, - "scripts": { - "post-autoload-dump": [ - "mkdir -p .Build/web/typo3conf/ext/", - "[ -L .Build/web/typo3conf/ext/variables ] || ln -snvf ../../../../. .Build/web/typo3conf/ext/variables" - ] } } diff --git a/ext_emconf.php b/ext_emconf.php deleted file mode 100644 index ce9901c..0000000 --- a/ext_emconf.php +++ /dev/null @@ -1,26 +0,0 @@ - 'Content Variables', - 'description' => 'Search and replace strings recursively after page generation using user-managed replacement definitions.', - 'category' => 'misc', - 'version' => '3.0.0', - 'state' => 'beta', - 'author' => 'Xavier Perseguers', - 'author_email' => 'xavier@causal.ch', - 'author_company' => 'Swisscom (Schweiz) AG', - 'constraints' => [ - 'depends' => [ - 'typo3' => '12.4.0-12.4.99', - 'php' => '8.1.0-8.3.99', - ], - 'conflicts' => [ - ], - 'suggests' => [ - ], - ] -]; diff --git a/ext_icon.png b/ext_icon.png deleted file mode 100644 index 6c3d566..0000000 Binary files a/ext_icon.png and /dev/null differ diff --git a/ext_localconf.php b/ext_localconf.php index b5a2ee8..8eadc45 100644 --- a/ext_localconf.php +++ b/ext_localconf.php @@ -1,14 +1,10 @@ replaceContent'; - $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['clearCachePostProc'][$extKey . '_clearcache'] = DataHandler::class . '->clearCachePostProc'; })('variables'); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..ed9d493 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,169 @@ +parameters: + ignoreErrors: + - + message: '#^Property TYPO3\\CMS\\Frontend\\Controller\\TypoScriptFrontendController\:\:\$content \(string\) does not accept mixed\.$#' + identifier: assign.propertyType + count: 1 + path: Classes/EventListener/ContentProcessor.php + + - + message: '#^Parameter \$key of class Sinso\\Variables\\Domain\\Model\\Marker constructor expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: Classes/Hooks/DataHandler.php + + - + message: '#^Possibly invalid array key type float\|int\|string\.$#' + identifier: offsetAccess.invalidOffset + count: 1 + path: Classes/Hooks/DataHandler.php + + - + message: '#^Binary operation "\-" between int and mixed results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Binary operation "\." between mixed and '' does not implement '' results in an error\.$#' + identifier: binaryOp.invalid + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Cannot access offset ''EXTCONF'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: Classes/Service/VariablesService.php + + - + message: '#^Cannot access offset ''ctrl'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Cannot access offset ''enablecolumns'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Cannot access offset ''endtime''\|''starttime'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Cannot access offset ''marker'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Cannot access offset ''postProcessMarkers'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Cannot access offset ''replacement'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Cannot access offset ''tx_variables_marker'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Cannot access offset ''uid'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: Classes/Service/VariablesService.php + + - + message: '#^Cannot access offset ''variables'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: Classes/Service/VariablesService.php + + - + message: '#^Cannot cast mixed to int\.$#' + identifier: cast.int + count: 3 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \#1 \$className of static method TYPO3\\CMS\\Core\\Utility\\GeneralUtility\:\:makeInstance\(\) expects class\-string\, mixed given\.$#' + identifier: argument.type + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \#1 \$fieldName of method TYPO3\\CMS\\Core\\Database\\Query\\Expression\\ExpressionBuilder\:\:gt\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \#1 \$fieldName of method TYPO3\\CMS\\Core\\Database\\Query\\Expression\\ExpressionBuilder\:\:lte\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \#1 \$identifier of method TYPO3\\CMS\\Core\\Database\\Query\\QueryBuilder\:\:quoteIdentifier\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 2 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \#2 \$array of function implode expects array\, array given\.$#' + identifier: argument.type + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \$key of class Sinso\\Variables\\Domain\\Model\\Marker constructor expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \$replacement of class Sinso\\Variables\\Domain\\Model\\Marker constructor expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \$uid of class Sinso\\Variables\\Domain\\Model\\Marker constructor expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter &\$text by\-ref type of method Sinso\\Variables\\Service\\VariablesService\:\:replaceMarkersInText\(\) expects string, string\|null given\.$#' + identifier: parameterByRef.type + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Unable to resolve the template type T in call to static method TYPO3\\CMS\\Core\\Utility\\GeneralUtility\:\:makeInstance\(\)$#' + identifier: argument.templateType + count: 1 + path: Classes/Service/VariablesService.php + + - + message: '#^Parameter \#1 \$params of method Sinso\\Variables\\Hooks\\DataHandler\:\:clearCachePostProc\(\) expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: Tests/Functional/Caching/FlushViaDataHandlerChangesTest.php + + - + message: '#^Property TYPO3\\TestingFramework\\Core\\Functional\\FunctionalTestCase\:\:\$configurationToUseInTestInstance \(array\\) does not accept array\.$#' + identifier: assign.propertyType + count: 1 + path: Tests/Functional/Frontend/AbstractProcessesMarkersTestCase.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..35db6e2 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,12 @@ +includes: + - phpstan-baseline.neon +parameters: + level: max + paths: + - Classes + - Tests + reportUnmatchedIgnoredErrors: false + typo3: + requestGetAttributeMapping: + 'frontend.page.information': 'TYPO3\CMS\Frontend\Page\PageInformation|null' + 'frontend.cache.collector': 'TYPO3\CMS\Core\Cache\CacheDataCollector|null' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d8a68b7..523903c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ + + Tests/Unit/ + Tests/Functional/ diff --git a/rector.php b/rector.php index f7e3f08..11f8007 100644 --- a/rector.php +++ b/rector.php @@ -3,105 +3,28 @@ declare(strict_types=1); use Rector\Config\RectorConfig; -use Rector\Core\Configuration\Option; -use Rector\Core\ValueObject\PhpVersion; -use Rector\Php55\Rector\String_\StringClassNameToClassConstantRector; -use Ssch\TYPO3Rector\Configuration\Typo3Option; -use Ssch\TYPO3Rector\FileProcessor\Composer\Rector\ExtensionComposerRector; -use Ssch\TYPO3Rector\FileProcessor\TypoScript\Rector\ExtbasePersistenceTypoScriptRector; -use Ssch\TYPO3Rector\FileProcessor\TypoScript\Rector\FileIncludeToImportStatementTypoScriptRector; -use Ssch\TYPO3Rector\Rector\General\ConvertImplicitVariablesToExplicitGlobalsRector; -use Ssch\TYPO3Rector\Rector\General\ExtEmConfRector; -use Ssch\TYPO3Rector\Rector\v9\v0\InjectAnnotationRector; +use Rector\PHPUnit\Set\PHPUnitSetList; +use Rector\Set\ValueObject\LevelSetList; +use Rector\ValueObject\PhpVersion; use Ssch\TYPO3Rector\Set\Typo3LevelSetList; -return static function (RectorConfig $rectorConfig): void { - $rectorConfig->sets([ - Typo3LevelSetList::UP_TO_TYPO3_11, +/** + * If FQDNs are imported globally at a later date, it must be ensured that + * extended domain models of the EXT:news extension are excluded. To check + * which files this affects, you can look in the configuration of the PhpCsFixer. + * @see https://docs.typo3.org/p/georgringer/news/main/en-us/Tutorials/ExtendNews/ProxyClassGenerator/Index.html + */ +return RectorConfig::configure() + ->withPaths([ + getcwd() . '/Classes', + getcwd() . '/Configuration', + getcwd() . '/Tests', + getcwd() . '/ext_localconf.php', + ]) + ->withImportNames(false, true, false) + ->withPhpVersion(PhpVersion::PHP_82) + ->withSets([ + LevelSetList::UP_TO_PHP_82, + Typo3LevelSetList::UP_TO_TYPO3_13, + PHPUnitSetList::ANNOTATIONS_TO_ATTRIBUTES, ]); - - // In order to have a better analysis from phpstan we teach it here some more things - $rectorConfig->phpstanConfig(Typo3Option::PHPSTAN_FOR_RECTOR_PATH); - - // FQN classes are not imported by default. If you don't do it manually after every Rector run, enable it by: - $rectorConfig->importNames(); - - // this will not import root namespace classes, like \DateTime or \Exception - $parameters = $rectorConfig->parameters(); - $parameters->set(Option::IMPORT_SHORT_CLASSES, false); - - // this prevents infinite loop issues due to symlinks in e.g. ".Build/" folders within single extensions - $parameters->set(Option::FOLLOW_SYMLINKS, false); - - // Define your target version which you want to support - $rectorConfig->phpVersion(PhpVersion::PHP_73); - - // If you have an editorconfig and changed files should keep their format enable it here - // $parameters->set(Option::ENABLE_EDITORCONFIG, true); - - // If you only want to process one/some TYPO3 extension(s), you can specify its path(s) here. - // If you use the option --config change __DIR__ to getcwd() - $rectorConfig->paths([ - __DIR__, - ]); - - // If you use the option --config change __DIR__ to getcwd() - $rectorConfig->skip([ - __DIR__ . '/vendor/*', - __DIR__ . '/.Build/*', - ]); - - // This is used by the class \Ssch\TYPO3Rector\Rector\PostRector\FullQualifiedNamePostRector to force FQN in this paths and files - $parameters->set(Typo3Option::PATHS_FULL_QUALIFIED_NAMESPACES, [ - # If you are targeting TYPO3 Version 11 use can now use Short namespace - # @see namespace https://docs.typo3.org/m/typo3/reference-coreapi/master/en-us/ExtensionArchitecture/ConfigurationFiles/Index.html - 'ext_localconf.php', - 'ext_tables.php', - __DIR__ . '/**/Configuration/*.php', - __DIR__ . '/**/Configuration/**/*.php', - ]); - - // If you have trouble that rector cannot run because some TYPO3 constants are not defined add an additional constants file - // @see https://github.com/sabbelasichon/typo3-rector/blob/master/typo3.constants.php - // @see https://github.com/rectorphp/rector/blob/main/docs/static_reflection_and_autoload.md#include-files - // $parameters->set(Option::BOOTSTRAP_FILES, [ - // __DIR__ . '/typo3.constants.php' - // ]); - - // register a single rule - // $rectorConfig->rule(InjectAnnotationRector::class); - - /** - * Useful rule from RectorPHP itself to transform i.e. GeneralUtility::makeInstance('TYPO3\CMS\Core\Log\LogManager') - * to GeneralUtility::makeInstance(\TYPO3\CMS\Core\Log\LogManager::class) calls. - * But be warned, sometimes it produces false positives (edge cases), so watch out - */ - // $rectorConfig->rule(StringClassNameToClassConstantRector::class); - - // Optional non-php file functionalities: - // @see https://github.com/sabbelasichon/typo3-rector/blob/main/docs/beyond_php_file_processors.md - - // Adapt your composer.json dependencies to the latest available version for the defined SetList - // $rectorConfig->sets([ - // Typo3SetList::COMPOSER_PACKAGES_104_CORE - // Typo3SetList::COMPOSER_PACKAGES_104_EXTENSIONS, - // ); - - // Rewrite your extbase persistence class mapping from typoscript into php according to official docs. - // This processor will create a summarized file with all of the typoscript rewrites combined into a single file. - // The filename can be passed as argument, "Configuration_Extbase_Persistence_Classes.php" is default. - $rectorConfig->ruleWithConfiguration(ExtbasePersistenceTypoScriptRector::class, [ - ExtbasePersistenceTypoScriptRector::FILENAME => 'Configuration_Extbase_Persistence_Classes.php', - ]); - // Add some general TYPO3 rules - $rectorConfig->rule(ConvertImplicitVariablesToExplicitGlobalsRector::class); - $rectorConfig->ruleWithConfiguration(ExtEmConfRector::class, [ - ExtEmConfRector::ADDITIONAL_VALUES_TO_BE_REMOVED => [], - ]); - $rectorConfig->ruleWithConfiguration(ExtensionComposerRector::class, [ - ExtensionComposerRector::TYPO3_VERSION_CONSTRAINT => '^10.4 || ^11.5', - ]); - - // Do you want to modernize your TypoScript include statements for files and move from to @import use the FileIncludeToImportStatementVisitor - // $rectorConfig->rule(FileIncludeToImportStatementTypoScriptRector::class); -};