1<?php
2
3use MediaWiki\Page\PageReference;
4use MediaWiki\Page\PageReferenceValue;
5
6/**
7 * @group Database
8 * @group Cache
9 * @covers LinkCache
10 */
11class LinkCacheTest extends MediaWikiIntegrationTestCase {
12	use LinkCacheTestTrait;
13
14	private function newLinkCache( WANObjectCache $wanCache = null ) {
15		if ( !$wanCache ) {
16			$wanCache = new WANObjectCache( [ 'cache' => new EmptyBagOStuff() ] );
17		}
18
19		return new LinkCache(
20			$this->getServiceContainer()->getTitleFormatter(),
21			$wanCache,
22			$this->getServiceContainer()->getNamespaceInfo(),
23			$this->getServiceContainer()->getDBLoadBalancer()
24		);
25	}
26
27	public function providePageAndLink() {
28		return [
29			[ new PageReferenceValue( NS_USER, __METHOD__, PageReference::LOCAL ) ],
30			[ new TitleValue( NS_USER, __METHOD__ ) ]
31		];
32	}
33
34	public function providePageAndLinkAndArray() {
35		return [
36			[ new PageReferenceValue( NS_USER, __METHOD__, PageReference::LOCAL ) ],
37			[ new TitleValue( NS_USER, __METHOD__ ) ],
38			[ [ 'page_namespace' => NS_USER, 'page_title' => __METHOD__ ] ],
39		];
40	}
41
42	private function getPageRow( $offset = 0 ) {
43		return (object)[
44			'page_id' => 8 + $offset,
45			'page_len' => 18,
46			'page_is_redirect' => 0,
47			'page_latest' => 118 + $offset,
48			'page_content_model' => CONTENT_MODEL_TEXT,
49			'page_lang' => 'xyz',
50			'page_restrictions' => 'test',
51			'page_touched' => '20200202020202',
52		];
53	}
54
55	/**
56	 * @dataProvider providePageAndLinkAndArray
57	 * @covers LinkCache::addGoodLinkObjFromRow()
58	 * @covers LinkCache::getGoodLinkRow()
59	 * @covers LinkCache::getGoodLinkID()
60	 * @covers LinkCache::getGoodLinkFieldObj()
61	 * @covers LinkCache::clearLink()
62	 */
63	public function testAddGoodLinkObjFromRow( $page ) {
64		$linkCache = $this->newLinkCache();
65
66		$row = $this->getPageRow();
67
68		$dbkey = is_array( $page ) ? $page['page_title'] : $page->getDBkey();
69		$ns = is_array( $page ) ? $page['page_namespace'] : $page->getNamespace();
70
71		$linkCache->addBadLinkObj( $page );
72		$linkCache->addGoodLinkObjFromRow( $page, $row );
73
74		$this->assertEquals(
75			$row,
76			$linkCache->getGoodLinkRow( $ns, $dbkey )
77		);
78
79		$this->assertSame( $row->page_id, $linkCache->getGoodLinkID( $page ) );
80		$this->assertFalse( $linkCache->isBadLink( $page ) );
81
82		$this->assertSame(
83			$row->page_id,
84			$linkCache->getGoodLinkFieldObj( $page, 'id' )
85		);
86		$this->assertSame(
87			$row->page_len,
88			$linkCache->getGoodLinkFieldObj( $page, 'length' )
89		);
90		$this->assertSame(
91			$row->page_is_redirect,
92			$linkCache->getGoodLinkFieldObj( $page, 'redirect' )
93		);
94		$this->assertSame(
95			$row->page_latest,
96			$linkCache->getGoodLinkFieldObj( $page, 'revision' )
97		);
98		$this->assertSame(
99			$row->page_content_model,
100			$linkCache->getGoodLinkFieldObj( $page, 'model' )
101		);
102		$this->assertSame(
103			$row->page_lang,
104			$linkCache->getGoodLinkFieldObj( $page, 'lang' )
105		);
106		$this->assertSame(
107			$row->page_restrictions,
108			$linkCache->getGoodLinkFieldObj( $page, 'restrictions' )
109		);
110
111		$this->assertEquals(
112			$row,
113			$linkCache->getGoodLinkRow( $ns, $dbkey )
114		);
115
116		$linkCache->clearBadLink( $page );
117		$this->assertNotNull( $linkCache->getGoodLinkID( $page ) );
118		$this->assertNotNull( $linkCache->getGoodLinkFieldObj( $page, 'length' ) );
119
120		$linkCache->clearLink( $page );
121		$this->assertSame( 0, $linkCache->getGoodLinkID( $page ) );
122		$this->assertNull( $linkCache->getGoodLinkFieldObj( $page, 'length' ) );
123		$this->assertNull( $linkCache->getGoodLinkRow( $ns, $dbkey ) );
124	}
125
126	/**
127	 * @covers LinkCache::addGoodLinkObjFromRow()
128	 * @covers LinkCache::getGoodLinkRow()
129	 * @covers LinkCache::getGoodLinkID()
130	 * @covers LinkCache::getGoodLinkFieldObj()
131	 */
132	public function testAddGoodLinkObjWithAllParameters() {
133		$linkCache = $this->getServiceContainer()->getLinkCache();
134
135		$page = new PageReferenceValue( NS_USER, __METHOD__, PageReference::LOCAL );
136		$this->addGoodLinkObject( 8, $page, 18, 0, 118, CONTENT_MODEL_TEXT, 'xyz' );
137
138		$row = $linkCache->getGoodLinkRow( $page->getNamespace(), $page->getDBkey() );
139		$this->assertEquals( 8, (int)$row->page_id );
140		$this->assertSame( 8, $linkCache->getGoodLinkID( $page ) );
141		$this->assertSame( 8, $linkCache->getGoodLinkFieldObj( $page, 'id' ) );
142
143		$this->assertSame(
144			18,
145			$linkCache->getGoodLinkFieldObj( $page, 'length' )
146		);
147		$this->assertSame(
148			0,
149			$linkCache->getGoodLinkFieldObj( $page, 'redirect' )
150		);
151		$this->assertSame(
152			118,
153			$linkCache->getGoodLinkFieldObj( $page, 'revision' )
154		);
155		$this->assertSame(
156			CONTENT_MODEL_TEXT,
157			$linkCache->getGoodLinkFieldObj( $page, 'model' )
158		);
159		$this->assertSame(
160			'xyz',
161			$linkCache->getGoodLinkFieldObj( $page, 'lang' )
162		);
163	}
164
165	/**
166	 * @covers LinkCache::addGoodLinkObjFromRow()
167	 * @covers LinkCache::getGoodLinkRow()
168	 * @covers LinkCache::getGoodLinkID()
169	 * @covers LinkCache::getGoodLinkFieldObj()
170	 */
171	public function testAddGoodLinkObjFromRowWithMinimalParameters() {
172		$linkCache = $this->getServiceContainer()->getLinkCache();
173
174		$page = new PageReferenceValue( NS_USER, __METHOD__, PageReference::LOCAL );
175
176		$this->addGoodLinkObject( 8, $page );
177		$expectedRow = [
178			'page_id' => 8,
179			'page_len' => -1,
180			'page_is_redirect' => 0,
181			'page_latest' => 0,
182			'page_content_model' => null,
183			'page_lang' => null,
184			'page_restrictions' => null
185		];
186
187		$actualRow = (array)$linkCache->getGoodLinkRow( $page->getNamespace(), $page->getDBkey() );
188		$this->assertEquals(
189			$expectedRow,
190			array_intersect_key( $actualRow, $expectedRow )
191		);
192
193		$this->assertSame( 8, $linkCache->getGoodLinkID( $page ) );
194		$this->assertSame( 8, $linkCache->getGoodLinkFieldObj( $page, 'id' ) );
195
196		$this->assertSame(
197			-1,
198			$linkCache->getGoodLinkFieldObj( $page, 'length' )
199		);
200		$this->assertSame(
201			0,
202			$linkCache->getGoodLinkFieldObj( $page, 'redirect' )
203		);
204		$this->assertSame(
205			0,
206			$linkCache->getGoodLinkFieldObj( $page, 'revision' )
207		);
208		$this->assertSame(
209			null,
210			$linkCache->getGoodLinkFieldObj( $page, 'model' )
211		);
212		$this->assertSame(
213			null,
214			$linkCache->getGoodLinkFieldObj( $page, 'lang' )
215		);
216	}
217
218	/**
219	 * @covers LinkCache::addGoodLinkObjFromRow()
220	 */
221	public function testAddGoodLinkObjFromRowWithInterwikiLink() {
222		$linkCache = $this->getServiceContainer()->getLinkCache();
223
224		$page = new TitleValue( NS_USER, __METHOD__, '', 'acme' );
225
226		$this->addGoodLinkObject( 8, $page );
227
228		$this->assertSame( 0, $linkCache->getGoodLinkID( $page ) );
229	}
230
231	/**
232	 * @dataProvider providePageAndLink
233	 * @covers LinkCache::addBadLinkObj()
234	 * @covers LinkCache::isBadLink()
235	 * @covers LinkCache::clearLink()
236	 */
237	public function testAddBadLinkObj( $key ) {
238		$linkCache = $this->getServiceContainer()->getLinkCache();
239		$this->assertFalse( $linkCache->isBadLink( $key ) );
240
241		$this->addGoodLinkObject( 17, $key );
242
243		$linkCache->addBadLinkObj( $key );
244		$this->assertTrue( $linkCache->isBadLink( $key ) );
245		$this->assertSame( 0, $linkCache->getGoodLinkID( $key ) );
246
247		$linkCache->clearLink( $key );
248		$this->assertFalse( $linkCache->isBadLink( $key ) );
249	}
250
251	/**
252	 * @covers LinkCache::addBadLinkObj()
253	 */
254	public function testAddBadLinkObjWithInterwikiLink() {
255		$linkCache = $this->newLinkCache();
256
257		$page = new TitleValue( NS_USER, __METHOD__, '', 'acme' );
258		$linkCache->addBadLinkObj( $page );
259
260		$this->assertFalse( $linkCache->isBadLink( $page ) );
261	}
262
263	/**
264	 * @covers LinkCache::addLinkObj()
265	 * @covers LinkCache::getGoodLinkFieldObj
266	 */
267	public function testAddLinkObj() {
268		$existing = $this->getExistingTestPage();
269		$missing = $this->getNonexistingTestPage();
270
271		$linkCache = $this->newLinkCache();
272
273		$linkCache->addLinkObj( $existing );
274		$linkCache->addLinkObj( $missing );
275
276		$this->assertTrue( $linkCache->isBadLink( $missing ) );
277		$this->assertFalse( $linkCache->isBadLink( $existing ) );
278
279		$this->assertSame( $existing->getId(), $linkCache->getGoodLinkID( $existing ) );
280		$this->assertTrue( $linkCache->isBadLink( $missing ) );
281
282		// Make sure nothing explodes when getting a field from a non-existing entry
283		$this->assertNull( $linkCache->getGoodLinkFieldObj( $missing, 'length' ) );
284	}
285
286	/**
287	 * @covers LinkCache::addLinkObj()
288	 */
289	public function testAddLinkObjUsesCachedInfo() {
290		$existing = $this->getExistingTestPage();
291		$missing = $this->getNonexistingTestPage();
292
293		$fakeRow = $this->getPageRow( $existing->getId() + 100 );
294
295		$linkCache = $this->newLinkCache();
296
297		// pretend the existing page is missing, and the missing page exists
298		$linkCache->addGoodLinkObjFromRow( $missing, $fakeRow );
299		$linkCache->addBadLinkObj( $existing );
300
301		// the LinkCache should use the cached info and not look into the database
302		$this->assertSame( (int)$fakeRow->page_id, $linkCache->addLinkObj( $missing ) );
303		$this->assertSame( 0, $linkCache->addLinkObj( $existing ) );
304
305		// now set the "read latest" flag and try again
306		$flags = IDBAccessObject::READ_LATEST;
307		$this->assertSame( 0, $linkCache->addLinkObj( $missing, $flags ) );
308		$this->assertSame( $existing->getId(), $linkCache->addLinkObj( $existing, $flags ) );
309	}
310
311	/**
312	 * @covers LinkCache::addLinkObj()
313	 * @covers LinkCache::getMutableCacheKeys()
314	 */
315	public function testAddLinkObjUsesWANCache() {
316		// Pages in some namespaces use the WAN cache: Template, File, Category, MediaWiki
317		$existing = $this->getExistingTestPage( Title::makeTitle( NS_TEMPLATE, __METHOD__ ) );
318
319		$fakeRow = $this->getPageRow( $existing->getId() + 100 );
320
321		$cache = new HashBagOStuff();
322		$wanCache = new WANObjectCache( [ 'cache' => $cache ] );
323		$linkCache = $this->newLinkCache( $wanCache );
324
325		// load the page row into the cache
326		$linkCache->addLinkObj( $existing );
327
328		$keys = $linkCache->getMutableCacheKeys( $wanCache, $existing );
329		$this->assertNotEmpty( $keys );
330
331		foreach ( $keys as $key ) {
332			$this->assertNotFalse( $wanCache->get( $key ) );
333		}
334
335		// replace real row data with fake, and assert that it gets used
336		$wanCache->set( $key, $fakeRow );
337		$linkCache->clearLink( $existing ); // clear local cache
338		$this->assertSame( (int)$fakeRow->page_id, $linkCache->addLinkObj( $existing ) );
339
340		// set the "read latest" flag and try again
341		$flags = IDBAccessObject::READ_LATEST;
342		$this->assertSame( $existing->getId(), $linkCache->addLinkObj( $existing, $flags ) );
343	}
344
345	public function testFalsyPageName() {
346		$linkCache = $this->newLinkCache();
347
348		// The stringified value is "0", which is falsy in PHP!
349		$link = new TitleValue( NS_MAIN, '0' );
350
351		$linkCache->addBadLinkObj( $link );
352		$this->assertTrue( $linkCache->isBadLink( $link ) );
353
354		$row = $this->getPageRow();
355		$linkCache->addGoodLinkObjFromRow( $link, $row );
356		$this->assertGreaterThan( 0, $linkCache->getGoodLinkID( $link ) );
357
358		$this->assertSame( $row, $linkCache->getGoodLinkRow( NS_MAIN, '0' ) );
359	}
360
361	public function testClearBadLinkWithString() {
362		$linkCache = $this->newLinkCache();
363		$linkCache->clearBadLink( 'Xyzzy' );
364		$this->addToAssertionCount( 1 );
365	}
366
367	public function testIsBadLinkWithString() {
368		$linkCache = $this->newLinkCache();
369		$this->assertFalse( $linkCache->isBadLink( 'Xyzzy' ) );
370	}
371
372	public function testGetGoodLinkIdWithString() {
373		$linkCache = $this->newLinkCache();
374		$this->assertSame( 0, $linkCache->getGoodLinkID( 'Xyzzy' ) );
375	}
376
377	public function provideInvalidPageParams() {
378		return [
379			'empty' => [ NS_MAIN, '' ],
380			'bad chars' => [ NS_MAIN, '_|_' ],
381			'empty in namspace' => [ NS_USER, '' ],
382			'special' => [ NS_SPECIAL, 'RecentChanges' ],
383		];
384	}
385
386	/**
387	 * @dataProvider provideInvalidPageParams
388	 * @covers LinkCache::getGoodLinkRow()
389	 */
390	public function testGetGoodLinkRowWithBadParams( $ns, $dbkey ) {
391		$linkCache = $this->newLinkCache();
392		$this->assertNull( $linkCache->getGoodLinkRow( $ns, $dbkey ) );
393	}
394
395	public function getRowIfExisting( $db, $ns, $dbkey, $queryOptions ) {
396		if ( $dbkey === 'Existing' ) {
397			return $this->getPageRow();
398		}
399
400		return null;
401	}
402
403	/**
404	 * @covers LinkCache::getGoodLinkRow()
405	 * @covers LinkCache::getGoodLinkFieldObj
406	 */
407	public function testGetGoodLinkRow() {
408		$existing = new TitleValue( NS_MAIN, 'Existing' );
409		$missing = new TitleValue( NS_MAIN, 'Missing' );
410
411		$linkCache = $this->newLinkCache();
412		$callback = [ $this, 'getRowIfExisting' ];
413
414		$linkCache->getGoodLinkRow( $existing->getNamespace(), $existing->getDBkey(), $callback );
415		$linkCache->getGoodLinkRow( $missing->getNamespace(), $missing->getDBkey(), $callback );
416
417		$this->assertTrue( $linkCache->isBadLink( $missing ) );
418		$this->assertFalse( $linkCache->isBadLink( $existing ) );
419
420		$this->assertGreaterThan( 0, $linkCache->getGoodLinkID( $existing ) );
421		$this->assertTrue( $linkCache->isBadLink( $missing ) );
422
423		// Make sure nothing explodes when getting a field from a non-existing entry
424		$this->assertNull( $linkCache->getGoodLinkFieldObj( $missing, 'length' ) );
425	}
426
427	/**
428	 * @covers LinkCache::getGoodLinkRow()
429	 */
430	public function testGetGoodLinkRowUsesCachedInfo() {
431		$existing = new TitleValue( NS_MAIN, 'Existing' );
432		$missing = new TitleValue( NS_MAIN, 'Missing' );
433		$callback = [ $this, 'getRowIfExisting' ];
434
435		$existingRow = $this->getPageRow( 0 );
436		$fakeRow = $this->getPageRow( 3 );
437
438		$linkCache = $this->newLinkCache();
439
440		// pretend the existing page is missing, and the missing page exists
441		$linkCache->addGoodLinkObjFromRow( $missing, $fakeRow );
442		$linkCache->addBadLinkObj( $existing );
443
444		// the LinkCache should use the cached info and not look into the database
445		$this->assertSame(
446			$fakeRow,
447			$linkCache->getGoodLinkRow( $missing->getNamespace(), $missing->getDBkey(), $callback )
448		);
449		$this->assertNull(
450			$linkCache->getGoodLinkRow( $existing->getNamespace(), $existing->getDBkey(), $callback )
451		);
452
453		// now set the "read latest" flag and try again
454		$flags = IDBAccessObject::READ_LATEST;
455		$this->assertNull(
456			$linkCache->getGoodLinkRow(
457				$missing->getNamespace(),
458				$missing->getDBkey(),
459				$callback,
460				$flags
461			)
462		);
463		$this->assertEquals(
464			$existingRow,
465			$linkCache->getGoodLinkRow(
466				$existing->getNamespace(),
467				$existing->getDBkey(),
468				$callback,
469				$flags
470			)
471		);
472
473		// pretend again that the missing page exists, but pretend even harder
474		$linkCache->addGoodLinkObjFromRow( $missing, $fakeRow, IDBAccessObject::READ_LATEST );
475
476		// the LinkCache should use the cached info and not look into the database
477		$this->assertSame(
478			$fakeRow,
479			$linkCache->getGoodLinkRow( $missing->getNamespace(), $missing->getDBkey(), $callback )
480		);
481
482		// now set the "read latest" flag and try again
483		$flags = IDBAccessObject::READ_LATEST;
484		$this->assertEquals(
485			$fakeRow,
486			$linkCache->getGoodLinkRow(
487				$missing->getNamespace(),
488				$missing->getDBkey(),
489				$callback,
490				$flags
491			)
492		);
493	}
494
495	/**
496	 * @covers LinkCache::getGoodLinkRow()
497	 * @covers LinkCache::getMutableCacheKeys()
498	 */
499	public function testGetGoodLinkRowUsesWANCache() {
500		// Pages in some namespaces use the WAN cache: Template, File, Category, MediaWiki
501		$existing = new TitleValue( NS_TEMPLATE, 'Existing' );
502		$callback = [ $this, 'getRowIfExisting' ];
503
504		$existingRow = $this->getPageRow( 0 );
505		$fakeRow = $this->getPageRow( 3 );
506
507		$cache = new HashBagOStuff();
508		$wanCache = new WANObjectCache( [ 'cache' => $cache ] );
509		$linkCache = $this->newLinkCache( $wanCache );
510
511		// load the page row into the cache
512		$linkCache->getGoodLinkRow( $existing->getNamespace(), $existing->getDBkey(), $callback );
513
514		$keys = $linkCache->getMutableCacheKeys( $wanCache, $existing );
515		$this->assertNotEmpty( $keys );
516
517		foreach ( $keys as $key ) {
518			$this->assertNotFalse( $wanCache->get( $key ) );
519		}
520
521		// replace real row data with fake, and assert that it gets used
522		$wanCache->set( $key, $fakeRow );
523		$linkCache->clearLink( $existing ); // clear local cache
524		$this->assertSame(
525			$fakeRow,
526			$linkCache->getGoodLinkRow(
527				$existing->getNamespace(),
528				$existing->getDBkey(),
529				$callback
530			)
531		);
532
533		// set the "read latest" flag and try again
534		$flags = IDBAccessObject::READ_LATEST;
535		$this->assertEquals(
536			$existingRow,
537			$linkCache->getGoodLinkRow(
538				$existing->getNamespace(),
539				$existing->getDBkey(),
540				$callback,
541				$flags
542			)
543		);
544	}
545}
546