1<?php 2 3use MediaWiki\Linker\LinkTarget; 4use MediaWiki\MediaWikiServices; 5use MediaWiki\Page\PageIdentity; 6use MediaWiki\Page\PageReference; 7use MediaWiki\Tests\Unit\DummyServicesTrait; 8use Wikimedia\TestingAccessWrapper; 9 10/** 11 * @group API 12 * @group medium 13 * @group Database 14 * @covers ApiPageSet 15 */ 16class ApiPageSetTest extends ApiTestCase { 17 use DummyServicesTrait; 18 19 public static function provideRedirectMergePolicy() { 20 return [ 21 'By default nothing is merged' => [ 22 null, 23 [] 24 ], 25 26 'A simple merge policy adds the redirect data in' => [ 27 static function ( $current, $new ) { 28 if ( !isset( $current['index'] ) || $new['index'] < $current['index'] ) { 29 $current['index'] = $new['index']; 30 } 31 return $current; 32 }, 33 [ 'index' => 1 ], 34 ], 35 ]; 36 } 37 38 /** 39 * @dataProvider provideRedirectMergePolicy 40 */ 41 public function testRedirectMergePolicyWithArrayResult( $mergePolicy, $expect ) { 42 list( $target, $pageSet ) = $this->createPageSetWithRedirect(); 43 $pageSet->setRedirectMergePolicy( $mergePolicy ); 44 $result = [ 45 $target->getArticleID() => [] 46 ]; 47 $pageSet->populateGeneratorData( $result ); 48 $this->assertEquals( $expect, $result[$target->getArticleID()] ); 49 } 50 51 /** 52 * @dataProvider provideRedirectMergePolicy 53 */ 54 public function testRedirectMergePolicyWithApiResult( $mergePolicy, $expect ) { 55 list( $target, $pageSet ) = $this->createPageSetWithRedirect(); 56 $pageSet->setRedirectMergePolicy( $mergePolicy ); 57 $result = new ApiResult( false ); 58 $result->addValue( null, 'pages', [ 59 $target->getArticleID() => [] 60 ] ); 61 $pageSet->populateGeneratorData( $result, [ 'pages' ] ); 62 $this->assertEquals( 63 $expect, 64 $result->getResultData( [ 'pages', $target->getArticleID() ] ) 65 ); 66 } 67 68 private function newApiPageSet( $reqParams = [] ) { 69 $request = new FauxRequest( $reqParams ); 70 $context = new RequestContext(); 71 $context->setRequest( $request ); 72 73 $main = new ApiMain( $context ); 74 $pageSet = new ApiPageSet( $main ); 75 76 return $pageSet; 77 } 78 79 protected function createPageSetWithRedirect( $targetContent = 'api page set test' ) { 80 $target = Title::makeTitle( NS_MAIN, 'UTRedirectTarget' ); 81 $sourceA = Title::makeTitle( NS_MAIN, 'UTRedirectSourceA' ); 82 $sourceB = Title::makeTitle( NS_MAIN, 'UTRedirectSourceB' ); 83 $this->editPage( 'UTRedirectTarget', $targetContent ); 84 $this->editPage( 'UTRedirectSourceA', '#REDIRECT [[UTRedirectTarget]]' ); 85 $this->editPage( 'UTRedirectSourceB', '#REDIRECT [[UTRedirectTarget]]' ); 86 87 $pageSet = $this->newApiPageSet( [ 'redirects' => 1 ] ); 88 89 $pageSet->setGeneratorData( $sourceA, [ 'index' => 1 ] ); 90 $pageSet->setGeneratorData( $sourceB, [ 'index' => 3 ] ); 91 $pageSet->populateFromTitles( [ $sourceA, $sourceB ] ); 92 93 return [ $target, $pageSet ]; 94 } 95 96 public function testRedirectMergePolicyRedirectLoop() { 97 $loopA = Title::makeTitle( NS_MAIN, 'UTPageRedirectOne' ); 98 $loopB = Title::makeTitle( NS_MAIN, 'UTPageRedirectTwo' ); 99 $this->editPage( 'UTPageRedirectOne', '#REDIRECT [[UTPageRedirectTwo]]' ); 100 $this->editPage( 'UTPageRedirectTwo', '#REDIRECT [[UTPageRedirectOne]]' ); 101 list( $target, $pageSet ) = $this->createPageSetWithRedirect( 102 '#REDIRECT [[UTPageRedirectOne]]' 103 ); 104 $pageSet->setRedirectMergePolicy( static function ( $cur, $new ) { 105 throw new \RuntimeException( 'unreachable, no merge when target is redirect loop' ); 106 } ); 107 // This could infinite loop in a bugged impl, but php doesn't offer 108 // a great way to time constrain this. 109 $result = new ApiResult( false ); 110 $pageSet->populateGeneratorData( $result ); 111 // Assert something, mostly we care that the above didn't infinite loop. 112 // This verifies the page set followed our redirect chain and saw the loop. 113 $this->assertEqualsCanonicalizing( 114 [ 115 'UTRedirectSourceA', 'UTRedirectSourceB', 'UTRedirectTarget', 116 'UTPageRedirectOne', 'UTPageRedirectTwo', 117 ], 118 array_map( static function ( $x ) { 119 return $x->getPrefixedText(); 120 }, $pageSet->getTitles() ) 121 ); 122 } 123 124 public function testHandleNormalization() { 125 $pageSet = $this->newApiPageSet( [ 'titles' => "a|B|a\xcc\x8a" ] ); 126 $pageSet->execute(); 127 128 $this->assertSame( 129 [ 0 => [ 'A' => -1, 'B' => -2, 'Å' => -3 ] ], 130 $pageSet->getAllTitlesByNamespace() 131 ); 132 $this->assertSame( 133 [ 134 [ 'fromencoded' => true, 'from' => 'a%CC%8A', 'to' => 'å' ], 135 [ 'fromencoded' => false, 'from' => 'a', 'to' => 'A' ], 136 [ 'fromencoded' => false, 'from' => 'å', 'to' => 'Å' ], 137 ], 138 $pageSet->getNormalizedTitlesAsResult() 139 ); 140 } 141 142 public function testSpecialRedirects() { 143 $id1 = $this->editPage( 'UTApiPageSet', 'UTApiPageSet in the default language' ) 144 ->value['revision-record']->getPageId(); 145 $id2 = $this->editPage( 'UTApiPageSet/de', 'UTApiPageSet in German' ) 146 ->value['revision-record']->getPageId(); 147 148 $user = $this->getTestUser()->getUser(); 149 $userName = $user->getName(); 150 $userDbkey = str_replace( ' ', '_', $userName ); 151 $request = new FauxRequest( [ 152 'titles' => implode( '|', [ 153 'Special:MyContributions', 154 'Special:MyPage', 155 'Special:MyTalk/subpage', 156 'Special:MyLanguage/UTApiPageSet', 157 ] ), 158 ] ); 159 $context = new RequestContext(); 160 $context->setRequest( $request ); 161 $context->setUser( $user ); 162 163 $main = new ApiMain( $context ); 164 $pageSet = new ApiPageSet( $main ); 165 $pageSet->execute(); 166 167 $this->assertEquals( [ 168 ], $pageSet->getRedirectTitlesAsResult() ); 169 $this->assertEquals( [ 170 [ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ], 171 [ 'ns' => -1, 'title' => 'Special:MyPage', 'special' => true ], 172 [ 'ns' => -1, 'title' => 'Special:MyTalk/subpage', 'special' => true ], 173 [ 'ns' => -1, 'title' => 'Special:MyLanguage/UTApiPageSet', 'special' => true ], 174 ], $pageSet->getInvalidTitlesAndRevisions() ); 175 $this->assertEquals( [ 176 ], $pageSet->getAllTitlesByNamespace() ); 177 178 $request->setVal( 'redirects', 1 ); 179 $main = new ApiMain( $context ); 180 $pageSet = new ApiPageSet( $main ); 181 $pageSet->execute(); 182 183 $this->assertEquals( [ 184 [ 'from' => 'Special:MyPage', 'to' => "User:$userName" ], 185 [ 'from' => 'Special:MyTalk/subpage', 'to' => "User talk:$userName/subpage" ], 186 [ 'from' => 'Special:MyLanguage/UTApiPageSet', 'to' => 'UTApiPageSet' ], 187 ], $pageSet->getRedirectTitlesAsResult() ); 188 $this->assertEquals( [ 189 [ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ], 190 [ 'ns' => 2, 'title' => "User:$userName", 'missing' => true ], 191 [ 'ns' => 3, 'title' => "User talk:$userName/subpage", 'missing' => true ], 192 ], $pageSet->getInvalidTitlesAndRevisions() ); 193 $this->assertEquals( [ 194 0 => [ 'UTApiPageSet' => $id1 ], 195 2 => [ $userDbkey => -2 ], 196 3 => [ "$userDbkey/subpage" => -3 ], 197 ], $pageSet->getAllTitlesByNamespace() ); 198 199 $context->setLanguage( 'de' ); 200 $main = new ApiMain( $context ); 201 $pageSet = new ApiPageSet( $main ); 202 $pageSet->execute(); 203 204 $this->assertEquals( [ 205 [ 'from' => 'Special:MyPage', 'to' => "User:$userName" ], 206 [ 'from' => 'Special:MyTalk/subpage', 'to' => "User talk:$userName/subpage" ], 207 [ 'from' => 'Special:MyLanguage/UTApiPageSet', 'to' => 'UTApiPageSet/de' ], 208 ], $pageSet->getRedirectTitlesAsResult() ); 209 $this->assertEquals( [ 210 [ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ], 211 [ 'ns' => 2, 'title' => "User:$userName", 'missing' => true ], 212 [ 'ns' => 3, 'title' => "User talk:$userName/subpage", 'missing' => true ], 213 ], $pageSet->getInvalidTitlesAndRevisions() ); 214 $this->assertEquals( [ 215 0 => [ 'UTApiPageSet/de' => $id2 ], 216 2 => [ $userDbkey => -2 ], 217 3 => [ "$userDbkey/subpage" => -3 ], 218 ], $pageSet->getAllTitlesByNamespace() ); 219 } 220 221 /** 222 * Test that ApiPageSet is calling GenderCache for provided user names to prefill the 223 * GenderCache and avoid a performance issue when loading each users' gender on it's own. 224 * The test is setting the "missLimit" to 0 on the GenderCache to trigger misses logic. 225 * When the "misses" property is no longer 0 at the end of the test, 226 * something was requested which is not part of the cache. Than the test is failing. 227 */ 228 public function testGenderCaching() { 229 // Set up the user namespace to have gender aliases to trigger the gender cache 230 $this->setMwGlobals( [ 231 'wgExtraGenderNamespaces' => [ NS_USER => [ 'male' => 'Male', 'female' => 'Female' ] ] 232 ] ); 233 $this->overrideMwServices(); 234 235 // User names to test with - it is not needed that the user exists in the database 236 // to trigger gender cache 237 $userNames = [ 238 'Female', 239 'Unknown', 240 'Male', 241 ]; 242 243 // Prepare the gender cache for testing - this is a fresh instance due to service override 244 $genderCache = TestingAccessWrapper::newFromObject( 245 MediaWikiServices::getInstance()->getGenderCache() 246 ); 247 $genderCache->missLimit = 0; 248 249 // Do an api request to trigger ApiPageSet code 250 $this->doApiRequest( [ 251 'action' => 'query', 252 'titles' => 'User:' . implode( '|User:', $userNames ), 253 ] ); 254 255 $this->assertSame( 0, $genderCache->misses, 256 'ApiPageSet does not prefill the gender cache correctly' ); 257 $this->assertEquals( $userNames, array_keys( $genderCache->cache ), 258 'ApiPageSet does not prefill all users into the gender cache' ); 259 } 260 261 public function testPopulateFromTitles() { 262 $interwikiLookup = $this->getDummyInterwikiLookup( [ 'acme' ] ); 263 $this->setService( 'InterwikiLookup', $interwikiLookup ); 264 265 $this->getExistingTestPage( 'ApiPageSetTest_existing' )->getTitle(); 266 $this->getExistingTestPage( 'ApiPageSetTest_redirect_target' )->getTitle(); 267 $this->getNonexistingTestPage( 'ApiPageSetTest_missing' )->getTitle(); 268 $redirectTitle = $this->getExistingTestPage( 'ApiPageSetTest_redirect' )->getTitle(); 269 $this->editPage( $redirectTitle, '#REDIRECT [[ApiPageSetTest_redirect_target]]' ); 270 271 $input = [ 272 'existing' => 'ApiPageSetTest_existing', 273 'missing' => 'ApiPageSetTest_missing', 274 'invalid' => 'ApiPageSetTest|invalid', 275 'redirect' => 'ApiPageSetTest_redirect', 276 'special' => 'Special:BlankPage', 277 'interwiki' => 'acme:ApiPageSetTest', 278 ]; 279 280 $pageSet = $this->newApiPageSet( [ 'redirects' => 1 ] ); 281 $pageSet->populateFromTitles( $input ); 282 283 $expectedPages = [ 284 new TitleValue( NS_MAIN, 'ApiPageSetTest_existing' ), 285 new TitleValue( NS_MAIN, 'ApiPageSetTest_redirect' ), 286 new TitleValue( NS_MAIN, 'ApiPageSetTest_missing' ), 287 288 // the redirect page and the target are included! 289 new TitleValue( NS_MAIN, 'ApiPageSetTest_redirect_target' ), 290 ]; 291 $this->assertLinkTargets( Title::class, $expectedPages, $pageSet->getTitles() ); 292 $this->assertLinkTargets( PageIdentity::class, $expectedPages, $pageSet->getPages() ); 293 294 $expectedGood = [ 295 new TitleValue( NS_MAIN, 'ApiPageSetTest_existing' ), 296 new TitleValue( NS_MAIN, 'ApiPageSetTest_redirect_target' ) 297 ]; 298 $this->assertLinkTargets( Title::class, $expectedGood, $pageSet->getGoodTitles() ); 299 $this->assertLinkTargets( PageIdentity::class, $expectedGood, $pageSet->getGoodPages() ); 300 301 $expectedMissing = [ new TitleValue( NS_MAIN, 'ApiPageSetTest_missing' ) ]; 302 $this->assertLinkTargets( 303 Title::class, 304 $expectedMissing, 305 $pageSet->getMissingTitles() 306 ); 307 $this->assertLinkTargets( 308 PageIdentity::class, 309 $expectedMissing, 310 $pageSet->getMissingPages() 311 ); 312 $this->assertSame( 313 [ NS_MAIN => [ 'ApiPageSetTest_missing' => -3 ] ], 314 $pageSet->getMissingTitlesByNamespace() 315 ); 316 317 $expectedGoodAndMissing = array_merge( $expectedGood, $expectedMissing ); 318 $this->assertLinkTargets( 319 Title::class, 320 $expectedGoodAndMissing, 321 $pageSet->getGoodAndMissingTitles() 322 ); 323 $this->assertLinkTargets( 324 PageIdentity::class, 325 $expectedGoodAndMissing, 326 $pageSet->getGoodAndMissingPages() 327 ); 328 329 $expectedSpecial = [ new TitleValue( NS_SPECIAL, 'BlankPage' ) ]; 330 $this->assertLinkTargets( Title::class, $expectedSpecial, $pageSet->getSpecialTitles() ); 331 $this->assertLinkTargets( PageReference::class, $expectedSpecial, $pageSet->getSpecialPages() ); 332 333 $expectedRedirects = [ 334 'ApiPageSetTest redirect' => new TitleValue( 335 NS_MAIN, 'ApiPageSetTest_redirect_target' 336 ) 337 ]; 338 $this->assertLinkTargets( Title::class, $expectedRedirects, $pageSet->getRedirectTitles() ); 339 $this->assertLinkTargets( LinkTarget::class, $expectedRedirects, $pageSet->getRedirectTargets() ); 340 341 $this->assertSame( [ 'acme:ApiPageSetTest' => 'acme' ], $pageSet->getInterwikiTitles() ); 342 $this->assertSame( 343 [ [ 'title' => 'acme:ApiPageSetTest', 'iw' => 'acme' ] ], 344 $pageSet->getInterwikiTitlesAsResult() 345 ); 346 347 $this->assertSame( 348 [ -1 => [ 349 'title' => 'ApiPageSetTest|invalid', 350 'invalidreason' => 'The requested page title contains invalid characters: "|".' 351 ] ], 352 $pageSet->getInvalidTitlesAndReasons() 353 ); 354 } 355 356 /** 357 * @param string $type 358 * @param LinkTarget[] $expected 359 * @param LinkTarget[]|PageReference[] $actual 360 */ 361 private function assertLinkTargets( $type, $expected, $actual ) { 362 reset( $actual ); 363 foreach ( $expected as $expKey => $exp ) { 364 $act = current( $actual ); 365 $this->assertNotFalse( $act, 'missing entry at key $expKey: ' . $exp ); 366 367 $actKey = key( $actual ); 368 next( $actual ); 369 370 if ( !is_int( $expKey ) ) { 371 $this->assertSame( $expKey, $actKey ); 372 } 373 $this->assertSame( $exp->getNamespace(), $act->getNamespace() ); 374 $this->assertSame( $exp->getDBkey(), $act->getDBkey() ); 375 376 $this->assertInstanceOf( $type, $act ); 377 378 if ( $actual instanceof LinkTarget ) { 379 $this->assertSame( $exp->getFragment(), $act->getFragment() ); 380 $this->assertSame( $exp->getInterwiki(), $act->getInterwiki() ); 381 } 382 } 383 384 $act = current( $actual ); 385 $this->assertFalse( $act, 'extra entry: ' . $act ); 386 } 387} 388