From 7edd1b9d615b2989ce02855a84452ff3d9671416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Schu=CC=88tz?= Date: Mon, 7 Sep 2026 23:19:21 +0200 Subject: [PATCH] BUGFIX: Reject suggestions without a context node --- Classes/Controller/SuggestController.php | 7 + .../Unit/Controller/SuggestControllerTest.php | 134 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 Tests/Unit/Controller/SuggestControllerTest.php diff --git a/Classes/Controller/SuggestController.php b/Classes/Controller/SuggestController.php index 051ad0b..0343fd3 100644 --- a/Classes/Controller/SuggestController.php +++ b/Classes/Controller/SuggestController.php @@ -95,6 +95,13 @@ public function indexAction(string $term = '', string $contextNodeIdentifier = ' return; } + if ($contextNodeIdentifier === '') { + $this->response->setStatusCode(400); + $result['errors'] = ['contextNodeIdentifier must not be empty']; + $this->view->assign('value', $result); + return; + } + $requestJson = $this->buildRequestForTerm($term, $contextNodeIdentifier, $dimensionCombination); try { diff --git a/Tests/Unit/Controller/SuggestControllerTest.php b/Tests/Unit/Controller/SuggestControllerTest.php new file mode 100644 index 0000000..5d79901 --- /dev/null +++ b/Tests/Unit/Controller/SuggestControllerTest.php @@ -0,0 +1,134 @@ +getMockBuilder(SuggestController::class) + ->onlyMethods(['createContentContext'])->getMock(); + $controller->expects(self::never())->method('createContentContext'); + $client = $this->createMock(ElasticSearchClient::class); + $client->expects(self::never())->method('getIndex'); + $cache = $this->createMock(VariableFrontend::class); + $cache->expects(self::never())->method('has'); + $response = new ActionResponse(); + $view = $this->createMock(JsonView::class); + $view->expects(self::once())->method('assign')->with('value', [ + 'completions' => [], + 'suggestions' => [], + 'errors' => ['contextNodeIdentifier must not be empty'] + ]); + $this->inject($controller, 'elasticSearchClient', $client); + $this->inject($controller, 'elasticSearchQueryTemplateCache', $cache); + $this->inject($controller, 'response', $response); + $this->inject($controller, 'view', $view); + + $controller->indexAction(...$arguments); + + self::assertSame(400, $response->getStatusCode()); + } + + public function missingContextArguments(): array + { + return ['omitted' => [[]], 'empty' => [['test', '', '{}']]]; + } + + public function testNonexistentContextStillReportsMissingNode(): void + { + $controller = $this->getMockBuilder(SuggestController::class) + ->onlyMethods(['createContentContext'])->getMock(); + $context = $this->createMock(ContentContext::class); + $context->expects(self::once())->method('getNodeByIdentifier')->with('nonexistent')->willReturn(null); + $controller->expects(self::once())->method('createContentContext')->with('live', [])->willReturn($context); + $this->inject($controller, 'elasticSearchClient', $this->createMock(ElasticSearchClient::class)); + $this->inject($controller, 'elasticSearchQueryTemplateCache', $this->createMock(VariableFrontend::class)); + + $this->expectException(\Exception::class); + $this->expectExceptionCode(1634467679); + $this->expectExceptionMessage('The context node for search with identifier nonexistent could not be found'); + $controller->indexAction('test', 'nonexistent', '{}'); + } + + /** @dataProvider validContexts */ + public function testValidContextPreservesSuggestions(bool $warm, string $site, string $dimensions): void + { + $controller = $this->getMockBuilder(SuggestController::class) + ->onlyMethods(['createContentContext'])->getMock(); + $cache = $this->createMock(VariableFrontend::class); + $cache->expects(self::once())->method('has')->with($site . '-' . md5($dimensions))->willReturn($warm); + $template = '{"query":{"prefix":"---term-soh2gufuNi---"},"_source":["neos_path"]}'; + if ($warm) { + $controller->expects(self::never())->method('createContentContext'); + $cache->expects(self::once())->method('get')->with($site . '-' . md5($dimensions))->willReturn($template); + $cache->expects(self::never())->method('set'); + } else { + $node = $this->createMock(NodeInterface::class); + $context = $this->createMock(ContentContext::class); + $context->expects(self::once())->method('getNodeByIdentifier')->with($site)->willReturn($node); + $controller->expects(self::once())->method('createContentContext') + ->with('live', json_decode($dimensions, true))->willReturn($context); + $query = $this->createMock(QueryInterface::class); + $query->method('toArray')->willReturn(['query' => ['prefix' => '---term-soh2gufuNi---']]); + $builder = $this->createMock(ElasticSearchQueryBuilder::class); + $builder->expects(self::once())->method('query')->with($node)->willReturnSelf(); + $builder->method('queryFilter')->willReturnSelf(); + $builder->method('limit')->willReturnSelf(); + $builder->method('getRequest')->willReturn($query); + $this->inject($controller, 'elasticSearchQueryBuilder', $builder); + $cache->expects(self::once())->method('set'); + } + $index = $this->createMock(Index::class); + $searchResponse = $this->createMock(Response::class); + $searchResponse->method('getTreatedContent')->willReturn([ + 'aggregations' => ['autocomplete' => ['buckets' => [['key' => 'testing']]]], + 'suggest' => ['suggestions' => [['options' => [['_source' => ['neos_path' => '/sites/example/page']]]]]] + ]); + $index->expects(self::once())->method('request')->with( + 'POST', + '/_search', + [], + '{"query":{"prefix":"test"},"_source":["neos_path"]}' + )->willReturn($searchResponse); + $client = $this->createMock(ElasticSearchClient::class); + $client->method('getIndex')->willReturn($index); + $view = $this->createMock(JsonView::class); + $view->expects(self::once())->method('assign')->with('value', [ + 'completions' => ['testing'], 'suggestions' => [['neos_path' => '/sites/example/page']] + ]); + $response = new ActionResponse(); + $this->inject($controller, 'elasticSearchClient', $client); + $this->inject($controller, 'elasticSearchQueryTemplateCache', $cache); + $this->inject($controller, 'response', $response); + $this->inject($controller, 'view', $view); + + $controller->indexAction('TEST second', $site, $dimensions); + + self::assertSame(200, $response->getStatusCode()); + } + + public function validContexts(): array + { + return [ + 'cold first site de' => [false, 'site-a', '{"language":["de"]}'], + 'warm first site de' => [true, 'site-a', '{"language":["de"]}'], + 'cold second site en' => [false, 'site-b', '{"language":["en"]}'], + 'warm second site en' => [true, 'site-b', '{"language":["en"]}'], + ]; + } +}