1a0393e546f6b2d36c1369615790b9bd860e2b5e
[lhc/web/wiklou.git] / tests / phpunit / includes / search / SearchEngineTest.php
1 <?php
2
3 use Wikimedia\Rdbms\LoadBalancerSingle;
4
5 /**
6 * @group Search
7 * @group Database
8 *
9 * @covers SearchEngine<extended>
10 * @note Coverage will only ever show one of on of the Search* classes
11 */
12 class SearchEngineTest extends MediaWikiLangTestCase {
13
14 /**
15 * @var SearchEngine
16 */
17 protected $search;
18
19 /**
20 * Checks for database type & version.
21 * Will skip current test if DB does not support search.
22 */
23 protected function setUp() {
24 parent::setUp();
25
26 // Search tests require MySQL or SQLite with FTS
27 $dbType = $this->db->getType();
28 $dbSupported = ( $dbType === 'mysql' )
29 || ( $dbType === 'sqlite' && $this->db->getFulltextSearchModule() == 'FTS3' );
30
31 if ( !$dbSupported ) {
32 $this->markTestSkipped( "MySQL or SQLite with FTS3 only" );
33 }
34
35 $searchType = SearchEngineFactory::getSearchEngineClass( $this->db );
36 $this->setMwGlobals( [
37 'wgSearchType' => $searchType,
38 'wgCapitalLinks' => true,
39 'wgCapitalLinkOverrides' => [
40 NS_CATEGORY => false // for testCompletionSearchMustRespectCapitalLinkOverrides
41 ]
42 ] );
43
44 $lb = LoadBalancerSingle::newFromConnection( $this->db );
45 $this->search = new $searchType( $lb );
46 }
47
48 protected function tearDown() {
49 unset( $this->search );
50
51 parent::tearDown();
52 }
53
54 public function addDBDataOnce() {
55 if ( !$this->isWikitextNS( NS_MAIN ) ) {
56 // @todo cover the case of non-wikitext content in the main namespace
57 return;
58 }
59
60 // Reset the search type back to default - some extensions may have
61 // overridden it.
62 $this->setMwGlobals( [
63 'wgSearchType' => null,
64 'wgCapitalLinks' => true,
65 'wgCapitalLinkOverrides' => [
66 NS_CATEGORY => false // for testCompletionSearchMustRespectCapitalLinkOverrides
67 ]
68 ] );
69
70 $this->insertPage( 'Not_Main_Page', 'This is not a main page' );
71 $this->insertPage(
72 'Talk:Not_Main_Page',
73 'This is not a talk page to the main page, see [[smithee]]'
74 );
75 $this->insertPage( 'Smithee', 'A smithee is one who smiths. See also [[Alan Smithee]]' );
76 $this->insertPage( 'Talk:Smithee', 'This article sucks.' );
77 $this->insertPage( 'Unrelated_page', 'Nothing in this page is about the S word.' );
78 $this->insertPage( 'Another_page', 'This page also is unrelated.' );
79 $this->insertPage( 'Help:Help', 'Help me!' );
80 $this->insertPage( 'Thppt', 'Blah blah' );
81 $this->insertPage( 'Alan_Smithee', 'yum' );
82 $this->insertPage( 'Pages', 'are\'food' );
83 $this->insertPage( 'HalfOneUp', 'AZ' );
84 $this->insertPage( 'FullOneUp', 'AZ' );
85 $this->insertPage( 'HalfTwoLow', 'az' );
86 $this->insertPage( 'FullTwoLow', 'az' );
87 $this->insertPage( 'HalfNumbers', '1234567890' );
88 $this->insertPage( 'FullNumbers', '1234567890' );
89 $this->insertPage( 'DomainName', 'example.com' );
90 $this->insertPage( 'DomainName', 'example.com' );
91 $this->insertPage( 'Category:search is not Search', '' );
92 $this->insertPage( 'Category:Search is not search', '' );
93 }
94
95 protected function fetchIds( $results ) {
96 if ( !$this->isWikitextNS( NS_MAIN ) ) {
97 $this->markTestIncomplete( __CLASS__ . " does no yet support non-wikitext content "
98 . "in the main namespace" );
99 }
100 $this->assertTrue( is_object( $results ) );
101
102 $matches = [];
103 foreach ( $results as $row ) {
104 $matches[] = $row->getTitle()->getPrefixedText();
105 }
106 $results->free();
107 # Search is not guaranteed to return results in a certain order;
108 # sort them numerically so we will compare simply that we received
109 # the expected matches.
110 sort( $matches );
111
112 return $matches;
113 }
114
115 public function testFullWidth() {
116 $this->assertEquals(
117 [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
118 $this->fetchIds( $this->search->searchText( 'AZ' ) ),
119 "Search for normalized from Half-width Upper" );
120 $this->assertEquals(
121 [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
122 $this->fetchIds( $this->search->searchText( 'az' ) ),
123 "Search for normalized from Half-width Lower" );
124 $this->assertEquals(
125 [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
126 $this->fetchIds( $this->search->searchText( 'AZ' ) ),
127 "Search for normalized from Full-width Upper" );
128 $this->assertEquals(
129 [ 'FullOneUp', 'FullTwoLow', 'HalfOneUp', 'HalfTwoLow' ],
130 $this->fetchIds( $this->search->searchText( 'az' ) ),
131 "Search for normalized from Full-width Lower" );
132 }
133
134 public function testTextSearch() {
135 $this->assertEquals(
136 [ 'Smithee' ],
137 $this->fetchIds( $this->search->searchText( 'smithee' ) ),
138 "Plain search" );
139 }
140
141 public function testWildcardSearch() {
142 $res = $this->search->searchText( 'smith*' );
143 $this->assertEquals(
144 [ 'Smithee' ],
145 $this->fetchIds( $res ),
146 "Search with wildcards" );
147
148 $res = $this->search->searchText( 'smithson*' );
149 $this->assertEquals(
150 [],
151 $this->fetchIds( $res ),
152 "Search with wildcards must not find unrelated articles" );
153
154 $res = $this->search->searchText( 'smith* smithee' );
155 $this->assertEquals(
156 [ 'Smithee' ],
157 $this->fetchIds( $res ),
158 "Search with wildcards can be combined with simple terms" );
159
160 $res = $this->search->searchText( 'smith* "one who smiths"' );
161 $this->assertEquals(
162 [ 'Smithee' ],
163 $this->fetchIds( $res ),
164 "Search with wildcards can be combined with phrase search" );
165 }
166
167 public function testPhraseSearch() {
168 $res = $this->search->searchText( '"smithee is one who smiths"' );
169 $this->assertEquals(
170 [ 'Smithee' ],
171 $this->fetchIds( $res ),
172 "Search a phrase" );
173
174 $res = $this->search->searchText( '"smithee is who smiths"' );
175 $this->assertEquals(
176 [],
177 $this->fetchIds( $res ),
178 "Phrase search is not sloppy, search terms must be adjacent" );
179
180 $res = $this->search->searchText( '"is smithee one who smiths"' );
181 $this->assertEquals(
182 [],
183 $this->fetchIds( $res ),
184 "Phrase search is ordered" );
185 }
186
187 public function testPhraseSearchHighlight() {
188 $phrase = "smithee is one who smiths";
189 $res = $this->search->searchText( "\"$phrase\"" );
190 $match = $res->getIterator()->current();
191 $snippet = "A <span class='searchmatch'>" . $phrase . "</span>";
192 $this->assertStringStartsWith( $snippet,
193 $match->getTextSnippet( $res->termMatches() ),
194 "Highlight a phrase search" );
195 }
196
197 public function testTextPowerSearch() {
198 $this->search->setNamespaces( [ 0, 1, 4 ] );
199 $this->assertEquals(
200 [
201 'Smithee',
202 'Talk:Not Main Page',
203 ],
204 $this->fetchIds( $this->search->searchText( 'smithee' ) ),
205 "Power search" );
206 }
207
208 public function testTitleSearch() {
209 $this->assertEquals(
210 [
211 'Alan Smithee',
212 'Smithee',
213 ],
214 $this->fetchIds( $this->search->searchTitle( 'smithee' ) ),
215 "Title search" );
216 }
217
218 public function testTextTitlePowerSearch() {
219 $this->search->setNamespaces( [ 0, 1, 4 ] );
220 $this->assertEquals(
221 [
222 'Alan Smithee',
223 'Smithee',
224 'Talk:Smithee',
225 ],
226 $this->fetchIds( $this->search->searchTitle( 'smithee' ) ),
227 "Title power search" );
228 }
229
230 public function provideCompletionSearchMustRespectCapitalLinkOverrides() {
231 return [
232 'Searching for "smithee" finds Smithee on NS_MAIN' => [
233 'smithee',
234 'Smithee',
235 [ NS_MAIN ],
236 ],
237 'Searching for "search is" will finds "search is not Search" on NS_CATEGORY' => [
238 'search is',
239 'Category:search is not Search',
240 [ NS_CATEGORY ],
241 ],
242 'Searching for "Search is" will finds "search is not Search" on NS_CATEGORY' => [
243 'Search is',
244 'Category:Search is not search',
245 [ NS_CATEGORY ],
246 ],
247 ];
248 }
249
250 /**
251 * Test that the search query is not munged using wrong CapitalLinks setup
252 * (in other test that the default search backend can benefit from wgCapitalLinksOverride)
253 * Guard against regressions like T208255
254 * @dataProvider provideCompletionSearchMustRespectCapitalLinkOverrides
255 * @covers SearchEngine::completionSearch
256 * @covers PrefixSearch::defaultSearchBackend
257 * @param string $search
258 * @param string $expectedSuggestion
259 * @param int[] $namespaces
260 */
261 public function testCompletionSearchMustRespectCapitalLinkOverrides(
262 $search,
263 $expectedSuggestion,
264 array $namespaces
265 ) {
266 $this->search->setNamespaces( $namespaces );
267 $results = $this->search->completionSearch( $search );
268 $this->assertEquals( 1, $results->getSize() );
269 $this->assertEquals( $expectedSuggestion, $results->getSuggestions()[0]->getText() );
270 }
271
272 /**
273 * @covers SearchEngine::getSearchIndexFields
274 */
275 public function testSearchIndexFields() {
276 /**
277 * @var SearchEngine $mockEngine
278 */
279 $mockEngine = $this->getMockBuilder( SearchEngine::class )
280 ->setMethods( [ 'makeSearchFieldMapping' ] )->getMock();
281
282 $mockFieldBuilder = function ( $name, $type ) {
283 $mockField =
284 $this->getMockBuilder( SearchIndexFieldDefinition::class )->setConstructorArgs( [
285 $name,
286 $type
287 ] )->getMock();
288
289 $mockField->expects( $this->any() )->method( 'getMapping' )->willReturn( [
290 'testData' => 'test',
291 'name' => $name,
292 'type' => $type,
293 ] );
294
295 $mockField->expects( $this->any() )
296 ->method( 'merge' )
297 ->willReturn( $mockField );
298
299 return $mockField;
300 };
301
302 $mockEngine->expects( $this->atLeastOnce() )
303 ->method( 'makeSearchFieldMapping' )
304 ->willReturnCallback( $mockFieldBuilder );
305
306 // Not using mock since PHPUnit mocks do not work properly with references in params
307 $this->setTemporaryHook( 'SearchIndexFields',
308 function ( &$fields, SearchEngine $engine ) use ( $mockFieldBuilder ) {
309 $fields['testField'] =
310 $mockFieldBuilder( "testField", SearchIndexField::INDEX_TYPE_TEXT );
311 return true;
312 } );
313
314 $fields = $mockEngine->getSearchIndexFields();
315 $this->assertArrayHasKey( 'language', $fields );
316 $this->assertArrayHasKey( 'category', $fields );
317 $this->assertInstanceOf( SearchIndexField::class, $fields['testField'] );
318
319 $mapping = $fields['testField']->getMapping( $mockEngine );
320 $this->assertArrayHasKey( 'testData', $mapping );
321 $this->assertEquals( 'test', $mapping['testData'] );
322 }
323
324 public function hookSearchIndexFields( $mockFieldBuilder, &$fields, SearchEngine $engine ) {
325 $fields['testField'] = $mockFieldBuilder( "testField", SearchIndexField::INDEX_TYPE_TEXT );
326 return true;
327 }
328
329 public function testAugmentorSearch() {
330 $this->search->setNamespaces( [ 0, 1, 4 ] );
331 $resultSet = $this->search->searchText( 'smithee' );
332 // Not using mock since PHPUnit mocks do not work properly with references in params
333 $this->mergeMwGlobalArrayValue( 'wgHooks',
334 [ 'SearchResultsAugment' => [ [ $this, 'addAugmentors' ] ] ] );
335 $this->search->augmentSearchResults( $resultSet );
336 foreach ( $resultSet as $result ) {
337 $id = $result->getTitle()->getArticleID();
338 $augmentData = "Result:$id:" . $result->getTitle()->getText();
339 $augmentData2 = "Result2:$id:" . $result->getTitle()->getText();
340 $this->assertEquals( [ 'testSet' => $augmentData, 'testRow' => $augmentData2 ],
341 $result->getExtensionData() );
342 }
343 }
344
345 public function addAugmentors( &$setAugmentors, &$rowAugmentors ) {
346 $setAugmentor = $this->createMock( ResultSetAugmentor::class );
347 $setAugmentor->expects( $this->once() )
348 ->method( 'augmentAll' )
349 ->willReturnCallback( function ( SearchResultSet $resultSet ) {
350 $data = [];
351 foreach ( $resultSet as $result ) {
352 $id = $result->getTitle()->getArticleID();
353 $data[$id] = "Result:$id:" . $result->getTitle()->getText();
354 }
355 return $data;
356 } );
357 $setAugmentors['testSet'] = $setAugmentor;
358
359 $rowAugmentor = $this->createMock( ResultAugmentor::class );
360 $rowAugmentor->expects( $this->exactly( 2 ) )
361 ->method( 'augment' )
362 ->willReturnCallback( function ( SearchResult $result ) {
363 $id = $result->getTitle()->getArticleID();
364 return "Result2:$id:" . $result->getTitle()->getText();
365 } );
366 $rowAugmentors['testRow'] = $rowAugmentor;
367 }
368
369 public function testFiltersMissing() {
370 $availableResults = [];
371 foreach ( range( 0, 11 ) as $i ) {
372 $title = "Search_Result_$i";
373 $availableResults[] = $title;
374 // pages not created must be filtered
375 if ( $i % 2 == 0 ) {
376 $this->editSearchResultPage( $title );
377 }
378 }
379 MockCompletionSearchEngine::addMockResults( 'foo', $availableResults );
380
381 $engine = new MockCompletionSearchEngine();
382 $engine->setLimitOffset( 10, 0 );
383 $results = $engine->completionSearch( 'foo' );
384 $this->assertEquals( 5, $results->getSize() );
385 $this->assertTrue( $results->hasMoreResults() );
386
387 $engine->setLimitOffset( 10, 10 );
388 $results = $engine->completionSearch( 'foo' );
389 $this->assertEquals( 1, $results->getSize() );
390 $this->assertFalse( $results->hasMoreResults() );
391 }
392
393 private function editSearchResultPage( $title ) {
394 $page = WikiPage::factory( Title::newFromText( $title ) );
395 $page->doEditContent(
396 new WikitextContent( 'UTContent' ),
397 'UTPageSummary',
398 EDIT_NEW | EDIT_SUPPRESS_RC
399 );
400 }
401
402 public function provideDataForParseNamespacePrefix() {
403 return [
404 'noop' => [
405 [
406 'query' => 'foo',
407 ],
408 false
409 ],
410 'empty' => [
411 [
412 'query' => '',
413 ],
414 false,
415 ],
416 'namespace prefix' => [
417 [
418 'query' => 'help:test',
419 ],
420 [ 'test', [ NS_HELP ] ],
421 ],
422 'accented namespace prefix with hook' => [
423 [
424 'query' => 'hélp:test',
425 'withHook' => true,
426 ],
427 [ 'test', [ NS_HELP ] ],
428 ],
429 'accented namespace prefix without hook' => [
430 [
431 'query' => 'hélp:test',
432 'withHook' => false,
433 ],
434 false,
435 ],
436 'all with all keyword allowed' => [
437 [
438 'query' => 'all:test',
439 'withAll' => true,
440 ],
441 [ 'test', null ],
442 ],
443 'all with all keyword disallowed' => [
444 [
445 'query' => 'all:test',
446 'withAll' => false,
447 ],
448 false
449 ],
450 'ns only' => [
451 [
452 'query' => 'help:',
453 ],
454 [ '', [ NS_HELP ] ]
455 ],
456 'all only' => [
457 [
458 'query' => 'all:',
459 'withAll' => true,
460 ],
461 [ '', null ]
462 ],
463 'all wins over namespace when first' => [
464 [
465 'query' => 'all:help:test',
466 'withAll' => true,
467 ],
468 [ 'help:test', null ]
469 ],
470 'ns wins over all when first' => [
471 [
472 'query' => 'help:all:test',
473 'withAll' => true,
474 ],
475 [ 'all:test', [ NS_HELP ] ]
476 ],
477 ];
478 }
479
480 /**
481 * @dataProvider provideDataForParseNamespacePrefix
482 * @param array $params
483 * @param array|false $expected
484 * @throws FatalError
485 * @throws MWException
486 */
487 public function testParseNamespacePrefix( array $params, $expected ) {
488 $this->setTemporaryHook( 'PrefixSearchExtractNamespace', function ( &$namespaces, &$query ) {
489 if ( strpos( $query, 'hélp:' ) === 0 ) {
490 $namespaces = [ NS_HELP ];
491 $query = substr( $query, strlen( 'hélp:' ) );
492 }
493 return false;
494 } );
495 $testSet = [];
496 if ( isset( $params['withAll'] ) && isset( $params['withHook'] ) ) {
497 $testSet[] = $params;
498 } elseif ( isset( $params['withAll'] ) ) {
499 $testSet[] = $params + [ 'withHook' => true ];
500 $testSet[] = $params + [ 'withHook' => false ];
501 } elseif ( isset( $params['withHook'] ) ) {
502 $testSet[] = $params + [ 'withAll' => true ];
503 $testSet[] = $params + [ 'withAll' => false ];
504 } else {
505 $testSet[] = $params + [ 'withAll' => true, 'withHook' => true ];
506 $testSet[] = $params + [ 'withAll' => true, 'withHook' => false ];
507 $testSet[] = $params + [ 'withAll' => false, 'withHook' => false ];
508 $testSet[] = $params + [ 'withAll' => true, 'withHook' => false ];
509 }
510
511 foreach ( $testSet as $test ) {
512 $actual = SearchEngine::parseNamespacePrefixes( $test['query'],
513 $test['withAll'], $test['withHook'] );
514 $this->assertEquals( $expected, $actual, 'with params: ' . print_r( $test, true ) );
515 }
516 }
517 }