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