1<?php
2
3namespace MediaWiki\Tests\Storage;
4
5use BagOStuff;
6use CommentStoreComment;
7use Content;
8use ContentHandler;
9use DeferredUpdates;
10use DummyContentHandlerForTesting;
11use JobQueueGroup;
12use LinksUpdate;
13use MediaWiki\Config\ServiceOptions;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\Revision\MutableRevisionRecord;
16use MediaWiki\Revision\MutableRevisionSlots;
17use MediaWiki\Revision\RevisionRecord;
18use MediaWiki\Revision\SlotRecord;
19use MediaWiki\Storage\DerivedPageDataUpdater;
20use MediaWiki\Storage\EditResult;
21use MediaWiki\Storage\EditResultCache;
22use MediaWiki\Storage\RevisionSlotsUpdate;
23use MediaWikiIntegrationTestCase;
24use MockTitleTrait;
25use MWCallableUpdate;
26use MWTimestamp;
27use PHPUnit\Framework\MockObject\MockObject;
28use TextContent;
29use TextContentHandler;
30use Title;
31use User;
32use Wikimedia\TestingAccessWrapper;
33use WikiPage;
34use WikitextContent;
35use WikitextContentHandler;
36
37/**
38 * @group Database
39 *
40 * @covers \MediaWiki\Storage\DerivedPageDataUpdater
41 */
42class DerivedPageDataUpdaterTest extends MediaWikiIntegrationTestCase {
43	use MockTitleTrait;
44
45	protected function setUp(): void {
46		parent::setUp();
47
48		$this->tablesUsed[] = 'page';
49	}
50
51	/**
52	 * @param string $title
53	 *
54	 * @return Title
55	 */
56	private function getTitle( $title ) {
57		return Title::makeTitleSafe( $this->getDefaultWikitextNS(), $title );
58	}
59
60	/**
61	 * @param string|Title $title
62	 *
63	 * @return WikiPage
64	 */
65	private function getPage( $title ) {
66		$title = ( $title instanceof Title ) ? $title : $this->getTitle( $title );
67
68		return WikiPage::factory( $title );
69	}
70
71	/**
72	 * @param string|Title|WikiPage $page
73	 * @param RevisionRecord|null $rec
74	 * @param User|null $user
75	 *
76	 * @return DerivedPageDataUpdater
77	 */
78	private function getDerivedPageDataUpdater(
79		$page, RevisionRecord $rec = null, User $user = null
80	) {
81		if ( is_string( $page ) || $page instanceof Title ) {
82			$page = $this->getPage( $page );
83		}
84
85		$page = TestingAccessWrapper::newFromObject( $page );
86		return $page->getDerivedDataUpdater( $user, $rec );
87	}
88
89	/**
90	 * Creates a revision in the database.
91	 *
92	 * @param WikiPage $page
93	 * @param string|Message|CommentStoreComment $summary
94	 * @param null|string|Content $content
95	 * @param User|null $user
96	 *
97	 * @return RevisionRecord|null
98	 */
99	private function createRevision( WikiPage $page, $summary, $content = null, $user = null ) {
100		$user = $user ?: $this->getTestUser()->getUser();
101		$comment = CommentStoreComment::newUnsavedComment( $summary );
102
103		if ( $content === null || is_string( $content ) ) {
104			$content = new WikitextContent( $content ?? $summary );
105		}
106
107		if ( !is_array( $content ) ) {
108			$content = [ 'main' => $content ];
109		}
110
111		$this->getDerivedPageDataUpdater( $page ); // flush cached instance before.
112
113		$updater = $page->newPageUpdater( $user );
114
115		foreach ( $content as $role => $c ) {
116			$updater->setContent( $role, $c );
117		}
118
119		$rev = $updater->saveRevision( $comment );
120		if ( !$updater->wasSuccessful() ) {
121			$this->fail( $updater->getStatus()->getWikiText() );
122		}
123
124		$this->getDerivedPageDataUpdater( $page ); // flush cached instance after.
125		return $rev;
126	}
127
128	// TODO: test setArticleCountMethod() and isCountable();
129	// TODO: test isRedirect() and wasRedirect()
130
131	/**
132	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOptions()
133	 */
134	public function testGetCanonicalParserOptions() {
135		$user = $this->getTestUser()->getUser();
136		$page = $this->getPage( __METHOD__ );
137
138		$parentRev = $this->createRevision( $page, 'first' );
139
140		$mainContent = new WikitextContent( 'Lorem ipsum' );
141
142		$update = new RevisionSlotsUpdate();
143		$update->modifyContent( SlotRecord::MAIN, $mainContent );
144		$updater = $this->getDerivedPageDataUpdater( $page );
145		$updater->prepareContent( $user, $update, false );
146
147		$options1 = $updater->getCanonicalParserOptions();
148		$this->assertSame( MediaWikiServices::getInstance()->getContentLanguage(),
149			$options1->getUserLangObj() );
150
151		$speculativeId = $options1->getSpeculativeRevId();
152		$this->assertSame( $parentRev->getId() + 1, $speculativeId );
153
154		$rev = $this->makeRevision(
155			$page->getTitle(),
156			$update,
157			$user,
158			$parentRev->getId() + 7,
159			$parentRev->getId()
160		);
161		$updater->prepareUpdate( $rev );
162
163		$options2 = $updater->getCanonicalParserOptions();
164
165		$currentRev = $options2->getCurrentRevisionRecordCallback()( $page->getTitle() );
166		$this->assertSame( $rev->getId(), $currentRev->getId() );
167	}
168
169	/**
170	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::grabCurrentRevision()
171	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
172	 */
173	public function testGrabCurrentRevision() {
174		$page = $this->getPage( __METHOD__ );
175
176		$updater0 = $this->getDerivedPageDataUpdater( $page );
177		$this->assertNull( $updater0->grabCurrentRevision() );
178		$this->assertFalse( $updater0->pageExisted() );
179
180		$rev1 = $this->createRevision( $page, 'first' );
181		$updater1 = $this->getDerivedPageDataUpdater( $page );
182		$this->assertSame( $rev1->getId(), $updater1->grabCurrentRevision()->getId() );
183		$this->assertFalse( $updater0->pageExisted() );
184		$this->assertTrue( $updater1->pageExisted() );
185
186		$rev2 = $this->createRevision( $page, 'second' );
187		$updater2 = $this->getDerivedPageDataUpdater( $page );
188		$this->assertSame( $rev1->getId(), $updater1->grabCurrentRevision()->getId() );
189		$this->assertSame( $rev2->getId(), $updater2->grabCurrentRevision()->getId() );
190	}
191
192	/**
193	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareContent()
194	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isContentPrepared()
195	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
196	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
197	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
198	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlots()
199	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawSlot()
200	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawContent()
201	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getModifiedSlotRoles()
202	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getTouchedSlotRoles()
203	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
204	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
205	 */
206	public function testPrepareContent() {
207		$slotRoleRegistry = MediaWikiServices::getInstance()->getSlotRoleRegistry();
208		if ( !$slotRoleRegistry->isDefinedRole( 'aux' ) ) {
209			$slotRoleRegistry->defineRoleWithModel(
210				'aux',
211				CONTENT_MODEL_WIKITEXT
212			);
213		}
214
215		$sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
216		$updater = $this->getDerivedPageDataUpdater( __METHOD__ );
217
218		$this->assertFalse( $updater->isContentPrepared() );
219
220		// TODO: test stash
221		// TODO: MCR: Test multiple slots. Test slot removal.
222		$mainContent = new WikitextContent( 'first [[main]] ~~~' );
223		$auxContent = new WikitextContent( 'inherited ~~~ content' );
224		$auxSlot = SlotRecord::newSaved(
225			10, 7, 'tt:7',
226			SlotRecord::newUnsaved( 'aux', $auxContent )
227		);
228
229		$update = new RevisionSlotsUpdate();
230		$update->modifyContent( SlotRecord::MAIN, $mainContent );
231		$update->modifySlot( SlotRecord::newInherited( $auxSlot ) );
232		// TODO: MCR: test removing slots!
233
234		$updater->prepareContent( $sysop, $update, false );
235
236		// second be ok to call again with the same params
237		$updater->prepareContent( $sysop, $update, false );
238
239		$this->assertNull( $updater->grabCurrentRevision() );
240		$this->assertTrue( $updater->isContentPrepared() );
241		$this->assertFalse( $updater->isUpdatePrepared() );
242		$this->assertFalse( $updater->pageExisted() );
243		$this->assertTrue( $updater->isCreation() );
244		$this->assertTrue( $updater->isChange() );
245		$this->assertFalse( $updater->isContentDeleted() );
246
247		$this->assertNotNull( $updater->getRevision() );
248		$this->assertNotNull( $updater->getRenderedRevision() );
249
250		$this->assertEquals( [ 'main', 'aux' ], $updater->getSlots()->getSlotRoles() );
251		$this->assertEquals( [ 'main' ], array_keys( $updater->getSlots()->getOriginalSlots() ) );
252		$this->assertEquals( [ 'aux' ], array_keys( $updater->getSlots()->getInheritedSlots() ) );
253		$this->assertEquals( [ 'main', 'aux' ], $updater->getModifiedSlotRoles() );
254		$this->assertEquals( [ 'main', 'aux' ], $updater->getTouchedSlotRoles() );
255
256		$mainSlot = $updater->getRawSlot( SlotRecord::MAIN );
257		$this->assertInstanceOf( SlotRecord::class, $mainSlot );
258		$this->assertStringNotContainsString(
259			'~~~',
260			$mainSlot->getContent()->serialize(),
261			'PST should apply.'
262		);
263		$this->assertStringContainsString( $sysop->getName(), $mainSlot->getContent()->serialize() );
264
265		$auxSlot = $updater->getRawSlot( 'aux' );
266		$this->assertInstanceOf( SlotRecord::class, $auxSlot );
267		$this->assertStringContainsString(
268			'~~~',
269			$auxSlot->getContent()->serialize(),
270			'No PST should apply.'
271		);
272
273		$mainOutput = $updater->getCanonicalParserOutput();
274		$this->assertStringContainsString( 'first', $mainOutput->getText() );
275		$this->assertStringContainsString( '<a ', $mainOutput->getText() );
276		$this->assertNotEmpty( $mainOutput->getLinks() );
277
278		$canonicalOutput = $updater->getCanonicalParserOutput();
279		$this->assertStringContainsString( 'first', $canonicalOutput->getText() );
280		$this->assertStringContainsString( '<a ', $canonicalOutput->getText() );
281		$this->assertStringContainsString( 'inherited ', $canonicalOutput->getText() );
282		$this->assertNotEmpty( $canonicalOutput->getLinks() );
283	}
284
285	/**
286	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareContent()
287	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
288	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
289	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
290	 */
291	public function testPrepareContentInherit() {
292		$sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
293		$page = $this->getPage( __METHOD__ );
294
295		$mainContent1 = new WikitextContent( 'first [[main]] ({{REVISIONUSER}}) #~~~#' );
296		$mainContent2 = new WikitextContent( 'second ({{subst:REVISIONUSER}}) #~~~#' );
297
298		$rev = $this->createRevision( $page, 'first', $mainContent1 );
299		$mainContent1 = $rev->getContent( SlotRecord::MAIN ); // get post-pst content
300		$userName = $rev->getUser()->getName();
301		$sysopName = $sysop->getName();
302
303		$update = new RevisionSlotsUpdate();
304		$update->modifyContent( SlotRecord::MAIN, $mainContent1 );
305		$updater1 = $this->getDerivedPageDataUpdater( $page );
306		$updater1->prepareContent( $sysop, $update, false );
307
308		$this->assertNotNull( $updater1->grabCurrentRevision() );
309		$this->assertTrue( $updater1->isContentPrepared() );
310		$this->assertTrue( $updater1->pageExisted() );
311		$this->assertFalse( $updater1->isCreation() );
312		$this->assertFalse( $updater1->isChange() );
313
314		$this->assertNotNull( $updater1->getRevision() );
315		$this->assertNotNull( $updater1->getRenderedRevision() );
316
317		// parser-output for null-edit uses the original author's name
318		$html = $updater1->getRenderedRevision()->getRevisionParserOutput()->getText();
319		$this->assertStringNotContainsString( $sysopName, $html, '{{REVISIONUSER}}' );
320		$this->assertStringNotContainsString( '{{REVISIONUSER}}', $html, '{{REVISIONUSER}}' );
321		$this->assertStringNotContainsString( '~~~', $html, 'signature ~~~' );
322		$this->assertStringContainsString( '(' . $userName . ')', $html, '{{REVISIONUSER}}' );
323		$this->assertStringContainsString( '>' . $userName . '<', $html, 'signature ~~~' );
324
325		// TODO: MCR: test inheritance from parent
326		$update = new RevisionSlotsUpdate();
327		$update->modifyContent( SlotRecord::MAIN, $mainContent2 );
328		$updater2 = $this->getDerivedPageDataUpdater( $page );
329		$updater2->prepareContent( $sysop, $update, false );
330
331		// non-null edit use the new user name in PST
332		$pstText = $updater2->getSlots()->getContent( SlotRecord::MAIN )->serialize();
333		$this->assertStringNotContainsString(
334			'{{subst:REVISIONUSER}}',
335			$pstText,
336			'{{subst:REVISIONUSER}}'
337		);
338		$this->assertStringNotContainsString( '~~~', $pstText, 'signature ~~~' );
339		$this->assertStringContainsString( '(' . $sysopName . ')', $pstText, '{{subst:REVISIONUSER}}' );
340		$this->assertStringContainsString( ':' . $sysopName . '|', $pstText, 'signature ~~~' );
341
342		$this->assertFalse( $updater2->isCreation() );
343		$this->assertTrue( $updater2->isChange() );
344	}
345
346	// TODO: test failure of prepareContent() when called again...
347	// - with different user
348	// - with different update
349	// - after calling prepareUpdate()
350
351	/**
352	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
353	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isUpdatePrepared()
354	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
355	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlots()
356	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawSlot()
357	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawContent()
358	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getModifiedSlotRoles()
359	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getTouchedSlotRoles()
360	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
361	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
362	 */
363	public function testPrepareUpdate() {
364		$page = $this->getPage( __METHOD__ );
365
366		$mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
367		$rev1 = $this->createRevision( $page, 'first', $mainContent1 );
368		$updater1 = $this->getDerivedPageDataUpdater( $page, $rev1 );
369
370		$options = []; // TODO: test *all* the options...
371		$updater1->prepareUpdate( $rev1, $options );
372
373		$this->assertTrue( $updater1->isUpdatePrepared() );
374		$this->assertTrue( $updater1->isContentPrepared() );
375		$this->assertTrue( $updater1->isCreation() );
376		$this->assertTrue( $updater1->isChange() );
377		$this->assertFalse( $updater1->isContentDeleted() );
378
379		$this->assertNotNull( $updater1->getRevision() );
380		$this->assertNotNull( $updater1->getRenderedRevision() );
381
382		$this->assertEquals( [ 'main' ], $updater1->getSlots()->getSlotRoles() );
383		$this->assertEquals( [ 'main' ], array_keys( $updater1->getSlots()->getOriginalSlots() ) );
384		$this->assertEquals( [], array_keys( $updater1->getSlots()->getInheritedSlots() ) );
385		$this->assertEquals( [ 'main' ], $updater1->getModifiedSlotRoles() );
386		$this->assertEquals( [ 'main' ], $updater1->getTouchedSlotRoles() );
387
388		// TODO: MCR: test multiple slots, test slot removal!
389
390		$this->assertInstanceOf( SlotRecord::class, $updater1->getRawSlot( SlotRecord::MAIN ) );
391		$this->assertStringNotContainsString(
392			'~~~~',
393			$updater1->getRawContent( SlotRecord::MAIN )->serialize()
394		);
395
396		$mainOutput = $updater1->getCanonicalParserOutput();
397		$this->assertStringContainsString( 'first', $mainOutput->getText() );
398		$this->assertStringContainsString( '<a ', $mainOutput->getText() );
399		$this->assertNotEmpty( $mainOutput->getLinks() );
400
401		$canonicalOutput = $updater1->getCanonicalParserOutput();
402		$this->assertStringContainsString( 'first', $canonicalOutput->getText() );
403		$this->assertStringContainsString( '<a ', $canonicalOutput->getText() );
404		$this->assertNotEmpty( $canonicalOutput->getLinks() );
405
406		$mainContent2 = new WikitextContent( 'second' );
407		$rev2 = $this->createRevision( $page, 'second', $mainContent2 );
408		$updater2 = $this->getDerivedPageDataUpdater( $page, $rev2 );
409
410		$options = []; // TODO: test *all* the options...
411		$updater2->prepareUpdate( $rev2, $options );
412
413		$this->assertFalse( $updater2->isCreation() );
414		$this->assertTrue( $updater2->isChange() );
415
416		$canonicalOutput = $updater2->getCanonicalParserOutput();
417		$this->assertStringContainsString( 'second', $canonicalOutput->getText() );
418	}
419
420	/**
421	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
422	 */
423	public function testPrepareUpdateReusesParserOutput() {
424		$user = $this->getTestUser()->getUser();
425		$page = $this->getPage( __METHOD__ );
426
427		$mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
428
429		$update = new RevisionSlotsUpdate();
430		$update->modifyContent( SlotRecord::MAIN, $mainContent1 );
431		$updater = $this->getDerivedPageDataUpdater( $page );
432		$updater->prepareContent( $user, $update, false );
433
434		$mainOutput = $updater->getSlotParserOutput( SlotRecord::MAIN );
435		$canonicalOutput = $updater->getCanonicalParserOutput();
436
437		$rev = $this->createRevision( $page, 'first', $mainContent1 );
438
439		$options = []; // TODO: test *all* the options...
440		$updater->prepareUpdate( $rev, $options );
441
442		$this->assertTrue( $updater->isUpdatePrepared() );
443		$this->assertTrue( $updater->isContentPrepared() );
444
445		$this->assertSame( $mainOutput, $updater->getSlotParserOutput( SlotRecord::MAIN ) );
446		$this->assertSame( $canonicalOutput, $updater->getCanonicalParserOutput() );
447	}
448
449	/**
450	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
451	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
452	 */
453	public function testPrepareUpdateOutputReset() {
454		$user = $this->getTestUser()->getUser();
455		$page = $this->getPage( __METHOD__ );
456
457		$mainContent1 = new WikitextContent( 'first --{{REVISIONID}}--' );
458
459		$update = new RevisionSlotsUpdate();
460		$update->modifyContent( SlotRecord::MAIN, $mainContent1 );
461		$updater = $this->getDerivedPageDataUpdater( $page );
462		$updater->prepareContent( $user, $update, false );
463
464		$mainOutput = $updater->getSlotParserOutput( SlotRecord::MAIN );
465		$canonicalOutput = $updater->getCanonicalParserOutput();
466
467		// prevent optimization on matching speculative ID
468		$mainOutput->setSpeculativeRevIdUsed( 0 );
469		$canonicalOutput->setSpeculativeRevIdUsed( 0 );
470
471		$rev = $this->createRevision( $page, 'first', $mainContent1 );
472
473		$options = []; // TODO: test *all* the options...
474		$updater->prepareUpdate( $rev, $options );
475
476		$this->assertTrue( $updater->isUpdatePrepared() );
477		$this->assertTrue( $updater->isContentPrepared() );
478
479		// ParserOutput objects should have been flushed.
480		$this->assertNotSame( $mainOutput, $updater->getSlotParserOutput( SlotRecord::MAIN ) );
481		$this->assertNotSame( $canonicalOutput, $updater->getCanonicalParserOutput() );
482
483		$html = $updater->getCanonicalParserOutput()->getText();
484		$this->assertStringContainsString( '--' . $rev->getId() . '--', $html );
485
486		// TODO: MCR: ensure that when the main slot uses {{REVISIONID}} but another slot is
487		// updated, the main slot is still re-rendered!
488	}
489
490	// TODO: test failure of prepareUpdate() when called again with a different revision
491	// TODO: test failure of prepareUpdate() on inconsistency with prepareContent.
492
493	/**
494	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getPreparedEdit()
495	 */
496	public function testGetPreparedEditAfterPrepareContent() {
497		$user = $this->getTestUser()->getUser();
498
499		$mainContent = new WikitextContent( 'first [[main]] ~~~' );
500		$update = new RevisionSlotsUpdate();
501		$update->modifyContent( SlotRecord::MAIN, $mainContent );
502
503		$updater = $this->getDerivedPageDataUpdater( __METHOD__ );
504		$updater->prepareContent( $user, $update, false );
505
506		$canonicalOutput = $updater->getCanonicalParserOutput();
507
508		$preparedEdit = $updater->getPreparedEdit();
509		$this->assertSame( $canonicalOutput->getCacheTime(), $preparedEdit->timestamp );
510		$this->assertSame( $canonicalOutput, $preparedEdit->output );
511		$this->assertSame( $mainContent, $preparedEdit->newContent );
512		$this->assertSame( $updater->getRawContent( SlotRecord::MAIN ), $preparedEdit->pstContent );
513		$this->assertSame( $updater->getCanonicalParserOptions(), $preparedEdit->popts );
514		$this->assertSame( null, $preparedEdit->revid );
515	}
516
517	/**
518	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getPreparedEdit()
519	 */
520	public function testGetPreparedEditAfterPrepareUpdate() {
521		$clock = MWTimestamp::convert( TS_UNIX, '20100101000000' );
522		MWTimestamp::setFakeTime( static function () use ( &$clock ) {
523			return $clock++;
524		} );
525
526		$page = $this->getPage( __METHOD__ );
527
528		$mainContent = new WikitextContent( 'first [[main]] ~~~' );
529		$update = new MutableRevisionSlots();
530		$update->setContent( SlotRecord::MAIN, $mainContent );
531
532		$rev = $this->createRevision( $page, __METHOD__ );
533
534		$updater = $this->getDerivedPageDataUpdater( $page );
535		$updater->prepareUpdate( $rev );
536
537		$canonicalOutput = $updater->getCanonicalParserOutput();
538
539		$preparedEdit = $updater->getPreparedEdit();
540		$this->assertSame( $canonicalOutput->getCacheTime(), $preparedEdit->timestamp );
541		$this->assertSame( $canonicalOutput, $preparedEdit->output );
542		$this->assertSame( $updater->getRawContent( SlotRecord::MAIN ), $preparedEdit->pstContent );
543		$this->assertSame( $updater->getCanonicalParserOptions(), $preparedEdit->popts );
544		$this->assertSame( $rev->getId(), $preparedEdit->revid );
545	}
546
547	public function testGetSecondaryDataUpdatesAfterPrepareContent() {
548		$user = $this->getTestUser()->getUser();
549		$page = $this->getPage( __METHOD__ );
550		$this->createRevision( $page, __METHOD__ );
551
552		$mainContent1 = new WikitextContent( 'first' );
553
554		$update = new RevisionSlotsUpdate();
555		$update->modifyContent( SlotRecord::MAIN, $mainContent1 );
556		$updater = $this->getDerivedPageDataUpdater( $page );
557		$updater->prepareContent( $user, $update, false );
558
559		$dataUpdates = $updater->getSecondaryDataUpdates();
560
561		$this->assertNotEmpty( $dataUpdates );
562
563		$linksUpdates = array_filter( $dataUpdates, static function ( $du ) {
564			return $du instanceof LinksUpdate;
565		} );
566		$this->assertCount( 1, $linksUpdates );
567	}
568
569	public function testAvoidSecondaryDataUpdatesOnNonHTMLContentHandlers() {
570		$this->setMwGlobals( [
571			'wgContentHandlers' => [
572				CONTENT_MODEL_WIKITEXT => WikitextContentHandler::class,
573				'testing' => DummyContentHandlerForTesting::class,
574			],
575		] );
576
577		MediaWikiServices::getInstance()->resetServiceForTesting( 'ContentHandlerFactory' );
578		$user = $this->getTestUser()->getUser();
579		$page = $this->getPage( __METHOD__ );
580		$this->createRevision( $page, __METHOD__ );
581
582		$contentHandler = new DummyContentHandlerForTesting( 'testing' );
583		$mainContent1 = $contentHandler->unserializeContent( serialize( 'first' ) );
584		$update = new RevisionSlotsUpdate();
585		$pcache = MediaWikiServices::getInstance()->getParserCache();
586		$pcache->deleteOptionsKey( $page );
587		$rev = $this->createRevision( $page, 'first', $mainContent1 );
588
589		// Run updates
590		$update->modifyContent( SlotRecord::MAIN, $mainContent1 );
591		$updater = $this->getDerivedPageDataUpdater( $page );
592		$updater->prepareContent( $user, $update, false );
593		$dataUpdates = $updater->getSecondaryDataUpdates();
594		$updater->prepareUpdate( $rev );
595		$updater->doUpdates();
596
597		// Links updates should be triggered
598		$this->assertNotEmpty( $dataUpdates );
599		$linksUpdates = array_filter( $dataUpdates, static function ( $du ) {
600			return $du instanceof LinksUpdate;
601		} );
602		$this->assertCount( 1, $linksUpdates );
603
604		// Parser cache should not be populated.
605		$cached = $pcache->get( $page, $updater->getCanonicalParserOptions() );
606		$this->assertFalse( $cached );
607	}
608
609	public function testGetSecondaryDataUpdatesDeleted() {
610		$user = $this->getTestUser()->getUser();
611		$page = $this->getPage( __METHOD__ );
612		$this->createRevision( $page, __METHOD__ );
613
614		$mainContent1 = new WikitextContent( 'first' );
615
616		$update = new RevisionSlotsUpdate();
617		$update->modifyContent( SlotRecord::MAIN, $mainContent1 );
618		$updater = $this->getDerivedPageDataUpdater( $page );
619		$updater->prepareContent( $user, $update, false );
620
621		// Test that nothing happens if the page was deleted in the meantime
622		// This can happen when started by the job queue
623		$page->doDeleteArticleReal( 'Test', $user );
624
625		$dataUpdates = $updater->getSecondaryDataUpdates();
626
627		$this->assertEmpty( $dataUpdates );
628	}
629
630	/**
631	 * @param string $name
632	 *
633	 * @return ContentHandler
634	 */
635	private function defineMockContentModelForUpdateTesting( $name ) {
636		/** @var ContentHandler|MockObject $handler */
637		$handler = $this->getMockBuilder( TextContentHandler::class )
638			->setConstructorArgs( [ $name ] )
639			->onlyMethods(
640				[ 'getSecondaryDataUpdates', 'getDeletionUpdates', 'unserializeContent' ]
641			)
642			->getMock();
643
644		$dataUpdate = new MWCallableUpdate( 'time' );
645		$dataUpdate->_name = "$name data update";
646
647		$deletionUpdate = new MWCallableUpdate( 'time' );
648		$deletionUpdate->_name = "$name deletion update";
649
650		$handler->method( 'getSecondaryDataUpdates' )->willReturn( [ $dataUpdate ] );
651		$handler->method( 'getDeletionUpdates' )->willReturn( [ $deletionUpdate ] );
652		$handler->method( 'unserializeContent' )->willReturnCallback(
653			function ( $text ) use ( $handler ) {
654				return $this->createMockContent( $handler, $text );
655			}
656		);
657
658		$this->mergeMwGlobalArrayValue(
659			'wgContentHandlers', [
660				$name => static function () use ( $handler ){
661					return $handler;
662				}
663			]
664		);
665
666		return $handler;
667	}
668
669	/**
670	 * @param ContentHandler $handler
671	 * @param string $text
672	 *
673	 * @return Content
674	 */
675	private function createMockContent( ContentHandler $handler, $text ) {
676		/** @var Content|MockObject $content */
677		$content = $this->getMockBuilder( TextContent::class )
678			->setConstructorArgs( [ $text ] )
679			->onlyMethods( [ 'getModel', 'getContentHandler' ] )
680			->getMock();
681
682		$content->method( 'getModel' )->willReturn( $handler->getModelID() );
683		$content->method( 'getContentHandler' )->willReturn( $handler );
684
685		return $content;
686	}
687
688	public function testGetSecondaryDataUpdatesWithSlotRemoval() {
689		$m1 = $this->defineMockContentModelForUpdateTesting( 'M1' );
690		$a1 = $this->defineMockContentModelForUpdateTesting( 'A1' );
691		$m2 = $this->defineMockContentModelForUpdateTesting( 'M2' );
692
693		$role = 'dpdu-test-a1';
694		$slotRoleRegistry = MediaWikiServices::getInstance()->getSlotRoleRegistry();
695		$slotRoleRegistry->defineRoleWithModel(
696			$role,
697			$a1->getModelID()
698		);
699
700		// pin the service instance for this test
701		$this->setService( 'SlotRoleRegistry', $slotRoleRegistry );
702
703		$mainContent1 = $this->createMockContent( $m1, 'main 1' );
704		$auxContent1 = $this->createMockContent( $a1, 'aux 1' );
705		$mainContent2 = $this->createMockContent( $m2, 'main 2' );
706
707		$user = $this->getTestUser()->getUser();
708		$page = $this->getPage( __METHOD__ );
709		$this->createRevision(
710			$page,
711			__METHOD__,
712			[ 'main' => $mainContent1, $role => $auxContent1 ]
713		);
714
715		$update = new RevisionSlotsUpdate();
716		$update->modifyContent( SlotRecord::MAIN, $mainContent2 );
717		$update->removeSlot( $role );
718
719		$page = $this->getPage( __METHOD__ );
720		$updater = $this->getDerivedPageDataUpdater( $page );
721		$updater->prepareContent( $user, $update, false );
722
723		$dataUpdates = $updater->getSecondaryDataUpdates();
724
725		$this->assertNotEmpty( $dataUpdates );
726
727		$updateNames = array_map( static function ( $du ) {
728			return $du->_name ?? get_class( $du );
729		}, $dataUpdates );
730
731		$this->assertContains( LinksUpdate::class, $updateNames );
732		$this->assertContains( 'A1 deletion update', $updateNames );
733		$this->assertContains( 'M2 data update', $updateNames );
734		$this->assertNotContains( 'M1 data update', $updateNames );
735	}
736
737	/**
738	 * Creates a dummy MutableRevisionRecord without touching the database.
739	 *
740	 * @param Title $title
741	 * @param RevisionSlotsUpdate $update
742	 * @param User $user
743	 * @param string $comment
744	 * @param int $id
745	 * @param int $parentId
746	 *
747	 * @return MutableRevisionRecord
748	 */
749	private function makeRevision(
750		Title $title,
751		RevisionSlotsUpdate $update,
752		User $user,
753		$comment,
754		$id = 0,
755		$parentId = 0
756	) {
757		$rev = new MutableRevisionRecord( $title );
758
759		$rev->applyUpdate( $update );
760		$rev->setUser( $user );
761		$rev->setComment( CommentStoreComment::newUnsavedComment( $comment ) );
762		$rev->setPageId( $title->getArticleID() );
763		$rev->setParentId( $parentId );
764
765		if ( $id ) {
766			$rev->setId( $id );
767		}
768
769		return $rev;
770	}
771
772	public function provideIsReusableFor() {
773		$title = $this->makeMockTitle( __CLASS__, [ 'id' => 23 ] );
774
775		$user1 = User::newFromName( 'Alice' );
776		$user2 = User::newFromName( 'Bob' );
777
778		$content1 = new WikitextContent( 'one' );
779		$content2 = new WikitextContent( 'two' );
780
781		$update1 = new RevisionSlotsUpdate();
782		$update1->modifyContent( SlotRecord::MAIN, $content1 );
783
784		$update1b = new RevisionSlotsUpdate();
785		$update1b->modifyContent( 'xyz', $content1 );
786
787		$update2 = new RevisionSlotsUpdate();
788		$update2->modifyContent( SlotRecord::MAIN, $content2 );
789
790		$rev1 = $this->makeRevision( $title, $update1, $user1, 'rev1', 11 );
791		$rev1b = $this->makeRevision( $title, $update1b, $user1, 'rev1', 11 );
792
793		$rev2 = $this->makeRevision( $title, $update2, $user1, 'rev2', 12 );
794		$rev2x = $this->makeRevision( $title, $update2, $user2, 'rev2', 12 );
795		$rev2y = $this->makeRevision( $title, $update2, $user1, 'rev2', 122 );
796
797		yield 'any' => [
798			'$prepUser' => null,
799			'$prepRevision' => null,
800			'$prepUpdate' => null,
801			'$forUser' => null,
802			'$forRevision' => null,
803			'$forUpdate' => null,
804			'$forParent' => null,
805			'$isReusable' => true,
806		];
807		yield 'for any' => [
808			'$prepUser' => $user1,
809			'$prepRevision' => $rev1,
810			'$prepUpdate' => $update1,
811			'$forUser' => null,
812			'$forRevision' => null,
813			'$forUpdate' => null,
814			'$forParent' => null,
815			'$isReusable' => true,
816		];
817		yield 'unprepared' => [
818			'$prepUser' => null,
819			'$prepRevision' => null,
820			'$prepUpdate' => null,
821			'$forUser' => $user1,
822			'$forRevision' => $rev1,
823			'$forUpdate' => $update1,
824			'$forParent' => 0,
825			'$isReusable' => true,
826		];
827		yield 'match prepareContent' => [
828			'$prepUser' => $user1,
829			'$prepRevision' => null,
830			'$prepUpdate' => $update1,
831			'$forUser' => $user1,
832			'$forRevision' => null,
833			'$forUpdate' => $update1,
834			'$forParent' => 0,
835			'$isReusable' => true,
836		];
837		yield 'match prepareUpdate' => [
838			'$prepUser' => null,
839			'$prepRevision' => $rev1,
840			'$prepUpdate' => null,
841			'$forUser' => $user1,
842			'$forRevision' => $rev1,
843			'$forUpdate' => null,
844			'$forParent' => 0,
845			'$isReusable' => true,
846		];
847		yield 'match all' => [
848			'$prepUser' => $user1,
849			'$prepRevision' => $rev1,
850			'$prepUpdate' => $update1,
851			'$forUser' => $user1,
852			'$forRevision' => $rev1,
853			'$forUpdate' => $update1,
854			'$forParent' => 0,
855			'$isReusable' => true,
856		];
857		yield 'mismatch prepareContent update' => [
858			'$prepUser' => $user1,
859			'$prepRevision' => null,
860			'$prepUpdate' => $update1,
861			'$forUser' => $user1,
862			'$forRevision' => null,
863			'$forUpdate' => $update1b,
864			'$forParent' => 0,
865			'$isReusable' => false,
866		];
867		yield 'mismatch prepareContent user' => [
868			'$prepUser' => $user1,
869			'$prepRevision' => null,
870			'$prepUpdate' => $update1,
871			'$forUser' => $user2,
872			'$forRevision' => null,
873			'$forUpdate' => $update1,
874			'$forParent' => 0,
875			'$isReusable' => false,
876		];
877		yield 'mismatch prepareContent parent' => [
878			'$prepUser' => $user1,
879			'$prepRevision' => null,
880			'$prepUpdate' => $update1,
881			'$forUser' => $user1,
882			'$forRevision' => null,
883			'$forUpdate' => $update1,
884			'$forParent' => 7,
885			'$isReusable' => false,
886		];
887		yield 'mismatch prepareUpdate revision update' => [
888			'$prepUser' => null,
889			'$prepRevision' => $rev1,
890			'$prepUpdate' => null,
891			'$forUser' => null,
892			'$forRevision' => $rev1b,
893			'$forUpdate' => null,
894			'$forParent' => 0,
895			'$isReusable' => false,
896		];
897		yield 'mismatch prepareUpdate revision id' => [
898			'$prepUser' => null,
899			'$prepRevision' => $rev2,
900			'$prepUpdate' => null,
901			'$forUser' => null,
902			'$forRevision' => $rev2y,
903			'$forUpdate' => null,
904			'$forParent' => 0,
905			'$isReusable' => false,
906		];
907	}
908
909	/**
910	 * @dataProvider provideIsReusableFor
911	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isReusableFor()
912	 */
913	public function testIsReusableFor(
914		?User $prepUser,
915		?RevisionRecord $prepRevision,
916		?RevisionSlotsUpdate $prepUpdate,
917		?User $forUser,
918		?RevisionRecord $forRevision,
919		?RevisionSlotsUpdate $forUpdate,
920		$forParent,
921		$isReusable
922	) {
923		$updater = $this->getDerivedPageDataUpdater( __METHOD__ );
924
925		if ( $prepUpdate ) {
926			$updater->prepareContent( $prepUser, $prepUpdate, false );
927		}
928
929		if ( $prepRevision ) {
930			$updater->prepareUpdate( $prepRevision );
931		}
932
933		$this->assertSame(
934			$isReusable,
935			$updater->isReusableFor( $forUser, $forRevision, $forUpdate, $forParent )
936		);
937	}
938
939	/**
940	 * * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCountable
941	 */
942	public function testIsCountableNotContentPage() {
943		$updater = $this->getDerivedPageDataUpdater(
944			Title::newFromText( 'Main_Page', NS_TALK )
945		);
946		self::assertFalse( $updater->isCountable() );
947	}
948
949	public function provideIsCountable() {
950		yield 'deleted revision' => [
951			'$articleCountMethod' => 'any',
952			'$wikitextContent' => 'Test',
953			'$revisionVisibility' => RevisionRecord::SUPPRESSED_ALL,
954			'$isCountable' => false
955		];
956		yield 'redirect' => [
957			'$articleCountMethod' => 'any',
958			'$wikitextContent' => '#REDIRECT [[Main_Page]]',
959			'$revisionVisibility' => 0,
960			'$isCountable' => false
961		];
962		yield 'no links count method any' => [
963			'$articleCountMethod' => 'any',
964			'$wikitextContent' => 'Test',
965			'$revisionVisibility' => 0,
966			'$isCountable' => true
967		];
968		yield 'no links count method link' => [
969			'$articleCountMethod' => 'link',
970			'$wikitextContent' => 'Test',
971			'$revisionVisibility' => 0,
972			'$isCountable' => false
973		];
974		yield 'with links count method link' => [
975			'$articleCountMethod' => 'link',
976			'$wikitextContent' => '[[Test]]',
977			'$revisionVisibility' => 0,
978			'$isCountable' => true
979		];
980	}
981
982	/**
983	 * @dataProvider provideIsCountable
984	 *
985	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCountable
986	 */
987	public function testIsCountable(
988		$articleCountMethod,
989		$wikitextContent,
990		$revisionVisibility,
991		$isCountable
992	) {
993		$this->setMwGlobals( [ 'wgArticleCountMethod' => $articleCountMethod ] );
994		$title = $this->getTitle( 'Main_Page' );
995		$content = new WikitextContent( $wikitextContent );
996		$update = new RevisionSlotsUpdate();
997		$update->modifyContent( SlotRecord::MAIN, $content );
998		$revision = $this->makeRevision( $title, $update, User::newFromName( 'Alice' ), 'rev1', 13 );
999		$revision->setVisibility( $revisionVisibility );
1000		$updater = $this->getDerivedPageDataUpdater( $title );
1001		$updater->prepareUpdate( $revision );
1002		self::assertSame( $isCountable, $updater->isCountable() );
1003	}
1004
1005	/**
1006	 * @throws \MWException
1007	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCountable
1008	 */
1009	public function testIsCountableNoModifiedSlots() {
1010		$page = $this->getPage( __METHOD__ );
1011		$content = [ 'main' => new WikitextContent( '[[Test]]' ) ];
1012		$rev = $this->createRevision( $page, 'first', $content );
1013		$nullRevision = MutableRevisionRecord::newFromParentRevision( $rev );
1014		$nullRevision->setId( 14 );
1015		$updater = $this->getDerivedPageDataUpdater( $page, $nullRevision );
1016		$updater->prepareUpdate( $nullRevision );
1017		$this->assertTrue( $updater->isCountable() );
1018	}
1019
1020	/**
1021	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates()
1022	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates()
1023	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
1024	 */
1025	public function testDoUpdates() {
1026		$page = $this->getPage( __METHOD__ );
1027
1028		$content = [ 'main' => new WikitextContent( 'first [[main]]' ) ];
1029
1030		$content['aux'] = new WikitextContent( 'Aux [[Nix]]' );
1031
1032		$slotRoleRegistry = MediaWikiServices::getInstance()->getSlotRoleRegistry();
1033		if ( !$slotRoleRegistry->isDefinedRole( 'aux' ) ) {
1034			$slotRoleRegistry->defineRoleWithModel(
1035				'aux',
1036				CONTENT_MODEL_WIKITEXT
1037			);
1038		}
1039
1040		$rev = $this->createRevision( $page, 'first', $content );
1041		$pageId = $page->getId();
1042
1043		$oldStats = $this->db->selectRow( 'site_stats', '*', '1=1' );
1044		$this->db->delete( 'pagelinks', '*' );
1045
1046		$pcache = MediaWikiServices::getInstance()->getParserCache();
1047		$pcache->deleteOptionsKey( $page );
1048
1049		$updater = $this->getDerivedPageDataUpdater( $page, $rev );
1050		$updater->setArticleCountMethod( 'link' );
1051
1052		$options = []; // TODO: test *all* the options...
1053		$updater->prepareUpdate( $rev, $options );
1054
1055		$updater->doUpdates();
1056
1057		// links table update
1058		$pageLinks = $this->db->select(
1059			'pagelinks',
1060			'*',
1061			[ 'pl_from' => $pageId ],
1062			__METHOD__,
1063			[ 'ORDER BY' => [ 'pl_namespace', 'pl_title' ] ]
1064		);
1065
1066		$pageLinksRow = $pageLinks->fetchObject();
1067		$this->assertIsObject( $pageLinksRow );
1068		$this->assertSame( 'Main', $pageLinksRow->pl_title );
1069
1070		$pageLinksRow = $pageLinks->fetchObject();
1071		$this->assertIsObject( $pageLinksRow );
1072		$this->assertSame( 'Nix', $pageLinksRow->pl_title );
1073
1074		// parser cache update
1075		$cached = $pcache->get( $page, $updater->getCanonicalParserOptions() );
1076		$this->assertIsObject( $cached );
1077		$this->assertEquals( $updater->getCanonicalParserOutput(), $cached );
1078
1079		// site stats
1080		$stats = $this->db->selectRow( 'site_stats', '*', '1=1' );
1081		$this->assertSame( $oldStats->ss_total_pages + 1, (int)$stats->ss_total_pages );
1082		$this->assertSame( $oldStats->ss_total_edits + 1, (int)$stats->ss_total_edits );
1083		$this->assertSame( $oldStats->ss_good_articles + 1, (int)$stats->ss_good_articles );
1084
1085		// TODO: MCR: test data updates for additional slots!
1086		// TODO: test update for edit without page creation
1087		// TODO: test message cache purge
1088		// TODO: test module cache purge
1089		// TODO: test CDN purge
1090		// TODO: test newtalk update
1091		// TODO: test search update
1092		// TODO: test site stats good_articles while turning the page into (or back from) a redir.
1093		// TODO: test category membership update (with setRcWatchCategoryMembership())
1094	}
1095
1096	/**
1097	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates()
1098	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates()
1099	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
1100	 */
1101	public function testDoUpdatesCacheSaveDeferral_canonical() {
1102		$page = $this->getPage( __METHOD__ );
1103
1104		// Case where user has canonical parser options
1105		$content = [ 'main' => new WikitextContent( 'rev ID ver #1: {{REVISIONID}}' ) ];
1106		$rev = $this->createRevision( $page, 'first', $content );
1107		$pcache = MediaWikiServices::getInstance()->getParserCache();
1108		$pcache->deleteOptionsKey( $page );
1109
1110		$this->db->startAtomic( __METHOD__ ); // let deferred updates queue up
1111
1112		$updater = $this->getDerivedPageDataUpdater( $page, $rev );
1113		$updater->prepareUpdate( $rev, [] );
1114		$updater->doUpdates();
1115
1116		$this->assertGreaterThan( 0, DeferredUpdates::pendingUpdatesCount(), 'Pending updates' );
1117		$this->assertNotFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) );
1118
1119		$this->db->endAtomic( __METHOD__ ); // run deferred updates
1120
1121		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount(), 'No pending updates' );
1122	}
1123
1124	/**
1125	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates()
1126	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates()
1127	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
1128	 */
1129	public function testDoUpdatesCacheSaveDeferral_noncanonical() {
1130		$page = $this->getPage( __METHOD__ );
1131
1132		// Case where user does not have canonical parser options
1133		$user = $this->getMutableTestUser()->getUser();
1134		$user->setOption(
1135			'thumbsize',
1136			$user->getOption( 'thumbsize' ) + 1
1137		);
1138		$content = [ 'main' => new WikitextContent( 'rev ID ver #2: {{REVISIONID}}' ) ];
1139		$rev = $this->createRevision( $page, 'first', $content, $user );
1140		$pcache = MediaWikiServices::getInstance()->getParserCache();
1141		$pcache->deleteOptionsKey( $page );
1142
1143		$this->db->startAtomic( __METHOD__ ); // let deferred updates queue up
1144
1145		$updater = $this->getDerivedPageDataUpdater( $page, $rev, $user );
1146		$updater->prepareUpdate( $rev, [] );
1147		$updater->doUpdates();
1148
1149		$this->assertGreaterThan( 1, DeferredUpdates::pendingUpdatesCount(), 'Pending updates' );
1150		$this->assertFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) );
1151
1152		$this->db->endAtomic( __METHOD__ ); // run deferred updates
1153
1154		$this->assertSame( 0, DeferredUpdates::pendingUpdatesCount(), 'No pending updates' );
1155		$this->assertNotFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) );
1156	}
1157
1158	public function provideEnqueueRevertedTagUpdateJob() {
1159		return [
1160			'approved' => [ true, 1 ],
1161			'not approved' => [ false, 0 ]
1162		];
1163	}
1164
1165	/**
1166	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates
1167	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::maybeEnqueueRevertedTagUpdateJob
1168	 * @dataProvider provideEnqueueRevertedTagUpdateJob
1169	 */
1170	public function testEnqueueRevertedTagUpdateJob( bool $approved, int $queueSize ) {
1171		$page = $this->getPage( __METHOD__ );
1172
1173		$content = [ 'main' => new WikitextContent( '1' ) ];
1174		$rev = $this->createRevision( $page, '', $content );
1175		$editResult = new EditResult(
1176			false,
1177			10,
1178			EditResult::REVERT_ROLLBACK,
1179			11,
1180			12,
1181			true,
1182			false,
1183			[ 'mw-rollback' ]
1184		);
1185
1186		$updater = $this->getDerivedPageDataUpdater( $page, $rev );
1187
1188		$updater->prepareUpdate( $rev, [
1189			'editResult' => $editResult,
1190			'approved' => $approved
1191		] );
1192		$updater->doUpdates();
1193
1194		$services = MediaWikiServices::getInstance();
1195		$editResultCache = new EditResultCache(
1196			$services->getMainObjectStash(),
1197			$services->getDBLoadBalancer(),
1198			new ServiceOptions(
1199				EditResultCache::CONSTRUCTOR_OPTIONS,
1200				[ 'RCMaxAge' => BagOStuff::TTL_MONTH ]
1201			)
1202		);
1203
1204		if ( $approved ) {
1205			$this->assertNull(
1206				$editResultCache->get( $rev->getId() ),
1207				'EditResult should not be cached when the revert is approved'
1208			);
1209		} else {
1210			$this->assertEquals(
1211				$editResult,
1212				$editResultCache->get( $rev->getId() ),
1213				'EditResult should be cached when the revert is not approved'
1214			);
1215		}
1216
1217		$jobQueueGroup = JobQueueGroup::singleton();
1218		$jobQueue = $jobQueueGroup->get( 'revertedTagUpdate' );
1219		$this->assertSame(
1220			$queueSize,
1221			$jobQueue->getSize()
1222		);
1223	}
1224
1225	/**
1226	 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
1227	 */
1228	public function testDoParserCacheUpdate() {
1229		$slotRoleRegistry = MediaWikiServices::getInstance()->getSlotRoleRegistry();
1230		if ( !$slotRoleRegistry->isDefinedRole( 'aux' ) ) {
1231			$slotRoleRegistry->defineRoleWithModel(
1232				'aux',
1233				CONTENT_MODEL_WIKITEXT
1234			);
1235		}
1236
1237		$page = $this->getPage( __METHOD__ );
1238		$this->createRevision( $page, 'Dummy' );
1239
1240		$user = $this->getTestUser()->getUser();
1241
1242		$update = new RevisionSlotsUpdate();
1243		$update->modifyContent( 'main', new WikitextContent( 'first [[Main]]' ) );
1244		$update->modifyContent( 'aux', new WikitextContent( 'Aux [[Nix]]' ) );
1245
1246		// Emulate update after edit ----------
1247		$pcache = MediaWikiServices::getInstance()->getParserCache();
1248		$pcache->deleteOptionsKey( $page );
1249
1250		$rev = $this->makeRevision( $page->getTitle(), $update, $user, 'rev', null );
1251		$rev->setTimestamp( '20100101000000' );
1252		$rev->setParentId( $page->getLatest() );
1253
1254		$updater = $this->getDerivedPageDataUpdater( $page );
1255		$updater->prepareContent( $user, $update, false );
1256
1257		$rev->setId( 11 );
1258		$updater->prepareUpdate( $rev );
1259
1260		// Force the page timestamp, so we notice whether ParserOutput::getTimestamp
1261		// or ParserOutput::getCacheTime are used.
1262		$page->setTimestamp( $rev->getTimestamp() );
1263		$updater->doParserCacheUpdate();
1264
1265		// The cached ParserOutput should not use the revision timestamp
1266		$cached = $pcache->get( $page, $updater->getCanonicalParserOptions(), true );
1267		$this->assertIsObject( $cached );
1268		$this->assertEquals( $updater->getCanonicalParserOutput(), $cached );
1269
1270		$this->assertSame( $rev->getTimestamp(), $cached->getCacheTime() );
1271		$this->assertSame( $rev->getId(), $cached->getCacheRevisionId() );
1272
1273		// Emulate forced update of an old revision ----------
1274		$pcache->deleteOptionsKey( $page );
1275
1276		$updater = $this->getDerivedPageDataUpdater( $page );
1277		$updater->prepareUpdate( $rev );
1278
1279		// Force the page timestamp, so we notice whether ParserOutput::getTimestamp
1280		// or ParserOutput::getCacheTime are used.
1281		$page->setTimestamp( $rev->getTimestamp() );
1282		$updater->doParserCacheUpdate();
1283
1284		// The cached ParserOutput should not use the revision timestamp
1285		$cached = $pcache->get( $page, $updater->getCanonicalParserOptions(), true );
1286		$this->assertIsObject( $cached );
1287		$this->assertEquals( $updater->getCanonicalParserOutput(), $cached );
1288
1289		$this->assertGreaterThan( $rev->getTimestamp(), $cached->getCacheTime() );
1290		$this->assertSame( $rev->getId(), $cached->getCacheRevisionId() );
1291	}
1292
1293}
1294