Add stash option to /page/html & /revision/html endpoints. When this option is set, the PageBundle returned by Parsoid is stashed and an etag is returned that can later be used to make use of the stashed PageBundle. The stash is for now backed by the BagOStuff returned by ObjectCache::getLocalClusterInstance(). This patch adds additional data to the ParserOutput stored in ParserCache. Old entries lacking that data will be ignored. Bug: T267990 Co-Authored-by: Nikki <nnikkhoui@wikimedia.org> Change-Id: Id35f1423a69e3ff63e4f9883b3f7e3f9521d81d5
390 lines
12 KiB
PHP
390 lines
12 KiB
PHP
<?php
|
|
|
|
namespace MediaWiki\Tests\Rest\Handler;
|
|
|
|
use DeferredUpdates;
|
|
use Exception;
|
|
use ExtensionRegistry;
|
|
use HashBagOStuff;
|
|
use HashConfig;
|
|
use MediaWiki\Config\ServiceOptions;
|
|
use MediaWiki\Json\JsonCodec;
|
|
use MediaWiki\Parser\ParserCacheFactory;
|
|
use MediaWiki\Rest\Handler\PageHTMLHandler;
|
|
use MediaWiki\Rest\LocalizedHttpException;
|
|
use MediaWiki\Rest\RequestData;
|
|
use MediaWikiIntegrationTestCase;
|
|
use MWTimestamp;
|
|
use NullStatsdDataFactory;
|
|
use PHPUnit\Framework\MockObject\MockObject;
|
|
use Psr\Log\NullLogger;
|
|
use WANObjectCache;
|
|
use Wikimedia\Message\MessageValue;
|
|
use Wikimedia\Parsoid\Core\ClientError;
|
|
use Wikimedia\Parsoid\Core\PageBundle;
|
|
use Wikimedia\Parsoid\Core\ResourceLimitExceededException;
|
|
use Wikimedia\Parsoid\Parsoid;
|
|
use Wikimedia\TestingAccessWrapper;
|
|
use WikiPage;
|
|
|
|
/**
|
|
* @covers \MediaWiki\Rest\Handler\PageHTMLHandler
|
|
* @group Database
|
|
*/
|
|
class PageHTMLHandlerTest extends MediaWikiIntegrationTestCase {
|
|
use HandlerTestTrait;
|
|
use HTMLHandlerTestTrait;
|
|
|
|
private const WIKITEXT = 'Hello \'\'\'World\'\'\'';
|
|
|
|
private const HTML = '>World</';
|
|
|
|
/** @var HashBagOStuff */
|
|
private $parserCacheBagOStuff;
|
|
|
|
protected function setUp(): void {
|
|
parent::setUp();
|
|
|
|
// Clean up these tables after each test
|
|
$this->tablesUsed = [
|
|
'page',
|
|
'revision',
|
|
'comment',
|
|
'text',
|
|
'content'
|
|
];
|
|
|
|
$this->parserCacheBagOStuff = new HashBagOStuff();
|
|
}
|
|
|
|
/**
|
|
* Checks whether Parsoid extension is installed and skips the test if it's not.
|
|
*/
|
|
private function checkParsoidInstalled() {
|
|
if ( !ExtensionRegistry::getInstance()->isLoaded( 'Parsoid' ) ) {
|
|
$this->markTestSkipped( 'Skip test, since parsoid is not configured' );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param Parsoid|MockObject|null $parsoid
|
|
*
|
|
* @return PageHTMLHandler
|
|
* @throws Exception
|
|
*/
|
|
private function newHandler( Parsoid $parsoid = null ): PageHTMLHandler {
|
|
$parserCacheFactoryOptions = new ServiceOptions( ParserCacheFactory::CONSTRUCTOR_OPTIONS, [
|
|
'ParserCacheUseJson' => true,
|
|
'CacheEpoch' => '20200202112233',
|
|
'OldRevisionParserCacheExpireTime' => 60,
|
|
] );
|
|
|
|
$parserCacheFactory = new ParserCacheFactory(
|
|
$this->parserCacheBagOStuff,
|
|
new WANObjectCache( [ 'cache' => $this->parserCacheBagOStuff ] ),
|
|
$this->createHookContainer(),
|
|
new JsonCodec(),
|
|
new NullStatsdDataFactory(),
|
|
new NullLogger(),
|
|
$parserCacheFactoryOptions,
|
|
$this->getServiceContainer()->getTitleFactory(),
|
|
$this->getServiceContainer()->getWikiPageFactory()
|
|
);
|
|
|
|
$handler = new PageHTMLHandler(
|
|
new HashConfig( [
|
|
'RightsUrl' => 'https://example.com/rights',
|
|
'RightsText' => 'some rights',
|
|
] ),
|
|
$this->getServiceContainer()->getRevisionLookup(),
|
|
$this->getServiceContainer()->getTitleFormatter(),
|
|
$parserCacheFactory,
|
|
$this->getServiceContainer()->getGlobalIdGenerator(),
|
|
$this->getServiceContainer()->getPageStore(),
|
|
$this->getParsoidOutputStash()
|
|
);
|
|
|
|
if ( $parsoid !== null ) {
|
|
$handlerWrapper = TestingAccessWrapper::newFromObject( $handler );
|
|
$helperWrapper = TestingAccessWrapper::newFromObject( $handlerWrapper->htmlHelper );
|
|
$helperWrapper->parsoid = $parsoid;
|
|
}
|
|
|
|
return $handler;
|
|
}
|
|
|
|
public function testExecuteWithHtml() {
|
|
$this->checkParsoidInstalled();
|
|
$page = $this->getExistingTestPage( 'HtmlEndpointTestPage/with/slashes' );
|
|
$this->assertTrue(
|
|
$this->editPage( $page, self::WIKITEXT )->isGood(),
|
|
'Edited a page'
|
|
);
|
|
|
|
$request = new RequestData(
|
|
[ 'pathParams' => [ 'title' => $page->getTitle()->getPrefixedText() ] ]
|
|
);
|
|
|
|
$handler = $this->newHandler();
|
|
$data = $this->executeHandlerAndGetBodyData( $handler, $request, [
|
|
'format' => 'with_html'
|
|
] );
|
|
|
|
$this->assertResponseData( $page, $data );
|
|
$this->assertStringContainsString( '<!DOCTYPE html>', $data['html'] );
|
|
$this->assertStringContainsString( '<html', $data['html'] );
|
|
$this->assertStringContainsString( self::HTML, $data['html'] );
|
|
}
|
|
|
|
public function testExecuteHtmlOnly() {
|
|
$this->checkParsoidInstalled();
|
|
$page = $this->getExistingTestPage( 'HtmlEndpointTestPage/with/slashes' );
|
|
$this->assertTrue(
|
|
$this->editPage( $page, self::WIKITEXT )->isGood(),
|
|
'Edited a page'
|
|
);
|
|
|
|
$request = new RequestData(
|
|
[ 'pathParams' => [ 'title' => $page->getTitle()->getPrefixedText() ] ]
|
|
);
|
|
|
|
$handler = $this->newHandler();
|
|
$response = $this->executeHandler( $handler, $request, [
|
|
'format' => 'html'
|
|
] );
|
|
|
|
$htmlResponse = (string)$response->getBody();
|
|
$this->assertStringContainsString( '<!DOCTYPE html>', $htmlResponse );
|
|
$this->assertStringContainsString( '<html', $htmlResponse );
|
|
$this->assertStringContainsString( self::HTML, $htmlResponse );
|
|
}
|
|
|
|
public function testHtmlIsCached() {
|
|
$this->checkParsoidInstalled();
|
|
|
|
$page = $this->getExistingTestPage( 'HtmlEndpointTestPage/with/slashes' );
|
|
$request = new RequestData(
|
|
[ 'pathParams' => [ 'title' => $page->getTitle()->getPrefixedText() ] ]
|
|
);
|
|
|
|
$parsoid = $this->createNoOpMock( Parsoid::class, [ 'wikitext2html' ] );
|
|
$parsoid->expects( $this->once() )
|
|
->method( 'wikitext2html' )
|
|
->willReturn( new PageBundle( 'mocked HTML', null, null, '1.0' ) );
|
|
|
|
$handler = $this->newHandler( $parsoid );
|
|
$response = $this->executeHandler( $handler, $request, [
|
|
'format' => 'html'
|
|
] );
|
|
$htmlResponse = (string)$response->getBody();
|
|
$this->assertStringContainsString( 'mocked HTML', $htmlResponse );
|
|
|
|
// check that we can run the test again and ensure that the parse is only run once
|
|
$handler = $this->newHandler( $parsoid );
|
|
$response = $this->executeHandler( $handler, $request, [
|
|
'format' => 'html'
|
|
] );
|
|
$htmlResponse = (string)$response->getBody();
|
|
$this->assertStringContainsString( 'mocked HTML', $htmlResponse );
|
|
}
|
|
|
|
public function testEtagLastModified() {
|
|
$this->checkParsoidInstalled();
|
|
|
|
$time = time();
|
|
MWTimestamp::setFakeTime( $time );
|
|
|
|
$page = $this->getExistingTestPage( 'HtmlEndpointTestPage/with/slashes' );
|
|
$request = new RequestData(
|
|
[ 'pathParams' => [ 'title' => $page->getTitle()->getPrefixedText() ] ]
|
|
);
|
|
|
|
// First, test it works if nothing was cached yet.
|
|
// Make some time pass since page was created:
|
|
$time += 10;
|
|
MWTimestamp::setFakeTime( $time );
|
|
$handler = $this->newHandler();
|
|
$response = $this->executeHandler( $handler, $request, [
|
|
'format' => 'html'
|
|
] );
|
|
$this->assertArrayHasKey( 'ETag', $response->getHeaders() );
|
|
$etag = $response->getHeaderLine( 'ETag' );
|
|
$this->assertStringMatchesFormat( '"' . $page->getLatest() . '/%x-%x-%x-%x-%x"', $etag );
|
|
$this->assertArrayHasKey( 'Last-Modified', $response->getHeaders() );
|
|
$this->assertSame( MWTimestamp::convert( TS_RFC2822, $time ),
|
|
$response->getHeaderLine( 'Last-Modified' ) );
|
|
|
|
// Now, test that headers work when getting from cache too.
|
|
$handler = $this->newHandler();
|
|
$response = $this->executeHandler( $handler, $request, [
|
|
'format' => 'html'
|
|
] );
|
|
$this->assertArrayHasKey( 'ETag', $response->getHeaders() );
|
|
$this->assertSame( $etag, $response->getHeaderLine( 'ETag' ) );
|
|
$etag = $response->getHeaderLine( 'ETag' );
|
|
$this->assertStringMatchesFormat( '"' . $page->getLatest() . '/%x-%x-%x-%x-%x"', $etag );
|
|
$this->assertArrayHasKey( 'Last-Modified', $response->getHeaders() );
|
|
$this->assertSame( MWTimestamp::convert( TS_RFC2822, $time ),
|
|
$response->getHeaderLine( 'Last-Modified' ) );
|
|
|
|
// Now, expire the cache
|
|
$time += 1000;
|
|
MWTimestamp::setFakeTime( $time );
|
|
$this->assertTrue(
|
|
$page->getTitle()->invalidateCache( MWTimestamp::convert( TS_MW, $time ) ),
|
|
'Can invalidate cache'
|
|
);
|
|
DeferredUpdates::doUpdates();
|
|
|
|
$handler = $this->newHandler();
|
|
$response = $this->executeHandler( $handler, $request, [
|
|
'format' => 'html'
|
|
] );
|
|
$this->assertArrayHasKey( 'ETag', $response->getHeaders() );
|
|
$this->assertNotSame( $etag, $response->getHeaderLine( 'ETag' ) );
|
|
$etag = $response->getHeaderLine( 'ETag' );
|
|
$this->assertStringMatchesFormat( '"' . $page->getLatest() . '/%x-%x-%x-%x-%x"', $etag );
|
|
$this->assertArrayHasKey( 'Last-Modified', $response->getHeaders() );
|
|
$this->assertSame( MWTimestamp::convert( TS_RFC2822, $time ),
|
|
$response->getHeaderLine( 'Last-Modified' ) );
|
|
}
|
|
|
|
public function provideHandlesParsoidError() {
|
|
yield 'ClientError' => [
|
|
new ClientError( 'TEST_TEST' ),
|
|
new LocalizedHttpException(
|
|
new MessageValue( 'rest-html-backend-error' ),
|
|
400,
|
|
[
|
|
'reason' => 'TEST_TEST'
|
|
]
|
|
)
|
|
];
|
|
yield 'ResourceLimitExceededException' => [
|
|
new ResourceLimitExceededException( 'TEST_TEST' ),
|
|
new LocalizedHttpException(
|
|
new MessageValue( 'rest-resource-limit-exceeded' ),
|
|
413,
|
|
[
|
|
'reason' => 'TEST_TEST'
|
|
]
|
|
)
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @dataProvider provideHandlesParsoidError
|
|
*/
|
|
public function testHandlesParsoidError(
|
|
Exception $parsoidException,
|
|
Exception $expectedException
|
|
) {
|
|
$this->checkParsoidInstalled();
|
|
|
|
$page = $this->getExistingTestPage( 'HtmlEndpointTestPage/with/slashes' );
|
|
$request = new RequestData(
|
|
[ 'pathParams' => [ 'title' => $page->getTitle()->getPrefixedText() ] ]
|
|
);
|
|
|
|
$parsoid = $this->createNoOpMock( Parsoid::class, [ 'wikitext2html' ] );
|
|
$parsoid->expects( $this->once() )
|
|
->method( 'wikitext2html' )
|
|
->willThrowException( $parsoidException );
|
|
|
|
$handler = $this->newHandler( $parsoid );
|
|
$this->expectExceptionObject( $expectedException );
|
|
$this->executeHandler( $handler, $request, [
|
|
'format' => 'html'
|
|
] );
|
|
}
|
|
|
|
public function testExecute_missingparam() {
|
|
$request = new RequestData();
|
|
|
|
$this->expectExceptionObject(
|
|
new LocalizedHttpException(
|
|
new MessageValue( "paramvalidator-missingparam", [ 'title' ] ),
|
|
400
|
|
)
|
|
);
|
|
|
|
$handler = $this->newHandler();
|
|
$this->executeHandler( $handler, $request );
|
|
}
|
|
|
|
public function testExecute_error() {
|
|
$request = new RequestData( [ 'pathParams' => [ 'title' => 'DoesNotExist8237456assda1234' ] ] );
|
|
|
|
$this->expectExceptionObject(
|
|
new LocalizedHttpException(
|
|
new MessageValue( "rest-nonexistent-title", [ 'testing' ] ),
|
|
404
|
|
)
|
|
);
|
|
|
|
$handler = $this->newHandler();
|
|
$this->executeHandler( $handler, $request );
|
|
}
|
|
|
|
/**
|
|
* @param WikiPage $page
|
|
* @param array $data
|
|
*/
|
|
private function assertResponseData( WikiPage $page, array $data ): void {
|
|
$this->assertSame( $page->getId(), $data['id'] );
|
|
$this->assertSame( $page->getTitle()->getPrefixedDBkey(), $data['key'] );
|
|
$this->assertSame( $page->getTitle()->getPrefixedText(), $data['title'] );
|
|
$this->assertSame( $page->getLatest(), $data['latest']['id'] );
|
|
$this->assertSame(
|
|
wfTimestampOrNull( TS_ISO_8601, $page->getTimestamp() ),
|
|
$data['latest']['timestamp']
|
|
);
|
|
$this->assertSame( CONTENT_MODEL_WIKITEXT, $data['content_model'] );
|
|
$this->assertSame( 'https://example.com/rights', $data['license']['url'] );
|
|
$this->assertSame( 'some rights', $data['license']['title'] );
|
|
}
|
|
|
|
/**
|
|
* Request One:
|
|
*
|
|
* When a request is made with no stash entries in the stash and stashing
|
|
* is set to false, don't stash anything. At this point, the stash is empty.
|
|
*
|
|
* Request Two:
|
|
*
|
|
* Once a request is made with stashing option set to true, we should have
|
|
* one entry in parsoid stash. So at this point, the stash is no longer empty
|
|
* as before.
|
|
*
|
|
* Request Three:
|
|
*
|
|
* Upon the third request, there is already a stash entry and if the 3rd request's
|
|
* stashing option is set to false, we're not invalidating the stash entries that
|
|
* exiting with the UUID. So, if we request a parsoid stashed object from the stash
|
|
* with a given UUID that exist, we should have a hit.
|
|
*/
|
|
public function testExecuteStashParsoidOutput() {
|
|
$page = $this->getExistingTestPage();
|
|
$outputStash = $this->getParsoidOutputStash();
|
|
|
|
[ /* $html1 */, $etag1, $stashKey1 ] = $this->executePageHTMLRequest( $page );
|
|
$this->assertNull( $outputStash->get( $stashKey1 ) );
|
|
|
|
[ /* $html2 */, $etag2, $stashKey2 ] = $this->executePageHTMLRequest( $page, [ 'stash' => true ] );
|
|
$this->assertNotNull( $outputStash->get( $stashKey2 ) );
|
|
|
|
[ /* $html3 */, $etag3, $stashKey3 ] = $this->executePageHTMLRequest( $page );
|
|
/**
|
|
* The stash for the previous request should still live at this point.
|
|
*/
|
|
$this->assertNotNull( $outputStash->get( $stashKey2 ) );
|
|
$this->assertNotNull( $outputStash->get( $stashKey3 ) );
|
|
$this->assertSame( $etag1, $etag3 );
|
|
$this->assertNotSame( $etag1, $etag2 );
|
|
|
|
// Make sure the output for stashed and unstashed doesn't have the same tag,
|
|
// since it will actually be different!
|
|
// FIXME: implement flavors and write test cases for them.
|
|
}
|
|
|
|
}
|