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