getDefaultWikitextNS(), $title ); } /** * @param string|Title $title * * @return WikiPage */ private function getPage( $title ) { $title = ( $title instanceof Title ) ? $title : $this->getTitle( $title ); return $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title ); } /** * @param string|Title|WikiPage $page * @param RevisionRecord|null $rec * @param User|null $user * * @return DerivedPageDataUpdater */ private function getDerivedPageDataUpdater( $page, RevisionRecord $rec = null, User $user = null ) { if ( is_string( $page ) || $page instanceof Title ) { $page = $this->getPage( $page ); } $page = TestingAccessWrapper::newFromObject( $page ); return $page->getDerivedDataUpdater( $user, $rec ); } /** * Creates a revision in the database. * * @param WikiPage $page * @param string|Message|CommentStoreComment $summary * @param null|string|Content|Content[] $content * @param User|null $user * * @return RevisionRecord|null */ private function createRevision( WikiPage $page, $summary, $content = null, $user = null ) { $user = $user ?: $this->getTestUser()->getUser(); $comment = CommentStoreComment::newUnsavedComment( $summary ); if ( $content === null || is_string( $content ) ) { $content = new WikitextContent( $content ?? $summary ); } if ( !is_array( $content ) ) { $content = [ SlotRecord::MAIN => $content ]; } $this->getDerivedPageDataUpdater( $page ); // flush cached instance before. $updater = $page->newPageUpdater( $user ); foreach ( $content as $role => $c ) { $updater->setContent( $role, $c ); } $rev = $updater->saveRevision( $comment ); if ( !$updater->wasSuccessful() ) { $this->fail( $updater->getStatus()->getWikiText() ); } $this->getDerivedPageDataUpdater( $page ); // flush cached instance after. return $rev; } // TODO: test setArticleCountMethod() and isCountable(); // TODO: test isRedirect() and wasRedirect() /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOptions() */ public function testGetCanonicalParserOptions() { $user = $this->getTestUser()->getUser(); $page = $this->getPage( __METHOD__ ); $parentRev = $this->createRevision( $page, 'first' ); $mainContent = new WikitextContent( 'Lorem ipsum' ); $update = new RevisionSlotsUpdate(); $update->modifyContent( SlotRecord::MAIN, $mainContent ); $updater = $this->getDerivedPageDataUpdater( $page ); $updater->prepareContent( $user, $update, false ); $options1 = $updater->getCanonicalParserOptions(); $this->assertSame( $this->getServiceContainer()->getContentLanguage(), $options1->getUserLangObj() ); $speculativeId = $options1->getSpeculativeRevId(); $this->assertSame( $parentRev->getId() + 1, $speculativeId ); $rev = $this->makeRevision( $page->getTitle(), $update, $user, $parentRev->getId() + 7, $parentRev->getId() ); $updater->prepareUpdate( $rev ); $options2 = $updater->getCanonicalParserOptions(); $currentRev = $options2->getCurrentRevisionRecordCallback()( $page->getTitle() ); $this->assertSame( $rev->getId(), $currentRev->getId() ); } /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::grabCurrentRevision() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted() */ public function testGrabCurrentRevision() { $page = $this->getPage( __METHOD__ ); $updater0 = $this->getDerivedPageDataUpdater( $page ); $this->assertNull( $updater0->grabCurrentRevision() ); $this->assertFalse( $updater0->pageExisted() ); $rev1 = $this->createRevision( $page, 'first' ); $updater1 = $this->getDerivedPageDataUpdater( $page ); $this->assertSame( $rev1->getId(), $updater1->grabCurrentRevision()->getId() ); $this->assertFalse( $updater0->pageExisted() ); $this->assertTrue( $updater1->pageExisted() ); $rev2 = $this->createRevision( $page, 'second' ); $updater2 = $this->getDerivedPageDataUpdater( $page ); $this->assertSame( $rev1->getId(), $updater1->grabCurrentRevision()->getId() ); $this->assertSame( $rev2->getId(), $updater2->grabCurrentRevision()->getId() ); } /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareContent() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isContentPrepared() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlots() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawSlot() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawContent() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getModifiedSlotRoles() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getTouchedSlotRoles() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput() */ public function testPrepareContent() { $slotRoleRegistry = $this->getServiceContainer()->getSlotRoleRegistry(); if ( !$slotRoleRegistry->isDefinedRole( 'aux' ) ) { $slotRoleRegistry->defineRoleWithModel( 'aux', CONTENT_MODEL_WIKITEXT ); } $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); $updater = $this->getDerivedPageDataUpdater( __METHOD__ ); $this->assertFalse( $updater->isContentPrepared() ); // TODO: test stash // TODO: MCR: Test multiple slots. Test slot removal. $mainContent = new WikitextContent( 'first [[main]] ~~~' ); $auxContent = new WikitextContent( 'inherited ~~~ content' ); $auxSlot = SlotRecord::newSaved( 10, 7, 'tt:7', SlotRecord::newUnsaved( 'aux', $auxContent ) ); $update = new RevisionSlotsUpdate(); $update->modifyContent( SlotRecord::MAIN, $mainContent ); $update->modifySlot( SlotRecord::newInherited( $auxSlot ) ); // TODO: MCR: test removing slots! $updater->prepareContent( $sysop, $update, false ); // second be ok to call again with the same params $updater->prepareContent( $sysop, $update, false ); $this->assertNull( $updater->grabCurrentRevision() ); $this->assertTrue( $updater->isContentPrepared() ); $this->assertFalse( $updater->isUpdatePrepared() ); $this->assertFalse( $updater->pageExisted() ); $this->assertTrue( $updater->isCreation() ); $this->assertTrue( $updater->isChange() ); $this->assertFalse( $updater->isContentDeleted() ); $this->assertNotNull( $updater->getRevision() ); $this->assertNotNull( $updater->getRenderedRevision() ); $this->assertEquals( [ SlotRecord::MAIN, 'aux' ], $updater->getSlots()->getSlotRoles() ); $this->assertEquals( [ SlotRecord::MAIN ], array_keys( $updater->getSlots()->getOriginalSlots() ) ); $this->assertEquals( [ 'aux' ], array_keys( $updater->getSlots()->getInheritedSlots() ) ); $this->assertEquals( [ SlotRecord::MAIN, 'aux' ], $updater->getModifiedSlotRoles() ); $this->assertEquals( [ SlotRecord::MAIN, 'aux' ], $updater->getTouchedSlotRoles() ); $mainSlot = $updater->getRawSlot( SlotRecord::MAIN ); $this->assertInstanceOf( SlotRecord::class, $mainSlot ); $this->assertStringNotContainsString( '~~~', $mainSlot->getContent()->serialize(), 'PST should apply.' ); $this->assertStringContainsString( $sysop->getName(), $mainSlot->getContent()->serialize() ); $auxSlot = $updater->getRawSlot( 'aux' ); $this->assertInstanceOf( SlotRecord::class, $auxSlot ); $this->assertStringContainsString( '~~~', $auxSlot->getContent()->serialize(), 'No PST should apply.' ); $mainOutput = $updater->getCanonicalParserOutput(); $this->assertStringContainsString( 'first', $mainOutput->getText() ); $this->assertStringContainsString( 'getText() ); $this->assertNotEmpty( $mainOutput->getLinks() ); $canonicalOutput = $updater->getCanonicalParserOutput(); $this->assertStringContainsString( 'first', $canonicalOutput->getText() ); $this->assertStringContainsString( 'getText() ); $this->assertStringContainsString( 'inherited ', $canonicalOutput->getText() ); $this->assertNotEmpty( $canonicalOutput->getLinks() ); } /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareContent() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::pageExisted() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange() */ public function testPrepareContentInherit() { $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); $page = $this->getPage( __METHOD__ ); $mainContent1 = new WikitextContent( 'first [[main]] ({{REVISIONUSER}}) #~~~#' ); $mainContent2 = new WikitextContent( 'second ({{subst:REVISIONUSER}}) #~~~#' ); $rev = $this->createRevision( $page, 'first', $mainContent1 ); $mainContent1 = $rev->getContent( SlotRecord::MAIN ); // get post-pst content $userName = $rev->getUser()->getName(); $sysopName = $sysop->getName(); $update = new RevisionSlotsUpdate(); $update->modifyContent( SlotRecord::MAIN, $mainContent1 ); $updater1 = $this->getDerivedPageDataUpdater( $page ); $updater1->prepareContent( $sysop, $update, false ); $this->assertNotNull( $updater1->grabCurrentRevision() ); $this->assertTrue( $updater1->isContentPrepared() ); $this->assertTrue( $updater1->pageExisted() ); $this->assertFalse( $updater1->isCreation() ); $this->assertFalse( $updater1->isChange() ); $this->assertNotNull( $updater1->getRevision() ); $this->assertNotNull( $updater1->getRenderedRevision() ); // parser-output for null-edit uses the original author's name $html = $updater1->getRenderedRevision()->getRevisionParserOutput()->getText(); $this->assertStringNotContainsString( $sysopName, $html, '{{REVISIONUSER}}' ); $this->assertStringNotContainsString( '{{REVISIONUSER}}', $html, '{{REVISIONUSER}}' ); $this->assertStringNotContainsString( '~~~', $html, 'signature ~~~' ); $this->assertStringContainsString( '(' . $userName . ')', $html, '{{REVISIONUSER}}' ); $this->assertStringContainsString( '>' . $userName . '<', $html, 'signature ~~~' ); // prepare forced dummy revision $emptyUpdate = new RevisionSlotsUpdate(); $updater1 = $this->getDerivedPageDataUpdater( $page ); $updater1->setForceEmptyRevision( true ); $updater1->prepareContent( $sysop, $emptyUpdate, false ); // dummy revision inherits slots, but not revision ID $mainSlot0 = $rev->getSlot( SlotRecord::MAIN ); $dummyRev = $updater1->getRevision(); $dummySlot = $dummyRev->getSlot( SlotRecord::MAIN ); $this->assertSame( $mainSlot0->getAddress(), $dummySlot->getAddress() ); $this->assertNull( $dummyRev->getId() ); $this->assertSame( $rev->getId(), $dummyRev->getParentId() ); // prepare non-null $update = new RevisionSlotsUpdate(); $update->modifyContent( SlotRecord::MAIN, $mainContent2 ); $updater2 = $this->getDerivedPageDataUpdater( $page ); $updater2->prepareContent( $sysop, $update, false ); // non-null edit use the new user name in PST $pstText = $updater2->getSlots()->getContent( SlotRecord::MAIN )->serialize(); $this->assertStringNotContainsString( '{{subst:REVISIONUSER}}', $pstText, '{{subst:REVISIONUSER}}' ); $this->assertStringNotContainsString( '~~~', $pstText, 'signature ~~~' ); $this->assertStringContainsString( '(' . $sysopName . ')', $pstText, '{{subst:REVISIONUSER}}' ); $this->assertStringContainsString( ':' . $sysopName . '|', $pstText, 'signature ~~~' ); $this->assertFalse( $updater2->isCreation() ); $this->assertTrue( $updater2->isChange() ); } // TODO: test failure of prepareContent() when called again... // - with different user // - with different update // - after calling prepareUpdate() /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isUpdatePrepared() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCreation() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlots() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawSlot() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getRawContent() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getModifiedSlotRoles() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getTouchedSlotRoles() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput() */ public function testPrepareUpdate() { $page = $this->getPage( __METHOD__ ); $mainContent1 = new WikitextContent( 'first [[main]] ~~~' ); $rev1 = $this->createRevision( $page, 'first', $mainContent1 ); $updater1 = $this->getDerivedPageDataUpdater( $page, $rev1 ); $options = []; // TODO: test *all* the options... $updater1->prepareUpdate( $rev1, $options ); $this->assertTrue( $updater1->isUpdatePrepared() ); $this->assertTrue( $updater1->isContentPrepared() ); $this->assertTrue( $updater1->isCreation() ); $this->assertTrue( $updater1->isChange() ); $this->assertFalse( $updater1->isContentDeleted() ); $this->assertNotNull( $updater1->getRevision() ); $this->assertNotNull( $updater1->getRenderedRevision() ); $this->assertEquals( [ SlotRecord::MAIN ], $updater1->getSlots()->getSlotRoles() ); $this->assertEquals( [ SlotRecord::MAIN ], array_keys( $updater1->getSlots()->getOriginalSlots() ) ); $this->assertEquals( [], array_keys( $updater1->getSlots()->getInheritedSlots() ) ); $this->assertEquals( [ SlotRecord::MAIN ], $updater1->getModifiedSlotRoles() ); $this->assertEquals( [ SlotRecord::MAIN ], $updater1->getTouchedSlotRoles() ); // TODO: MCR: test multiple slots, test slot removal! $this->assertInstanceOf( SlotRecord::class, $updater1->getRawSlot( SlotRecord::MAIN ) ); $this->assertStringNotContainsString( '~~~~', $updater1->getRawContent( SlotRecord::MAIN )->serialize() ); $mainOutput = $updater1->getCanonicalParserOutput(); $this->assertStringContainsString( 'first', $mainOutput->getText() ); $this->assertStringContainsString( 'getText() ); $this->assertNotEmpty( $mainOutput->getLinks() ); $canonicalOutput = $updater1->getCanonicalParserOutput(); $this->assertStringContainsString( 'first', $canonicalOutput->getText() ); $this->assertStringContainsString( 'getText() ); $this->assertNotEmpty( $canonicalOutput->getLinks() ); $mainContent2 = new WikitextContent( 'second' ); $rev2 = $this->createRevision( $page, 'second', $mainContent2 ); $updater2 = $this->getDerivedPageDataUpdater( $page, $rev2 ); $options = []; // TODO: test *all* the options... $updater2->prepareUpdate( $rev2, $options ); $this->assertFalse( $updater2->isCreation() ); $this->assertTrue( $updater2->isChange() ); $canonicalOutput = $updater2->getCanonicalParserOutput(); $this->assertStringContainsString( 'second', $canonicalOutput->getText() ); } /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate() */ public function testPrepareUpdateReusesParserOutput() { $user = $this->getTestUser()->getUser(); $page = $this->getPage( __METHOD__ ); $mainContent1 = new WikitextContent( 'first [[main]] ~~~' ); $update = new RevisionSlotsUpdate(); $update->modifyContent( SlotRecord::MAIN, $mainContent1 ); $updater = $this->getDerivedPageDataUpdater( $page ); $updater->prepareContent( $user, $update, false ); $mainOutput = $updater->getSlotParserOutput( SlotRecord::MAIN ); $canonicalOutput = $updater->getCanonicalParserOutput(); $rev = $this->createRevision( $page, 'first', $mainContent1 ); $options = []; // TODO: test *all* the options... $updater->prepareUpdate( $rev, $options ); $this->assertTrue( $updater->isUpdatePrepared() ); $this->assertTrue( $updater->isContentPrepared() ); $this->assertSame( $mainOutput, $updater->getSlotParserOutput( SlotRecord::MAIN ) ); $this->assertSame( $canonicalOutput, $updater->getCanonicalParserOutput() ); } /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::prepareUpdate() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getSlotParserOutput() */ public function testPrepareUpdateOutputReset() { $user = $this->getTestUser()->getUser(); $page = $this->getPage( __METHOD__ ); $mainContent1 = new WikitextContent( 'first --{{REVISIONID}}--' ); $update = new RevisionSlotsUpdate(); $update->modifyContent( SlotRecord::MAIN, $mainContent1 ); $updater = $this->getDerivedPageDataUpdater( $page ); $updater->prepareContent( $user, $update, false ); $mainOutput = $updater->getSlotParserOutput( SlotRecord::MAIN ); $canonicalOutput = $updater->getCanonicalParserOutput(); // prevent optimization on matching speculative ID $mainOutput->setSpeculativeRevIdUsed( 0 ); $canonicalOutput->setSpeculativeRevIdUsed( 0 ); $rev = $this->createRevision( $page, 'first', $mainContent1 ); $options = []; // TODO: test *all* the options... $updater->prepareUpdate( $rev, $options ); $this->assertTrue( $updater->isUpdatePrepared() ); $this->assertTrue( $updater->isContentPrepared() ); // ParserOutput objects should have been flushed. $this->assertNotSame( $mainOutput, $updater->getSlotParserOutput( SlotRecord::MAIN ) ); $this->assertNotSame( $canonicalOutput, $updater->getCanonicalParserOutput() ); $html = $updater->getCanonicalParserOutput()->getText(); $this->assertStringContainsString( '--' . $rev->getId() . '--', $html ); // TODO: MCR: ensure that when the main slot uses {{REVISIONID}} but another slot is // updated, the main slot is still re-rendered! } // TODO: test failure of prepareUpdate() when called again with a different revision // TODO: test failure of prepareUpdate() on inconsistency with prepareContent. /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getPreparedEdit() */ public function testGetPreparedEditAfterPrepareContent() { $user = $this->getTestUser()->getUser(); $mainContent = new WikitextContent( 'first [[main]] ~~~' ); $update = new RevisionSlotsUpdate(); $update->modifyContent( SlotRecord::MAIN, $mainContent ); $updater = $this->getDerivedPageDataUpdater( __METHOD__ ); $updater->prepareContent( $user, $update, false ); $canonicalOutput = $updater->getCanonicalParserOutput(); $preparedEdit = $updater->getPreparedEdit(); $this->assertSame( $canonicalOutput->getCacheTime(), $preparedEdit->timestamp ); $this->assertSame( $canonicalOutput, $preparedEdit->output ); $this->assertSame( $mainContent, $preparedEdit->newContent ); $this->assertSame( $updater->getRawContent( SlotRecord::MAIN ), $preparedEdit->pstContent ); $this->assertSame( $updater->getCanonicalParserOptions(), $preparedEdit->popts ); $this->assertSame( null, $preparedEdit->revid ); } /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getPreparedEdit() */ public function testGetPreparedEditAfterPrepareUpdate() { $clock = MWTimestamp::convert( TS_UNIX, '20100101000000' ); MWTimestamp::setFakeTime( static function () use ( &$clock ) { return $clock++; } ); $page = $this->getPage( __METHOD__ ); $mainContent = new WikitextContent( 'first [[main]] ~~~' ); $update = new MutableRevisionSlots(); $update->setContent( SlotRecord::MAIN, $mainContent ); $rev = $this->createRevision( $page, __METHOD__ ); $updater = $this->getDerivedPageDataUpdater( $page ); $updater->prepareUpdate( $rev ); $canonicalOutput = $updater->getCanonicalParserOutput(); $preparedEdit = $updater->getPreparedEdit(); $this->assertSame( $canonicalOutput->getCacheTime(), $preparedEdit->timestamp ); $this->assertSame( $canonicalOutput, $preparedEdit->output ); $this->assertSame( $updater->getRawContent( SlotRecord::MAIN ), $preparedEdit->pstContent ); $this->assertSame( $updater->getCanonicalParserOptions(), $preparedEdit->popts ); $this->assertSame( $rev->getId(), $preparedEdit->revid ); } public function testGetSecondaryDataUpdatesAfterPrepareContent() { $user = $this->getTestUser()->getUser(); $page = $this->getPage( __METHOD__ ); $this->createRevision( $page, __METHOD__ ); $mainContent1 = new WikitextContent( 'first' ); $update = new RevisionSlotsUpdate(); $update->modifyContent( SlotRecord::MAIN, $mainContent1 ); $updater = $this->getDerivedPageDataUpdater( $page ); $updater->prepareContent( $user, $update, false ); $dataUpdates = $updater->getSecondaryDataUpdates(); $this->assertNotEmpty( $dataUpdates ); $linksUpdates = array_filter( $dataUpdates, static function ( $du ) { return $du instanceof LinksUpdate; } ); $this->assertCount( 1, $linksUpdates ); } public function testAvoidSecondaryDataUpdatesOnNonHTMLContentHandlers() { $this->overrideConfigValue( MainConfigNames::ContentHandlers, [ CONTENT_MODEL_WIKITEXT => [ 'class' => WikitextContentHandler::class, 'services' => [ 'TitleFactory', 'ParserFactory', 'GlobalIdGenerator', 'LanguageNameUtils', 'LinkRenderer', 'MagicWordFactory', 'ParsoidParserFactory', ], ], 'testing' => DummyContentHandlerForTesting::class, ] ); $user = $this->getTestUser()->getUser(); $page = $this->getPage( __METHOD__ ); $this->createRevision( $page, __METHOD__ ); $contentHandler = new DummyContentHandlerForTesting( 'testing' ); $mainContent1 = $contentHandler->unserializeContent( serialize( 'first' ) ); $update = new RevisionSlotsUpdate(); $pcache = $this->getServiceContainer()->getParserCache(); $pcache->deleteOptionsKey( $page ); $rev = $this->createRevision( $page, 'first', $mainContent1 ); // Run updates $update->modifyContent( SlotRecord::MAIN, $mainContent1 ); $updater = $this->getDerivedPageDataUpdater( $page ); $updater->prepareContent( $user, $update, false ); $dataUpdates = $updater->getSecondaryDataUpdates(); $updater->prepareUpdate( $rev ); $updater->doUpdates(); // Links updates should be triggered $this->assertNotEmpty( $dataUpdates ); $linksUpdates = array_filter( $dataUpdates, static function ( $du ) { return $du instanceof LinksUpdate; } ); $this->assertCount( 1, $linksUpdates ); // Parser cache should not be populated. $cached = $pcache->get( $page, $updater->getCanonicalParserOptions() ); $this->assertFalse( $cached ); } public function testGetSecondaryDataUpdatesDeleted() { $user = $this->getTestUser()->getUser(); $page = $this->getPage( __METHOD__ ); $this->createRevision( $page, __METHOD__ ); $mainContent1 = new WikitextContent( 'first' ); $update = new RevisionSlotsUpdate(); $update->modifyContent( SlotRecord::MAIN, $mainContent1 ); $updater = $this->getDerivedPageDataUpdater( $page ); $updater->prepareContent( $user, $update, false ); // Test that nothing happens if the page was deleted in the meantime // This can happen when started by the job queue $this->deletePage( $page ); $dataUpdates = $updater->getSecondaryDataUpdates(); $this->assertSame( [], $dataUpdates ); } /** * @param string $name * * @return ContentHandler */ private function defineMockContentModelForUpdateTesting( $name ) { /** @var ContentHandler|MockObject $handler */ $handler = $this->getMockBuilder( TextContentHandler::class ) ->setConstructorArgs( [ $name ] ) ->onlyMethods( [ 'getSecondaryDataUpdates', 'getDeletionUpdates', 'unserializeContent' ] ) ->getMock(); $dataUpdate = new MWCallableUpdate( 'time', "$name data update" ); $deletionUpdate = new MWCallableUpdate( 'time', "$name deletion update" ); $handler->method( 'getSecondaryDataUpdates' )->willReturn( [ $dataUpdate ] ); $handler->method( 'getDeletionUpdates' )->willReturn( [ $deletionUpdate ] ); $handler->method( 'unserializeContent' )->willReturnCallback( function ( $text ) use ( $handler ) { return $this->createMockContent( $handler, $text ); } ); $this->mergeMwGlobalArrayValue( 'wgContentHandlers', [ $name => static function () use ( $handler ){ return $handler; } ] ); return $handler; } /** * @param ContentHandler $handler * @param string $text * * @return Content */ private function createMockContent( ContentHandler $handler, $text ) { /** @var Content|MockObject $content */ $content = $this->getMockBuilder( TextContent::class ) ->setConstructorArgs( [ $text ] ) ->onlyMethods( [ 'getModel', 'getContentHandler' ] ) ->getMock(); $content->method( 'getModel' )->willReturn( $handler->getModelID() ); $content->method( 'getContentHandler' )->willReturn( $handler ); return $content; } public function testGetSecondaryDataUpdatesWithSlotRemoval() { $m1 = $this->defineMockContentModelForUpdateTesting( 'M1' ); $a1 = $this->defineMockContentModelForUpdateTesting( 'A1' ); $m2 = $this->defineMockContentModelForUpdateTesting( 'M2' ); $role = 'dpdu-test-a1'; $slotRoleRegistry = $this->getServiceContainer()->getSlotRoleRegistry(); $slotRoleRegistry->defineRoleWithModel( $role, $a1->getModelID() ); // pin the service instance for this test $this->setService( 'SlotRoleRegistry', $slotRoleRegistry ); $mainContent1 = $this->createMockContent( $m1, 'main 1' ); $auxContent1 = $this->createMockContent( $a1, 'aux 1' ); $mainContent2 = $this->createMockContent( $m2, 'main 2' ); $user = $this->getTestUser()->getUser(); $page = $this->getPage( __METHOD__ ); $this->createRevision( $page, __METHOD__, [ SlotRecord::MAIN => $mainContent1, $role => $auxContent1 ] ); $update = new RevisionSlotsUpdate(); $update->modifyContent( SlotRecord::MAIN, $mainContent2 ); $update->removeSlot( $role ); $page = $this->getPage( __METHOD__ ); $updater = $this->getDerivedPageDataUpdater( $page ); $updater->prepareContent( $user, $update, false ); $dataUpdates = $updater->getSecondaryDataUpdates(); $this->assertNotEmpty( $dataUpdates ); $updateNames = array_map( static function ( $du ) { return $du instanceof MWCallableUpdate ? $du->getOrigin() : get_class( $du ); }, $dataUpdates ); $this->assertContains( LinksUpdate::class, $updateNames ); $this->assertContains( 'A1 deletion update', $updateNames ); $this->assertContains( 'M2 data update', $updateNames ); $this->assertNotContains( 'M1 data update', $updateNames ); } /** * Creates a dummy MutableRevisionRecord without touching the database. * * @param PageIdentity $title * @param string|Content|Content[]|RevisionSlotsUpdate $update * @param UserIdentity|null $user * @param string $comment * @param int $id * @param int $parentId * * @return MutableRevisionRecord */ private function makeRevision( PageIdentity $title, $update, UserIdentity $user = null, $comment = "testing", $id = 0, $parentId = 0 ) { $rev = new MutableRevisionRecord( $title ); if ( $update instanceof RevisionSlotsUpdate ) { $rev->applyUpdate( $update ); } else { if ( is_string( $update ) ) { $update = new WikitextContent( $update ); } if ( !is_array( $update ) ) { $update = [ SlotRecord::MAIN => $update ]; } foreach ( $update as $role => $content ) { $rev->setContent( $role, $content ); } } if ( !$user ) { $user = $this->getTestUser()->getUser(); } $rev->setUser( $user ); $rev->setComment( CommentStoreComment::newUnsavedComment( $comment ) ); $rev->setPageId( $title->getId() ); $rev->setParentId( $parentId ); if ( $id ) { $rev->setId( $id ); } return $rev; } public function provideIsReusableFor() { $title = PageIdentityValue::localIdentity( 1234, NS_MAIN, __CLASS__ ); $user1 = new UserIdentityValue( 111, 'Alice' ); $user2 = new UserIdentityValue( 222, 'Bob' ); $content1 = new WikitextContent( 'one' ); $content2 = new WikitextContent( 'two' ); $update1 = new RevisionSlotsUpdate(); $update1->modifyContent( SlotRecord::MAIN, $content1 ); $update1b = new RevisionSlotsUpdate(); $update1b->modifyContent( 'xyz', $content1 ); $update2 = new RevisionSlotsUpdate(); $update2->modifyContent( SlotRecord::MAIN, $content2 ); $rev1 = $this->makeRevision( $title, $update1, $user1, 'rev1', 11 ); $rev1b = $this->makeRevision( $title, $update1b, $user1, 'rev1', 11 ); $rev2 = $this->makeRevision( $title, $update2, $user1, 'rev2', 12 ); $rev2x = $this->makeRevision( $title, $update2, $user2, 'rev2', 12 ); $rev2y = $this->makeRevision( $title, $update2, $user1, 'rev2', 122 ); yield 'any' => [ '$prepUser' => null, '$prepRevision' => null, '$prepUpdate' => null, '$forUser' => null, '$forRevision' => null, '$forUpdate' => null, '$forParent' => null, '$isReusable' => true, ]; yield 'for any' => [ '$prepUser' => $user1, '$prepRevision' => $rev1, '$prepUpdate' => $update1, '$forUser' => null, '$forRevision' => null, '$forUpdate' => null, '$forParent' => null, '$isReusable' => true, ]; yield 'unprepared' => [ '$prepUser' => null, '$prepRevision' => null, '$prepUpdate' => null, '$forUser' => $user1, '$forRevision' => $rev1, '$forUpdate' => $update1, '$forParent' => 0, '$isReusable' => true, ]; yield 'match prepareContent' => [ '$prepUser' => $user1, '$prepRevision' => null, '$prepUpdate' => $update1, '$forUser' => $user1, '$forRevision' => null, '$forUpdate' => $update1, '$forParent' => 0, '$isReusable' => true, ]; yield 'match prepareUpdate' => [ '$prepUser' => null, '$prepRevision' => $rev1, '$prepUpdate' => null, '$forUser' => $user1, '$forRevision' => $rev1, '$forUpdate' => null, '$forParent' => 0, '$isReusable' => true, ]; yield 'match all' => [ '$prepUser' => $user1, '$prepRevision' => $rev1, '$prepUpdate' => $update1, '$forUser' => $user1, '$forRevision' => $rev1, '$forUpdate' => $update1, '$forParent' => 0, '$isReusable' => true, ]; yield 'mismatch prepareContent update' => [ '$prepUser' => $user1, '$prepRevision' => null, '$prepUpdate' => $update1, '$forUser' => $user1, '$forRevision' => null, '$forUpdate' => $update1b, '$forParent' => 0, '$isReusable' => false, ]; yield 'mismatch prepareContent user' => [ '$prepUser' => $user1, '$prepRevision' => null, '$prepUpdate' => $update1, '$forUser' => $user2, '$forRevision' => null, '$forUpdate' => $update1, '$forParent' => 0, '$isReusable' => false, ]; yield 'mismatch prepareContent parent' => [ '$prepUser' => $user1, '$prepRevision' => null, '$prepUpdate' => $update1, '$forUser' => $user1, '$forRevision' => null, '$forUpdate' => $update1, '$forParent' => 7, '$isReusable' => false, ]; yield 'mismatch prepareUpdate revision update' => [ '$prepUser' => null, '$prepRevision' => $rev1, '$prepUpdate' => null, '$forUser' => null, '$forRevision' => $rev1b, '$forUpdate' => null, '$forParent' => 0, '$isReusable' => false, ]; yield 'mismatch prepareUpdate revision id' => [ '$prepUser' => null, '$prepRevision' => $rev2, '$prepUpdate' => null, '$forUser' => null, '$forRevision' => $rev2y, '$forUpdate' => null, '$forParent' => 0, '$isReusable' => false, ]; } /** * @dataProvider provideIsReusableFor * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isReusableFor() */ public function testIsReusableFor( ?UserIdentity $prepUser, ?RevisionRecord $prepRevision, ?RevisionSlotsUpdate $prepUpdate, ?UserIdentity $forUser, ?RevisionRecord $forRevision, ?RevisionSlotsUpdate $forUpdate, $forParent, $isReusable ) { $updater = $this->getDerivedPageDataUpdater( __METHOD__ ); if ( $prepUpdate ) { $updater->prepareContent( $prepUser, $prepUpdate, false ); } if ( $prepRevision ) { $updater->prepareUpdate( $prepRevision ); } $this->assertSame( $isReusable, $updater->isReusableFor( $forUser, $forRevision, $forUpdate, $forParent ) ); } /** * * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCountable */ public function testIsCountableNotContentPage() { $updater = $this->getDerivedPageDataUpdater( Title::makeTitle( NS_TALK, 'Main_Page' ) ); self::assertFalse( $updater->isCountable() ); } public static function provideIsCountable() { yield 'deleted revision' => [ '$articleCountMethod' => 'any', '$wikitextContent' => 'Test', '$revisionVisibility' => RevisionRecord::SUPPRESSED_ALL, '$isCountable' => false ]; yield 'redirect' => [ '$articleCountMethod' => 'any', '$wikitextContent' => '#REDIRECT [[Main_Page]]', '$revisionVisibility' => 0, '$isCountable' => false ]; yield 'no links count method any' => [ '$articleCountMethod' => 'any', '$wikitextContent' => 'Test', '$revisionVisibility' => 0, '$isCountable' => true ]; yield 'no links count method link' => [ '$articleCountMethod' => 'link', '$wikitextContent' => 'Test', '$revisionVisibility' => 0, '$isCountable' => false ]; yield 'with links count method link' => [ '$articleCountMethod' => 'link', '$wikitextContent' => '[[Test]]', '$revisionVisibility' => 0, '$isCountable' => true ]; } /** * @dataProvider provideIsCountable * * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCountable */ public function testIsCountable( $articleCountMethod, $wikitextContent, $revisionVisibility, $isCountable ) { $this->overrideConfigValue( MainConfigNames::ArticleCountMethod, $articleCountMethod ); $title = $this->getTitle( 'Main_Page' ); $content = new WikitextContent( $wikitextContent ); $update = new RevisionSlotsUpdate(); $update->modifyContent( SlotRecord::MAIN, $content ); $revision = $this->makeRevision( $title, $update, User::newFromName( 'Alice' ), 'rev1', 13 ); $revision->setVisibility( $revisionVisibility ); $updater = $this->getDerivedPageDataUpdater( $title ); $updater->prepareUpdate( $revision ); self::assertSame( $isCountable, $updater->isCountable() ); } /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isCountable */ public function testIsCountableNoModifiedSlots() { $page = $this->getPage( __METHOD__ ); $content = [ SlotRecord::MAIN => new WikitextContent( '[[Test]]' ) ]; $rev = $this->createRevision( $page, 'first', $content ); $nullRevision = MutableRevisionRecord::newFromParentRevision( $rev ); $nullRevision->setId( 14 ); $updater = $this->getDerivedPageDataUpdater( $page, $nullRevision ); $updater->prepareUpdate( $nullRevision ); $this->assertTrue( $updater->isCountable() ); } /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate() */ public function testDoUpdates() { $page = $this->getPage( __METHOD__ ); $content = [ SlotRecord::MAIN => new WikitextContent( 'first [[main]]' ) ]; $content['aux'] = new WikitextContent( 'Aux [[Nix]]' ); $slotRoleRegistry = $this->getServiceContainer()->getSlotRoleRegistry(); if ( !$slotRoleRegistry->isDefinedRole( 'aux' ) ) { $slotRoleRegistry->defineRoleWithModel( 'aux', CONTENT_MODEL_WIKITEXT ); } $rev = $this->createRevision( $page, 'first', $content ); $pageId = $page->getId(); $oldStats = $this->getDb()->newSelectQueryBuilder() ->select( '*' ) ->from( 'site_stats' ) ->where( '1=1' ) ->fetchRow(); $this->getDb()->newDeleteQueryBuilder() ->deleteFrom( 'pagelinks' ) ->where( ISQLPlatform::ALL_ROWS ) ->caller( __METHOD__ ) ->execute(); $pcache = $this->getServiceContainer()->getParserCache(); $pcache->deleteOptionsKey( $page ); $updater = $this->getDerivedPageDataUpdater( $page, $rev ); $updater->setArticleCountMethod( 'link' ); $options = []; // TODO: test *all* the options... $updater->prepareUpdate( $rev, $options ); $updater->doUpdates(); // links table update $pageLinks = $this->getDb()->newSelectQueryBuilder() ->select( 'lt_title' ) ->from( 'pagelinks' ) ->join( 'linktarget', null, 'pl_target_id=lt_id' ) ->where( [ 'pl_from' => $pageId ] ) ->orderBy( [ 'lt_namespace', 'lt_title' ] ) ->caller( __METHOD__ )->fetchResultSet(); $pageLinksRow = $pageLinks->fetchObject(); $this->assertIsObject( $pageLinksRow ); $this->assertSame( 'Main', $pageLinksRow->lt_title ); $pageLinksRow = $pageLinks->fetchObject(); $this->assertIsObject( $pageLinksRow ); $this->assertSame( 'Nix', $pageLinksRow->lt_title ); // parser cache update $cached = $pcache->get( $page, $updater->getCanonicalParserOptions() ); $this->assertIsObject( $cached ); $this->assertEquals( $updater->getCanonicalParserOutput(), $cached ); // site stats $stats = $this->getDb()->newSelectQueryBuilder() ->select( '*' ) ->from( 'site_stats' ) ->where( '1=1' ) ->fetchRow(); $this->assertSame( $oldStats->ss_total_pages + 1, (int)$stats->ss_total_pages ); $this->assertSame( $oldStats->ss_total_edits + 1, (int)$stats->ss_total_edits ); $this->assertSame( $oldStats->ss_good_articles + 1, (int)$stats->ss_good_articles ); // TODO: MCR: test data updates for additional slots! // TODO: test update for edit without page creation // TODO: test message cache purge // TODO: test module cache purge // TODO: test CDN purge // TODO: test newtalk update // TODO: test search update // TODO: test site stats good_articles while turning the page into (or back from) a redir. // TODO: test category membership update (with setRcWatchCategoryMembership()) } /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate() */ public function testDoUpdatesCacheSaveDeferral_canonical() { $page = $this->getPage( __METHOD__ ); // Case where user has canonical parser options $content = [ SlotRecord::MAIN => new WikitextContent( 'rev ID ver #1: {{REVISIONID}}' ) ]; $rev = $this->createRevision( $page, 'first', $content ); $pcache = $this->getServiceContainer()->getParserCache(); $pcache->deleteOptionsKey( $page ); $this->getDb()->startAtomic( __METHOD__ ); // let deferred updates queue up $updater = $this->getDerivedPageDataUpdater( $page, $rev ); $updater->prepareUpdate( $rev, [] ); $updater->doUpdates(); $this->assertGreaterThan( 0, DeferredUpdates::pendingUpdatesCount(), 'Pending updates' ); $this->assertNotFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) ); $this->getDb()->endAtomic( __METHOD__ ); // run deferred updates $this->assertSame( 0, DeferredUpdates::pendingUpdatesCount(), 'No pending updates' ); } /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doSecondaryDataUpdates() * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate() */ public function testDoUpdatesCacheSaveDeferral_noncanonical() { $page = $this->getPage( __METHOD__ ); // Case where user does not have canonical parser options $user = $this->getMutableTestUser()->getUser(); $services = $this->getServiceContainer(); $userOptionsManager = $services->getUserOptionsManager(); $userOptionsManager->setOption( $user, 'thumbsize', $userOptionsManager->getOption( $user, 'thumbsize' ) + 1 ); $content = [ SlotRecord::MAIN => new WikitextContent( 'rev ID ver #2: {{REVISIONID}}' ) ]; $rev = $this->createRevision( $page, 'first', $content, $user ); $pcache = $services->getParserCache(); $pcache->deleteOptionsKey( $page ); $this->getDb()->startAtomic( __METHOD__ ); // let deferred updates queue up $updater = $this->getDerivedPageDataUpdater( $page, $rev, $user ); $updater->prepareUpdate( $rev, [] ); $updater->doUpdates(); $this->assertGreaterThan( 1, DeferredUpdates::pendingUpdatesCount(), 'Pending updates' ); $this->assertFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) ); $this->getDb()->endAtomic( __METHOD__ ); // run deferred updates $this->assertSame( 0, DeferredUpdates::pendingUpdatesCount(), 'No pending updates' ); $this->assertNotFalse( $pcache->get( $page, $updater->getCanonicalParserOptions() ) ); } public static function provideEnqueueRevertedTagUpdateJob() { return [ 'approved' => [ true, 1 ], 'not approved' => [ false, 0 ] ]; } /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doUpdates * @covers \MediaWiki\Storage\DerivedPageDataUpdater::maybeEnqueueRevertedTagUpdateJob * @dataProvider provideEnqueueRevertedTagUpdateJob */ public function testEnqueueRevertedTagUpdateJob( bool $approved, int $queueSize ) { $page = $this->getPage( __METHOD__ ); $content = [ SlotRecord::MAIN => new WikitextContent( '1' ) ]; $rev = $this->createRevision( $page, '', $content ); $editResult = new EditResult( false, 10, EditResult::REVERT_ROLLBACK, 11, 12, true, false, [ 'mw-rollback' ] ); $updater = $this->getDerivedPageDataUpdater( $page, $rev ); $updater->prepareUpdate( $rev, [ 'editResult' => $editResult, 'approved' => $approved ] ); $updater->doUpdates(); $services = $this->getServiceContainer(); $editResultCache = new EditResultCache( $services->getMainObjectStash(), $services->getConnectionProvider(), new ServiceOptions( EditResultCache::CONSTRUCTOR_OPTIONS, [ 'RCMaxAge' => BagOStuff::TTL_MONTH ] ) ); if ( $approved ) { $this->assertNull( $editResultCache->get( $rev->getId() ), 'EditResult should not be cached when the revert is approved' ); } else { $this->assertEquals( $editResult, $editResultCache->get( $rev->getId() ), 'EditResult should be cached when the revert is not approved' ); } $jobQueueGroup = $this->getServiceContainer()->getJobQueueGroup(); $jobQueue = $jobQueueGroup->get( 'revertedTagUpdate' ); $this->assertSame( $queueSize, $jobQueue->getSize() ); } /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate() * @covers \ParsoidCachePrewarmJob::doParsoidCacheUpdate() */ public function testDoParserCacheUpdate() { $this->overrideConfigValue( MainConfigNames::ParsoidCacheConfig, [ 'CacheThresholdTime' => 0.0, 'WarmParsoidParserCache' => true, // enable caching ] ); $slotRoleRegistry = $this->getServiceContainer()->getSlotRoleRegistry(); if ( !$slotRoleRegistry->isDefinedRole( 'aux' ) ) { $slotRoleRegistry->defineRoleWithModel( 'aux', CONTENT_MODEL_WIKITEXT ); } // Create page $page = $this->getPage( __METHOD__ ); ConvertibleTimestamp::setFakeTime( '2022-01-01T00:01:00Z' ); $this->createRevision( $page, 'Dummy' ); // Assert cache update after edit ---------- $parserCacheFactory = $this->getServiceContainer()->getParserCacheFactory(); $parserCache = $parserCacheFactory->getParserCache( ParserCacheFactory::DEFAULT_NAME ); $parsoidCache = $parserCacheFactory->getParserCache( "parsoid-" . ParserCacheFactory::DEFAULT_NAME ); $parserCache->deleteOptionsKey( $page ); $parsoidCache->deleteOptionsKey( $page ); $user = $this->getTestUser()->getUser(); ConvertibleTimestamp::setFakeTime( '2022-01-01T00:02:00Z' ); $updater = $page->newPageUpdater( $user ); $updater->setContent( SlotRecord::MAIN, new WikitextContent( 'first [[Main]]' ) ); $updater->setContent( 'aux', new WikitextContent( 'Aux [[Nix]]' ) ); $rev = $updater->saveRevision( CommentStoreComment::newUnsavedComment( 'testing' ) ); // run all the jobs ConvertibleTimestamp::setFakeTime( '2022-01-01T00:03:00Z' ); $this->runJobs(); // Parsoid cache should have an entry $parserOptions = ParserOptions::newFromAnon(); $parserOptions->setUseParsoid(); $parsoidCached = $parsoidCache->get( $page, $parserOptions, true ); $this->assertIsObject( $parsoidCached ); $this->assertStringContainsString( 'first', $parsoidCached->getRawText() ); // The parsoid parser output is generated during runJobs(), after the last call to setFakeTime(). $this->assertGreaterThan( $rev->getTimestamp(), $parsoidCached->getCacheTime() ); $this->assertSame( $rev->getId(), $parsoidCached->getCacheRevisionId() ); // Check that ParsoidRenderID::newFromParserOutput() doesn't throw, // so we know that $parsoidCached is valid. ParsoidRenderID::newFromParserOutput( $parsoidCached ); // The cached ParserOutput should not use the revision timestamp // Create nwe ParserOptions object since we setUseParsoid() above $parserOptions = ParserOptions::newFromAnon(); $cached = $parserCache->get( $page, $parserOptions, true ); $this->assertIsObject( $cached ); $this->assertNotSame( $parsoidCached, $cached ); $this->assertStringContainsString( 'first', $cached->getRawText() ); // The regular parser output is generated immediately during saveRevision(), // so it uses the same timestamp as the revision. $this->assertSame( $rev->getTimestamp(), $cached->getCacheTime() ); $this->assertSame( $rev->getId(), $cached->getCacheRevisionId() ); // Emulate forced update of an old revision ---------- ConvertibleTimestamp::setFakeTime( '2022-01-01T00:04:00Z' ); $parserCache->deleteOptionsKey( $page ); $updater = $this->getDerivedPageDataUpdater( $page ); $updater->prepareUpdate( $rev ); // Force the page timestamp, so we notice whether ParserOutput::getTimestamp // or ParserOutput::getCacheTime are used. $page->setTimestamp( $rev->getTimestamp() ); $updater->doParserCacheUpdate(); // The cached ParserOutput should not use the revision timestamp $cached = $parserCache->get( $page, $updater->getCanonicalParserOptions(), true ); $this->assertIsObject( $cached ); $this->assertEquals( $updater->getCanonicalParserOutput(), $cached ); $this->assertGreaterThan( $rev->getTimestamp(), $cached->getCacheTime() ); $this->assertSame( $rev->getId(), $cached->getCacheRevisionId() ); } /** * @covers \MediaWiki\Storage\DerivedPageDataUpdater::doParserCacheUpdate() * @covers \ParsoidCachePrewarmJob::doParsoidCacheUpdate() */ public function testDoParserCacheUpdateForJavaScriptContent() { $this->overrideConfigValue( MainConfigNames::ParsoidCacheConfig, [ 'CacheThresholdTime' => 0.0, 'WarmParsoidParserCache' => true, // enable caching ] ); $page = $this->getPage( __METHOD__ ); $this->createRevision( $page, 'Dummy' ); $user = $this->getTestUser()->getUser(); $update = new RevisionSlotsUpdate(); $update->modifyContent( SlotRecord::MAIN, new JavaScriptContent( '{ first: "main"; }' ) ); // Emulate update after edit ---------- $parserCacheFactory = $this->getServiceContainer()->getParserCacheFactory(); $parserCache = $parserCacheFactory->getParserCache( ParserCacheFactory::DEFAULT_NAME ); $parsoidCache = $parserCacheFactory->getParserCache( ParserOutputAccess::PARSOID_PCACHE_NAME ); $parserCache->deleteOptionsKey( $page ); $parsoidCache->deleteOptionsKey( $page ); $rev = $this->makeRevision( $page->getTitle(), $update, $user, 'rev', null ); $rev->setTimestamp( '20100101000000' ); $rev->setParentId( $page->getLatest() ); $updater = $this->getDerivedPageDataUpdater( $page ); $updater->prepareContent( $user, $update, false ); $rev->setId( 1107 ); $updater->prepareUpdate( $rev ); // Force the page timestamp, so we notice whether ParserOutput::getTimestamp // or ParserOutput::getCacheTime are used. // Also ensure $page->getLatest() returns the correct revision ID, so the parser // cache doesn't get confused. TestingAccessWrapper::newFromObject( $page )->setLastEdit( $rev ); $updater->doParserCacheUpdate(); // The cached ParserOutput should not use the revision timestamp $cached = $parserCache->get( $page, $updater->getCanonicalParserOptions(), true ); $this->assertIsObject( $cached ); } /** * Helper for testTemplateUpdate * * @param WikiPage $page * @param string $content */ private function editAndUpdate( $page, $content ) { $this->createRevision( $page, $content ); $this->getServiceContainer()->resetServiceForTesting( 'BacklinkCacheFactory' ); $this->runJobs(); } /** * Regression test for T368006 */ public function testTemplateUpdate() { $clock = MWTimestamp::convert( TS_UNIX, '20100101000000' ); MWTimestamp::setFakeTime( static function () use ( &$clock ) { return $clock++; } ); $template = $this->getPage( 'Template:TestTemplateUpdate' ); $page = $this->getPage( 'TestTemplateUpdate' ); $this->editAndUpdate( $template, '1' ); $this->editAndUpdate( $page, '{{TestTemplateUpdate}}' ); $oldTouched = $page->getTouched(); $page->clear(); $this->editAndUpdate( $template, '2' ); $newTouched = $page->getTouched(); $this->assertGreaterThan( $oldTouched, $newTouched ); } }