1<?php
2
3use MediaWiki\MediaWikiServices;
4use Wikimedia\TestingAccessWrapper;
5
6/**
7 * @group API
8 * @group medium
9 * @group Database
10 * @covers ApiPageSet
11 */
12class ApiPageSetTest extends ApiTestCase {
13	public static function provideRedirectMergePolicy() {
14		return [
15			'By default nothing is merged' => [
16				null,
17				[]
18			],
19
20			'A simple merge policy adds the redirect data in' => [
21				static function ( $current, $new ) {
22					if ( !isset( $current['index'] ) || $new['index'] < $current['index'] ) {
23						$current['index'] = $new['index'];
24					}
25					return $current;
26				},
27				[ 'index' => 1 ],
28			],
29		];
30	}
31
32	/**
33	 * @dataProvider provideRedirectMergePolicy
34	 */
35	public function testRedirectMergePolicyWithArrayResult( $mergePolicy, $expect ) {
36		list( $target, $pageSet ) = $this->createPageSetWithRedirect();
37		$pageSet->setRedirectMergePolicy( $mergePolicy );
38		$result = [
39			$target->getArticleID() => []
40		];
41		$pageSet->populateGeneratorData( $result );
42		$this->assertEquals( $expect, $result[$target->getArticleID()] );
43	}
44
45	/**
46	 * @dataProvider provideRedirectMergePolicy
47	 */
48	public function testRedirectMergePolicyWithApiResult( $mergePolicy, $expect ) {
49		list( $target, $pageSet ) = $this->createPageSetWithRedirect();
50		$pageSet->setRedirectMergePolicy( $mergePolicy );
51		$result = new ApiResult( false );
52		$result->addValue( null, 'pages', [
53			$target->getArticleID() => []
54		] );
55		$pageSet->populateGeneratorData( $result, [ 'pages' ] );
56		$this->assertEquals(
57			$expect,
58			$result->getResultData( [ 'pages', $target->getArticleID() ] )
59		);
60	}
61
62	protected function createPageSetWithRedirect( $targetContent = 'api page set test' ) {
63		$target = Title::makeTitle( NS_MAIN, 'UTRedirectTarget' );
64		$sourceA = Title::makeTitle( NS_MAIN, 'UTRedirectSourceA' );
65		$sourceB = Title::makeTitle( NS_MAIN, 'UTRedirectSourceB' );
66		self::editPage( 'UTRedirectTarget', $targetContent );
67		self::editPage( 'UTRedirectSourceA', '#REDIRECT [[UTRedirectTarget]]' );
68		self::editPage( 'UTRedirectSourceB', '#REDIRECT [[UTRedirectTarget]]' );
69
70		$request = new FauxRequest( [ 'redirects' => 1 ] );
71		$context = new RequestContext();
72		$context->setRequest( $request );
73		$main = new ApiMain( $context );
74		$pageSet = new ApiPageSet( $main );
75
76		$pageSet->setGeneratorData( $sourceA, [ 'index' => 1 ] );
77		$pageSet->setGeneratorData( $sourceB, [ 'index' => 3 ] );
78		$pageSet->populateFromTitles( [ $sourceA, $sourceB ] );
79
80		return [ $target, $pageSet ];
81	}
82
83	public function testRedirectMergePolicyRedirectLoop() {
84		$loopA = Title::makeTitle( NS_MAIN, 'UTPageRedirectOne' );
85		$loopB = Title::makeTitle( NS_MAIN, 'UTPageRedirectTwo' );
86		self::editPage( 'UTPageRedirectOne', '#REDIRECT [[UTPageRedirectTwo]]' );
87		self::editPage( 'UTPageRedirectTwo', '#REDIRECT [[UTPageRedirectOne]]' );
88		list( $target, $pageSet ) = $this->createPageSetWithRedirect(
89			'#REDIRECT [[UTPageRedirectOne]]'
90		);
91		$pageSet->setRedirectMergePolicy( static function ( $cur, $new ) {
92			throw new \RuntimeException( 'unreachable, no merge when target is redirect loop' );
93		} );
94		// This could infinite loop in a bugged impl, but php doesn't offer
95		// a great way to time constrain this.
96		$result = new ApiResult( false );
97		$pageSet->populateGeneratorData( $result );
98		// Assert something, mostly we care that the above didn't infinite loop.
99		// This verifies the page set followed our redirect chain and saw the loop.
100		$this->assertEqualsCanonicalizing(
101			[
102				'UTRedirectSourceA', 'UTRedirectSourceB', 'UTRedirectTarget',
103				'UTPageRedirectOne', 'UTPageRedirectTwo',
104			],
105			array_map( static function ( $x ) {
106				return $x->getPrefixedText();
107			}, $pageSet->getTitles() )
108		);
109	}
110
111	public function testHandleNormalization() {
112		$context = new RequestContext();
113		$context->setRequest( new FauxRequest( [ 'titles' => "a|B|a\xcc\x8a" ] ) );
114		$main = new ApiMain( $context );
115		$pageSet = new ApiPageSet( $main );
116		$pageSet->execute();
117
118		$this->assertSame(
119			[ 0 => [ 'A' => -1, 'B' => -2, 'Å' => -3 ] ],
120			$pageSet->getAllTitlesByNamespace()
121		);
122		$this->assertSame(
123			[
124				[ 'fromencoded' => true, 'from' => 'a%CC%8A', 'to' => 'å' ],
125				[ 'fromencoded' => false, 'from' => 'a', 'to' => 'A' ],
126				[ 'fromencoded' => false, 'from' => 'å', 'to' => 'Å' ],
127			],
128			$pageSet->getNormalizedTitlesAsResult()
129		);
130	}
131
132	public function testSpecialRedirects() {
133		$id1 = self::editPage( 'UTApiPageSet', 'UTApiPageSet in the default language' )
134			->value['revision-record']->getPageId();
135		$id2 = self::editPage( 'UTApiPageSet/de', 'UTApiPageSet in German' )
136			->value['revision-record']->getPageId();
137
138		$user = $this->getTestUser()->getUser();
139		$userName = $user->getName();
140		$userDbkey = str_replace( ' ', '_', $userName );
141		$request = new FauxRequest( [
142			'titles' => implode( '|', [
143				'Special:MyContributions',
144				'Special:MyPage',
145				'Special:MyTalk/subpage',
146				'Special:MyLanguage/UTApiPageSet',
147			] ),
148		] );
149		$context = new RequestContext();
150		$context->setRequest( $request );
151		$context->setUser( $user );
152
153		$main = new ApiMain( $context );
154		$pageSet = new ApiPageSet( $main );
155		$pageSet->execute();
156
157		$this->assertEquals( [
158		], $pageSet->getRedirectTitlesAsResult() );
159		$this->assertEquals( [
160			[ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ],
161			[ 'ns' => -1, 'title' => 'Special:MyPage', 'special' => true ],
162			[ 'ns' => -1, 'title' => 'Special:MyTalk/subpage', 'special' => true ],
163			[ 'ns' => -1, 'title' => 'Special:MyLanguage/UTApiPageSet', 'special' => true ],
164		], $pageSet->getInvalidTitlesAndRevisions() );
165		$this->assertEquals( [
166		], $pageSet->getAllTitlesByNamespace() );
167
168		$request->setVal( 'redirects', 1 );
169		$main = new ApiMain( $context );
170		$pageSet = new ApiPageSet( $main );
171		$pageSet->execute();
172
173		$this->assertEquals( [
174			[ 'from' => 'Special:MyPage', 'to' => "User:$userName" ],
175			[ 'from' => 'Special:MyTalk/subpage', 'to' => "User talk:$userName/subpage" ],
176			[ 'from' => 'Special:MyLanguage/UTApiPageSet', 'to' => 'UTApiPageSet' ],
177		], $pageSet->getRedirectTitlesAsResult() );
178		$this->assertEquals( [
179			[ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ],
180			[ 'ns' => 2, 'title' => "User:$userName", 'missing' => true ],
181			[ 'ns' => 3, 'title' => "User talk:$userName/subpage", 'missing' => true ],
182		], $pageSet->getInvalidTitlesAndRevisions() );
183		$this->assertEquals( [
184			0 => [ 'UTApiPageSet' => $id1 ],
185			2 => [ $userDbkey => -2 ],
186			3 => [ "$userDbkey/subpage" => -3 ],
187		], $pageSet->getAllTitlesByNamespace() );
188
189		$context->setLanguage( 'de' );
190		$main = new ApiMain( $context );
191		$pageSet = new ApiPageSet( $main );
192		$pageSet->execute();
193
194		$this->assertEquals( [
195			[ 'from' => 'Special:MyPage', 'to' => "User:$userName" ],
196			[ 'from' => 'Special:MyTalk/subpage', 'to' => "User talk:$userName/subpage" ],
197			[ 'from' => 'Special:MyLanguage/UTApiPageSet', 'to' => 'UTApiPageSet/de' ],
198		], $pageSet->getRedirectTitlesAsResult() );
199		$this->assertEquals( [
200			[ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ],
201			[ 'ns' => 2, 'title' => "User:$userName", 'missing' => true ],
202			[ 'ns' => 3, 'title' => "User talk:$userName/subpage", 'missing' => true ],
203		], $pageSet->getInvalidTitlesAndRevisions() );
204		$this->assertEquals( [
205			0 => [ 'UTApiPageSet/de' => $id2 ],
206			2 => [ $userDbkey => -2 ],
207			3 => [ "$userDbkey/subpage" => -3 ],
208		], $pageSet->getAllTitlesByNamespace() );
209	}
210
211	/**
212	 * Test that ApiPageSet is calling GenderCache for provided user names to prefill the
213	 * GenderCache and avoid a performance issue when loading each users' gender on it's own.
214	 * The test is setting the "missLimit" to 0 on the GenderCache to trigger misses logic.
215	 * When the "misses" property is no longer 0 at the end of the test,
216	 * something was requested which is not part of the cache. Than the test is failing.
217	 */
218	public function testGenderCaching() {
219		// Set up the user namespace to have gender aliases to trigger the gender cache
220		$this->setMwGlobals( [
221			'wgExtraGenderNamespaces' => [ NS_USER => [ 'male' => 'Male', 'female' => 'Female' ] ]
222		] );
223		$this->overrideMwServices();
224
225		// User names to test with - it is not needed that the user exists in the database
226		// to trigger gender cache
227		$userNames = [
228			'Female',
229			'Unknown',
230			'Male',
231		];
232
233		// Prepare the gender cache for testing - this is a fresh instance due to service override
234		$genderCache = TestingAccessWrapper::newFromObject(
235			MediaWikiServices::getInstance()->getGenderCache()
236		);
237		$genderCache->missLimit = 0;
238
239		// Do an api request to trigger ApiPageSet code
240		$this->doApiRequest( [
241			'action' => 'query',
242			'titles' => 'User:' . implode( '|User:', $userNames ),
243		] );
244
245		$this->assertSame( 0, $genderCache->misses,
246			'ApiPageSet does not prefill the gender cache correctly' );
247		$this->assertEquals( $userNames, array_keys( $genderCache->cache ),
248			'ApiPageSet does not prefill all users into the gender cache' );
249	}
250}
251