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