1<?php 2 3namespace MediaWiki\Tests\Rest\Helper; 4 5use BagOStuff; 6use DeferredUpdates; 7use EmptyBagOStuff; 8use Exception; 9use ExtensionRegistry; 10use HashBagOStuff; 11use MediaWiki\Parser\RevisionOutputCache; 12use MediaWiki\Rest\Handler\ParsoidHTMLHelper; 13use MediaWiki\Rest\LocalizedHttpException; 14use MediaWikiIntegrationTestCase; 15use MWTimestamp; 16use NullStatsdDataFactory; 17use ParserCache; 18use PHPUnit\Framework\MockObject\MockObject; 19use Psr\Log\NullLogger; 20use WANObjectCache; 21use Wikimedia\Message\MessageValue; 22use Wikimedia\Parsoid\Core\ClientError; 23use Wikimedia\Parsoid\Core\PageBundle; 24use Wikimedia\Parsoid\Core\ResourceLimitExceededException; 25use Wikimedia\Parsoid\Parsoid; 26use Wikimedia\TestingAccessWrapper; 27 28/** 29 * @covers \MediaWiki\Rest\Handler\ParsoidHTMLHelper 30 * @group Database 31 */ 32class ParsoidHTMLHelperTest extends MediaWikiIntegrationTestCase { 33 34 private const CACHE_EPOCH = '20001111010101'; 35 36 private const TIMESTAMP_OLD = '20200101112233'; 37 private const TIMESTAMP = '20200101223344'; 38 private const TIMESTAMP_LATER = '20200101234200'; 39 40 private const WIKITEXT_OLD = 'Hello \'\'\'Goat\'\'\''; 41 private const WIKITEXT = 'Hello \'\'\'World\'\'\''; 42 43 private const HTML_OLD = '<p>Hello <b>Goat</b></p>'; 44 private const HTML = '<p>Hello <b>World</b></p>'; 45 46 protected function setUp(): void { 47 parent::setUp(); 48 49 if ( !ExtensionRegistry::getInstance()->isLoaded( 'Parsoid' ) ) { 50 $this->markTestSkipped( 'Parsoid is not configured' ); 51 } 52 53 $this->setMwGlobals( 'wgCacheEpoch', self::CACHE_EPOCH ); 54 55 // Clean up these tables after each test 56 $this->tablesUsed = [ 57 'page', 58 'revision', 59 'comment', 60 'text', 61 'content' 62 ]; 63 } 64 65 /** 66 * @param BagOStuff|null $cache 67 * @param Parsoid|MockObject|null $parsoid 68 * @return ParsoidHTMLHelper 69 * @throws Exception 70 */ 71 private function newHelper( BagOStuff $cache = null, Parsoid $parsoid = null ): ParsoidHTMLHelper { 72 $cache = $cache ?: new EmptyBagOStuff(); 73 74 $parserCache = new ParserCache( 75 'TestPCache', 76 $cache, 77 self::CACHE_EPOCH, 78 $this->getServiceContainer()->getHookContainer(), 79 $this->getServiceContainer()->getJsonCodec(), 80 new NullStatsdDataFactory(), 81 new NullLogger(), 82 $this->getServiceContainer()->getTitleFactory(), 83 $this->getServiceContainer()->getWikiPageFactory() 84 ); 85 86 $revisionOutputCache = new RevisionOutputCache( 87 'TestRCache', 88 new WANObjectCache( [ 'cache' => $cache ] ), 89 60 * 60, 90 self::CACHE_EPOCH, 91 $this->getServiceContainer()->getJsonCodec(), 92 new NullStatsdDataFactory(), 93 new NullLogger() 94 ); 95 96 $helper = new ParsoidHTMLHelper( 97 $parserCache, 98 $revisionOutputCache, 99 $this->getServiceContainer()->getGlobalIdGenerator() 100 ); 101 102 if ( $parsoid !== null ) { 103 $wrapper = TestingAccessWrapper::newFromObject( $helper ); 104 $wrapper->parsoid = $parsoid; 105 } 106 107 return $helper; 108 } 109 110 private function getExistingPageWithRevisions( $name ) { 111 $page = $this->getNonexistingTestPage( $name ); 112 113 MWTimestamp::setFakeTime( self::TIMESTAMP_OLD ); 114 $this->editPage( $page, self::WIKITEXT_OLD ); 115 $revisions['first'] = $page->getRevisionRecord(); 116 117 MWTimestamp::setFakeTime( self::TIMESTAMP ); 118 $this->editPage( $page, self::WIKITEXT ); 119 $revisions['latest'] = $page->getRevisionRecord(); 120 121 MWTimestamp::setFakeTime( self::TIMESTAMP_LATER ); 122 return [ $page, $revisions ]; 123 } 124 125 public function provideRevisionReferences() { 126 return [ 127 'current' => [ null, [ 'html' => self::HTML, 'timestamp' => self::TIMESTAMP ] ], 128 'old' => [ 'first', [ 'html' => self::HTML_OLD, 'timestamp' => self::TIMESTAMP_OLD ] ], 129 ]; 130 } 131 132 /** 133 * @dataProvider provideRevisionReferences() 134 */ 135 public function testGetHtml( $revRef, $revInfo ) { 136 [ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ ); 137 $rev = $revRef ? $revisions[ $revRef ] : null; 138 139 $helper = $this->newHelper(); 140 $helper->init( $page, $rev ); 141 142 $htmlresult = $helper->getHtml()->getRawText(); 143 144 $this->assertStringContainsString( '<!DOCTYPE html>', $htmlresult ); 145 $this->assertStringContainsString( '<html', $htmlresult ); 146 $this->assertStringContainsString( $revInfo['html'], $htmlresult ); 147 } 148 149 /** 150 * @dataProvider provideRevisionReferences() 151 */ 152 public function testHtmlIsCached( $revRef, $revInfo ) { 153 [ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ ); 154 $rev = $revRef ? $revisions[ $revRef ] : null; 155 156 $cache = new HashBagOStuff(); 157 $parsoid = $this->createNoOpMock( Parsoid::class, [ 'wikitext2html' ] ); 158 $parsoid->expects( $this->once() ) 159 ->method( 'wikitext2html' ) 160 ->willReturn( new PageBundle( 'mocked HTML', null, null, '1.0' ) ); 161 162 $helper = $this->newHelper( $cache, $parsoid ); 163 164 $helper->init( $page, $rev ); 165 $htmlresult = $helper->getHtml()->getRawText(); 166 $this->assertStringContainsString( 'mocked HTML', $htmlresult ); 167 168 // check that we can run the test again and ensure that the parse is only run once 169 $helper = $this->newHelper( $cache, $parsoid ); 170 $helper->init( $page, $rev ); 171 $htmlresult = $helper->getHtml()->getRawText(); 172 $this->assertStringContainsString( 'mocked HTML', $htmlresult ); 173 } 174 175 /** 176 * @dataProvider provideRevisionReferences() 177 */ 178 public function testEtagLastModified( $revRef, $revInfo ) { 179 [ $page, $revisions ] = $this->getExistingPageWithRevisions( __METHOD__ ); 180 $rev = $revRef ? $revisions[ $revRef ] : null; 181 182 $cache = new HashBagOStuff(); 183 184 // First, test it works if nothing was cached yet. 185 $helper = $this->newHelper( $cache ); 186 $helper->init( $page, $rev ); 187 $etag = $helper->getETag(); 188 $lastModified = $helper->getLastModified(); 189 $helper->getHtml(); // put HTML into the cache 190 191 // make sure the etag didn't change after getHtml(); 192 $this->assertSame( $etag, $helper->getETag() ); 193 $this->assertSame( 194 MWTimestamp::convert( TS_MW, $lastModified ), 195 MWTimestamp::convert( TS_MW, $helper->getLastModified() ) 196 ); 197 198 // Advance the time, but not so much that caches would expire. 199 // The time in the header should remain as before. 200 $now = MWTimestamp::convert( TS_UNIX, self::TIMESTAMP_LATER ) + 100; 201 MWTimestamp::setFakeTime( $now ); 202 $helper = $this->newHelper( $cache ); 203 $helper->init( $page, $rev ); 204 205 $this->assertSame( $etag, $helper->getETag() ); 206 $this->assertSame( 207 MWTimestamp::convert( TS_MW, $lastModified ), 208 MWTimestamp::convert( TS_MW, $helper->getLastModified() ) 209 ); 210 211 // Now, expire the cache. etag and timestamp should change 212 $now = MWTimestamp::convert( TS_UNIX, self::TIMESTAMP_LATER ) + 10000; 213 MWTimestamp::setFakeTime( $now ); 214 $this->assertTrue( 215 $page->getTitle()->invalidateCache( MWTimestamp::convert( TS_MW, $now ) ), 216 'Sanity: can invalidate cache' 217 ); 218 DeferredUpdates::doUpdates(); 219 $page->clear(); 220 221 $helper = $this->newHelper( $cache ); 222 $helper->init( $page, $rev ); 223 224 $this->assertNotSame( $etag, $helper->getETag() ); 225 $this->assertSame( 226 MWTimestamp::convert( TS_MW, $now ), 227 MWTimestamp::convert( TS_MW, $helper->getLastModified() ) 228 ); 229 } 230 231 public function provideHandlesParsoidError() { 232 yield 'ClientError' => [ 233 new ClientError( 'TEST_TEST' ), 234 new LocalizedHttpException( 235 new MessageValue( 'rest-html-backend-error' ), 236 400, 237 [ 238 'reason' => 'TEST_TEST' 239 ] 240 ) 241 ]; 242 yield 'ResourceLimitExceededException' => [ 243 new ResourceLimitExceededException( 'TEST_TEST' ), 244 new LocalizedHttpException( 245 new MessageValue( 'rest-resource-limit-exceeded' ), 246 413, 247 [ 248 'reason' => 'TEST_TEST' 249 ] 250 ) 251 ]; 252 } 253 254 /** 255 * @dataProvider provideHandlesParsoidError 256 */ 257 public function testHandlesParsoidError( 258 Exception $parsoidException, 259 Exception $expectedException 260 ) { 261 $page = $this->getExistingTestPage( __METHOD__ ); 262 263 $parsoid = $this->createNoOpMock( Parsoid::class, [ 'wikitext2html' ] ); 264 $parsoid->expects( $this->once() ) 265 ->method( 'wikitext2html' ) 266 ->willThrowException( $parsoidException ); 267 268 $helper = $this->newHelper( null, $parsoid ); 269 $helper->init( $page ); 270 271 $this->expectExceptionObject( $expectedException ); 272 $helper->getHtml(); 273 } 274 275} 276