Localisation updates from https://translatewiki.net.
[mediawiki.git] / tests / phpunit / mocks / DummyServicesTrait.php
blob160c0386b8ae558120a7e4ebb433c00354735372
1 <?php
3 /**
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation; either version 2 of the License, or
7 * (at your option) any later version.
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License along
15 * with this program; if not, write to the Free Software Foundation, Inc.,
16 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 * http://www.gnu.org/copyleft/gpl.html
19 * @file
22 namespace MediaWiki\Tests\Unit;
24 use Interwiki;
25 use InvalidArgumentException;
26 use MediaWiki\Cache\CacheKeyHelper;
27 use MediaWiki\Cache\GenderCache;
28 use MediaWiki\CommentFormatter\CommentParser;
29 use MediaWiki\CommentFormatter\CommentParserFactory;
30 use MediaWiki\CommentStore\CommentStore;
31 use MediaWiki\Config\ServiceOptions;
32 use MediaWiki\Content\IContentHandlerFactory;
33 use MediaWiki\Interwiki\InterwikiLookup;
34 use MediaWiki\Language\Language;
35 use MediaWiki\Languages\LanguageNameUtils;
36 use MediaWiki\Linker\LinkTarget;
37 use MediaWiki\MainConfigSchema;
38 use MediaWiki\Page\PageReference;
39 use MediaWiki\Tests\MockDatabase;
40 use MediaWiki\Title\MalformedTitleException;
41 use MediaWiki\Title\MediaWikiTitleCodec;
42 use MediaWiki\Title\NamespaceInfo;
43 use MediaWiki\Title\TitleFormatter;
44 use MediaWiki\Title\TitleParser;
45 use MediaWiki\User\TempUser\RealTempUserConfig;
46 use MediaWiki\User\UserIdentity;
47 use MediaWiki\User\UserNameUtils;
48 use MediaWiki\Watchlist\WatchedItem;
49 use MediaWiki\Watchlist\WatchedItemStore;
50 use PHPUnit\Framework\MockObject\MockObject;
51 use Psr\Container\ContainerInterface;
52 use Psr\Log\NullLogger;
53 use Wikimedia\Message\ITextFormatter;
54 use Wikimedia\Message\MessageSpecifier;
55 use Wikimedia\Message\MessageValue;
56 use Wikimedia\ObjectFactory\ObjectFactory;
57 use Wikimedia\Rdbms\ConfiguredReadOnlyMode;
58 use Wikimedia\Rdbms\ILBFactory;
59 use Wikimedia\Rdbms\ILoadBalancer;
60 use Wikimedia\Rdbms\LBFactory;
61 use Wikimedia\Rdbms\LBFactorySingle;
62 use Wikimedia\Rdbms\ReadOnlyMode;
63 use Wikimedia\Services\NoSuchServiceException;
65 /**
66 * Trait to get helper services that can be used in unit tests
68 * Getters are in the form getDummy{ServiceName} because they *might* be
69 * returning mock objects (like getDummyWatchedItemStore), they *might* be
70 * returning real services but with dependencies that are mocks (like
71 * getDummyMediaWikiTitleCodec), or they *might* be full real services
72 * with no mocks (like getDummyNamespaceInfo) but with the name "dummy"
73 * to be consistent.
75 * @internal
76 * @author DannyS712
78 trait DummyServicesTrait {
80 /**
81 * @var array
82 * Data for the watched item store, keys are result of getWatchedItemStoreKey()
83 * and the value is 'true' for indefinitely watched, or a string with an expiration;
84 * if there is no entry here than the page is not watched
86 private $watchedItemStoreData = [];
88 /**
89 * @return array keys are the setting name, values are the default value.
91 private static function getDefaultSettings(): array {
92 static $defaultSettings = null;
93 if ( $defaultSettings !== null ) {
94 return $defaultSettings;
96 $defaultSettings = iterator_to_array( MainConfigSchema::listDefaultValues() );
97 return $defaultSettings;
101 * @param CommentParser $parser to always return
102 * @return CommentParserFactory
104 private function getDummyCommentParserFactory(
105 CommentParser $parser
106 ): CommentParserFactory {
107 return new class( $parser ) extends CommentParserFactory {
108 private $parser;
110 public function __construct( $parser ) {
111 $this->parser = $parser;
114 public function create() {
115 return $this->parser;
121 * @param array $contentHandlers map of content model to a ContentHandler object to
122 * return (or to `true` for a content model to be defined but not actually have any
123 * content handlers).
124 * @param string[] $allContentFormats specific content formats to claim support for,
125 * by default none
126 * @return IContentHandlerFactory
128 private function getDummyContentHandlerFactory(
129 array $contentHandlers = [],
130 array $allContentFormats = []
131 ): IContentHandlerFactory {
132 $contentHandlerFactory = $this->createMock( IContentHandlerFactory::class );
133 $contentHandlerFactory->method( 'getContentHandler' )
134 ->willReturnCallback(
135 static function ( string $modelId ) use ( $contentHandlers ) {
136 // interface has a return typehint, if $contentHandlers
137 // doesn't have that key or the value isn't an instance of
138 // ContentHandler will throw exception
139 return $contentHandlers[ $modelId ];
142 $contentHandlerFactory->method( 'getContentModels' )
143 ->willReturn( array_keys( $contentHandlers ) );
144 $contentHandlerFactory->method( 'getAllContentFormats' )
145 ->willReturn( $allContentFormats );
146 $contentHandlerFactory->method( 'isDefinedModel' )
147 ->willReturnCallback(
148 static function ( string $modelId ) use ( $contentHandlers ) {
149 return array_key_exists( $modelId, $contentHandlers );
152 return $contentHandlerFactory;
156 * @param array $dbOptions Options for the Database constructor
157 * @return LBFactory
159 private function getDummyDBLoadBalancerFactory( $dbOptions = [] ): LBFactory {
160 return LBFactorySingle::newFromConnection( new MockDatabase( $dbOptions ) );
164 * @param array $interwikis valid interwikis, either a string if all that matters is
165 * that it is valid, or an array with some or all of the information for a row
166 * from the interwiki table (iw_prefix, iw_url, iw_api, iw_wikiid, iw_local, iw_trans).
167 * Like the real InterwikiLookup interface, the iw_api/iw_wikiid/iw_local/iw_trans are
168 * all optional, defaulting to empty strings or 0 as approriate. *Unlike* the real
169 * InterwikiLookup interface, iw_url is also optional, defaulting to an empty string.
170 * @return InterwikiLookup
172 private function getDummyInterwikiLookup( array $interwikis = [] ): InterwikiLookup {
173 // Normalize into full arrays, indexed by prefix
174 $allInterwikiRows = [];
175 $defaultInterwiki = [
176 // No prefix
177 'iw_url' => '',
178 'iw_api' => '',
179 'iw_wikiid' => '',
180 'iw_local' => 0,
181 'iw_trans' => 0,
183 foreach ( $interwikis as $validInterwiki ) {
184 if ( is_string( $validInterwiki ) ) {
185 // All we got is that a prefix is valid
186 $interwikiRow = [ 'iw_prefix' => $validInterwiki ] + $defaultInterwiki;
187 } elseif ( is_array( $validInterwiki ) ) {
188 if ( !isset( $validInterwiki['iw_prefix'] ) ) {
189 throw new InvalidArgumentException(
190 'Cannot save a valid interwiki without a prefix'
193 $interwikiRow = $validInterwiki + $defaultInterwiki;
194 } else {
195 throw new InvalidArgumentException(
196 'Interwikis must be in the form of a string or an array'
200 // Indexed by prefix to make lookup easier
201 $allInterwikiRows[ $interwikiRow['iw_prefix'] ] = $interwikiRow;
204 // Actual implementation
205 return new class( $allInterwikiRows ) implements InterwikiLookup {
206 private $allInterwikiRows;
208 public function __construct( $allInterwikiRows ) {
209 $this->allInterwikiRows = $allInterwikiRows;
212 public function isValidInterwiki( $prefix ) {
213 return (bool)$this->fetch( $prefix );
216 public function fetch( $prefix ) {
217 if ( $prefix == '' ) {
218 return null;
220 // Interwikis are lowercase, but we might be given a prefix that
221 // has uppercase characters, eg. from UserNameUtils normalization
222 // in ClassicInterwikiLookup::fetch this would use Language::lc which
223 // would decide between mb_strtolower and strtolower, but we can assume
224 // that everything is in English for tests
225 $prefix = strtolower( $prefix );
226 if ( !isset( $this->allInterwikiRows[ $prefix ] ) ) {
227 return false;
230 $row = $this->allInterwikiRows[ $prefix ];
231 return new Interwiki(
232 $row['iw_prefix'],
233 $row['iw_url'],
234 $row['iw_api'],
235 $row['iw_wikiid'],
236 $row['iw_local'],
237 $row['iw_trans']
241 public function getAllPrefixes( $local = null ) {
242 if ( $local === null ) {
243 return array_values( $this->allInterwikiRows );
245 return array_values(
246 array_filter(
247 $this->allInterwikiRows,
248 static function ( $row ) use ( $local ) {
249 return $row['iw_local'] == (int)$local;
255 public function invalidateCache( $prefix ) {
256 // Nothing to do
262 * @param array $options keys are
263 * - anything in LanguageNameUtils::CONSTRUCTOR_OPTIONS, any missing options will default
264 * to the MainConfigSchema defaults
265 * - 'hookContainer' if specific hooks need to be registered, otherwise an empty
266 * container will be used
267 * @return LanguageNameUtils
269 private function getDummyLanguageNameUtils( array $options = [] ): LanguageNameUtils {
270 // configuration is based on the defaults in MainConfigSchema
271 $serviceOptions = new ServiceOptions(
272 LanguageNameUtils::CONSTRUCTOR_OPTIONS,
273 $options, // caller can override the default config by specifying it here
274 self::getDefaultSettings()
276 return new LanguageNameUtils(
277 $serviceOptions,
278 $options['hookContainer'] ?? $this->createHookContainer()
283 * @param array $options see getDummyMediaWikiTitleCodec for supported options
284 * @return TitleFormatter
286 private function getDummyTitleFormatter( array $options = [] ): TitleFormatter {
287 return $this->getDummyMediaWikiTitleCodec( $options );
291 * @param array $options see getDummyMediaWikiTitleCodec for supported options
292 * @return TitleParser
294 private function getDummyTitleParser( array $options = [] ): TitleParser {
295 return $this->getDummyMediaWikiTitleCodec( $options );
299 * Note: you should probably use getDummyTitleFormatter or getDummyTitleParser,
300 * unless you actually need both services, in which case it doesn't make sense
301 * to get two different objects when they are implemented together.
303 * Note that MediaWikiTitleCodec can throw MalformedTitleException which cannot be
304 * created in unit tests - you can change this by providing a callback to
305 * MediaWikiTitleCodec::overrideCreateMalformedTitleExceptionCallback() to use to
306 * create the exception that can return a mock. If you use the option 'throwMockExceptions'
307 * here, the callback will be replaced with one that throws a generic mock
308 * MalformedTitleException, i.e. without taking into account the actual message or
309 * parameters provided. This is useful for cases where only the fact that an exception
310 * is thrown, rather than the specific message in the exception, matters, like for
311 * detecting invalid titles.
313 * @param array $options Supported keys:
314 * - validInterwikis: array of interwiki info to pass to getDummyInterwikiLookup
315 * - throwMockExceptions: boolean, see above
316 * - any of the options passed to getDummyNamespaceInfo (the same $options is passed on)
318 * @return MediaWikiTitleCodec
320 private function getDummyMediaWikiTitleCodec( array $options = [] ): MediaWikiTitleCodec {
321 $baseConfig = [
322 'validInterwikis' => [],
323 'throwMockExceptions' => false,
325 $config = $options + $baseConfig;
327 $namespaceInfo = $this->getDummyNamespaceInfo( $options );
329 /** @var Language|MockObject $language */
330 $language = $this->createMock( Language::class );
331 $language->method( 'ucfirst' )->willReturnCallback( 'ucfirst' );
332 $language->method( 'lc' )->willReturnCallback(
333 static function ( $str, $first ) {
334 return $first ? lcfirst( $str ) : strtolower( $str );
337 $language->method( 'getNsIndex' )->willReturnCallback(
338 static function ( $text ) use ( $namespaceInfo ) {
339 $text = strtolower( $text );
340 if ( $text === '' ) {
341 return NS_MAIN;
343 // based on the real Language::getNsIndex but without
344 // the support for translated namespace names
345 // We do still support English aliases "Image" and
346 // "Image_talk" though
347 $index = $namespaceInfo->getCanonicalIndex( $text );
349 if ( $index !== null ) {
350 return $index;
352 $aliases = [
353 'image' => NS_FILE,
354 'image_talk' => NS_FILE,
356 return $aliases[$text] ?? false;
359 $language->method( 'getNsText' )->willReturnCallback(
360 static function ( $index ) use ( $namespaceInfo ) {
361 // based on the real Language::getNsText but without
362 // the support for translated namespace names
363 $namespaces = $namespaceInfo->getCanonicalNamespaces();
364 return $namespaces[$index] ?? false;
367 // Not dealing with genders, most languages don't - as a result,
368 // the GenderCache is never used and thus a no-op mock
369 $language->method( 'needsGenderDistinction' )->willReturn( false );
371 /** @var GenderCache|MockObject $genderCache */
372 $genderCache = $this->createMock( GenderCache::class );
374 $interwikiLookup = $this->getDummyInterwikiLookup( $config['validInterwikis'] );
376 $titleCodec = new MediaWikiTitleCodec(
377 $language,
378 $genderCache,
379 [ 'en' ],
380 $interwikiLookup,
381 $namespaceInfo
384 if ( $config['throwMockExceptions'] ) {
385 // Throw mock `MalformedTitleException`s, doesn't take into account the
386 // specifics of the parameters provided
387 $titleCodec->overrideCreateMalformedTitleExceptionCallback(
388 function ( $errorMessage, $titleText = null, $errorMessageParameters = [] ) {
389 return $this->createMock( MalformedTitleException::class );
394 return $titleCodec;
398 * @param array $options Valid keys are 'hookContainer' for a specific HookContainer
399 * to use (falls back to just creating an empty one), plus any of the configuration
400 * included in NamespaceInfo::CONSTRUCTOR_OPTIONS
401 * @return NamespaceInfo
403 private function getDummyNamespaceInfo( array $options = [] ): NamespaceInfo {
404 // configuration is based on the defaults in MainConfigSchema
405 $serviceOptions = new ServiceOptions(
406 NamespaceInfo::CONSTRUCTOR_OPTIONS,
407 $options, // caller can override the default config by specifying it here
408 self::getDefaultSettings()
410 return new NamespaceInfo(
411 $serviceOptions,
412 $options['hookContainer'] ?? $this->createHookContainer(),
419 * @param array<string,mixed> $services services that exist, keys are service names,
420 * values are the service to return. Any service not in this array does not exist.
421 * @return ObjectFactory
423 private function getDummyObjectFactory( array $services = [] ): ObjectFactory {
424 $container = $this->createMock( ContainerInterface::class );
425 $container->method( 'has' )
426 ->willReturnCallback( static function ( $serviceName ) use ( $services ) {
427 return array_key_exists( $serviceName, $services );
428 } );
429 $container->method( 'get' )
430 ->willReturnCallback( static function ( $serviceName ) use ( $services ) {
431 if ( array_key_exists( $serviceName, $services ) ) {
432 return $services[$serviceName];
434 // Need to throw some exception that implements the PSR
435 // NotFoundExceptionInterface, use the exception from the Services
436 // library which implements it and has a helpful message
437 throw new NoSuchServiceException( $serviceName );
438 } );
439 return new ObjectFactory( $container );
443 * @param string|bool $startingReason If false, the read only mode isn't active,
444 * otherwise it is active and this is the reason (true maps to a fallback reason)
445 * @return ReadOnlyMode
447 private function getDummyReadOnlyMode( $startingReason ): ReadOnlyMode {
448 if ( $startingReason === true ) {
449 $startingReason = 'Random reason';
451 $loadBalancer = $this->createMock( ILoadBalancer::class );
452 $loadBalancer->method( 'getReadOnlyReason' )->willReturn( false );
453 $lbFactory = $this->createMock( ILBFactory::class );
454 $lbFactory->method( 'getMainLB' )->willReturn( $loadBalancer );
455 return new ReadOnlyMode(
456 new ConfiguredReadOnlyMode( $startingReason, null ),
457 $lbFactory
462 * @param bool $dumpMessages Whether MessageValue objects should be formatted by dumping
463 * them rather than just returning the key
464 * @return ITextFormatter
466 private function getDummyTextFormatter( bool $dumpMessages = false ): ITextFormatter {
467 return new class( $dumpMessages ) implements ITextFormatter {
468 private bool $dumpMessages;
470 public function __construct( bool $dumpMessages ) {
471 $this->dumpMessages = $dumpMessages;
474 public function getLangCode(): string {
475 return 'qqx';
478 public function format( MessageSpecifier $message ): string {
479 if ( $this->dumpMessages && $message instanceof MessageValue ) {
480 return $message->dump();
482 return $message->getKey();
488 * @param array $options Supported keys:
489 * - any of the configuration options used in the ServiceOptions
490 * - logger: logger to use, defaults to a NullLogger
491 * - textFormatter: ITextFormatter to use, defaults to a mock where the 'format' method
492 * (the only one used by UserNameUtils) just returns the key of the MessageValue provided)
493 * - titleParser: TitleParser to use, otherwise we will use getDummyTitleParser()
494 * - any of the options passed to getDummyTitleParser (the same $options is passed on if
495 * no titleParser is provided) (we change the default for "validInterwikis" to be
496 * [ 'interwiki' ] instead of an empty array if not provided)
497 * - hookContainer: specific HookContainer to use, default to creating an empty one via
498 * $this->createHookContainer()
499 * @return UserNameUtils
501 private function getDummyUserNameUtils( array $options = [] ) {
502 $serviceOptions = new ServiceOptions(
503 UserNameUtils::CONSTRUCTOR_OPTIONS,
504 $options,
505 self::getDefaultSettings() // fallback for options not in $options
508 // The only methods we call on the Language object is ucfirst and getNsText,
509 // avoid needing to create a mock in each test.
510 // Note that the actual Language::ucfirst is a bit more complicated than this
511 // but since the tests are all in English the plain php `ucfirst` should be enough.
512 $contentLang = $this->createMock( Language::class );
513 $contentLang->method( 'ucfirst' )
514 ->willReturnCallback( 'ucfirst' );
515 $contentLang->method( 'getNsText' )->with( NS_USER )
516 ->willReturn( 'User' );
518 $logger = $options['logger'] ?? new NullLogger();
520 $textFormatter = $options['textFormatter'] ?? $this->getDummyTextFormatter();
522 $titleParser = $options['titleParser'] ?? false;
523 if ( !$titleParser ) {
524 // The TitleParser from DummyServicesTrait::getDummyTitleParser is really a
525 // MediaWikiTitleCodec object, and by passing `throwMockExceptions` we replace
526 // the actual creation of `MalformedTitleException`s with mocks - see
527 // MediaWikiTitleCodec::overrideCreateMalformedTitleExceptionCallback()
528 // The UserNameUtils code doesn't care about the message in the exception,
529 // just whether it is thrown.
530 $titleParser = $this->getDummyTitleParser(
531 $options + [
532 'validInterwikis' => [ 'interwiki' ],
533 'throwMockExceptions' => true
538 return new UserNameUtils(
539 $serviceOptions,
540 $contentLang,
541 $logger,
542 $titleParser,
543 $textFormatter,
544 $options['hookContainer'] ?? $this->createHookContainer(),
545 $options['tempUserConfig'] ?? new RealTempUserConfig( [
546 'enabled' => true,
547 'expireAfterDays' => null,
548 'actions' => [ 'edit' ],
549 'serialProvider' => [ 'type' => 'local' ],
550 'serialMapping' => [ 'type' => 'plain-numeric' ],
551 'reservedPattern' => '!$1',
552 'matchPattern' => '*$1',
553 'genPattern' => '*Unregistered $1'
559 * @param UserIdentity $user Should only be called with registered users
560 * @param LinkTarget|PageReference $page
561 * @return string
563 private function getWatchedItemStoreKey( UserIdentity $user, $page ): string {
564 return 'u' . (string)$user->getId() . ':' . CacheKeyHelper::getKeyForPage( $page );
568 * @return WatchedItemStore|MockObject
570 private function getDummyWatchedItemStore() {
571 // The WatchedItemStoreInterface has a lot of stuff, but most tests only depend
572 // on the basic getWatchedItem/addWatch/removeWatch/isWatched/isTempWatched
573 // We mock WatchedItemStore and support those 5 methods, and it even handles
574 // keep track of different pages and users!
575 // Note: we store no expiration as true, so we can use isset(), but its represented
576 // by null elsewhere, so we need to convert
577 $mock = $this->createNoOpMock(
578 WatchedItemStore::class,
579 [ 'getWatchedItem', 'addWatch', 'removeWatch', 'isWatched', 'isTempWatched' ]
581 $mock->method( 'getWatchedItem' )->willReturnCallback( function ( $user, $target ) {
582 $dataKey = $this->getWatchedItemStoreKey( $user, $target );
583 if ( isset( $this->watchedItemStoreData[ $dataKey ] ) ) {
584 $expiry = $this->watchedItemStoreData[ $dataKey ];
585 // We store no expiration as true, so we can use isset(), but its
586 // represented by null elsewhere, including in WatchedItem
587 $expiry = ( $expiry === true ? null : $expiry );
588 return new WatchedItem(
589 $user,
590 $target,
591 null,
592 $expiry
595 return false;
596 } );
597 $mock->method( 'addWatch' )->willReturnCallback( function ( $user, $target, $expiry ) {
598 if ( !$user->isRegistered() ) {
599 return false;
601 $dataKey = $this->getWatchedItemStoreKey( $user, $target );
602 $this->watchedItemStoreData[ $dataKey ] = ( $expiry === null ? true : $expiry );
603 return true;
604 } );
605 $mock->method( 'removeWatch' )->willReturnCallback( function ( $user, $target ) {
606 if ( !$user->isRegistered() ) {
607 return false;
609 $dataKey = $this->getWatchedItemStoreKey( $user, $target );
610 if ( isset( $this->watchedItemStoreData[ $dataKey ] ) ) {
611 unset( $this->watchedItemStoreData[ $dataKey ] );
612 return true;
614 return false;
615 } );
616 $mock->method( 'isWatched' )->willReturnCallback( function ( $user, $target ) {
617 $dataKey = $this->getWatchedItemStoreKey( $user, $target );
618 return isset( $this->watchedItemStoreData[ $dataKey ] );
619 } );
620 $mock->method( 'isTempWatched' )->willReturnCallback( function ( $user, $target ) {
621 $dataKey = $this->getWatchedItemStoreKey( $user, $target );
622 return isset( $this->watchedItemStoreData[ $dataKey ] ) &&
623 $this->watchedItemStoreData[ $dataKey ] !== true;
624 } );
625 return $mock;
628 private function getDummyCommentStore(): CommentStore {
629 $mockLang = $this->createNoOpMock( Language::class,
630 [ 'truncateForVisual', 'truncateForDatabase' ] );
631 $mockLang->method( $this->logicalOr( 'truncateForDatabase', 'truncateForVisual' ) )
632 ->willReturnCallback(
633 static function ( string $text, int $limit ): string {
634 if ( strlen( $text ) > $limit - 3 ) {
635 return substr( $text, 0, $limit - 3 ) . '...';
637 return $text;
640 return new CommentStore( $mockLang );