wiki.techinc.nl/tests/phpunit/includes/api/ApiOptionsTest.php
Tim Starling 94151fcde9 user: Move "reset kinds" concept to PreferencesFactory
Fix the dependency loop between PreferencesFactory and
UserOptionsManager by moving the concept of "reset kinds" or "option
kinds" to PreferencesFactory.

Note that this is a half-baked feature from 2013 (I5f9ba5b0dfe7c2ea)
that is not really used for anything. Apparently only the "all" and
"unused" kinds are used.

The strong dependencies on PreferencesFactory internal details show that
this feature belongs in PreferencesFactory. But UserOptionsManager can
reset "all" preferences without help from PreferencesFactory, so add a
helper for that.

The rationale for putting it in UserOptionsManager was that eventually
all preference definition information should move to UserOptionsManager
(T250822). I don't agree with that. UserOptionsManager is the key/value
store which backs PreferencesFactory. I need to refactor it further for
T323076 and it will help to have these concepts be separate.

Hard-deprecate UserOptionsManager methods resetOptions, listOptionKinds
and getOptionKinds. Add convenience methods to replace calls to
resetOptions().

I couldn't understand the logic in resetOptions(). Why was it copying
old values instead of just omitting them? Why was it assigning null but
only for "all"? setOption() had a documented method for resetting an
option to the default, so I just used that.

Bug: T323076
Depends-On: I1ed0a1a9f6492fb50254104fa4bc9f2130218323
Change-Id: I900fd4a48c96d91491eae54824e7bf02a004843d
2024-06-11 23:50:21 +00:00

519 lines
15 KiB
PHP

<?php
namespace MediaWiki\Tests\Api;
namespace MediaWiki\Tests\Api;
use ApiMain;
use ApiOptions;
use ApiUsageException;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\Preferences\DefaultPreferencesFactory;
use MediaWiki\Request\FauxRequest;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use MediaWiki\Title\Title;
use MediaWiki\User\Options\UserOptionsManager;
use MediaWiki\User\User;
use PHPUnit\Framework\MockObject\MockObject;
/**
* @group API
* @group Database
* @group medium
*
* @covers \ApiOptions
*/
class ApiOptionsTest extends ApiTestCase {
use MockAuthorityTrait;
/** @var MockObject */
private $mUserMock;
/** @var MockObject */
private $userOptionsManagerMock;
/** @var ApiOptions */
private $mTested;
private $mSession;
/** @var DerivativeContext */
private $mContext;
private static $Success = [ 'options' => 'success' ];
protected function setUp(): void {
parent::setUp();
$this->mUserMock = $this->createMock( User::class );
// No actual DB data
$this->mUserMock->method( 'getInstanceForUpdate' )->willReturn( $this->mUserMock );
$this->mUserMock->method( 'isAllowedAny' )->willReturn( true );
// Create a new context
$this->mContext = new DerivativeContext( new RequestContext() );
$this->mContext->getContext()->setTitle( Title::makeTitle( NS_MAIN, 'Test' ) );
$this->mContext->setAuthority(
$this->mockUserAuthorityWithPermissions( $this->mUserMock, [ 'editmyoptions' ] )
);
$main = new ApiMain( $this->mContext );
// Empty session
$this->mSession = [];
$this->userOptionsManagerMock = $this->createNoOpMock(
UserOptionsManager::class,
[ 'getOptions', 'resetOptionsByName', 'setOption' ]
);
// Needs to return something
$this->userOptionsManagerMock->method( 'getOptions' )->willReturn( [] );
$preferencesFactory = $this->createNoOpMock(
DefaultPreferencesFactory::class,
[ 'getFormDescriptor', 'listResetKinds', 'getResetKinds', 'getOptionNamesForReset' ]
);
$preferencesFactory->method( 'getFormDescriptor' )
->willReturnCallback( [ $this, 'getPreferencesFormDescription' ] );
$preferencesFactory->method( 'listResetKinds' )->willReturn(
[
'registered',
'registered-multiselect',
'registered-checkmatrix',
'userjs',
'special',
'unused'
]
);
$preferencesFactory->method( 'getResetKinds' )
->willReturnCallback( [ $this, 'getResetKinds' ] );
$preferencesFactory->method( 'getOptionNamesForReset' )
->willReturn( [] );
$this->mTested = new ApiOptions( $main, 'options', $this->userOptionsManagerMock, $preferencesFactory );
$this->mergeMwGlobalArrayValue( 'wgDefaultUserOptions', [
'testradio' => 'option1',
] );
}
public function getPreferencesFormDescription() {
$preferences = [];
foreach ( [ 'name', 'willBeNull', 'willBeEmpty', 'willBeHappy' ] as $k ) {
$preferences[$k] = [
'type' => 'text',
'section' => 'test',
'label' => "\u{00A0}",
];
}
$preferences['testmultiselect'] = [
'type' => 'multiselect',
'options' => [
'Test' => [
'<span dir="auto">Some HTML here for option 1</span>' => 'opt1',
'<span dir="auto">Some HTML here for option 2</span>' => 'opt2',
'<span dir="auto">Some HTML here for option 3</span>' => 'opt3',
'<span dir="auto">Some HTML here for option 4</span>' => 'opt4',
],
],
'section' => 'test',
'label' => "\u{00A0}",
'prefix' => 'testmultiselect-',
'default' => [],
];
$preferences['testradio'] = [
'type' => 'radio',
'options' => [ 'Option 1' => 'option1', 'Option 2' => 'option2' ],
'section' => 'test',
];
return $preferences;
}
/**
* @param mixed $unused
* @param IContextSource $context
* @param array|null $options
*
* @return array
*/
public function getResetKinds( $unused, IContextSource $context, $options = null ) {
// Match with above.
$kinds = [
'name' => 'registered',
'willBeNull' => 'registered',
'willBeEmpty' => 'registered',
'willBeHappy' => 'registered',
'testradio' => 'registered',
'testmultiselect-opt1' => 'registered-multiselect',
'testmultiselect-opt2' => 'registered-multiselect',
'testmultiselect-opt3' => 'registered-multiselect',
'testmultiselect-opt4' => 'registered-multiselect',
'special' => 'special',
];
if ( $options === null ) {
return $kinds;
}
$mapping = [];
foreach ( $options as $key => $value ) {
if ( isset( $kinds[$key] ) ) {
$mapping[$key] = $kinds[$key];
} elseif ( str_starts_with( $key, 'userjs-' ) ) {
$mapping[$key] = 'userjs';
} else {
$mapping[$key] = 'unused';
}
}
return $mapping;
}
private function getSampleRequest( $custom = [] ) {
$request = [
'token' => '123ABC',
'change' => null,
'optionname' => null,
'optionvalue' => null,
];
return array_merge( $request, $custom );
}
private function executeQuery( $request ) {
$this->mContext->setRequest( new FauxRequest( $request, true, $this->mSession ) );
$this->mUserMock->method( 'getRequest' )->willReturn( $this->mContext->getRequest() );
$this->mTested->execute();
return $this->mTested->getResult()->getResultData( null, [ 'Strip' => 'all' ] );
}
public function testNoToken() {
$request = $this->getSampleRequest( [ 'token' => null ] );
$this->expectException( ApiUsageException::class );
$this->executeQuery( $request );
}
public function testAnon() {
$this->mUserMock
->method( 'isRegistered' )
->willReturn( false );
try {
$request = $this->getSampleRequest();
$this->executeQuery( $request );
} catch ( ApiUsageException $e ) {
$this->assertApiErrorCode( 'notloggedin', $e );
return;
}
$this->fail( "ApiUsageException was not thrown" );
}
public function testNoOptionname() {
$this->mUserMock->method( 'isRegistered' )->willReturn( true );
$this->mUserMock->method( 'isNamed' )->willReturn( true );
try {
$request = $this->getSampleRequest( [ 'optionvalue' => '1' ] );
$this->executeQuery( $request );
} catch ( ApiUsageException $e ) {
$this->assertApiErrorCode( 'nooptionname', $e );
return;
}
$this->fail( "ApiUsageException was not thrown" );
}
public function testNoChanges() {
$this->mUserMock->method( 'isRegistered' )->willReturn( true );
$this->mUserMock->method( 'isNamed' )->willReturn( true );
$this->userOptionsManagerMock->expects( $this->never() )
->method( 'resetOptionsByName' );
$this->userOptionsManagerMock->expects( $this->never() )
->method( 'setOption' );
$this->mUserMock->expects( $this->never() )
->method( 'saveSettings' );
try {
$request = $this->getSampleRequest();
$this->executeQuery( $request );
} catch ( ApiUsageException $e ) {
$this->assertApiErrorCode( 'nochanges', $e );
return;
}
$this->fail( "ApiUsageException was not thrown" );
}
public function userScenarios() {
return [
[ true, true, false ],
[ true, false, true ],
];
}
/**
* @dataProvider userScenarios
*/
public function testReset( $isRegistered, $isNamed, $expectException ) {
$this->mUserMock->method( 'isRegistered' )->willReturn( $isRegistered );
$this->mUserMock->method( 'isNamed' )->willReturn( $isNamed );
if ( $expectException ) {
$this->userOptionsManagerMock->expects( $this->never() )->method( 'resetOptionsByName' );
$this->userOptionsManagerMock->expects( $this->never() )->method( 'setOption' );
$this->mUserMock->expects( $this->never() )->method( 'saveSettings' );
} else {
$this->userOptionsManagerMock->expects( $this->once() )->method( 'resetOptionsByName' );
$this->userOptionsManagerMock->expects( $this->never() )->method( 'setOption' );
$this->mUserMock->expects( $this->once() )->method( 'saveSettings' );
}
$request = $this->getSampleRequest( [ 'reset' => '' ] );
try {
$response = $this->executeQuery( $request );
if ( $expectException ) {
$this->fail( 'Expected a "notloggedin" error.' );
} else {
$this->assertEquals( self::$Success, $response );
}
} catch ( ApiUsageException $e ) {
if ( !$expectException ) {
$this->fail( 'Unexpected "notloggedin" error.' );
} else {
$this->assertApiErrorCode( 'notloggedin', $e );
}
}
}
/**
* @dataProvider userScenarios
*/
public function testResetKinds( $isRegistered, $isNamed, $expectException ) {
$this->mUserMock->method( 'isRegistered' )->willReturn( $isRegistered );
$this->mUserMock->method( 'isNamed' )->willReturn( $isNamed );
if ( $expectException ) {
$this->mUserMock->expects( $this->never() )->method( 'saveSettings' );
$this->userOptionsManagerMock->expects( $this->never() )->method( 'resetOptionsByName' );
$this->userOptionsManagerMock->expects( $this->never() )->method( 'setOption' );
} else {
$this->userOptionsManagerMock->expects( $this->once() )->method( 'resetOptionsByName' );
$this->userOptionsManagerMock->expects( $this->never() )->method( 'setOption' );
$this->mUserMock->expects( $this->once() )->method( 'saveSettings' );
}
$request = $this->getSampleRequest( [ 'reset' => '', 'resetkinds' => 'registered' ] );
try {
$response = $this->executeQuery( $request );
if ( $expectException ) {
$this->fail( "Expected an ApiUsageException" );
} else {
$this->assertEquals( self::$Success, $response );
}
} catch ( ApiUsageException $e ) {
if ( !$expectException ) {
throw $e;
}
$this->assertNotNull( $e->getMessageObject() );
$this->assertApiErrorCode( 'notloggedin', $e );
}
}
/**
* @dataProvider userScenarios
*/
public function testResetChangeOption( $isRegistered, $isNamed, $expectException ) {
$this->mUserMock->method( 'isRegistered' )->willReturn( $isRegistered );
$this->mUserMock->method( 'isNamed' )->willReturn( $isNamed );
if ( $expectException ) {
$this->userOptionsManagerMock->expects( $this->never() )->method( 'resetOptionsByName' );
$this->userOptionsManagerMock->expects( $this->never() )->method( 'setOption' );
$this->mUserMock->expects( $this->never() )->method( 'saveSettings' );
} else {
$this->userOptionsManagerMock->expects( $this->once() )->method( 'resetOptionsByName' );
$expectedOptions = [
'willBeHappy' => 'Happy',
'name' => 'value',
];
$this->userOptionsManagerMock->expects( $this->exactly( count( $expectedOptions ) ) )
->method( 'setOption' )
->willReturnCallback( function ( $user, $oname, $val ) use ( &$expectedOptions ) {
$this->assertSame( $this->mUserMock, $user );
$this->assertArrayHasKey( $oname, $expectedOptions );
$this->assertSame( $expectedOptions[$oname], $val );
unset( $expectedOptions[$oname] );
} );
$this->mUserMock->expects( $this->once() )->method( 'saveSettings' );
}
$args = [
'reset' => '',
'change' => 'willBeHappy=Happy',
'optionname' => 'name',
'optionvalue' => 'value'
];
try {
$response = $this->executeQuery( $this->getSampleRequest( $args ) );
if ( $expectException ) {
$this->fail( "Expected an ApiUsageException" );
} else {
$this->assertEquals( self::$Success, $response );
}
} catch ( ApiUsageException $e ) {
if ( !$expectException ) {
throw $e;
}
$this->assertNotNull( $e->getMessageObject() );
$this->assertApiErrorCode( 'notloggedin', $e );
}
}
/**
* @dataProvider provideOptionManupulation
*/
public function testOptionManupulation( array $params, array $setOptions, array $result = null,
$message = ''
) {
$this->mUserMock->method( 'isRegistered' )->willReturn( true );
$this->mUserMock->method( 'isNamed' )->willReturn( true );
$this->userOptionsManagerMock->expects( $this->never() )
->method( 'resetOptionsByName' );
$expectedOptions = [];
foreach ( $setOptions as [ $opt, $val ] ) {
$expectedOptions[$opt] = $val;
}
$this->userOptionsManagerMock->expects( $this->exactly( count( $setOptions ) ) )
->method( 'setOption' )
->willReturnCallback( function ( $user, $oname, $val ) use ( &$expectedOptions ) {
$this->assertSame( $this->mUserMock, $user );
$this->assertArrayHasKey( $oname, $expectedOptions );
$this->assertSame( $expectedOptions[$oname], $val );
unset( $expectedOptions[$oname] );
} );
if ( $setOptions ) {
$this->mUserMock->expects( $this->once() )
->method( 'saveSettings' );
} else {
$this->mUserMock->expects( $this->never() )
->method( 'saveSettings' );
}
$request = $this->getSampleRequest( $params );
$response = $this->executeQuery( $request );
if ( !$result ) {
$result = self::$Success;
}
$this->assertEquals( $result, $response, $message );
}
public static function provideOptionManupulation() {
return [
[
[ 'change' => 'userjs-option=1' ],
[ [ 'userjs-option', '1' ] ],
null,
'Setting userjs options',
],
[
[ 'change' => 'willBeNull|willBeEmpty=|willBeHappy=Happy' ],
[
[ 'willBeNull', null ],
[ 'willBeEmpty', '' ],
[ 'willBeHappy', 'Happy' ],
],
null,
'Basic option setting',
],
[
[ 'change' => 'testradio=option2' ],
[ [ 'testradio', 'option2' ] ],
null,
'Changing radio options',
],
[
[ 'change' => 'testradio' ],
[ [ 'testradio', null ] ],
null,
'Resetting radio options',
],
[
[ 'change' => 'unknownOption=1' ],
[],
[
'options' => 'success',
'warnings' => [
'options' => [
'warnings' => "Validation error for \"unknownOption\": not a valid preference."
],
],
],
'Unrecognized options should be rejected',
],
[
[ 'change' => 'special=1' ],
[],
[
'options' => 'success',
'warnings' => [
'options' => [
'warnings' => "Validation error for \"special\": cannot be set by this module."
]
]
],
'Refuse setting special options',
],
[
[
'change' => 'testmultiselect-opt1=1|testmultiselect-opt2|'
. 'testmultiselect-opt3=|testmultiselect-opt4=0'
],
[
[ 'testmultiselect-opt1', true ],
[ 'testmultiselect-opt2', null ],
[ 'testmultiselect-opt3', false ],
[ 'testmultiselect-opt4', false ],
],
null,
'Setting multiselect options',
],
[
[ 'optionname' => 'name', 'optionvalue' => 'value' ],
[ [ 'name', 'value' ] ],
null,
'Setting options via optionname/optionvalue'
],
[
[ 'optionname' => 'name' ],
[ [ 'name', null ] ],
null,
'Resetting options via optionname without optionvalue',
],
[
[ 'optionname' => 'name', 'optionvalue' => str_repeat( '测试', 16383 ) ],
[],
[
'options' => 'success',
'warnings' => [
'options' => [
'warnings' => 'Validation error for "name": value too long (no more than 65,530 bytes allowed).'
],
],
],
'Options with too long value should be rejected',
],
];
}
}