3 namespace MediaWiki\Tests\Storage
;
5 use CommentStoreComment
;
9 use MediaWiki\MediaWikiServices
;
10 use MediaWiki\Revision\MutableRevisionRecord
;
11 use MediaWiki\Revision\MutableRevisionSlots
;
12 use MediaWiki\Revision\RevisionRecord
;
13 use MediaWiki\Revision\SlotRecord
;
14 use MediaWiki\Storage\DerivedPageDataUpdater
;
15 use MediaWiki\Storage\RevisionSlotsUpdate
;
16 use MediaWikiTestCase
;
19 use PHPUnit\Framework\MockObject\MockObject
;
21 use TextContentHandler
;
24 use Wikimedia\TestingAccessWrapper
;
32 * @covers \MediaWiki\Storage\DerivedPageDataUpdater
34 class DerivedPageDataUpdaterTest
extends MediaWikiTestCase
{
36 public function tearDown() {
37 MWTimestamp
::setFakeTime( false );
43 * @param string $title
47 private function getTitle( $title ) {
48 return Title
::makeTitleSafe( $this->getDefaultWikitextNS(), $title );
52 * @param string|Title $title
56 private function getPage( $title ) {
57 $title = ( $title instanceof Title
) ?
$title : $this->getTitle( $title );
59 return WikiPage
::factory( $title );
63 * @param string|Title|WikiPage $page
64 * @param RevisionRecord|null $rec
65 * @param User|null $user
67 * @return DerivedPageDataUpdater
69 private function getDerivedPageDataUpdater(
70 $page, RevisionRecord
$rec = null, User
$user = null
72 if ( is_string( $page ) ||
$page instanceof Title
) {
73 $page = $this->getPage( $page );
76 $page = TestingAccessWrapper
::newFromObject( $page );
77 return $page->getDerivedDataUpdater( $user, $rec );
81 * Creates a revision in the database.
83 * @param WikiPage $page
84 * @param string|Message|CommentStoreComment $summary
85 * @param null|string|Content $content
86 * @param User|null $user
88 * @return RevisionRecord|null
90 private function createRevision( WikiPage
$page, $summary, $content = null, $user = null ) {
91 $user = $user ?
: $this->getTestUser()->getUser();
92 $comment = CommentStoreComment
::newUnsavedComment( $summary );
94 if ( $content === null ||
is_string( $content ) ) {
95 $content = new WikitextContent( $content ??
$summary );
98 if ( !is_array( $content ) ) {
99 $content = [ 'main' => $content ];
102 $this->getDerivedPageDataUpdater( $page ); // flush cached instance before.
104 $updater = $page->newPageUpdater( $user );
106 foreach ( $content as $role => $c ) {
107 $updater->setContent( $role, $c );
110 $rev = $updater->saveRevision( $comment );
111 if ( !$updater->wasSuccessful() ) {
112 $this->fail( $updater->getStatus()->getWikiText() );
115 $this->getDerivedPageDataUpdater( $page ); // flush cached instance after.
119 // TODO: test setArticleCountMethod() and isCountable();
120 // TODO: test isRedirect() and wasRedirect()
123 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOptions()
125 public function testGetCanonicalParserOptions() {
126 $user = $this->getTestUser()->getUser();
127 $page = $this->getPage( __METHOD__
);
129 $parentRev = $this->createRevision( $page, 'first' );
131 $mainContent = new WikitextContent( 'Lorem ipsum' );
133 $update = new RevisionSlotsUpdate();
134 $update->modifyContent( SlotRecord
::MAIN
, $mainContent );
135 $updater = $this->getDerivedPageDataUpdater( $page );
136 $updater->prepareContent( $user, $update, false );
138 $options1 = $updater->getCanonicalParserOptions();
139 $this->assertSame( MediaWikiServices
::getInstance()->getContentLanguage(),
140 $options1->getUserLangObj() );
142 $speculativeId = $options1->getSpeculativeRevId();
143 $this->assertSame( $parentRev->getId() +
1, $speculativeId );
145 $rev = $this->makeRevision(
149 $parentRev->getId() +
7,
152 $updater->prepareUpdate( $rev );
154 $options2 = $updater->getCanonicalParserOptions();
156 $currentRev = call_user_func( $options2->getCurrentRevisionCallback(), $page->getTitle() );
157 $this->assertSame( $rev->getId(), $currentRev->getId() );
161 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::grabCurrentRevision()
162 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
164 public function testGrabCurrentRevision() {
165 $page = $this->getPage( __METHOD__
);
167 $updater0 = $this->getDerivedPageDataUpdater( $page );
168 $this->assertNull( $updater0->grabCurrentRevision() );
169 $this->assertFalse( $updater0->pageExisted() );
171 $rev1 = $this->createRevision( $page, 'first' );
172 $updater1 = $this->getDerivedPageDataUpdater( $page );
173 $this->assertSame( $rev1->getId(), $updater1->grabCurrentRevision()->getId() );
174 $this->assertFalse( $updater0->pageExisted() );
175 $this->assertTrue( $updater1->pageExisted() );
177 $rev2 = $this->createRevision( $page, 'second' );
178 $updater2 = $this->getDerivedPageDataUpdater( $page );
179 $this->assertSame( $rev1->getId(), $updater1->grabCurrentRevision()->getId() );
180 $this->assertSame( $rev2->getId(), $updater2->grabCurrentRevision()->getId() );
184 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareContent()
185 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isContentPrepared()
186 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
187 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
188 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
189 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlots()
190 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawSlot()
191 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawContent()
192 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getModifiedSlotRoles()
193 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getTouchedSlotRoles()
194 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
195 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
197 public function testPrepareContent() {
198 MediaWikiServices
::getInstance()->getSlotRoleRegistry()->defineRoleWithModel(
200 CONTENT_MODEL_WIKITEXT
203 $sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
204 $updater = $this->getDerivedPageDataUpdater( __METHOD__
);
206 $this->assertFalse( $updater->isContentPrepared() );
209 // TODO: MCR: Test multiple slots. Test slot removal.
210 $mainContent = new WikitextContent( 'first [[main]] ~~~' );
211 $auxContent = new WikitextContent( 'inherited ~~~ content' );
212 $auxSlot = SlotRecord
::newSaved(
214 SlotRecord
::newUnsaved( 'aux', $auxContent )
217 $update = new RevisionSlotsUpdate();
218 $update->modifyContent( SlotRecord
::MAIN
, $mainContent );
219 $update->modifySlot( SlotRecord
::newInherited( $auxSlot ) );
220 // TODO: MCR: test removing slots!
222 $updater->prepareContent( $sysop, $update, false );
224 // second be ok to call again with the same params
225 $updater->prepareContent( $sysop, $update, false );
227 $this->assertNull( $updater->grabCurrentRevision() );
228 $this->assertTrue( $updater->isContentPrepared() );
229 $this->assertFalse( $updater->isUpdatePrepared() );
230 $this->assertFalse( $updater->pageExisted() );
231 $this->assertTrue( $updater->isCreation() );
232 $this->assertTrue( $updater->isChange() );
233 $this->assertFalse( $updater->isContentDeleted() );
235 $this->assertNotNull( $updater->getRevision() );
236 $this->assertNotNull( $updater->getRenderedRevision() );
238 $this->assertEquals( [ 'main', 'aux' ], $updater->getSlots()->getSlotRoles() );
239 $this->assertEquals( [ 'main' ], array_keys( $updater->getSlots()->getOriginalSlots() ) );
240 $this->assertEquals( [ 'aux' ], array_keys( $updater->getSlots()->getInheritedSlots() ) );
241 $this->assertEquals( [ 'main', 'aux' ], $updater->getModifiedSlotRoles() );
242 $this->assertEquals( [ 'main', 'aux' ], $updater->getTouchedSlotRoles() );
244 $mainSlot = $updater->getRawSlot( SlotRecord
::MAIN
);
245 $this->assertInstanceOf( SlotRecord
::class, $mainSlot );
246 $this->assertNotContains( '~~~', $mainSlot->getContent()->serialize(), 'PST should apply.' );
247 $this->assertContains( $sysop->getName(), $mainSlot->getContent()->serialize() );
249 $auxSlot = $updater->getRawSlot( 'aux' );
250 $this->assertInstanceOf( SlotRecord
::class, $auxSlot );
251 $this->assertContains( '~~~', $auxSlot->getContent()->serialize(), 'No PST should apply.' );
253 $mainOutput = $updater->getCanonicalParserOutput();
254 $this->assertContains( 'first', $mainOutput->getText() );
255 $this->assertContains( '<a ', $mainOutput->getText() );
256 $this->assertNotEmpty( $mainOutput->getLinks() );
258 $canonicalOutput = $updater->getCanonicalParserOutput();
259 $this->assertContains( 'first', $canonicalOutput->getText() );
260 $this->assertContains( '<a ', $canonicalOutput->getText() );
261 $this->assertContains( 'inherited ', $canonicalOutput->getText() );
262 $this->assertNotEmpty( $canonicalOutput->getLinks() );
266 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareContent()
267 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted()
268 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
269 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
271 public function testPrepareContentInherit() {
272 $sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
273 $page = $this->getPage( __METHOD__
);
275 $mainContent1 = new WikitextContent( 'first [[main]] ({{REVISIONUSER}}) #~~~#' );
276 $mainContent2 = new WikitextContent( 'second ({{subst:REVISIONUSER}}) #~~~#' );
278 $rev = $this->createRevision( $page, 'first', $mainContent1 );
279 $mainContent1 = $rev->getContent( SlotRecord
::MAIN
); // get post-pst content
280 $userName = $rev->getUser()->getName();
281 $sysopName = $sysop->getName();
283 $update = new RevisionSlotsUpdate();
284 $update->modifyContent( SlotRecord
::MAIN
, $mainContent1 );
285 $updater1 = $this->getDerivedPageDataUpdater( $page );
286 $updater1->prepareContent( $sysop, $update, false );
288 $this->assertNotNull( $updater1->grabCurrentRevision() );
289 $this->assertTrue( $updater1->isContentPrepared() );
290 $this->assertTrue( $updater1->pageExisted() );
291 $this->assertFalse( $updater1->isCreation() );
292 $this->assertFalse( $updater1->isChange() );
294 $this->assertNotNull( $updater1->getRevision() );
295 $this->assertNotNull( $updater1->getRenderedRevision() );
297 // parser-output for null-edit uses the original author's name
298 $html = $updater1->getRenderedRevision()->getRevisionParserOutput()->getText();
299 $this->assertNotContains( $sysopName, $html, '{{REVISIONUSER}}' );
300 $this->assertNotContains( '{{REVISIONUSER}}', $html, '{{REVISIONUSER}}' );
301 $this->assertNotContains( '~~~', $html, 'signature ~~~' );
302 $this->assertContains( '(' . $userName . ')', $html, '{{REVISIONUSER}}' );
303 $this->assertContains( '>' . $userName . '<', $html, 'signature ~~~' );
305 // TODO: MCR: test inheritance from parent
306 $update = new RevisionSlotsUpdate();
307 $update->modifyContent( SlotRecord
::MAIN
, $mainContent2 );
308 $updater2 = $this->getDerivedPageDataUpdater( $page );
309 $updater2->prepareContent( $sysop, $update, false );
311 // non-null edit use the new user name in PST
312 $pstText = $updater2->getSlots()->getContent( SlotRecord
::MAIN
)->serialize();
313 $this->assertNotContains( '{{subst:REVISIONUSER}}', $pstText, '{{subst:REVISIONUSER}}' );
314 $this->assertNotContains( '~~~', $pstText, 'signature ~~~' );
315 $this->assertContains( '(' . $sysopName . ')', $pstText, '{{subst:REVISIONUSER}}' );
316 $this->assertContains( ':' . $sysopName . '|', $pstText, 'signature ~~~' );
318 $this->assertFalse( $updater2->isCreation() );
319 $this->assertTrue( $updater2->isChange() );
322 // TODO: test failure of prepareContent() when called again...
323 // - with different user
324 // - with different update
325 // - after calling prepareUpdate()
328 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
329 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isUpdatePrepared()
330 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation()
331 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlots()
332 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawSlot()
333 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawContent()
334 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getModifiedSlotRoles()
335 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getTouchedSlotRoles()
336 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
337 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
339 public function testPrepareUpdate() {
340 $page = $this->getPage( __METHOD__
);
342 $mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
343 $rev1 = $this->createRevision( $page, 'first', $mainContent1 );
344 $updater1 = $this->getDerivedPageDataUpdater( $page, $rev1 );
346 $options = []; // TODO: test *all* the options...
347 $updater1->prepareUpdate( $rev1, $options );
349 $this->assertTrue( $updater1->isUpdatePrepared() );
350 $this->assertTrue( $updater1->isContentPrepared() );
351 $this->assertTrue( $updater1->isCreation() );
352 $this->assertTrue( $updater1->isChange() );
353 $this->assertFalse( $updater1->isContentDeleted() );
355 $this->assertNotNull( $updater1->getRevision() );
356 $this->assertNotNull( $updater1->getRenderedRevision() );
358 $this->assertEquals( [ 'main' ], $updater1->getSlots()->getSlotRoles() );
359 $this->assertEquals( [ 'main' ], array_keys( $updater1->getSlots()->getOriginalSlots() ) );
360 $this->assertEquals( [], array_keys( $updater1->getSlots()->getInheritedSlots() ) );
361 $this->assertEquals( [ 'main' ], $updater1->getModifiedSlotRoles() );
362 $this->assertEquals( [ 'main' ], $updater1->getTouchedSlotRoles() );
364 // TODO: MCR: test multiple slots, test slot removal!
366 $this->assertInstanceOf( SlotRecord
::class, $updater1->getRawSlot( SlotRecord
::MAIN
) );
367 $this->assertNotContains( '~~~~', $updater1->getRawContent( SlotRecord
::MAIN
)->serialize() );
369 $mainOutput = $updater1->getCanonicalParserOutput();
370 $this->assertContains( 'first', $mainOutput->getText() );
371 $this->assertContains( '<a ', $mainOutput->getText() );
372 $this->assertNotEmpty( $mainOutput->getLinks() );
374 $canonicalOutput = $updater1->getCanonicalParserOutput();
375 $this->assertContains( 'first', $canonicalOutput->getText() );
376 $this->assertContains( '<a ', $canonicalOutput->getText() );
377 $this->assertNotEmpty( $canonicalOutput->getLinks() );
379 $mainContent2 = new WikitextContent( 'second' );
380 $rev2 = $this->createRevision( $page, 'second', $mainContent2 );
381 $updater2 = $this->getDerivedPageDataUpdater( $page, $rev2 );
383 $options = []; // TODO: test *all* the options...
384 $updater2->prepareUpdate( $rev2, $options );
386 $this->assertFalse( $updater2->isCreation() );
387 $this->assertTrue( $updater2->isChange() );
389 $canonicalOutput = $updater2->getCanonicalParserOutput();
390 $this->assertContains( 'second', $canonicalOutput->getText() );
394 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
396 public function testPrepareUpdateReusesParserOutput() {
397 $user = $this->getTestUser()->getUser();
398 $page = $this->getPage( __METHOD__
);
400 $mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
402 $update = new RevisionSlotsUpdate();
403 $update->modifyContent( SlotRecord
::MAIN
, $mainContent1 );
404 $updater = $this->getDerivedPageDataUpdater( $page );
405 $updater->prepareContent( $user, $update, false );
407 $mainOutput = $updater->getSlotParserOutput( SlotRecord
::MAIN
);
408 $canonicalOutput = $updater->getCanonicalParserOutput();
410 $rev = $this->createRevision( $page, 'first', $mainContent1 );
412 $options = []; // TODO: test *all* the options...
413 $updater->prepareUpdate( $rev, $options );
415 $this->assertTrue( $updater->isUpdatePrepared() );
416 $this->assertTrue( $updater->isContentPrepared() );
418 $this->assertSame( $mainOutput, $updater->getSlotParserOutput( SlotRecord
::MAIN
) );
419 $this->assertSame( $canonicalOutput, $updater->getCanonicalParserOutput() );
423 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate()
424 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput()
426 public function testPrepareUpdateOutputReset() {
427 $user = $this->getTestUser()->getUser();
428 $page = $this->getPage( __METHOD__
);
430 $mainContent1 = new WikitextContent( 'first --{{REVISIONID}}--' );
432 $update = new RevisionSlotsUpdate();
433 $update->modifyContent( SlotRecord
::MAIN
, $mainContent1 );
434 $updater = $this->getDerivedPageDataUpdater( $page );
435 $updater->prepareContent( $user, $update, false );
437 $mainOutput = $updater->getSlotParserOutput( SlotRecord
::MAIN
);
438 $canonicalOutput = $updater->getCanonicalParserOutput();
440 // prevent optimization on matching speculative ID
441 $mainOutput->setSpeculativeRevIdUsed( 0 );
442 $canonicalOutput->setSpeculativeRevIdUsed( 0 );
444 $rev = $this->createRevision( $page, 'first', $mainContent1 );
446 $options = []; // TODO: test *all* the options...
447 $updater->prepareUpdate( $rev, $options );
449 $this->assertTrue( $updater->isUpdatePrepared() );
450 $this->assertTrue( $updater->isContentPrepared() );
452 // ParserOutput objects should have been flushed.
453 $this->assertNotSame( $mainOutput, $updater->getSlotParserOutput( SlotRecord
::MAIN
) );
454 $this->assertNotSame( $canonicalOutput, $updater->getCanonicalParserOutput() );
456 $html = $updater->getCanonicalParserOutput()->getText();
457 $this->assertContains( '--' . $rev->getId() . '--', $html );
459 // TODO: MCR: ensure that when the main slot uses {{REVISIONID}} but another slot is
460 // updated, the main slot is still re-rendered!
463 // TODO: test failure of prepareUpdate() when called again with a different revision
464 // TODO: test failure of prepareUpdate() on inconsistency with prepareContent.
467 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getPreparedEdit()
469 public function testGetPreparedEditAfterPrepareContent() {
470 $user = $this->getTestUser()->getUser();
472 $mainContent = new WikitextContent( 'first [[main]] ~~~' );
473 $update = new RevisionSlotsUpdate();
474 $update->modifyContent( SlotRecord
::MAIN
, $mainContent );
476 $updater = $this->getDerivedPageDataUpdater( __METHOD__
);
477 $updater->prepareContent( $user, $update, false );
479 $canonicalOutput = $updater->getCanonicalParserOutput();
481 $preparedEdit = $updater->getPreparedEdit();
482 $this->assertSame( $canonicalOutput->getCacheTime(), $preparedEdit->timestamp
);
483 $this->assertSame( $canonicalOutput, $preparedEdit->output
);
484 $this->assertSame( $mainContent, $preparedEdit->newContent
);
485 $this->assertSame( $updater->getRawContent( SlotRecord
::MAIN
), $preparedEdit->pstContent
);
486 $this->assertSame( $updater->getCanonicalParserOptions(), $preparedEdit->popts
);
487 $this->assertSame( null, $preparedEdit->revid
);
491 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getPreparedEdit()
493 public function testGetPreparedEditAfterPrepareUpdate() {
494 $clock = MWTimestamp
::convert( TS_UNIX
, '20100101000000' );
495 MWTimestamp
::setFakeTime( function () use ( &$clock ) {
499 $page = $this->getPage( __METHOD__
);
501 $mainContent = new WikitextContent( 'first [[main]] ~~~' );
502 $update = new MutableRevisionSlots();
503 $update->setContent( SlotRecord
::MAIN
, $mainContent );
505 $rev = $this->createRevision( $page, __METHOD__
);
507 $updater = $this->getDerivedPageDataUpdater( $page );
508 $updater->prepareUpdate( $rev );
510 $canonicalOutput = $updater->getCanonicalParserOutput();
512 $preparedEdit = $updater->getPreparedEdit();
513 $this->assertSame( $canonicalOutput->getCacheTime(), $preparedEdit->timestamp
);
514 $this->assertSame( $canonicalOutput, $preparedEdit->output
);
515 $this->assertSame( $updater->getRawContent( SlotRecord
::MAIN
), $preparedEdit->pstContent
);
516 $this->assertSame( $updater->getCanonicalParserOptions(), $preparedEdit->popts
);
517 $this->assertSame( $rev->getId(), $preparedEdit->revid
);
520 public function testGetSecondaryDataUpdatesAfterPrepareContent() {
521 $user = $this->getTestUser()->getUser();
522 $page = $this->getPage( __METHOD__
);
523 $this->createRevision( $page, __METHOD__
);
525 $mainContent1 = new WikitextContent( 'first' );
527 $update = new RevisionSlotsUpdate();
528 $update->modifyContent( SlotRecord
::MAIN
, $mainContent1 );
529 $updater = $this->getDerivedPageDataUpdater( $page );
530 $updater->prepareContent( $user, $update, false );
532 $dataUpdates = $updater->getSecondaryDataUpdates();
534 $this->assertNotEmpty( $dataUpdates );
536 $linksUpdates = array_filter( $dataUpdates, function ( $du ) {
537 return $du instanceof LinksUpdate
;
539 $this->assertCount( 1, $linksUpdates );
543 * @param string $name
545 * @return ContentHandler
547 private function defineMockContentModelForUpdateTesting( $name ) {
548 /** @var ContentHandler|MockObject $handler */
549 $handler = $this->getMockBuilder( TextContentHandler
::class )
550 ->setConstructorArgs( [ $name ] )
552 [ 'getSecondaryDataUpdates', 'getDeletionUpdates', 'unserializeContent' ]
556 $dataUpdate = new MWCallableUpdate( 'time' );
557 $dataUpdate->_name
= "$name data update";
559 $deletionUpdate = new MWCallableUpdate( 'time' );
560 $deletionUpdate->_name
= "$name deletion update";
562 $handler->method( 'getSecondaryDataUpdates' )->willReturn( [ $dataUpdate ] );
563 $handler->method( 'getDeletionUpdates' )->willReturn( [ $deletionUpdate ] );
564 $handler->method( 'unserializeContent' )->willReturnCallback(
565 function ( $text ) use ( $handler ) {
566 return $this->createMockContent( $handler, $text );
570 $this->mergeMwGlobalArrayValue(
571 'wgContentHandlers', [
572 $name => function () use ( $handler ){
582 * @param ContentHandler $handler
583 * @param string $text
587 private function createMockContent( ContentHandler
$handler, $text ) {
588 /** @var Content|MockObject $content */
589 $content = $this->getMockBuilder( TextContent
::class )
590 ->setConstructorArgs( [ $text ] )
591 ->setMethods( [ 'getModel', 'getContentHandler' ] )
594 $content->method( 'getModel' )->willReturn( $handler->getModelID() );
595 $content->method( 'getContentHandler' )->willReturn( $handler );
600 public function testGetSecondaryDataUpdatesWithSlotRemoval() {
601 if ( !$this->hasMultiSlotSupport() ) {
602 $this->markTestSkipped( 'Slot removal cannot happen with MCR being enabled' );
605 $m1 = $this->defineMockContentModelForUpdateTesting( 'M1' );
606 $a1 = $this->defineMockContentModelForUpdateTesting( 'A1' );
607 $m2 = $this->defineMockContentModelForUpdateTesting( 'M2' );
609 MediaWikiServices
::getInstance()->getSlotRoleRegistry()->defineRoleWithModel(
614 $mainContent1 = $this->createMockContent( $m1, 'main 1' );
615 $auxContent1 = $this->createMockContent( $a1, 'aux 1' );
616 $mainContent2 = $this->createMockContent( $m2, 'main 2' );
618 $user = $this->getTestUser()->getUser();
619 $page = $this->getPage( __METHOD__
);
620 $this->createRevision(
623 [ 'main' => $mainContent1, 'aux' => $auxContent1 ]
626 $update = new RevisionSlotsUpdate();
627 $update->modifyContent( SlotRecord
::MAIN
, $mainContent2 );
628 $update->removeSlot( 'aux' );
630 $page = $this->getPage( __METHOD__
);
631 $updater = $this->getDerivedPageDataUpdater( $page );
632 $updater->prepareContent( $user, $update, false );
634 $dataUpdates = $updater->getSecondaryDataUpdates();
636 $this->assertNotEmpty( $dataUpdates );
638 $updateNames = array_map( function ( $du ) {
639 return isset( $du->_name
) ?
$du->_name
: get_class( $du );
642 $this->assertContains( LinksUpdate
::class, $updateNames );
643 $this->assertContains( 'A1 deletion update', $updateNames );
644 $this->assertContains( 'M2 data update', $updateNames );
645 $this->assertNotContains( 'M1 data update', $updateNames );
649 * Creates a dummy revision object without touching the database.
651 * @param Title $title
652 * @param RevisionSlotsUpdate $update
654 * @param string $comment
656 * @param int $parentId
658 * @return MutableRevisionRecord
660 private function makeRevision(
662 RevisionSlotsUpdate
$update,
668 $rev = new MutableRevisionRecord( $title );
670 $rev->applyUpdate( $update );
671 $rev->setUser( $user );
672 $rev->setComment( CommentStoreComment
::newUnsavedComment( $comment ) );
673 $rev->setPageId( $title->getArticleID() );
674 $rev->setParentId( $parentId );
687 private function getMockTitle( $id = 23 ) {
688 $mock = $this->getMockBuilder( Title
::class )
689 ->disableOriginalConstructor()
691 $mock->expects( $this->any() )
692 ->method( 'getDBkey' )
693 ->will( $this->returnValue( __CLASS__
) );
694 $mock->expects( $this->any() )
695 ->method( 'getArticleID' )
696 ->will( $this->returnValue( $id ) );
701 public function provideIsReusableFor() {
702 $title = $this->getMockTitle();
704 $user1 = User
::newFromName( 'Alice' );
705 $user2 = User
::newFromName( 'Bob' );
707 $content1 = new WikitextContent( 'one' );
708 $content2 = new WikitextContent( 'two' );
710 $update1 = new RevisionSlotsUpdate();
711 $update1->modifyContent( SlotRecord
::MAIN
, $content1 );
713 $update1b = new RevisionSlotsUpdate();
714 $update1b->modifyContent( 'xyz', $content1 );
716 $update2 = new RevisionSlotsUpdate();
717 $update2->modifyContent( SlotRecord
::MAIN
, $content2 );
719 $rev1 = $this->makeRevision( $title, $update1, $user1, 'rev1', 11 );
720 $rev1b = $this->makeRevision( $title, $update1b, $user1, 'rev1', 11 );
722 $rev2 = $this->makeRevision( $title, $update2, $user1, 'rev2', 12 );
723 $rev2x = $this->makeRevision( $title, $update2, $user2, 'rev2', 12 );
724 $rev2y = $this->makeRevision( $title, $update2, $user1, 'rev2', 122 );
728 '$prepRevision' => null,
729 '$prepUpdate' => null,
731 '$forRevision' => null,
732 '$forUpdate' => null,
733 '$forParent' => null,
734 '$isReusable' => true,
737 '$prepUser' => $user1,
738 '$prepRevision' => $rev1,
739 '$prepUpdate' => $update1,
741 '$forRevision' => null,
742 '$forUpdate' => null,
743 '$forParent' => null,
744 '$isReusable' => true,
746 yield
'unprepared' => [
748 '$prepRevision' => null,
749 '$prepUpdate' => null,
750 '$forUser' => $user1,
751 '$forRevision' => $rev1,
752 '$forUpdate' => $update1,
754 '$isReusable' => true,
756 yield
'match prepareContent' => [
757 '$prepUser' => $user1,
758 '$prepRevision' => null,
759 '$prepUpdate' => $update1,
760 '$forUser' => $user1,
761 '$forRevision' => null,
762 '$forUpdate' => $update1,
764 '$isReusable' => true,
766 yield
'match prepareUpdate' => [
768 '$prepRevision' => $rev1,
769 '$prepUpdate' => null,
770 '$forUser' => $user1,
771 '$forRevision' => $rev1,
772 '$forUpdate' => null,
774 '$isReusable' => true,
776 yield
'match all' => [
777 '$prepUser' => $user1,
778 '$prepRevision' => $rev1,
779 '$prepUpdate' => $update1,
780 '$forUser' => $user1,
781 '$forRevision' => $rev1,
782 '$forUpdate' => $update1,
784 '$isReusable' => true,
786 yield
'mismatch prepareContent update' => [
787 '$prepUser' => $user1,
788 '$prepRevision' => null,
789 '$prepUpdate' => $update1,
790 '$forUser' => $user1,
791 '$forRevision' => null,
792 '$forUpdate' => $update1b,
794 '$isReusable' => false,
796 yield
'mismatch prepareContent user' => [
797 '$prepUser' => $user1,
798 '$prepRevision' => null,
799 '$prepUpdate' => $update1,
800 '$forUser' => $user2,
801 '$forRevision' => null,
802 '$forUpdate' => $update1,
804 '$isReusable' => false,
806 yield
'mismatch prepareContent parent' => [
807 '$prepUser' => $user1,
808 '$prepRevision' => null,
809 '$prepUpdate' => $update1,
810 '$forUser' => $user1,
811 '$forRevision' => null,
812 '$forUpdate' => $update1,
814 '$isReusable' => false,
816 yield
'mismatch prepareUpdate revision update' => [
818 '$prepRevision' => $rev1,
819 '$prepUpdate' => null,
821 '$forRevision' => $rev1b,
822 '$forUpdate' => null,
824 '$isReusable' => false,
826 yield
'mismatch prepareUpdate revision id' => [
828 '$prepRevision' => $rev2,
829 '$prepUpdate' => null,
831 '$forRevision' => $rev2y,
832 '$forUpdate' => null,
834 '$isReusable' => false,
839 * @dataProvider provideIsReusableFor
840 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isReusableFor()
842 * @param User|null $prepUser
843 * @param RevisionRecord|null $prepRevision
844 * @param RevisionSlotsUpdate|null $prepUpdate
845 * @param User|null $forUser
846 * @param RevisionRecord|null $forRevision
847 * @param RevisionSlotsUpdate|null $forUpdate
848 * @param int|null $forParent
849 * @param bool $isReusable
851 public function testIsReusableFor(
852 User
$prepUser = null,
853 RevisionRecord
$prepRevision = null,
854 RevisionSlotsUpdate
$prepUpdate = null,
855 User
$forUser = null,
856 RevisionRecord
$forRevision = null,
857 RevisionSlotsUpdate
$forUpdate = null,
861 $updater = $this->getDerivedPageDataUpdater( __METHOD__
);
864 $updater->prepareContent( $prepUser, $prepUpdate, false );
867 if ( $prepRevision ) {
868 $updater->prepareUpdate( $prepRevision );
873 $updater->isReusableFor( $forUser, $forRevision, $forUpdate, $forParent )
878 * * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCountable
880 public function testIsCountableNotContentPage() {
881 $updater = $this->getDerivedPageDataUpdater(
882 Title
::newFromText( 'Main_Page', NS_TALK
)
884 self
::assertFalse( $updater->isCountable() );
887 public function provideIsCountable() {
888 yield
'deleted revision' => [
889 '$articleCountMethod' => 'any',
890 '$wikitextContent' => 'Test',
891 '$revisionVisibility' => RevisionRecord
::SUPPRESSED_ALL
,
892 '$isCountable' => false
894 yield
'redirect' => [
895 '$articleCountMethod' => 'any',
896 '$wikitextContent' => '#REDIRECT [[Main_Page]]',
897 '$revisionVisibility' => 0,
898 '$isCountable' => false
900 yield
'no links count method any' => [
901 '$articleCountMethod' => 'any',
902 '$wikitextContent' => 'Test',
903 '$revisionVisibility' => 0,
904 '$isCountable' => true
906 yield
'no links count method link' => [
907 '$articleCountMethod' => 'link',
908 '$wikitextContent' => 'Test',
909 '$revisionVisibility' => 0,
910 '$isCountable' => false
912 yield
'with links count method link' => [
913 '$articleCountMethod' => 'link',
914 '$wikitextContent' => '[[Test]]',
915 '$revisionVisibility' => 0,
916 '$isCountable' => true
921 * @dataProvider provideIsCountable
923 * @param string $articleCountMethod
924 * @param string $wikitextContent
925 * @param int $revisionVisibility
926 * @param bool $isCountable
927 * @throws \MWException
928 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCountable
930 public function testIsCountable(
936 $this->setMwGlobals( [ 'wgArticleCountMethod' => $articleCountMethod ] );
937 $title = $this->getTitle( 'Main_Page' );
938 $content = new WikitextContent( $wikitextContent );
939 $update = new RevisionSlotsUpdate();
940 $update->modifyContent( SlotRecord
::MAIN
, $content );
941 $revision = $this->makeRevision( $title, $update, User
::newFromName( 'Alice' ), 'rev1', 13 );
942 $revision->setVisibility( $revisionVisibility );
943 $updater = $this->getDerivedPageDataUpdater( $title );
944 $updater->prepareUpdate( $revision );
945 self
::assertSame( $isCountable, $updater->isCountable() );
949 * @throws \MWException
950 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCountable
952 public function testIsCountableNoModifiedSlots() {
953 $page = $this->getPage( __METHOD__
);
954 $content = [ 'main' => new WikitextContent( '[[Test]]' ) ];
955 $rev = $this->createRevision( $page, 'first', $content );
956 $nullRevision = MutableRevisionRecord
::newFromParentRevision( $rev );
957 $nullRevision->setId( 14 );
958 $updater = $this->getDerivedPageDataUpdater( $page, $nullRevision );
959 $updater->prepareUpdate( $nullRevision );
960 $this->assertTrue( $updater->isCountable() );
964 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates()
965 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates()
966 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
968 public function testDoUpdates() {
969 $page = $this->getPage( __METHOD__
);
971 $content = [ 'main' => new WikitextContent( 'first [[main]]' ) ];
973 if ( $this->hasMultiSlotSupport() ) {
974 $content['aux'] = new WikitextContent( 'Aux [[Nix]]' );
976 MediaWikiServices
::getInstance()->getSlotRoleRegistry()->defineRoleWithModel(
978 CONTENT_MODEL_WIKITEXT
982 $rev = $this->createRevision( $page, 'first', $content );
983 $pageId = $page->getId();
985 $oldStats = $this->db
->selectRow( 'site_stats', '*', '1=1' );
986 $this->db
->delete( 'pagelinks', '*' );
988 $pcache = MediaWikiServices
::getInstance()->getParserCache();
989 $pcache->deleteOptionsKey( $page );
991 $updater = $this->getDerivedPageDataUpdater( $page, $rev );
992 $updater->setArticleCountMethod( 'link' );
994 $options = []; // TODO: test *all* the options...
995 $updater->prepareUpdate( $rev, $options );
997 $updater->doUpdates();
999 // links table update
1000 $pageLinks = $this->db
->select(
1003 [ 'pl_from' => $pageId ],
1005 [ 'ORDER BY' => 'pl_namespace, pl_title' ]
1008 $pageLinksRow = $pageLinks->fetchObject();
1009 $this->assertInternalType( 'object', $pageLinksRow );
1010 $this->assertSame( 'Main', $pageLinksRow->pl_title
);
1012 if ( $this->hasMultiSlotSupport() ) {
1013 $pageLinksRow = $pageLinks->fetchObject();
1014 $this->assertInternalType( 'object', $pageLinksRow );
1015 $this->assertSame( 'Nix', $pageLinksRow->pl_title
);
1018 // parser cache update
1019 $cached = $pcache->get( $page, $updater->getCanonicalParserOptions() );
1020 $this->assertInternalType( 'object', $cached );
1021 $this->assertSame( $updater->getCanonicalParserOutput(), $cached );
1024 $stats = $this->db
->selectRow( 'site_stats', '*', '1=1' );
1025 $this->assertSame( $oldStats->ss_total_pages +
1, (int)$stats->ss_total_pages
);
1026 $this->assertSame( $oldStats->ss_total_edits +
1, (int)$stats->ss_total_edits
);
1027 $this->assertSame( $oldStats->ss_good_articles +
1, (int)$stats->ss_good_articles
);
1029 // TODO: MCR: test data updates for additional slots!
1030 // TODO: test update for edit without page creation
1031 // TODO: test message cache purge
1032 // TODO: test module cache purge
1033 // TODO: test CDN purge
1034 // TODO: test newtalk update
1035 // TODO: test search update
1036 // TODO: test site stats good_articles while turning the page into (or back from) a redir.
1037 // TODO: test category membership update (with setRcWatchCategoryMembership())
1041 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates()
1042 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates()
1043 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
1045 public function testDoUpdatesCacheSaveDeferral_canonical() {
1046 $page = $this->getPage( __METHOD__
);
1048 // Case where user has canonical parser options
1049 $content = [ 'main' => new WikitextContent( 'rev ID ver #1: {{REVISIONID}}' ) ];
1050 $rev = $this->createRevision( $page, 'first', $content );
1051 $pcache = MediaWikiServices
::getInstance()->getParserCache();
1052 $pcache->deleteOptionsKey( $page );
1054 $this->db
->startAtomic( __METHOD__
); // let deferred updates queue up
1056 $updater = $this->getDerivedPageDataUpdater( $page, $rev );
1057 $updater->prepareUpdate( $rev, [] );
1058 $updater->doUpdates();
1060 $this->assertGreaterThan( 0, DeferredUpdates
::pendingUpdatesCount(), 'Pending updates' );
1061 $this->assertNotFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) );
1063 $this->db
->endAtomic( __METHOD__
); // run deferred updates
1065 $this->assertEquals( 0, DeferredUpdates
::pendingUpdatesCount(), 'No pending updates' );
1069 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates()
1070 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates()
1071 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
1073 public function testDoUpdatesCacheSaveDeferral_noncanonical() {
1074 $page = $this->getPage( __METHOD__
);
1076 // Case where user does not have canonical parser options
1077 $user = $this->getMutableTestUser()->getUser();
1080 $user->getOption( 'thumbsize' ) +
1
1082 $content = [ 'main' => new WikitextContent( 'rev ID ver #2: {{REVISIONID}}' ) ];
1083 $rev = $this->createRevision( $page, 'first', $content, $user );
1084 $pcache = MediaWikiServices
::getInstance()->getParserCache();
1085 $pcache->deleteOptionsKey( $page );
1087 $this->db
->startAtomic( __METHOD__
); // let deferred updates queue up
1089 $updater = $this->getDerivedPageDataUpdater( $page, $rev, $user );
1090 $updater->prepareUpdate( $rev, [] );
1091 $updater->doUpdates();
1093 $this->assertGreaterThan( 1, DeferredUpdates
::pendingUpdatesCount(), 'Pending updates' );
1094 $this->assertFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) );
1096 $this->db
->endAtomic( __METHOD__
); // run deferred updates
1098 $this->assertEquals( 0, DeferredUpdates
::pendingUpdatesCount(), 'No pending updates' );
1099 $this->assertNotFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) );
1103 * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate()
1105 public function testDoParserCacheUpdate() {
1106 if ( $this->hasMultiSlotSupport() ) {
1107 MediaWikiServices
::getInstance()->getSlotRoleRegistry()->defineRoleWithModel(
1109 CONTENT_MODEL_WIKITEXT
1113 $page = $this->getPage( __METHOD__
);
1114 $this->createRevision( $page, 'Dummy' );
1116 $user = $this->getTestUser()->getUser();
1118 $update = new RevisionSlotsUpdate();
1119 $update->modifyContent( 'main', new WikitextContent( 'first [[Main]]' ) );
1121 if ( $this->hasMultiSlotSupport() ) {
1122 $update->modifyContent( 'aux', new WikitextContent( 'Aux [[Nix]]' ) );
1125 // Emulate update after edit ----------
1126 $pcache = MediaWikiServices
::getInstance()->getParserCache();
1127 $pcache->deleteOptionsKey( $page );
1129 $rev = $this->makeRevision( $page->getTitle(), $update, $user, 'rev', null );
1130 $rev->setTimestamp( '20100101000000' );
1131 $rev->setParentId( $page->getLatest() );
1133 $updater = $this->getDerivedPageDataUpdater( $page );
1134 $updater->prepareContent( $user, $update, false );
1137 $updater->prepareUpdate( $rev );
1139 // Force the page timestamp, so we notice whether ParserOutput::getTimestamp
1140 // or ParserOutput::getCacheTime are used.
1141 $page->setTimestamp( $rev->getTimestamp() );
1142 $updater->doParserCacheUpdate();
1144 // The cached ParserOutput should not use the revision timestamp
1145 $cached = $pcache->get( $page, $updater->getCanonicalParserOptions(), true );
1146 $this->assertInternalType( 'object', $cached );
1147 $this->assertSame( $updater->getCanonicalParserOutput(), $cached );
1149 $this->assertSame( $rev->getTimestamp(), $cached->getCacheTime() );
1150 $this->assertSame( $rev->getId(), $cached->getCacheRevisionId() );
1152 // Emulate forced update of an old revision ----------
1153 $pcache->deleteOptionsKey( $page );
1155 $updater = $this->getDerivedPageDataUpdater( $page );
1156 $updater->prepareUpdate( $rev );
1158 // Force the page timestamp, so we notice whether ParserOutput::getTimestamp
1159 // or ParserOutput::getCacheTime are used.
1160 $page->setTimestamp( $rev->getTimestamp() );
1161 $updater->doParserCacheUpdate();
1163 // The cached ParserOutput should not use the revision timestamp
1164 $cached = $pcache->get( $page, $updater->getCanonicalParserOptions(), true );
1165 $this->assertInternalType( 'object', $cached );
1166 $this->assertSame( $updater->getCanonicalParserOutput(), $cached );
1168 $this->assertGreaterThan( $rev->getTimestamp(), $cached->getCacheTime() );
1169 $this->assertSame( $rev->getId(), $cached->getCacheRevisionId() );
1175 private function hasMultiSlotSupport() {
1176 global $wgMultiContentRevisionSchemaMigrationStage;
1178 return ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW
)
1179 && ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW
);