diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1772d5f..9d00209 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,19 +6,23 @@ jobs: tests: name: Tests runs-on: ubuntu-latest + continue-on-error: ${{ matrix.experimental }} strategy: fail-fast: false matrix: - php: [ '8.2', '8.3' ] + php: [ '8.2', '8.3', '8.4', '8.5' ] typo3: [ '12', '13' ] + experimental: [false] composerInstall: [ 'composerInstallLowest', 'composerInstallHighest' ] include: - - typo3: '12' - php: '8.1' + - typo3: '14' + php: '8.5' composerInstall: 'composerInstallLowest' - - typo3: '12' - php: '8.1' + experimental: true + - typo3: '14' + php: '8.5' composerInstall: 'composerInstallHighest' + experimental: true steps: - name: Checkout @@ -42,11 +46,11 @@ jobs: - name: PHPStan run: Build/Scripts/runTests.sh -t ${{ matrix.typo3 }} -p ${{ matrix.php }} -s phpstan -e "--error-format=github" - #- name: Functional tests - #run: Build/Scripts/runTests.sh -t ${{ matrix.typo3 }} -p ${{ matrix.php }} -s functional + - name: Functional tests + run: Build/Scripts/runTests.sh -t ${{ matrix.typo3 }} -p ${{ matrix.php }} -s functional - #- name: Unit tests - #run: Build/Scripts/runTests.sh -t ${{ matrix.typo3 }} -p ${{ matrix.php }} -s unit + - name: Unit tests + run: Build/Scripts/runTests.sh -t ${{ matrix.typo3 }} -p ${{ matrix.php }} -s unit #- name: Functional tests coverage #if: matrix.php == '8.2' && matrix.typo3 == '13' && matrix.composerInstall == 'composerInstallHighest' diff --git a/Build/Scripts/runTests.sh b/Build/Scripts/runTests.sh index 25d68c2..cafa4e1 100755 --- a/Build/Scripts/runTests.sh +++ b/Build/Scripts/runTests.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # -# EXT:yoast_seo test runner based on docker/podman. +# EXT:frontend_request test runner based on docker/podman. # trap 'cleanUp;exit 2' SIGINT @@ -144,7 +144,7 @@ handleDbmsOptions() { loadHelp() { # Load help text into $HELP read -r -d '' HELP < + -t <12|13|14> Only with -s composerInstall|composerInstallLowest|composerInstallHighest Specifies the TYPO3 CORE Version to be used - - 11.5: use TYPO3 v11 (default) - - 12.4: use TYPO3 v12 - - 13.3: use TYPO3 v13 + - 12: use TYPO3 v12 + - 13: use TYPO3 v13 (default) + - 14: use TYPO3 v14 - -p <8.0|8.2|8.3|8.4> + -p <8.0|8.2|8.3|8.4|8.5> Specifies the PHP minor version to be used - 8.0: use PHP 8.0 - 8.2: (default) use PHP 8.2 - 8.3: use PHP 8.3 - 8.4: use PHP 8.4 + - 8.5: use PHP 8.5 -x Only with -s functional|unit @@ -278,7 +279,7 @@ fi # Option defaults TEST_SUITE="help" -TYPO3_VERSION="11" +TYPO3_VERSION="13" EXTRA_TEST_OPTIONS="" DATABASE_DRIVER="" DBMS="sqlite" @@ -320,7 +321,7 @@ while getopts "a:b:d:i:s:p:e:t:xy:nhu" OPT; do ;; p) PHP_VERSION=${OPTARG} - if ! [[ ${PHP_VERSION} =~ ^(8.0|8.1|8.2|8.3|8.4)$ ]]; then + if ! [[ ${PHP_VERSION} =~ ^(8.2|8.3|8.4|8.5)$ ]]; then INVALID_OPTIONS+=("p ${OPTARG}") fi ;; @@ -329,7 +330,7 @@ while getopts "a:b:d:i:s:p:e:t:xy:nhu" OPT; do ;; t) TYPO3_VERSION=${OPTARG} - if ! [[ ${TYPO3_VERSION} =~ ^(11|12|13)$ ]]; then + if ! [[ ${TYPO3_VERSION} =~ ^(12|13|14)$ ]]; then INVALID_OPTIONS+=("-t ${OPTARG}") fi ;; @@ -495,17 +496,17 @@ case ${TEST_SUITE} in cleanComposer stashComposerFiles ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name composer-install-highest-${SUFFIX} -e COMPOSER_CACHE_DIR=.cache/composer -e COMPOSER_ROOT_VERSION=${COMPOSER_ROOT_VERSION} ${IMAGE_PHP} /bin/bash -c " - if [ ${TYPO3_VERSION} -eq 11 ]; then - composer require --no-ansi --no-interaction --no-progress --no-install \ - typo3/cms-core:^11.5 typo3/cms-dashboard:^11.5 || exit 1 - fi if [ ${TYPO3_VERSION} -eq 12 ]; then composer require --no-ansi --no-interaction --no-progress --no-install \ - typo3/cms-core:^12.4 typo3/cms-dashboard:^12.4 || exit 1 + typo3/cms-core:^12.4 || exit 1 fi if [ ${TYPO3_VERSION} -eq 13 ]; then composer require --no-ansi --no-interaction --no-progress --no-install \ - typo3/cms-core:^13.3 typo3/cms-dashboard:^13.3 || exit 1 + typo3/cms-core:^13.4 || exit 1 + fi + if [ ${TYPO3_VERSION} -eq 14 ]; then + composer require --no-ansi --no-interaction --no-progress --no-install \ + typo3/cms-core:^14.1 || exit 1 fi composer update --no-progress --no-interaction || exit 1 composer show || exit 1 @@ -517,17 +518,17 @@ case ${TEST_SUITE} in cleanComposer stashComposerFiles ${CONTAINER_BIN} run ${CONTAINER_COMMON_PARAMS} --name composer-install-lowest-${SUFFIX} -e COMPOSER_CACHE_DIR=.cache/composer -e COMPOSER_ROOT_VERSION=${COMPOSER_ROOT_VERSION} ${IMAGE_PHP} /bin/bash -c " - if [ ${TYPO3_VERSION} -eq 11 ]; then - composer require --no-ansi --no-interaction --no-progress --no-install \ - typo3/cms-core:^11.5 typo3/cms-dashboard:^11.5 || exit 1 - fi if [ ${TYPO3_VERSION} -eq 12 ]; then composer require --no-ansi --no-interaction --no-progress --no-install \ - typo3/cms-core:^12.4 typo3/cms-dashboard:^12.4 || exit 1 + typo3/cms-core:^12.4 || exit 1 fi if [ ${TYPO3_VERSION} -eq 13 ]; then composer require --no-ansi --no-interaction --no-progress --no-install \ - typo3/cms-core:^13.3 typo3/cms-dashboard:^13.3 || exit 1 + typo3/cms-core:^13.4 || exit 1 + fi + if [ ${TYPO3_VERSION} -eq 14 ]; then + composer require --no-ansi --no-interaction --no-progress --no-install \ + typo3/cms-core:^14.1 || exit 1 fi composer update --no-ansi --no-interaction --no-progress --with-dependencies --prefer-lowest || exit 1 composer show || exit 1 diff --git a/Build/phpunit/FunctionalTests.xml b/Build/phpunit/FunctionalTests.xml new file mode 100644 index 0000000..0f78bfe --- /dev/null +++ b/Build/phpunit/FunctionalTests.xml @@ -0,0 +1,15 @@ + + + + + ../../Tests/Functional/ + + + diff --git a/Build/phpunit/UnitTests.xml b/Build/phpunit/UnitTests.xml new file mode 100644 index 0000000..0393a11 --- /dev/null +++ b/Build/phpunit/UnitTests.xml @@ -0,0 +1,15 @@ + + + + + ../../Tests/Unit/ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..822e9e6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Change Log + +This changelog is according to [Keep a Changelog](http://keepachangelog.com). + +All notable changes to this project will be documented in this file. +We will follow [Semantic Versioning](http://semver.org/). + +## [2.0.0] = 2026-02-19 + +### Added + +- TYPO3 14 Support +- Functional and unit tests + +### Removed + +- PHP 8.1 support + +## 1.0.0 - 2025-11-07 + +### Added + +- First version of the extension, javascript to make a frontend request and returning a json with page information diff --git a/README.md b/README.md index 569b71b..5c37bbd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ [![Latest Stable Version](https://poser.pugx.org/maxserv/frontend-request/v/stable)](https://extensions.typo3.org/extension/frontend_request/) +[![TYPO3 14](https://img.shields.io/badge/TYPO3-14-orange.svg?style=flat-square)](https://get.typo3.org/version/14) [![TYPO3 13](https://img.shields.io/badge/TYPO3-13-orange.svg?style=flat-square)](https://get.typo3.org/version/13) [![TYPO3 12](https://img.shields.io/badge/TYPO3-12-orange.svg?style=flat-square)](https://get.typo3.org/version/12) [![Total Downloads](https://poser.pugx.org/maxserv/frontend-request/d/total)](https://packagist.org/packages/maxserv/frontend-request) diff --git a/Tests/.gitkeep b/Tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/Tests/Functional/PageParser/ParserCollectorTest.php b/Tests/Functional/PageParser/ParserCollectorTest.php new file mode 100644 index 0000000..fd756d1 --- /dev/null +++ b/Tests/Functional/PageParser/ParserCollectorTest.php @@ -0,0 +1,39 @@ +get(ParserCollector::class); + $parsers = $parserCollector->getAll(); + + self::assertArrayHasKey('title', $parsers); + self::assertArrayHasKey('body', $parsers); + self::assertArrayHasKey('metadata', $parsers); + self::assertArrayHasKey('locale', $parsers); + self::assertArrayHasKey('url', $parsers); + self::assertArrayHasKey('favicon', $parsers); + } + + #[Test] + public function parserCollectorReturnsCorrectNumberOfParsers(): void + { + $parserCollector = $this->get(ParserCollector::class); + $parsers = $parserCollector->getAll(); + + self::assertCount(6, $parsers); + } +} diff --git a/Tests/Unit/Builder/RequestParametersBuilderTest.php b/Tests/Unit/Builder/RequestParametersBuilderTest.php new file mode 100644 index 0000000..1052543 --- /dev/null +++ b/Tests/Unit/Builder/RequestParametersBuilderTest.php @@ -0,0 +1,124 @@ +subject = new RequestParametersBuilder(); + } + + #[Test] + public function buildFromQueryParams(): void + { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getQueryParams')->willReturn([ + 'pageId' => '42', + 'languageId' => '1', + 'additionalGetVars' => 'tx_news=5', + ]); + + $parameters = $this->subject->build($request); + + self::assertSame(42, $parameters->getPageId()); + self::assertSame(1, $parameters->getLanguageId()); + self::assertSame('tx_news=5', $parameters->getAdditionalParameters()); + self::assertTrue($parameters->isValid()); + } + + #[Test] + public function buildFromQueryParamsWithoutAdditionalGetVars(): void + { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getQueryParams')->willReturn([ + 'pageId' => '10', + 'languageId' => '0', + ]); + + $parameters = $this->subject->build($request); + + self::assertSame(10, $parameters->getPageId()); + self::assertSame(0, $parameters->getLanguageId()); + self::assertSame('', $parameters->getAdditionalParameters()); + } + + #[Test] + public function buildFromJsonBody(): void + { + $body = $this->createMock(StreamInterface::class); + $body->method('getContents')->willReturn('{"pageId":7,"languageId":2,"additionalGetVars":"type=123"}'); + + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getQueryParams')->willReturn([]); + $request->method('getBody')->willReturn($body); + + $parameters = $this->subject->build($request); + + self::assertSame(7, $parameters->getPageId()); + self::assertSame(2, $parameters->getLanguageId()); + self::assertSame('type=123', $parameters->getAdditionalParameters()); + } + + #[Test] + public function buildFallsBackToEmptyParametersWhenNoData(): void + { + $body = $this->createMock(StreamInterface::class); + $body->method('getContents')->willReturn(''); + + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getQueryParams')->willReturn([]); + $request->method('getBody')->willReturn($body); + + $parameters = $this->subject->build($request); + + self::assertNull($parameters->getPageId()); + self::assertNull($parameters->getLanguageId()); + self::assertFalse($parameters->isValid()); + } + + #[Test] + public function buildHandlesInvalidJson(): void + { + $body = $this->createMock(StreamInterface::class); + $body->method('getContents')->willReturn('{invalid json}'); + + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getQueryParams')->willReturn([]); + $request->method('getBody')->willReturn($body); + + $parameters = $this->subject->build($request); + + self::assertFalse($parameters->isValid()); + } + + #[Test] + public function buildPrefersQueryParamsOverJsonBody(): void + { + $body = $this->createMock(StreamInterface::class); + $body->method('getContents')->willReturn('{"pageId":99,"languageId":9}'); + + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getQueryParams')->willReturn([ + 'pageId' => '1', + 'languageId' => '0', + ]); + $request->method('getBody')->willReturn($body); + + $parameters = $this->subject->build($request); + + self::assertSame(1, $parameters->getPageId()); + self::assertSame(0, $parameters->getLanguageId()); + } +} diff --git a/Tests/Unit/Controller/RequestControllerTest.php b/Tests/Unit/Controller/RequestControllerTest.php new file mode 100644 index 0000000..2940eb4 --- /dev/null +++ b/Tests/Unit/Controller/RequestControllerTest.php @@ -0,0 +1,31 @@ +createMock(ServerRequestInterface::class); + $response = $this->createMock(ResponseInterface::class); + + $handler = $this->createMock(RequestHandler::class); + $handler->expects(self::once()) + ->method('handle') + ->with($request) + ->willReturn($response); + + $controller = new RequestController($handler); + self::assertSame($response, $controller->requestAction($request)); + } +} diff --git a/Tests/Unit/Dto/RequestContextTest.php b/Tests/Unit/Dto/RequestContextTest.php new file mode 100644 index 0000000..c28dc15 --- /dev/null +++ b/Tests/Unit/Dto/RequestContextTest.php @@ -0,0 +1,35 @@ +getUrl()); + self::assertNull($context->getSite()); + self::assertNull($context->getParameters()); + } + + #[Test] + public function constructorAcceptsValues(): void + { + $site = new Site('test', 1, []); + $parameters = new RequestParameters(1, 0); + $context = new RequestContext('https://example.com/page', $site, $parameters); + + self::assertSame('https://example.com/page', $context->getUrl()); + self::assertSame($site, $context->getSite()); + self::assertSame($parameters, $context->getParameters()); + } +} diff --git a/Tests/Unit/Dto/RequestErrorTest.php b/Tests/Unit/Dto/RequestErrorTest.php new file mode 100644 index 0000000..5274435 --- /dev/null +++ b/Tests/Unit/Dto/RequestErrorTest.php @@ -0,0 +1,50 @@ + [ + 'reason' => 'Something went wrong', + 'url' => 'https://example.com', + 'statusCode' => 500, + ], + ], $error->jsonSerialize()); + } + + #[Test] + public function constructorDefaultValues(): void + { + $error = new RequestError('Bad request'); + self::assertSame([ + 'error' => [ + 'reason' => 'Bad request', + 'url' => '', + 'statusCode' => 400, + ], + ], $error->jsonSerialize()); + } + + #[Test] + public function jsonEncodeProducesExpectedOutput(): void + { + $error = new RequestError('Not found', 'https://example.com/page', 404); + $json = json_encode($error); + $decoded = json_decode($json, true); + + self::assertSame('Not found', $decoded['error']['reason']); + self::assertSame('https://example.com/page', $decoded['error']['url']); + self::assertSame(404, $decoded['error']['statusCode']); + } +} diff --git a/Tests/Unit/Dto/RequestParametersTest.php b/Tests/Unit/Dto/RequestParametersTest.php new file mode 100644 index 0000000..a7a6377 --- /dev/null +++ b/Tests/Unit/Dto/RequestParametersTest.php @@ -0,0 +1,92 @@ +getPageId()); + self::assertNull($parameters->getLanguageId()); + self::assertNull($parameters->getAdditionalParameters()); + } + + #[Test] + public function constructorAcceptsValues(): void + { + $parameters = new RequestParameters(42, 1, 'tx_news=1'); + self::assertSame(42, $parameters->getPageId()); + self::assertSame(1, $parameters->getLanguageId()); + self::assertSame('tx_news=1', $parameters->getAdditionalParameters()); + } + + #[Test] + public function setPageIdUpdatesValue(): void + { + $parameters = new RequestParameters(); + $parameters->setPageId(10); + self::assertSame(10, $parameters->getPageId()); + } + + #[Test] + public function setLanguageIdUpdatesValue(): void + { + $parameters = new RequestParameters(); + $parameters->setLanguageId(2); + self::assertSame(2, $parameters->getLanguageId()); + } + + #[Test] + public function setAdditionalParametersOverwritesValue(): void + { + $parameters = new RequestParameters(1, 0, 'foo=bar'); + $parameters->setAdditionalParameters('baz=qux'); + self::assertSame('baz=qux', $parameters->getAdditionalParameters()); + } + + #[Test] + public function addAdditionalParametersSetsValueWhenNull(): void + { + $parameters = new RequestParameters(); + $parameters->addAdditionalParameters('foo=bar'); + self::assertSame('foo=bar', $parameters->getAdditionalParameters()); + } + + #[Test] + public function addAdditionalParametersAppendsWithAmpersand(): void + { + $parameters = new RequestParameters(1, 0, 'foo=bar'); + $parameters->addAdditionalParameters('baz=qux'); + self::assertSame('foo=bar&baz=qux', $parameters->getAdditionalParameters()); + } + + /** + * @return array + */ + public static function isValidDataProvider(): array + { + return [ + 'both set' => [1, 0, true], + 'pageId null' => [null, 0, false], + 'languageId null' => [1, null, false], + 'both null' => [null, null, false], + ]; + } + + #[Test] + #[DataProvider('isValidDataProvider')] + public function isValidReturnsExpectedResult(?int $pageId, ?int $languageId, bool $expected): void + { + $parameters = new RequestParameters($pageId, $languageId); + self::assertSame($expected, $parameters->isValid()); + } +} diff --git a/Tests/Unit/Dto/RequestResultTest.php b/Tests/Unit/Dto/RequestResultTest.php new file mode 100644 index 0000000..48dbdf4 --- /dev/null +++ b/Tests/Unit/Dto/RequestResultTest.php @@ -0,0 +1,58 @@ +getData()); + } + + #[Test] + public function constructorAcceptsInitialData(): void + { + $data = ['title' => 'Hello', 'locale' => 'en']; + $result = new RequestResult($data); + self::assertSame($data, $result->getData()); + } + + #[Test] + public function addDataAddsKeyValuePair(): void + { + $result = new RequestResult(); + $result->addData('title', 'Test Page'); + self::assertSame(['title' => 'Test Page'], $result->getData()); + } + + #[Test] + public function addDataOverwritesExistingKey(): void + { + $result = new RequestResult(['title' => 'Old']); + $result->addData('title', 'New'); + self::assertSame(['title' => 'New'], $result->getData()); + } + + #[Test] + public function jsonSerializeReturnsDataArray(): void + { + $data = ['title' => 'Page', 'locale' => 'en']; + $result = new RequestResult($data); + self::assertSame($data, $result->jsonSerialize()); + } + + #[Test] + public function jsonEncodeProducesExpectedOutput(): void + { + $result = new RequestResult(['title' => 'Test']); + self::assertSame('{"title":"Test"}', json_encode($result)); + } +} diff --git a/Tests/Unit/Event/ModifyRequestEventTest.php b/Tests/Unit/Event/ModifyRequestEventTest.php new file mode 100644 index 0000000..b6d637b --- /dev/null +++ b/Tests/Unit/Event/ModifyRequestEventTest.php @@ -0,0 +1,37 @@ +createMock(RequestInterface::class); + $context = new RequestContext('https://example.com'); + $event = new ModifyRequestEvent($request, $context); + + self::assertSame($request, $event->getRequest()); + self::assertSame($context, $event->getContext()); + } + + #[Test] + public function setRequestModifiesRequest(): void + { + $originalRequest = $this->createMock(RequestInterface::class); + $modifiedRequest = $this->createMock(RequestInterface::class); + $context = new RequestContext('https://example.com'); + $event = new ModifyRequestEvent($originalRequest, $context); + + $event->setRequest($modifiedRequest); + self::assertSame($modifiedRequest, $event->getRequest()); + } +} diff --git a/Tests/Unit/Event/ModifyUrlEventTest.php b/Tests/Unit/Event/ModifyUrlEventTest.php new file mode 100644 index 0000000..03bf825 --- /dev/null +++ b/Tests/Unit/Event/ModifyUrlEventTest.php @@ -0,0 +1,37 @@ +getUrl()); + self::assertSame($site, $event->getSite()); + self::assertSame($parameters, $event->getParameters()); + } + + #[Test] + public function setUrlModifiesUrl(): void + { + $site = new Site('test', 1, []); + $parameters = new RequestParameters(1, 0); + $event = new ModifyUrlEvent('https://example.com', $site, $parameters); + + $event->setUrl('https://modified.com/page'); + self::assertSame('https://modified.com/page', $event->getUrl()); + } +} diff --git a/Tests/Unit/Handler/RequestHandlerTest.php b/Tests/Unit/Handler/RequestHandlerTest.php new file mode 100644 index 0000000..4874fe8 --- /dev/null +++ b/Tests/Unit/Handler/RequestHandlerTest.php @@ -0,0 +1,128 @@ +parametersBuilder = $this->createMock(RequestParametersBuilder::class); + $this->contextBuilder = $this->createMock(RequestContextBuilder::class); + $this->frontendRequest = $this->createMock(FrontendRequest::class); + $this->pageParser = $this->createMock(PageParser::class); + $this->responseFactory = $this->createMock(ResponseFactoryInterface::class); + + $this->subject = new RequestHandler( + $this->parametersBuilder, + $this->contextBuilder, + $this->frontendRequest, + $this->pageParser, + $this->responseFactory, + ); + } + + private function mockJsonResponse(): ResponseInterface + { + $body = $this->createMock(StreamInterface::class); + $body->method('write')->willReturn(1); + + $response = $this->createMock(ResponseInterface::class); + $response->method('withHeader')->willReturn($response); + $response->method('withStatus')->willReturn($response); + $response->method('getBody')->willReturn($body); + + $this->responseFactory->method('createResponse')->willReturn($response); + + return $response; + } + + #[Test] + public function handleReturns400ForInvalidParameters(): void + { + $serverRequest = $this->createMock(ServerRequestInterface::class); + $invalidParams = new RequestParameters(); + + $this->parametersBuilder->method('build')->willReturn($invalidParams); + + $response = $this->mockJsonResponse(); + $response->expects(self::once())->method('withStatus')->with(400)->willReturn($response); + + $this->subject->handle($serverRequest); + } + + #[Test] + public function handleReturnsSuccessfulResponse(): void + { + $serverRequest = $this->createMock(ServerRequestInterface::class); + $validParams = new RequestParameters(1, 0); + $context = new RequestContext('https://example.com'); + $result = new RequestResult(['title' => 'Test']); + + $this->parametersBuilder->method('build')->willReturn($validParams); + $this->contextBuilder->method('build')->with($validParams)->willReturn($context); + $this->frontendRequest->method('getHtmlResponse')->with($context)->willReturn(''); + $this->pageParser->method('parsePage')->with('', $context)->willReturn($result); + + $response = $this->mockJsonResponse(); + + $resultResponse = $this->subject->handle($serverRequest); + self::assertSame($response, $resultResponse); + } + + #[Test] + public function handleCatchesExceptionAndReturnsError(): void + { + $serverRequest = $this->createMock(ServerRequestInterface::class); + $validParams = new RequestParameters(1, 0); + $context = new RequestContext('https://example.com'); + + $this->parametersBuilder->method('build')->willReturn($validParams); + $this->contextBuilder->method('build')->willReturn($context); + $this->frontendRequest->method('getHtmlResponse') + ->willThrowException(new \RuntimeException('500')); + + $body = $this->createMock(StreamInterface::class); + $body->expects(self::once()) + ->method('write') + ->with(self::callback(function (string $json): bool { + $data = json_decode($json, true); + return $data['error']['reason'] === 'Request failed' + && $data['error']['url'] === 'https://example.com' + && $data['error']['statusCode'] === 500; + })); + + $response = $this->createMock(ResponseInterface::class); + $response->method('withHeader')->willReturn($response); + $response->method('withStatus')->willReturn($response); + $response->method('getBody')->willReturn($body); + + $this->responseFactory->method('createResponse')->willReturn($response); + + $this->subject->handle($serverRequest); + } +} diff --git a/Tests/Unit/PageParser/PageParserTest.php b/Tests/Unit/PageParser/PageParserTest.php new file mode 100644 index 0000000..0767fd8 --- /dev/null +++ b/Tests/Unit/PageParser/PageParserTest.php @@ -0,0 +1,73 @@ +createMock(ParserInterface::class); + $titleParser->method('getIdentifier')->willReturn('title'); + $titleParser->method('parse')->willReturn('Test Page'); + + $localeParser = $this->createMock(ParserInterface::class); + $localeParser->method('getIdentifier')->willReturn('locale'); + $localeParser->method('parse')->willReturn('en'); + + $collector = $this->createMock(ParserCollector::class); + $collector->method('getAll')->willReturn([ + 'title' => $titleParser, + 'locale' => $localeParser, + ]); + + $pageParser = new PageParser($collector); + $context = new RequestContext('https://example.com'); + $result = $pageParser->parsePage('', $context); + + self::assertSame('Test Page', $result->getData()['title']); + self::assertSame('en', $result->getData()['locale']); + } + + #[Test] + public function parsePageReturnsEmptyResultWhenNoParsers(): void + { + $collector = $this->createMock(ParserCollector::class); + $collector->method('getAll')->willReturn([]); + + $pageParser = new PageParser($collector); + $context = new RequestContext('https://example.com'); + $result = $pageParser->parsePage('', $context); + + self::assertSame([], $result->getData()); + } + + #[Test] + public function parsePagePassesContentAndContextToParsers(): void + { + $html = 'Hello'; + $context = new RequestContext('https://example.com/page'); + + $parser = $this->createMock(ParserInterface::class); + $parser->method('getIdentifier')->willReturn('test'); + $parser->expects(self::once()) + ->method('parse') + ->with($html, $context) + ->willReturn('parsed'); + + $collector = $this->createMock(ParserCollector::class); + $collector->method('getAll')->willReturn(['test' => $parser]); + + $pageParser = new PageParser($collector); + $pageParser->parsePage($html, $context); + } +} diff --git a/Tests/Unit/PageParser/Parser/BodyParserTest.php b/Tests/Unit/PageParser/Parser/BodyParserTest.php new file mode 100644 index 0000000..9d33419 --- /dev/null +++ b/Tests/Unit/PageParser/Parser/BodyParserTest.php @@ -0,0 +1,65 @@ +subject = new BodyParser(); + } + + #[Test] + public function getIdentifierReturnsBody(): void + { + self::assertSame('body', $this->subject->getIdentifier()); + } + + /** + * @return array + */ + public static function parseDataProvider(): array + { + return [ + 'simple body' => [ + '

Hello

', + '

Hello

', + ], + 'body with attributes' => [ + '
Content
', + '
Content
', + ], + 'multiline body' => [ + "\n

Title

\n

Text

\n", + "\n

Title

\n

Text

\n", + ], + 'missing body' => [ + 'No body', + '', + ], + 'empty body' => [ + '', + '', + ], + ]; + } + + #[Test] + #[DataProvider('parseDataProvider')] + public function parseExtractsBody(string $html, string $expected): void + { + $context = new RequestContext(); + self::assertSame($expected, $this->subject->parse($html, $context)); + } +} diff --git a/Tests/Unit/PageParser/Parser/LocaleParserTest.php b/Tests/Unit/PageParser/Parser/LocaleParserTest.php new file mode 100644 index 0000000..098f8c7 --- /dev/null +++ b/Tests/Unit/PageParser/Parser/LocaleParserTest.php @@ -0,0 +1,65 @@ +subject = new LocaleParser(); + } + + #[Test] + public function getIdentifierReturnsLocale(): void + { + self::assertSame('locale', $this->subject->getIdentifier()); + } + + /** + * @return array + */ + public static function parseDataProvider(): array + { + return [ + 'simple locale' => [ + '', + 'nl', + ], + 'locale with region' => [ + '', + 'en', + ], + 'locale with region lowercase' => [ + '', + 'de', + ], + 'no lang attribute defaults to en' => [ + '', + 'en', + ], + 'html with other attributes' => [ + '', + 'fr', + ], + ]; + } + + #[Test] + #[DataProvider('parseDataProvider')] + public function parseExtractsLocale(string $html, string $expected): void + { + $context = new RequestContext(); + self::assertSame($expected, $this->subject->parse($html, $context)); + } +} diff --git a/Tests/Unit/PageParser/Parser/MetadataParserTest.php b/Tests/Unit/PageParser/Parser/MetadataParserTest.php new file mode 100644 index 0000000..cedeca5 --- /dev/null +++ b/Tests/Unit/PageParser/Parser/MetadataParserTest.php @@ -0,0 +1,79 @@ +subject = new MetadataParser(); + } + + #[Test] + public function getIdentifierReturnsMetadata(): void + { + self::assertSame('metadata', $this->subject->getIdentifier()); + } + + #[Test] + public function parseExtractsMultipleMetaTags(): void + { + $html = '' + . '' + . '' + . '' + . ''; + + $context = new RequestContext(); + $result = $this->subject->parse($html, $context); + + self::assertSame([ + 'description' => 'A test page', + 'keywords' => 'test,page', + 'author' => 'John', + ], $result); + } + + #[Test] + public function parseReturnsEmptyArrayWhenNoMetaTags(): void + { + $html = 'No meta'; + $context = new RequestContext(); + self::assertSame([], $this->subject->parse($html, $context)); + } + + #[Test] + public function parseHandlesEmptyContent(): void + { + $html = ''; + $context = new RequestContext(); + $result = $this->subject->parse($html, $context); + + self::assertSame(['robots' => ''], $result); + } + + #[Test] + public function parseIgnoresMetaTagsWithoutNameAttribute(): void + { + $html = '' + . '' + . '' + . '' + . ''; + + $context = new RequestContext(); + $result = $this->subject->parse($html, $context); + + self::assertSame(['viewport' => 'width=device-width'], $result); + } +} diff --git a/Tests/Unit/PageParser/Parser/TitleParserTest.php b/Tests/Unit/PageParser/Parser/TitleParserTest.php new file mode 100644 index 0000000..c62cece --- /dev/null +++ b/Tests/Unit/PageParser/Parser/TitleParserTest.php @@ -0,0 +1,69 @@ +subject = new TitleParser(); + } + + #[Test] + public function getIdentifierReturnsTitle(): void + { + self::assertSame('title', $this->subject->getIdentifier()); + } + + /** + * @return array + */ + public static function parseDataProvider(): array + { + return [ + 'simple title' => [ + 'My Page', + 'My Page', + ], + 'title with entities' => [ + 'Tom & Jerry', + 'Tom & Jerry', + ], + 'title with html tags' => [ + '<b>Bold</b> Title', + 'Bold Title', + ], + 'missing title' => [ + 'No title', + '', + ], + 'empty title' => [ + '', + '', + ], + 'case insensitive' => [ + 'Upper', + 'Upper', + ], + ]; + } + + #[Test] + #[DataProvider('parseDataProvider')] + public function parseExtractsTitle(string $html, string $expected): void + { + $context = new RequestContext(); + self::assertSame($expected, $this->subject->parse($html, $context)); + } +} diff --git a/Tests/Unit/PageParser/Parser/UrlParserTest.php b/Tests/Unit/PageParser/Parser/UrlParserTest.php new file mode 100644 index 0000000..2187096 --- /dev/null +++ b/Tests/Unit/PageParser/Parser/UrlParserTest.php @@ -0,0 +1,37 @@ +createMock(UrlService::class); + $parser = new UrlParser($urlService); + self::assertSame('url', $parser->getIdentifier()); + } + + #[Test] + public function parseDelegatesToUrlService(): void + { + $context = new RequestContext('https://example.com/page'); + + $urlService = $this->createMock(UrlService::class); + $urlService->expects(self::once()) + ->method('getUrl') + ->with($context) + ->willReturn('https://example.com/page'); + + $parser = new UrlParser($urlService); + self::assertSame('https://example.com/page', $parser->parse('', $context)); + } +} diff --git a/Tests/Unit/PageParser/ParserCollectorTest.php b/Tests/Unit/PageParser/ParserCollectorTest.php new file mode 100644 index 0000000..9dc6078 --- /dev/null +++ b/Tests/Unit/PageParser/ParserCollectorTest.php @@ -0,0 +1,55 @@ +getAll()); + } + + #[Test] + public function getAllReturnsParsersKeyedByIdentifier(): void + { + $parserA = $this->createMock(ParserInterface::class); + $parserA->method('getIdentifier')->willReturn('title'); + + $parserB = $this->createMock(ParserInterface::class); + $parserB->method('getIdentifier')->willReturn('body'); + + $collector = new ParserCollector([$parserA, $parserB]); + $result = $collector->getAll(); + + self::assertCount(2, $result); + self::assertArrayHasKey('title', $result); + self::assertArrayHasKey('body', $result); + self::assertSame($parserA, $result['title']); + self::assertSame($parserB, $result['body']); + } + + #[Test] + public function laterParserOverwritesEarlierWithSameIdentifier(): void + { + $parserA = $this->createMock(ParserInterface::class); + $parserA->method('getIdentifier')->willReturn('title'); + + $parserB = $this->createMock(ParserInterface::class); + $parserB->method('getIdentifier')->willReturn('title'); + + $collector = new ParserCollector([$parserA, $parserB]); + $result = $collector->getAll(); + + self::assertCount(1, $result); + self::assertSame($parserB, $result['title']); + } +} diff --git a/Tests/Unit/PageParser/Service/UrlServiceTest.php b/Tests/Unit/PageParser/Service/UrlServiceTest.php new file mode 100644 index 0000000..ee678be --- /dev/null +++ b/Tests/Unit/PageParser/Service/UrlServiceTest.php @@ -0,0 +1,103 @@ +subject = new UrlService(); + } + + /** + * @return array + */ + public static function getBaseUrlDataProvider(): array + { + return [ + 'simple https url' => [ + 'https://example.com/page', + 'https://example.com', + ], + 'http url' => [ + 'http://example.com/page', + 'http://example.com', + ], + 'url with port' => [ + 'https://example.com:8080/page', + 'https://example.com:8080', + ], + 'url with trailing slash' => [ + 'https://example.com/', + 'https://example.com', + ], + ]; + } + + #[Test] + #[DataProvider('getBaseUrlDataProvider')] + public function getBaseUrlReturnsSchemeAndHost(string $url, string $expected): void + { + $context = new RequestContext($url); + self::assertSame($expected, $this->subject->getBaseUrl($context)); + } + + #[Test] + public function getBaseUrlReturnsFallbackForEmptyUrl(): void + { + $context = new RequestContext(''); + self::assertSame('//', $this->subject->getBaseUrl($context)); + } + + /** + * @return array + */ + public static function getUrlDataProvider(): array + { + return [ + 'url with path' => [ + 'https://example.com/my/page', + 'https://example.com/my/page', + ], + 'url with port and path' => [ + 'https://example.com:3000/app/page', + 'https://example.com:3000/app/page', + ], + 'url with trailing slash stripped' => [ + 'https://example.com/page/', + 'https://example.com/page', + ], + ]; + } + + #[Test] + #[DataProvider('getUrlDataProvider')] + public function getUrlReturnsFullUrl(string $url, string $expected): void + { + $context = new RequestContext($url); + self::assertSame($expected, $this->subject->getUrl($context)); + } + + #[Test] + public function getBaseUrlCachesResult(): void + { + $context = new RequestContext('https://example.com/page'); + + $first = $this->subject->getBaseUrl($context); + $second = $this->subject->getBaseUrl($context); + + self::assertSame($first, $second); + self::assertSame('https://example.com', $first); + } +} diff --git a/Tests/Unit/Request/FrontendRequestTest.php b/Tests/Unit/Request/FrontendRequestTest.php new file mode 100644 index 0000000..b880a63 --- /dev/null +++ b/Tests/Unit/Request/FrontendRequestTest.php @@ -0,0 +1,146 @@ +requestFactory = $this->createMock(RequestFactory::class); + $this->client = $this->createMock(ClientInterface::class); + $this->eventDispatcher = $this->createMock(EventDispatcherInterface::class); + + $this->subject = new FrontendRequest( + $this->requestFactory, + $this->client, + $this->eventDispatcher, + ); + } + + #[Test] + public function getHtmlResponseReturnsBodyOnSuccess(): void + { + $context = new RequestContext('https://example.com/page'); + $request = $this->createMock(RequestInterface::class); + + $this->requestFactory->method('createRequest') + ->with('GET', 'https://example.com/page') + ->willReturn($request); + + $event = new ModifyRequestEvent($request, $context); + $this->eventDispatcher->method('dispatch')->willReturn($event); + + $body = $this->createMock(StreamInterface::class); + $body->method('getContents')->willReturn('Hello'); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($body); + + $this->client->method('sendRequest')->with($request)->willReturn($response); + + self::assertSame('Hello', $this->subject->getHtmlResponse($context)); + } + + #[Test] + public function getHtmlResponseThrowsOnNon200Status(): void + { + $context = new RequestContext('https://example.com/page'); + $request = $this->createMock(RequestInterface::class); + + $this->requestFactory->method('createRequest')->willReturn($request); + + $event = new ModifyRequestEvent($request, $context); + $this->eventDispatcher->method('dispatch')->willReturn($event); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(404); + + $this->client->method('sendRequest')->willReturn($response); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('404'); + + $this->subject->getHtmlResponse($context); + } + + #[Test] + public function getHtmlResponseThrowsOnRequestException(): void + { + $context = new RequestContext('https://example.com/page'); + $request = $this->createMock(RequestInterface::class); + + $this->requestFactory->method('createRequest')->willReturn($request); + + $event = new ModifyRequestEvent($request, $context); + $this->eventDispatcher->method('dispatch')->willReturn($event); + + $requestException = new RequestException( + 'Connection refused', + $request, + null, + ); + + $this->client->method('sendRequest')->willThrowException($requestException); + + $this->expectException(\RuntimeException::class); + + $this->subject->getHtmlResponse($context); + } + + #[Test] + public function getHtmlResponseDispatchesModifyRequestEvent(): void + { + $context = new RequestContext('https://example.com/page'); + $originalRequest = $this->createMock(RequestInterface::class); + $modifiedRequest = $this->createMock(RequestInterface::class); + + $this->requestFactory->method('createRequest')->willReturn($originalRequest); + + $this->eventDispatcher->expects(self::once()) + ->method('dispatch') + ->with(self::callback(function (ModifyRequestEvent $event) use ($originalRequest, $context): bool { + return $event->getRequest() === $originalRequest + && $event->getContext() === $context; + })) + ->willReturnCallback(function (ModifyRequestEvent $event) use ($modifiedRequest): ModifyRequestEvent { + $event->setRequest($modifiedRequest); + return $event; + }); + + $body = $this->createMock(StreamInterface::class); + $body->method('getContents')->willReturn(''); + + $response = $this->createMock(ResponseInterface::class); + $response->method('getStatusCode')->willReturn(200); + $response->method('getBody')->willReturn($body); + + $this->client->expects(self::once()) + ->method('sendRequest') + ->with($modifiedRequest) + ->willReturn($response); + + $this->subject->getHtmlResponse($context); + } +} diff --git a/composer.json b/composer.json index 397b35f..a151442 100644 --- a/composer.json +++ b/composer.json @@ -21,11 +21,11 @@ "docs": "https://docs.typo3.org/p/maxserv/frontend_request/main/en-us/" }, "require": { - "php": "^8.1", + "php": "^8.2", "ext-json": "*", - "typo3/cms-backend": "^12.4.15 || ^13.4", - "typo3/cms-core": "^12.4.15 || ^13.4", - "typo3/cms-frontend": "^12.4.15 || ^13.4" + "typo3/cms-backend": "^12.4.25 || ^13.4 || ^14.1", + "typo3/cms-core": "^12.4.25 || ^13.4 || ^14.1", + "typo3/cms-frontend": "^12.4.25 || ^13.4 || ^14.1" }, "require-dev": { "composer/class-map-generator": "^1.3.4", @@ -34,7 +34,7 @@ "friendsofphp/php-cs-fixer": "^3.60.0", "php-parallel-lint/php-parallel-lint": "^1.4", "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.9", + "phpstan/phpstan": "^1.12", "phpunit/phpunit": "^10.1", "saschaegerer/phpstan-typo3": "^1.10", "typo3/coding-standards": "^0.7.1 || ^0.8.0", diff --git a/ext_emconf.php b/ext_emconf.php index 32c20f6..9a777fc 100644 --- a/ext_emconf.php +++ b/ext_emconf.php @@ -8,10 +8,10 @@ 'author_company' => 'MaxServ B.V.', 'author_email' => 'support@maxserv.com', 'state' => 'stable', - 'version' => '1.0.0', + 'version' => '2.0.0', 'constraints' => [ 'depends' => [ - 'typo3' => '12.4.0-13.4.99', + 'typo3' => '12.4.0-14.4.99', ], ], 'autoload' => [