This is a follow-up to: I0b683461212a357c7eb09ddec59c87539e323c65 and I40a8372a76f33c5f62ea73bb1180dd7c47412c89 which explicitly for backward compatibility reasons supports IBufferingStatsdDataFactory. Now that we've fully switched to StatsFactory together with the `copyToStatsdAt()` method, we're fine to fully remove this `instanceof` logic. Bug: T356815 Change-Id: I164d82904b6d3fb575cb973c14f9454569bf09ac
382 lines
12 KiB
PHP
382 lines
12 KiB
PHP
<?php
|
|
|
|
namespace MediaWiki\Tests\Parser;
|
|
|
|
use BagOStuff;
|
|
use HashBagOStuff;
|
|
use InvalidArgumentException;
|
|
use MediaWiki\Json\JsonCodec;
|
|
use MediaWiki\Page\PageIdentity;
|
|
use MediaWiki\Page\PageIdentityValue;
|
|
use MediaWiki\Parser\ParserOutput;
|
|
use MediaWiki\Parser\RevisionOutputCache;
|
|
use MediaWiki\Revision\MutableRevisionRecord;
|
|
use MediaWiki\Revision\RevisionRecord;
|
|
use MediaWiki\Tests\Json\JsonUnserializableSuperClass;
|
|
use MediaWiki\User\User;
|
|
use MediaWiki\Utils\MWTimestamp;
|
|
use MediaWikiIntegrationTestCase;
|
|
use ParserOptions;
|
|
use Psr\Log\LoggerInterface;
|
|
use Psr\Log\LogLevel;
|
|
use Psr\Log\NullLogger;
|
|
use TestLogger;
|
|
use WANObjectCache;
|
|
use Wikimedia\Stats\StatsFactory;
|
|
use Wikimedia\TestingAccessWrapper;
|
|
use Wikimedia\UUID\GlobalIdGenerator;
|
|
|
|
/**
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache
|
|
* @package MediaWiki\Tests\Parser
|
|
*/
|
|
class RevisionOutputCacheTest extends MediaWikiIntegrationTestCase {
|
|
|
|
/** @var int */
|
|
private $time;
|
|
|
|
/** @var string */
|
|
private $cacheTime;
|
|
|
|
/** @var RevisionRecord */
|
|
private $revision;
|
|
|
|
protected function setUp(): void {
|
|
parent::setUp();
|
|
|
|
$this->time = time();
|
|
$this->cacheTime = MWTimestamp::convert( TS_MW, $this->time + 1 );
|
|
MWTimestamp::setFakeTime( $this->time );
|
|
|
|
$this->revision = new MutableRevisionRecord(
|
|
new PageIdentityValue(
|
|
42,
|
|
NS_MAIN,
|
|
'Testing_Testing',
|
|
PageIdentity::LOCAL
|
|
),
|
|
RevisionRecord::LOCAL
|
|
);
|
|
$this->revision->setId( 24 );
|
|
$this->revision->setTimestamp( MWTimestamp::convert( TS_MW, $this->time ) );
|
|
}
|
|
|
|
/**
|
|
* @param BagOStuff|null $storage
|
|
* @param LoggerInterface|null $logger
|
|
* @param int $expiry
|
|
* @param string $epoch
|
|
*
|
|
* @return RevisionOutputCache
|
|
*/
|
|
private function createRevisionOutputCache(
|
|
BagOStuff $storage = null,
|
|
LoggerInterface $logger = null,
|
|
$expiry = 3600,
|
|
$epoch = '19900220000000'
|
|
): RevisionOutputCache {
|
|
$globalIdGenerator = $this->createMock( GlobalIdGenerator::class );
|
|
$globalIdGenerator->method( 'newUUIDv1' )->willReturn( 'uuid-uuid' );
|
|
return new RevisionOutputCache(
|
|
'test',
|
|
new WANObjectCache( [ 'cache' => $storage ?: new HashBagOStuff() ] ),
|
|
$expiry,
|
|
$epoch,
|
|
new JsonCodec(),
|
|
StatsFactory::newNull(),
|
|
$logger ?: new NullLogger(),
|
|
$globalIdGenerator
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array
|
|
*/
|
|
private function getDummyUsedOptions(): array {
|
|
return array_slice(
|
|
ParserOptions::allCacheVaryingOptions(),
|
|
0,
|
|
2
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return ParserOutput
|
|
*/
|
|
private function createDummyParserOutput(): ParserOutput {
|
|
$parserOutput = new ParserOutput();
|
|
$parserOutput->setRawText( 'TEST' );
|
|
foreach ( $this->getDummyUsedOptions() as $option ) {
|
|
$parserOutput->recordOption( $option );
|
|
}
|
|
$parserOutput->updateCacheExpiry( 4242 );
|
|
return $parserOutput;
|
|
}
|
|
|
|
/**
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::makeParserOutputKey
|
|
*/
|
|
public function testMakeParserOutputKey() {
|
|
$cache = $this->createRevisionOutputCache();
|
|
|
|
$options1 = ParserOptions::newFromAnon();
|
|
$options1->setOption( $this->getDummyUsedOptions()[0], 'value1' );
|
|
$key1 = $cache->makeParserOutputKey( $this->revision, $options1, $this->getDummyUsedOptions() );
|
|
$this->assertNotNull( $key1 );
|
|
|
|
$options2 = ParserOptions::newFromAnon();
|
|
$options2->setOption( $this->getDummyUsedOptions()[0], 'value2' );
|
|
$key2 = $cache->makeParserOutputKey( $this->revision, $options2, $this->getDummyUsedOptions() );
|
|
$this->assertNotNull( $key2 );
|
|
$this->assertNotSame( $key1, $key2 );
|
|
}
|
|
|
|
/*
|
|
* Test that fetching without storing first returns false.
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::get
|
|
*/
|
|
public function testGetEmpty() {
|
|
$cache = $this->createRevisionOutputCache();
|
|
$options = ParserOptions::newFromAnon();
|
|
|
|
$this->assertFalse( $cache->get( $this->revision, $options ) );
|
|
}
|
|
|
|
/**
|
|
* Test that fetching with the same options return the saved value.
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::get
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::save
|
|
*/
|
|
public function testSaveGetSameOptions() {
|
|
$cache = $this->createRevisionOutputCache();
|
|
$parserOutput = new ParserOutput( 'TEST_TEXT' );
|
|
|
|
$options1 = ParserOptions::newFromAnon();
|
|
$options1->setOption( $this->getDummyUsedOptions()[0], 'value1' );
|
|
$cache->save( $parserOutput, $this->revision, $options1, $this->cacheTime );
|
|
|
|
$savedOutput = $cache->get( $this->revision, $options1 );
|
|
$this->assertInstanceOf( ParserOutput::class, $savedOutput );
|
|
// RevisionOutputCache adds a comment to the HTML, so check if the result starts with page content.
|
|
$this->assertStringStartsWith( 'TEST_TEXT', $savedOutput->getText() );
|
|
$this->assertSame( $this->cacheTime, $savedOutput->getCacheTime() );
|
|
$this->assertSame( $this->revision->getId(), $savedOutput->getCacheRevisionId() );
|
|
}
|
|
|
|
/**
|
|
* Test that non-cacheable output is not stored
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::save
|
|
*/
|
|
public function testDoesNotStoreNonCacheable() {
|
|
$cache = $this->createRevisionOutputCache();
|
|
$parserOutput = new ParserOutput( 'TEST_TEXT' );
|
|
$parserOutput->updateCacheExpiry( 0 );
|
|
|
|
$options1 = ParserOptions::newFromAnon();
|
|
$cache->save( $parserOutput, $this->revision, $options1, $this->cacheTime );
|
|
|
|
$this->assertFalse( $cache->get( $this->revision, $options1 ) );
|
|
}
|
|
|
|
/**
|
|
* Test that setting the cache epoch will cause outdated entries to be ignored
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::get
|
|
*/
|
|
public function testExpiresByEpoch() {
|
|
$store = new HashBagOStuff();
|
|
$cache = $this->createRevisionOutputCache( $store );
|
|
$parserOutput = new ParserOutput( 'TEST_TEXT' );
|
|
|
|
$options = ParserOptions::newFromAnon();
|
|
$cache->save( $parserOutput, $this->revision, $options, $this->cacheTime );
|
|
|
|
// determine cache epoch younger than cache time
|
|
$cacheTime = MWTimestamp::convert( TS_UNIX, $parserOutput->getCacheTime() );
|
|
$epoch = MWTimestamp::convert( TS_MW, $cacheTime + 60 );
|
|
|
|
// create a cache with the new epoch
|
|
$cache = $this->createRevisionOutputCache( $store, null, 60 * 60, $epoch );
|
|
$this->assertFalse( $cache->get( $this->revision, $options ) );
|
|
}
|
|
|
|
/**
|
|
* Test that setting the cache expiry period will cause outdated entries to be ignored
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::get
|
|
*/
|
|
public function testExpiresByDuration() {
|
|
$store = new HashBagOStuff();
|
|
|
|
// original cache is good for an hour
|
|
$cache = $this->createRevisionOutputCache( $store );
|
|
$parserOutput = new ParserOutput( 'TEST_TEXT' );
|
|
|
|
$options = ParserOptions::newFromAnon();
|
|
$cache->save( $parserOutput, $this->revision, $options, $this->cacheTime );
|
|
|
|
// move the clock forward by 60 seconds
|
|
$cacheTime = MWTimestamp::convert( TS_UNIX, $parserOutput->getCacheTime() );
|
|
MWTimestamp::setFakeTime( $cacheTime + 60 );
|
|
|
|
// create a cache that expires after 30 seconds
|
|
$cache = $this->createRevisionOutputCache( $store, null, 30 );
|
|
$this->assertFalse( $cache->get( $this->revision, $options ) );
|
|
}
|
|
|
|
/**
|
|
* Test that ParserOptions::isSafeToCache is respected on save
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::save
|
|
*/
|
|
public function testDoesNotStoreNotSafeToCache() {
|
|
$cache = $this->createRevisionOutputCache();
|
|
$parserOutput = new ParserOutput( 'TEST_TEXT' );
|
|
|
|
$options = ParserOptions::newFromAnon();
|
|
$options->setOption( 'wrapclass', 'wrapwrap' );
|
|
|
|
$cache->save( $parserOutput, $this->revision, $options, $this->cacheTime );
|
|
|
|
$this->assertFalse( $cache->get( $this->revision, $options ) );
|
|
}
|
|
|
|
/**
|
|
* Test that ParserOptions::isSafeToCache is respected on get
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::get
|
|
*/
|
|
public function testDoesNotGetNotSafeToCache() {
|
|
$cache = $this->createRevisionOutputCache();
|
|
$parserOutput = new ParserOutput( 'TEST_TEXT' );
|
|
|
|
$cache->save( $parserOutput, $this->revision, ParserOptions::newFromAnon(), $this->cacheTime );
|
|
|
|
$otherOptions = ParserOptions::newFromAnon();
|
|
$otherOptions->setOption( 'wrapclass', 'wrapwrap' );
|
|
|
|
$this->assertFalse( $cache->get( $this->revision, $otherOptions ) );
|
|
}
|
|
|
|
/**
|
|
* Test that fetching with different used option don't return a value.
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::get
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::save
|
|
*/
|
|
public function testSaveGetDifferentUsedOption() {
|
|
$cache = $this->createRevisionOutputCache();
|
|
$parserOutput = new ParserOutput( 'TEST_TEXT' );
|
|
$optionName = $this->getDummyUsedOptions()[0];
|
|
$parserOutput->recordOption( $optionName );
|
|
|
|
$options1 = ParserOptions::newFromAnon();
|
|
$options1->setOption( $optionName, 'value1' );
|
|
$cache->save( $parserOutput, $this->revision, $options1, $this->cacheTime );
|
|
|
|
$options2 = ParserOptions::newFromAnon();
|
|
$options2->setOption( $optionName, 'value2' );
|
|
$this->assertFalse( $cache->get( $this->revision, $options2 ) );
|
|
}
|
|
|
|
/**
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::save
|
|
*/
|
|
public function testSaveNoText() {
|
|
$this->expectException( InvalidArgumentException::class );
|
|
$this->createRevisionOutputCache()->save(
|
|
new ParserOutput( null ),
|
|
$this->revision,
|
|
ParserOptions::newFromAnon()
|
|
);
|
|
}
|
|
|
|
public static function provideCorruptData() {
|
|
yield 'JSON serialization, bad data' => [ 'bla bla' ];
|
|
yield 'JSON serialization, no _class_' => [ '{"test":"test"}' ];
|
|
yield 'JSON serialization, non-existing _class_' => [ '{"_class_":"NonExistentBogusClass"}' ];
|
|
|
|
$wrongInstance = new JsonUnserializableSuperClass( 'test' );
|
|
yield 'JSON serialization, wrong class' => [ json_encode( $wrongInstance->jsonSerialize() ) ];
|
|
}
|
|
|
|
/**
|
|
* Test that we handle corrupt data gracefully.
|
|
* This is important for forward-compatibility with JSON serialization.
|
|
* We want to be sure that we don't crash horribly if we have to roll
|
|
* back to a version of the code that doesn't know about JSON.
|
|
*
|
|
* @dataProvider provideCorruptData
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::get
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::restoreFromJson
|
|
* @param string $data
|
|
*/
|
|
public function testCorruptData( string $data ) {
|
|
$cache = $this->createRevisionOutputCache();
|
|
$parserOutput = new ParserOutput( 'TEST_TEXT' );
|
|
|
|
$options1 = ParserOptions::newFromAnon();
|
|
$cache->save( $parserOutput, $this->revision, $options1, $this->cacheTime );
|
|
|
|
$outputKey = $cache->makeParserOutputKey(
|
|
$this->revision,
|
|
$options1,
|
|
$parserOutput->getUsedOptions()
|
|
);
|
|
|
|
$backend = TestingAccessWrapper::newFromObject( $cache )->cache;
|
|
$backend->set( $outputKey, $data );
|
|
|
|
// just make sure we don't crash and burn
|
|
$this->assertFalse( $cache->get( $this->revision, $options1 ) );
|
|
}
|
|
|
|
/**
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::encodeAsJson
|
|
*/
|
|
public function testNonSerializableJsonIsReported() {
|
|
$testLogger = new TestLogger( true );
|
|
$cache = $this->createRevisionOutputCache( null, $testLogger );
|
|
|
|
$parserOutput = $this->createDummyParserOutput();
|
|
$parserOutput->setExtensionData( 'test', new User() );
|
|
$cache->save( $parserOutput, $this->revision, ParserOptions::newFromAnon() );
|
|
$this->assertArraySubmapSame(
|
|
[ [ LogLevel::ERROR, 'Unable to serialize JSON' ] ],
|
|
$testLogger->getBuffer()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::encodeAsJson
|
|
*/
|
|
public function testCyclicStructuresDoNotBlowUpInJson() {
|
|
$this->markTestSkipped( 'Temporarily disabled: T314338' );
|
|
$testLogger = new TestLogger( true );
|
|
$cache = $this->createRevisionOutputCache( null, $testLogger );
|
|
|
|
$parserOutput = $this->createDummyParserOutput();
|
|
$cyclicArray = [ 'a' => 'b' ];
|
|
$cyclicArray['c'] = &$cyclicArray;
|
|
$parserOutput->setExtensionData( 'test', $cyclicArray );
|
|
$cache->save( $parserOutput, $this->revision, ParserOptions::newFromAnon() );
|
|
$this->assertArraySubmapSame(
|
|
[ [ LogLevel::ERROR, 'Unable to serialize JSON' ] ],
|
|
$testLogger->getBuffer()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Tests that unicode characters are not \u escaped
|
|
* @covers \MediaWiki\Parser\RevisionOutputCache::encodeAsJson
|
|
*/
|
|
public function testJsonEncodeUnicode() {
|
|
$unicodeCharacter = "Э";
|
|
$cache = $this->createRevisionOutputCache( new HashBagOStuff() );
|
|
$parserOutput = $this->createDummyParserOutput();
|
|
$parserOutput->setRawText( $unicodeCharacter );
|
|
$cache->save( $parserOutput, $this->revision, ParserOptions::newFromAnon() );
|
|
|
|
$backend = TestingAccessWrapper::newFromObject( $cache )->cache;
|
|
$json = $backend->get(
|
|
$cache->makeParserOutputKey( $this->revision, ParserOptions::newFromAnon() )
|
|
);
|
|
$this->assertStringNotContainsString( "\u003E", $json );
|
|
$this->assertStringContainsString( $unicodeCharacter, $json );
|
|
}
|
|
}
|