wiki.techinc.nl/tests/phpunit/includes/user/UserOptionsManagerTest.php

331 lines
12 KiB
PHP
Raw Normal View History

<?php
use MediaWiki\Config\ServiceOptions;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserIdentityValue;
use MediaWiki\User\UserOptionsLookup;
use MediaWiki\User\UserOptionsManager;
use Psr\Log\NullLogger;
use Wikimedia\Rdbms\DBConnRef;
Improvements to user preferences fetching/saving == Status quo == When saving user preferences, we want to lock the rows to avoid accidentally overriding a concurrent options update. So usually what extensions do is: $value = $mngr->getOption( 'option', ..., READ_LOCKING ); if ( $value !== 'new_value' ) { $mngr->setOption( 'option', 'new_value' ); $mngr->saveOptions() } Previously for extra caution we've ignored all caches in options manager if >= READ_LOCKING flags were passed. This resulted in re-reading all the options multiple times. At worst, 3 times: 1. If READ_NORMAL read was made for update - that's once, 2. On setOption, one more read, forcefully from primary 3. On saveOptions, one more read from primary, non-locking, to figure out which option keys need to be deleted. Also, READ_LOCKING was not used where it clearly had to be used, for example right before the update. This was trying to fix any kind of error on part of the manager clients, unsuccessfully so. == New approach == 1. Cache modified user options separately from originals and merge them on demand. This means when refetching originals with LOCKING we don't wipe out all modifications made to the cache with setOption. Extra bonus - we no longer need to load all options to set an option. 2. Split the originals cache into 2 layers - one for stuff that comes from DB directly, and one with applied normalizations and whatever hooks modify. This let's us avoid refetching DB options after we save them, but still let's the hooks execute on newly set options after they're saved. 3. Cache options with all query flags. This is a bit controversial, but ideally LOCKING flags will be applied on options fetch right before they are saved. We have to re-read options with LOCKING in saveOptions to avoid races, but if the caller did 'getOption( ..., LOCKING), setOption(), save()' we will not need to re-select with LOCKING again. Bug: T280220 Change-Id: Ibed2789f5260b725fd806b4470631aa30d814ce6
2021-06-11 18:48:19 +00:00
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Timestamp\ConvertibleTimestamp;
/**
* @group Database
* @covers MediaWiki\User\UserOptionsManager
*/
class UserOptionsManagerTest extends UserOptionsLookupTest {
protected function setUp(): void {
parent::setUp();
$this->tablesUsed[] = 'user';
$this->tablesUsed[] = 'user_properties';
}
/**
* @param array $overrides supported keys:
* - 'language' - string language code
* - 'defaults' - array default preferences
* - 'lb' - ILoadBalancer
* - 'hookContainer' - HookContainer
* @return UserOptionsManager
*/
private function getManager( array $overrides = [] ) {
$services = $this->getServiceContainer();
return new UserOptionsManager(
new ServiceOptions(
UserOptionsManager::CONSTRUCTOR_OPTIONS,
new HashConfig( [
'HiddenPrefs' => [ 'hidden_user_option' ],
'LocalTZoffset' => 0,
] )
),
$this->getDefaultManager(
$overrides['language'] ?? 'qqq',
$overrides['defaults'] ?? []
),
$services->getLanguageConverterFactory(),
$overrides['lb'] ?? $services->getDBLoadBalancer(),
Hooks::run() call site migration Migrate all callers of Hooks::run() to use the new HookContainer/HookRunner system. General principles: * Use DI if it is already used. We're not changing the way state is managed in this patch. * HookContainer is always injected, not HookRunner. HookContainer is a service, it's a more generic interface, it is the only thing that provides isRegistered() which is needed in some cases, and a HookRunner can be efficiently constructed from it (confirmed by benchmark). Because HookContainer is needed for object construction, it is also needed by all factories. * "Ask your friendly local base class". Big hierarchies like SpecialPage and ApiBase have getHookContainer() and getHookRunner() methods in the base class, and classes that extend that base class are not expected to know or care where the base class gets its HookContainer from. * ProtectedHookAccessorTrait provides protected getHookContainer() and getHookRunner() methods, getting them from the global service container. The point of this is to ease migration to DI by ensuring that call sites ask their local friendly base class rather than getting a HookRunner from the service container directly. * Private $this->hookRunner. In some smaller classes where accessor methods did not seem warranted, there is a private HookRunner property which is accessed directly. Very rarely (two cases), there is a protected property, for consistency with code that conventionally assumes protected=private, but in cases where the class might actually be overridden, a protected accessor is preferred over a protected property. * The last resort: Hooks::runner(). Mostly for static, file-scope and global code. In a few cases it was used for objects with broken construction schemes, out of horror or laziness. Constructors with new required arguments: * AuthManager * BadFileLookup * BlockManager * ClassicInterwikiLookup * ContentHandlerFactory * ContentSecurityPolicy * DefaultOptionsManager * DerivedPageDataUpdater * FullSearchResultWidget * HtmlCacheUpdater * LanguageFactory * LanguageNameUtils * LinkRenderer * LinkRendererFactory * LocalisationCache * MagicWordFactory * MessageCache * NamespaceInfo * PageEditStash * PageHandlerFactory * PageUpdater * ParserFactory * PermissionManager * RevisionStore * RevisionStoreFactory * SearchEngineConfig * SearchEngineFactory * SearchFormWidget * SearchNearMatcher * SessionBackend * SpecialPageFactory * UserNameUtils * UserOptionsManager * WatchedItemQueryService * WatchedItemStore Constructors with new optional arguments: * DefaultPreferencesFactory * Language * LinkHolderArray * MovePage * Parser * ParserCache * PasswordReset * Router setHookContainer() now required after construction: * AuthenticationProvider * ResourceLoaderModule * SearchEngine Change-Id: Id442b0dbe43aba84bd5cf801d86dedc768b082c7
2020-03-19 02:42:09 +00:00
new NullLogger(),
$overrides['hookContainer'] ?? $services->getHookContainer(),
$services->getUserFactory()
);
}
protected function getLookup(
string $langCode = 'qqq',
array $defaultOptionsOverrides = []
): UserOptionsLookup {
return $this->getManager( [
'language' => $langCode,
'defaults' => $defaultOptionsOverrides,
] );
}
/**
* @covers MediaWiki\User\UserOptionsManager::getOption
*/
public function testGetOptionsExcludeDefaults() {
$manager = $this->getManager( [ 'defaults' => [
'null_vs_false' => null,
'null_vs_string' => null,
'false_vs_int' => false,
'false_vs_string' => false,
'int_vs_string' => 0,
'true_vs_int' => true,
'true_vs_string' => true,
] ] );
$user = $this->getAnon( __METHOD__ );
$manager->setOption( $user, 'null_vs_false', false );
$manager->setOption( $user, 'null_vs_string', '' );
$manager->setOption( $user, 'false_vs_int', 0 );
$manager->setOption( $user, 'false_vs_string', '0' );
$manager->setOption( $user, 'int_vs_string', '0' );
$manager->setOption( $user, 'true_vs_int', 1 );
$manager->setOption( $user, 'true_vs_string', '1' );
$manager->setOption( $user, 'new_option', 'new_value' );
$expected = [
// Note that the old, relaxed array_diff-approach considered null equal to false and ""
'null_vs_false' => false,
'language' => 'en',
'variant' => 'en',
'new_option' => 'new_value',
];
$this->assertSame( $expected, $manager->getOptions( $user, UserOptionsManager::EXCLUDE_DEFAULTS ) );
}
/**
* @covers MediaWiki\User\UserOptionsManager::getOption
*/
public function testGetOptionHiddenPref() {
$user = $this->getAnon( __METHOD__ );
$manager = $this->getManager();
$manager->setOption( $user, 'hidden_user_option', 'hidden_value' );
$this->assertNull( $manager->getOption( $user, 'hidden_user_option' ) );
$this->assertSame( 'hidden_value',
$manager->getOption( $user, 'hidden_user_option', null, true ) );
}
/**
* @covers MediaWiki\User\UserOptionsManager::setOption
*/
public function testSetOptionNullIsDefault() {
$user = $this->getAnon( __METHOD__ );
$manager = $this->getManager();
$manager->setOption( $user, 'default_string_option', 'override_value' );
$this->assertSame( 'override_value', $manager->getOption( $user, 'default_string_option' ) );
$manager->setOption( $user, 'default_string_option', null );
$this->assertSame( 'string_value', $manager->getOption( $user, 'default_string_option' ) );
}
/**
* @covers MediaWiki\User\UserOptionsManager::getOption
* @covers MediaWiki\User\UserOptionsManager::setOption
* @covers MediaWiki\User\UserOptionsManager::saveOptions
*/
public function testGetSetSave() {
$user = $this->getTestUser()->getUser();
$manager = $this->getManager();
$this->assertSame( [], $manager->getOptions( $user, UserOptionsManager::EXCLUDE_DEFAULTS ) );
$manager->setOption( $user, 'string_option', 'user_value' );
$manager->setOption( $user, 'int_option', 42 );
$manager->setOption( $user, 'bool_option', true );
$this->assertSame( 'user_value', $manager->getOption( $user, 'string_option' ) );
$this->assertSame( 42, $manager->getIntOption( $user, 'int_option' ) );
$this->assertSame( true, $manager->getBoolOption( $user, 'bool_option' ) );
$manager->saveOptions( $user );
$this->assertSame( 'user_value', $manager->getOption( $user, 'string_option' ) );
$this->assertSame( 42, $manager->getIntOption( $user, 'int_option' ) );
$this->assertSame( true, $manager->getBoolOption( $user, 'bool_option' ) );
$manager = $this->getManager();
$this->assertSame( 'user_value', $manager->getOption( $user, 'string_option' ) );
$this->assertSame( 42, $manager->getIntOption( $user, 'int_option' ) );
$this->assertSame( true, $manager->getBoolOption( $user, 'bool_option' ) );
}
/**
* @covers MediaWiki\User\UserOptionsManager::loadUserOptions
*/
public function testLoadUserOptionsHook() {
$user = UserIdentityValue::newRegistered( 42, 'Test' );
$manager = $this->getManager( [
'hookContainer' => $this->createHookContainer( [
'LoadUserOptions' => function ( UserIdentity $hookUser, array &$options ) use ( $user ) {
$this->assertTrue( $hookUser->equals( $user ) );
$options['from_hook'] = 'value_from_hook';
}
] )
] );
$this->assertSame( 'value_from_hook', $manager->getOption( $user, 'from_hook' ) );
}
/**
* @covers MediaWiki\User\UserOptionsManager::saveOptions
*/
public function testSaveUserOptionsHookAbort() {
$manager = $this->getManager( [
'hookContainer' => $this->createHookContainer( [
'SaveUserOptions' => static function () {
return false;
}
] )
] );
$user = UserIdentityValue::newRegistered( 42, 'Test' );
$manager->setOption( $user, 'will_be_aborted_by_hook', 'value' );
$manager->saveOptions( $user );
$this->assertNull( $this->getManager()->getOption( $user, 'will_be_aborted_by_hook' ) );
}
/**
* @covers MediaWiki\User\UserOptionsManager::saveOptions
*/
public function testSaveUserOptionsHookModify() {
$user = UserIdentityValue::newRegistered( 42, 'Test' );
$manager = $this->getManager( [
'defaults' => [
'reset_to_default_by_hook' => 'default',
],
'hookContainer' => $this->createHookContainer( [
'SaveUserOptions' => function ( UserIdentity $hookUser, array &$modifiedOptions ) use ( $user ) {
$this->assertTrue( $user->equals( $hookUser ) );
$modifiedOptions['reset_to_default_by_hook'] = null;
unset( $modifiedOptions['blocked_by_hook'] );
$modifiedOptions['new_from_hook'] = 'value_from_hook';
}
] ),
] );
$manager->setOption( $user, 'reset_to_default_by_hook', 'not default' );
$manager->setOption( $user, 'blocked_by_hook', 'blocked value' );
$manager->saveOptions( $user );
$this->assertSame( 'value_from_hook', $manager->getOption( $user, 'new_from_hook' ) );
$this->assertSame( 'default', $manager->getOption( $user, 'reset_to_default_by_hook' ) );
$this->assertNull( $manager->getOption( $user, 'blocked_by_hook' ) );
$manager->clearUserOptionsCache( $user );
$this->assertSame( 'value_from_hook', $manager->getOption( $user, 'new_from_hook' ) );
$this->assertSame( 'default', $manager->getOption( $user, 'reset_to_default_by_hook' ) );
$this->assertNull( $manager->getOption( $user, 'blocked_by_hook' ) );
}
/**
* @covers MediaWiki\User\UserOptionsManager::saveOptions
*/
public function testSaveUserOptionsHookOriginal() {
$user = UserIdentityValue::newRegistered( 42, 'Test' );
$manager = $this->getManager( [
'language' => 'ja',
'hookContainer' => $this->createHookContainer( [
'SaveUserOptions' => function (
UserIdentity $hookUser,
array &$modifiedOptions,
array $originalOptions
) use ( $user ) {
if ( $hookUser->equals( $user ) ) {
$this->assertSame( 'ja', $originalOptions['language'] );
$this->assertSame( 'ru', $modifiedOptions['language'] );
$modifiedOptions['language'] = 'tr';
}
return true;
}
] ),
] );
$manager->setOption( $user, 'language', 'ru' );
$manager->saveOptions( $user );
$this->assertSame( 'tr', $manager->getOption( $user, 'language' ) );
}
/**
* @covers \MediaWiki\User\UserOptionsManager::loadUserOptions
*/
public function testInfiniteRecursionOnLoadUserOptionsHook() {
$user = UserIdentityValue::newRegistered( 42, 'Test' );
$manager = $this->getManager( [
'hookContainer' => $this->createHookContainer( [
'LoadUserOptions' => function ( UserIdentity $hookUser ) use ( $user, &$manager, &$recursionCounter ) {
if ( $hookUser->equals( $user ) ) {
$recursionCounter += 1;
$this->assertSame( 1, $recursionCounter );
$manager->loadUserOptions( $hookUser );
}
}
] )
] );
$recursionCounter = 0;
$manager->loadUserOptions( $user, UserOptionsManager::READ_LATEST );
$this->assertSame( 1, $recursionCounter );
}
public function testSaveOptionsForAnonUser() {
$this->expectException( InvalidArgumentException::class );
$this->getManager()->saveOptions( $this->getAnon( __METHOD__ ) );
}
/**
* @covers \MediaWiki\User\UserOptionsManager::resetOptions
*/
public function testUserOptionsSaveAfterReset() {
$user = $this->getTestUser()->getUser();
$manager = $this->getManager();
$manager->setOption( $user, 'test_option', 'test_value' );
$manager->saveOptions( $user );
$manager->clearUserOptionsCache( $user );
$this->assertSame( 'test_value', $manager->getOption( $user, 'test_option' ) );
$manager->resetOptions( $user, RequestContext::getMain(), 'all' );
$this->assertNull( $manager->getOption( $user, 'test_option' ) );
$manager->saveOptions( $user );
$manager->clearUserOptionsCache( $user );
$this->assertNull( $manager->getOption( $user, 'test_option' ) );
}
Improvements to user preferences fetching/saving == Status quo == When saving user preferences, we want to lock the rows to avoid accidentally overriding a concurrent options update. So usually what extensions do is: $value = $mngr->getOption( 'option', ..., READ_LOCKING ); if ( $value !== 'new_value' ) { $mngr->setOption( 'option', 'new_value' ); $mngr->saveOptions() } Previously for extra caution we've ignored all caches in options manager if >= READ_LOCKING flags were passed. This resulted in re-reading all the options multiple times. At worst, 3 times: 1. If READ_NORMAL read was made for update - that's once, 2. On setOption, one more read, forcefully from primary 3. On saveOptions, one more read from primary, non-locking, to figure out which option keys need to be deleted. Also, READ_LOCKING was not used where it clearly had to be used, for example right before the update. This was trying to fix any kind of error on part of the manager clients, unsuccessfully so. == New approach == 1. Cache modified user options separately from originals and merge them on demand. This means when refetching originals with LOCKING we don't wipe out all modifications made to the cache with setOption. Extra bonus - we no longer need to load all options to set an option. 2. Split the originals cache into 2 layers - one for stuff that comes from DB directly, and one with applied normalizations and whatever hooks modify. This let's us avoid refetching DB options after we save them, but still let's the hooks execute on newly set options after they're saved. 3. Cache options with all query flags. This is a bit controversial, but ideally LOCKING flags will be applied on options fetch right before they are saved. We have to re-read options with LOCKING in saveOptions to avoid races, but if the caller did 'getOption( ..., LOCKING), setOption(), save()' we will not need to re-select with LOCKING again. Bug: T280220 Change-Id: Ibed2789f5260b725fd806b4470631aa30d814ce6
2021-06-11 18:48:19 +00:00
public function testOptionsForUpdateNotRefetchedBeforeInsert() {
$mockDb = $this->createMock( DBConnRef::class );
Improvements to user preferences fetching/saving == Status quo == When saving user preferences, we want to lock the rows to avoid accidentally overriding a concurrent options update. So usually what extensions do is: $value = $mngr->getOption( 'option', ..., READ_LOCKING ); if ( $value !== 'new_value' ) { $mngr->setOption( 'option', 'new_value' ); $mngr->saveOptions() } Previously for extra caution we've ignored all caches in options manager if >= READ_LOCKING flags were passed. This resulted in re-reading all the options multiple times. At worst, 3 times: 1. If READ_NORMAL read was made for update - that's once, 2. On setOption, one more read, forcefully from primary 3. On saveOptions, one more read from primary, non-locking, to figure out which option keys need to be deleted. Also, READ_LOCKING was not used where it clearly had to be used, for example right before the update. This was trying to fix any kind of error on part of the manager clients, unsuccessfully so. == New approach == 1. Cache modified user options separately from originals and merge them on demand. This means when refetching originals with LOCKING we don't wipe out all modifications made to the cache with setOption. Extra bonus - we no longer need to load all options to set an option. 2. Split the originals cache into 2 layers - one for stuff that comes from DB directly, and one with applied normalizations and whatever hooks modify. This let's us avoid refetching DB options after we save them, but still let's the hooks execute on newly set options after they're saved. 3. Cache options with all query flags. This is a bit controversial, but ideally LOCKING flags will be applied on options fetch right before they are saved. We have to re-read options with LOCKING in saveOptions to avoid races, but if the caller did 'getOption( ..., LOCKING), setOption(), save()' we will not need to re-select with LOCKING again. Bug: T280220 Change-Id: Ibed2789f5260b725fd806b4470631aa30d814ce6
2021-06-11 18:48:19 +00:00
$mockDb->expects( $this->once() ) // This is critical what we are testing
->method( 'select' )
->willReturn( new FakeResultWrapper( [
[
'up_value' => 'blabla',
'up_property' => 'test_option',
]
] ) );
$mockLoadBalancer = $this->createMock( ILoadBalancer::class );
$mockLoadBalancer
->method( 'getConnectionRef' )
->willReturn( $mockDb );
$user = $this->getTestUser()->getUser();
$manager = $this->getManager( [
'lb' => $mockLoadBalancer,
] );
Improvements to user preferences fetching/saving == Status quo == When saving user preferences, we want to lock the rows to avoid accidentally overriding a concurrent options update. So usually what extensions do is: $value = $mngr->getOption( 'option', ..., READ_LOCKING ); if ( $value !== 'new_value' ) { $mngr->setOption( 'option', 'new_value' ); $mngr->saveOptions() } Previously for extra caution we've ignored all caches in options manager if >= READ_LOCKING flags were passed. This resulted in re-reading all the options multiple times. At worst, 3 times: 1. If READ_NORMAL read was made for update - that's once, 2. On setOption, one more read, forcefully from primary 3. On saveOptions, one more read from primary, non-locking, to figure out which option keys need to be deleted. Also, READ_LOCKING was not used where it clearly had to be used, for example right before the update. This was trying to fix any kind of error on part of the manager clients, unsuccessfully so. == New approach == 1. Cache modified user options separately from originals and merge them on demand. This means when refetching originals with LOCKING we don't wipe out all modifications made to the cache with setOption. Extra bonus - we no longer need to load all options to set an option. 2. Split the originals cache into 2 layers - one for stuff that comes from DB directly, and one with applied normalizations and whatever hooks modify. This let's us avoid refetching DB options after we save them, but still let's the hooks execute on newly set options after they're saved. 3. Cache options with all query flags. This is a bit controversial, but ideally LOCKING flags will be applied on options fetch right before they are saved. We have to re-read options with LOCKING in saveOptions to avoid races, but if the caller did 'getOption( ..., LOCKING), setOption(), save()' we will not need to re-select with LOCKING again. Bug: T280220 Change-Id: Ibed2789f5260b725fd806b4470631aa30d814ce6
2021-06-11 18:48:19 +00:00
$manager->getOption(
$user,
'test_option',
null,
false,
UserOptionsManager::READ_LOCKING
);
$manager->getOption( $user, 'test_option2' );
$manager->setOption( $user, 'test_option', 'test_value' );
$manager->setOption( $user, 'test_option2', 'test_value2' );
$manager->saveOptions( $user );
}
/**
* @covers \MediaWiki\User\UserOptionsManager::saveOptions
*/
public function testUpdatesUserTouched() {
$user = $this->getTestUser()->getUser();
$userTouched = $user->getDBTouched();
$newTouched = ConvertibleTimestamp::convert(
TS_MW,
intval( ConvertibleTimestamp::convert( TS_UNIX, $userTouched ) ) + 100
);
ConvertibleTimestamp::setFakeTime( $newTouched );
$manager = $this->getManager();
$manager->setOption( $user, 'test_option', 'test_value' );
$manager->saveOptions( $user );
$this->assertSame( $newTouched, $user->getDBTouched() );
$user->clearInstanceCache();
$this->assertSame( $newTouched, $user->getDBTouched() );
}
}