Merge "docs: Fix typo"
[mediawiki.git] / tests / phpunit / includes / language / MessageCacheTest.php
blob05278ed02d150134ce5d293062671f3843e5d7b1
1 <?php
3 use MediaWiki\CommentStore\CommentStoreComment;
4 use MediaWiki\Content\ContentHandler;
5 use MediaWiki\Deferred\DeferredUpdates;
6 use MediaWiki\Language\RawMessage;
7 use MediaWiki\MainConfigNames;
8 use MediaWiki\Page\PageIdentityValue;
9 use MediaWiki\Revision\RevisionRecord;
10 use MediaWiki\Revision\SlotRecord;
11 use MediaWiki\Title\Title;
12 use Wikimedia\TestingAccessWrapper;
14 /**
15 * @group Language
16 * @group Database
17 * @covers \MessageCache
19 class MessageCacheTest extends MediaWikiLangTestCase {
21 protected function setUp(): void {
22 parent::setUp();
23 $this->configureLanguages();
24 $this->getServiceContainer()->getMessageCache()->enable();
27 /**
28 * Helper function -- setup site language for testing
30 protected function configureLanguages() {
31 // for the test, we need the content language to be anything but English,
32 // let's choose e.g. German (de)
33 $this->setUserLang( 'de' );
34 $this->setContentLang( 'de' );
37 public function addDBDataOnce() {
38 $this->configureLanguages();
40 // Set up messages and fallbacks ab -> ru -> de
41 $this->makePage( 'FallbackLanguageTest-Full', 'ab' );
42 $this->makePage( 'FallbackLanguageTest-Full', 'ru' );
43 $this->makePage( 'FallbackLanguageTest-Full', 'de' );
45 // Fallbacks where ab does not exist
46 $this->makePage( 'FallbackLanguageTest-Partial', 'ru' );
47 $this->makePage( 'FallbackLanguageTest-Partial', 'de' );
49 // Fallback to the content language
50 $this->makePage( 'FallbackLanguageTest-ContLang', 'de' );
52 // Full key tests -- always want russian
53 $this->makePage( 'MessageCacheTest-FullKeyTest', 'ab' );
54 $this->makePage( 'MessageCacheTest-FullKeyTest', 'ru' );
56 // In content language -- get base if no derivative
57 $this->makePage( 'FallbackLanguageTest-NoDervContLang', 'de', 'de/none' );
60 /**
61 * Helper function for addDBData -- adds a simple page to the database
63 * @param string $title Title of page to be created
64 * @param string $lang Language and content of the created page
65 * @param string|null $content Content of the created page, or null for a generic string
67 * @return RevisionRecord
69 private function makePage( $title, $lang, $content = null ) {
70 $content ??= $lang;
71 if ( $lang !== $this->getServiceContainer()->getContentLanguageCode()->toString() ) {
72 $title = "$title/$lang";
75 $title = Title::makeTitle( NS_MEDIAWIKI, $title );
76 $wikiPage = $this->getServiceContainer()->getWikiPageFactory()->newFromTitle( $title );
77 $content = ContentHandler::makeContent( $content, $title );
78 $summary = CommentStoreComment::newUnsavedComment( "$lang translation test case" );
80 $newRevision = $wikiPage->newPageUpdater( $this->getTestSysop()->getUser() )
81 ->setContent( SlotRecord::MAIN, $content )
82 ->saveRevision( $summary );
84 $this->assertNotNull( $newRevision, 'Create page ' . $title->getPrefixedDBkey() );
86 // Run the updates if no outer transaction is active
87 DeferredUpdates::tryOpportunisticExecute();
89 return $newRevision;
92 /**
93 * Test message fallbacks, T3495
95 * @dataProvider provideMessagesForFallback
97 public function testMessageFallbacks( $message, $langCode, $expectedContent ) {
98 $lang = $this->getServiceContainer()->getLanguageFactory()->getLanguage( $langCode );
99 $result = $this->getServiceContainer()->getMessageCache()->get( $message, true, $lang );
100 $this->assertEquals( $expectedContent, $result, "Message fallback failed." );
103 public static function provideMessagesForFallback() {
104 return [
105 [ 'FallbackLanguageTest-Full', 'ab', 'ab' ],
106 [ 'FallbackLanguageTest-Partial', 'ab', 'ru' ],
107 [ 'FallbackLanguageTest-ContLang', 'ab', 'de' ],
108 [ 'FallbackLanguageTest-None', 'ab', false ],
110 // T48579
111 [ 'FallbackLanguageTest-NoDervContLang', 'de', 'de/none' ],
112 // UI language different from content language should only use de/none as last option
113 [ 'FallbackLanguageTest-NoDervContLang', 'fit', 'de/none' ],
117 public function testReplaceMsg() {
118 $messageCache = $this->getServiceContainer()->getMessageCache();
119 $message = 'go';
120 $uckey = $this->getServiceContainer()->getContentLanguage()->ucfirst( $message );
121 $oldText = $messageCache->get( $message ); // "Ausführen"
123 $dbw = $this->getDb();
124 $dbw->startAtomic( __METHOD__ ); // simulate request and block deferred updates
125 $messageCache->replace( $uckey, 'Allez!' );
126 $this->assertEquals( 'Allez!',
127 $messageCache->getMsgFromNamespace( $uckey, 'de' ),
128 'Updates are reflected in-process immediately' );
129 $this->assertEquals( 'Allez!',
130 $messageCache->get( $message ),
131 'Updates are reflected in-process immediately' );
132 $this->makePage( 'Go', 'de', 'Race!' );
133 $dbw->endAtomic( __METHOD__ );
134 $this->runDeferredUpdates();
136 $this->assertSame( 0,
137 DeferredUpdates::pendingUpdatesCount(),
138 'Post-commit deferred update triggers a run of all updates' );
140 $this->assertEquals( 'Race!', $messageCache->get( $message ), 'Correct final contents' );
142 $this->makePage( 'Go', 'de', $oldText );
143 $messageCache->replace( $uckey, $oldText ); // deferred update runs immediately
144 $this->assertEquals( $oldText, $messageCache->get( $message ), 'Content restored' );
147 public function testReplaceCache() {
148 $this->overrideConfigValues( [
149 MainConfigNames::MainCacheType => CACHE_HASH,
150 ] );
152 $messageCache = $this->getServiceContainer()->getMessageCache();
153 $messageCache->enable();
155 // Populate one key
156 $this->makePage( 'Key1', 'de', 'Value1' );
157 $this->assertSame( 0,
158 DeferredUpdates::pendingUpdatesCount(),
159 'Post-commit deferred update triggers a run of all updates' );
160 $this->assertEquals( 'Value1', $messageCache->get( 'Key1' ), 'Key1 was successfully edited' );
162 // Screw up the database so MessageCache::loadFromDB() will
163 // produce the wrong result for reloading Key1
164 $this->getDb()->newDeleteQueryBuilder()
165 ->deleteFrom( 'page' )
166 ->where( [ 'page_namespace' => NS_MEDIAWIKI, 'page_title' => 'Key1' ] )
167 ->caller( __METHOD__ )
168 ->execute();
170 // Populate the second key
171 $this->makePage( 'Key2', 'de', 'Value2' );
172 $this->assertSame( 0,
173 DeferredUpdates::pendingUpdatesCount(),
174 'Post-commit deferred update triggers a run of all updates' );
175 $this->assertEquals( 'Value2', $messageCache->get( 'Key2' ), 'Key2 was successfully edited' );
177 // Now test that the second edit didn't reload Key1
178 $this->assertEquals( 'Value1', $messageCache->get( 'Key1' ),
179 'Key1 wasn\'t reloaded by edit of Key2' );
183 * @dataProvider provideNormalizeKey
185 public function testNormalizeKey( $key, $expected ) {
186 $actual = MessageCache::normalizeKey( $key );
187 $this->assertEquals( $expected, $actual );
190 public static function provideNormalizeKey() {
191 return [
192 [ 'Foo', 'foo' ],
193 [ 'foo', 'foo' ],
194 [ 'fOo', 'fOo' ],
195 [ 'FOO', 'fOO' ],
196 [ 'Foo bar', 'foo_bar' ],
197 [ 'Ćab', 'ćab' ],
198 [ 'Ćab_e 3', 'ćab_e_3' ],
199 [ 'ĆAB', 'ćAB' ],
200 [ 'ćab', 'ćab' ],
201 [ 'ćaB', 'ćaB' ],
205 public function testNoDBAccessContentLanguage() {
206 $languageCode = $this->getServiceContainer()->getMainConfig()->get( MainConfigNames::LanguageCode );
208 $dbr = $this->getDb();
210 $messageCache = $this->getServiceContainer()->getMessageCache();
211 $messageCache->getMsgFromNamespace( 'allpages', $languageCode );
213 $this->assertSame( 0, $dbr->trxLevel() );
214 $dbr->setFlag( DBO_TRX, $dbr::REMEMBER_PRIOR ); // make queries trigger TRX
216 $messageCache->getMsgFromNamespace( 'go', $languageCode );
218 $dbr->restoreFlags();
220 $this->assertSame( 0, $dbr->trxLevel(), "No DB read queries (content language)" );
223 public function testNoDBAccessNonContentLanguage() {
224 $dbr = $this->getDb();
226 $messageCache = $this->getServiceContainer()->getMessageCache();
227 $messageCache->getMsgFromNamespace( 'allpages/nl', 'nl' );
229 $this->assertSame( 0, $dbr->trxLevel() );
230 $dbr->setFlag( DBO_TRX, $dbr::REMEMBER_PRIOR ); // make queries trigger TRX
232 $messageCache->getMsgFromNamespace( 'go/nl', 'nl' );
234 $dbr->restoreFlags();
236 $this->assertSame( 0, $dbr->trxLevel(), "No DB read queries (non-content language)" );
240 * Regression test for T218918
242 public function testLoadFromDB_fetchLatestRevision() {
243 // Create three revisions of the same message page.
244 // Must be an existing message key.
245 $key = 'Log';
246 $this->makePage( $key, 'de', 'Test eins' );
247 $this->makePage( $key, 'de', 'Test zwei' );
248 $r3 = $this->makePage( $key, 'de', 'Test drei' );
250 // Create an out-of-sequence revision by importing a
251 // revision with an old timestamp. Hacky.
252 $importRevision = new WikiRevision();
253 $title = Title::newFromLinkTarget( $r3->getPageAsLinkTarget() );
254 $importRevision->setTitle( $title );
255 $importRevision->setComment( 'Imported edit' );
256 $importRevision->setTimestamp( '19991122001122' );
257 $content = ContentHandler::makeContent( 'IMPORTED OLD TEST', $title );
258 $importRevision->setContent( SlotRecord::MAIN, $content );
259 $importRevision->setUsername( 'ext>Alan Smithee' );
261 $importer = $this->getServiceContainer()->getWikiRevisionOldRevisionImporterNoUpdates();
262 $importer->import( $importRevision );
264 // Now, load the message from the wiki page
265 $messageCache = $this->getServiceContainer()->getMessageCache();
266 $messageCache->enable();
267 $messageCache = TestingAccessWrapper::newFromObject( $messageCache );
269 $cache = $messageCache->loadFromDB( 'de' );
271 $this->assertArrayHasKey( $key, $cache );
273 // Text in the cache has an extra space in front!
274 $this->assertSame( ' ' . 'Test drei', $cache[$key] );
278 * @dataProvider provideIsMainCacheable
279 * @param string|null $code The language code
280 * @param string $message The message key
281 * @param bool $expected
283 public function testIsMainCacheable( $code, $message, $expected ) {
284 $messageCache = TestingAccessWrapper::newFromObject(
285 $this->getServiceContainer()->getMessageCache() );
286 $this->assertSame( $expected, $messageCache->isMainCacheable( $message, $code ) );
289 public static function provideIsMainCacheable() {
290 $cases = [
291 [ 'allpages', true ],
292 [ 'Allpages', true ],
293 [ 'Allpages/bat', true ],
294 [ 'Conversiontable/zh-tw', true ],
295 [ 'My_special_message', false ],
297 foreach ( [ null, 'en', 'fr' ] as $code ) {
298 foreach ( $cases as $case ) {
299 yield array_merge( [ $code ], $case );
305 * @dataProvider provideLocalOverride
306 * @param string $messageKey
308 public function testLocalOverride( $messageKey ) {
309 $messageCache = $this->getServiceContainer()->getMessageCache();
310 $languageFactory = $this->getServiceContainer()->getLanguageFactory();
311 $languageZh = $languageFactory->getLanguage( 'zh' );
312 $languageZh_tw = $languageFactory->getLanguage( 'zh-tw' );
313 $languageZh_hk = $languageFactory->getLanguage( 'zh-hk' );
314 $languageZh_mo = $languageFactory->getLanguage( 'zh-mo' );
315 $oldMessageZh = $messageCache->get( $messageKey, true, $languageZh );
316 $oldMessageZh_tw = $messageCache->get( $messageKey, true, $languageZh_tw );
318 $localOverrideHK = $messageKey . '_zh-hk';
319 $this->makePage( ucfirst( $messageKey ), 'zh-hk', $localOverrideHK );
320 $this->assertEquals( $oldMessageZh, $messageCache->get( $messageKey, true, $languageZh ), 'Local override overlapped (main code)' );
321 $this->assertEquals( $oldMessageZh_tw, $messageCache->get( $messageKey, true, $languageZh_tw ), 'Local override overlapped' );
322 $this->assertEquals( $localOverrideHK, $messageCache->get( $messageKey, true, $languageZh_hk ), 'Local override failed (self)' );
323 $this->assertEquals( $localOverrideHK, $messageCache->get( $messageKey, true, $languageZh_mo ), 'Local override failed (fallback)' );
326 public static function provideLocalOverride() {
327 return [
328 // Preloaded with preloadedMessages
329 [ 'nstab-main' ],
330 // Not preloaded
331 [ 'nstab-help' ],
335 public function testNestedMessageParse() {
336 $msgOuter = ( new RawMessage( '[[Link|{{#language:}}]]' ) )
337 ->inLanguage( 'outer' )
338 ->page( new PageIdentityValue( 1, NS_MAIN, 'Link', PageIdentityValue::LOCAL ) );
340 // T372891: Allow nested message parsing
341 // Any hook from Linker or LinkRenderer will do for this test, but this one is the simplest
342 $this->setTemporaryHook( 'SelfLinkBegin', static function ( $nt, &$html, &$trail, &$prefix, &$ret ) {
343 $msgInner = ( new RawMessage( '{{#language:}}' ) )->inLanguage( 'inner' );
344 $html .= $msgInner->escaped();
345 } );
347 $this->assertEquals( '<a class="mw-selflink selflink">outerinner</a>', $msgOuter->parse() );
350 /** @dataProvider provideXssLanguage */
351 public function testXssLanguage( array $config, bool $expectXssMessage ): void {
352 $this->overrideConfigValues( $config + [
353 MainConfigNames::UseXssLanguage => false,
354 MainConfigNames::RawHtmlMessages => [],
355 ] );
357 $xss = $this->getServiceContainer()->getLanguageFactory()->getLanguage( 'x-xss' );
358 $message = $this->getServiceContainer()->getMessageCache()
359 ->get( 'key', true, $xss );
360 if ( $expectXssMessage ) {
361 $this->assertSame(
362 "<script>alert('key')</script>\"><script>alert('key')</script><x y=\"(\$*)",
363 $message
365 } else {
366 $this->assertFalse( $message );
370 public static function provideXssLanguage(): iterable {
371 yield 'default' => [
372 'config' => [],
373 'expectXssMessage' => false,
376 yield 'enabled' => [
377 'config' => [
378 MainConfigNames::UseXssLanguage => true,
380 'expectXssMessage' => true,
383 yield 'enabled but message marked as raw' => [
384 'config' => [
385 MainConfigNames::UseXssLanguage => true,
386 MainConfigNames::RawHtmlMessages => [ 'key' ],
388 'expectXssMessage' => false,