summaryrefslogtreecommitdiff
path: root/www/wiki/tests/phpunit/includes/api
diff options
context:
space:
mode:
Diffstat (limited to 'www/wiki/tests/phpunit/includes/api')
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiBaseTest.php1275
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiBlockTest.php252
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiCheckTokenTest.php95
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiClearHasMsgTest.php24
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiComparePagesTest.php653
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiContinuationManagerTest.php198
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiDeleteTest.php168
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiDisabledTest.php19
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiEditPageTest.php1604
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiErrorFormatterTest.php642
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiLoginTest.php301
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiLogoutTest.php75
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiMainTest.php1072
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiMessageTest.php189
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiModuleManagerTest.php330
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiMoveTest.php393
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiOpenSearchTest.php67
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiOptionsTest.php418
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiPageSetTest.php179
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiParseTest.php849
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiPurgeTest.php40
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiQueryAllPagesTest.php36
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php976
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php1608
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php542
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiResultTest.php1410
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiRevisionDeleteTest.php117
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php51
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiStashEditTest.php27
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiTestCase.php260
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiTestCaseUpload.php8
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiTestContext.php21
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiTokensTest.php40
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiUnblockTest.php23
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiUploadTest.php560
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiUploadTestCase.php153
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiUsageExceptionTest.php44
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiUserrightsTest.php358
-rw-r--r--www/wiki/tests/phpunit/includes/api/ApiWatchTest.php148
-rw-r--r--www/wiki/tests/phpunit/includes/api/MockApi.php27
-rw-r--r--www/wiki/tests/phpunit/includes/api/MockApiQueryBase.php19
-rw-r--r--www/wiki/tests/phpunit/includes/api/PrefixUniquenessTest.php30
-rw-r--r--www/wiki/tests/phpunit/includes/api/RandomImageGenerator.php497
-rw-r--r--www/wiki/tests/phpunit/includes/api/UserWrapper.php25
-rw-r--r--www/wiki/tests/phpunit/includes/api/format/ApiFormatBaseTest.php388
-rw-r--r--www/wiki/tests/phpunit/includes/api/format/ApiFormatJsonTest.php129
-rw-r--r--www/wiki/tests/phpunit/includes/api/format/ApiFormatNoneTest.php51
-rw-r--r--www/wiki/tests/phpunit/includes/api/format/ApiFormatPhpTest.php139
-rw-r--r--www/wiki/tests/phpunit/includes/api/format/ApiFormatRawTest.php120
-rw-r--r--www/wiki/tests/phpunit/includes/api/format/ApiFormatTestBase.php93
-rw-r--r--www/wiki/tests/phpunit/includes/api/format/ApiFormatXmlTest.php123
-rw-r--r--www/wiki/tests/phpunit/includes/api/generateRandomImages.php45
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryBasicTest.php346
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php71
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTest.php323
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php210
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php44
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryTest.php151
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryTestBase.php158
-rw-r--r--www/wiki/tests/phpunit/includes/api/query/ApiQueryUserContributionsTest.php194
-rw-r--r--www/wiki/tests/phpunit/includes/api/words.txt1000
61 files changed, 19408 insertions, 0 deletions
diff --git a/www/wiki/tests/phpunit/includes/api/ApiBaseTest.php b/www/wiki/tests/phpunit/includes/api/ApiBaseTest.php
new file mode 100644
index 00000000..4bffc742
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiBaseTest.php
@@ -0,0 +1,1275 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiBase
+ */
+class ApiBaseTest extends ApiTestCase {
+ /**
+ * This covers a variety of stub methods that return a fixed value.
+ *
+ * @param string|array $method Name of method, or [ name, params... ]
+ * @param string $value Expected value
+ *
+ * @dataProvider provideStubMethods
+ */
+ public function testStubMethods( $expected, $method, $args = [] ) {
+ // Some of these are protected
+ $mock = TestingAccessWrapper::newFromObject( new MockApi() );
+ $result = call_user_func_array( [ $mock, $method ], $args );
+ $this->assertSame( $expected, $result );
+ }
+
+ public function provideStubMethods() {
+ return [
+ [ null, 'getModuleManager' ],
+ [ null, 'getCustomPrinter' ],
+ [ [], 'getHelpUrls' ],
+ // @todo This is actually overriden by MockApi
+ // [ [], 'getAllowedParams' ],
+ [ true, 'shouldCheckMaxLag' ],
+ [ true, 'isReadMode' ],
+ [ false, 'isWriteMode' ],
+ [ false, 'mustBePosted' ],
+ [ false, 'isDeprecated' ],
+ [ false, 'isInternal' ],
+ [ false, 'needsToken' ],
+ [ null, 'getWebUITokenSalt', [ [] ] ],
+ [ null, 'getConditionalRequestData', [ 'etag' ] ],
+ [ null, 'dynamicParameterDocumentation' ],
+ ];
+ }
+
+ public function testRequireOnlyOneParameterDefault() {
+ $mock = new MockApi();
+ $mock->requireOnlyOneParameter(
+ [ "filename" => "foo.txt", "enablechunks" => false ],
+ "filename", "enablechunks"
+ );
+ $this->assertTrue( true );
+ }
+
+ /**
+ * @expectedException ApiUsageException
+ */
+ public function testRequireOnlyOneParameterZero() {
+ $mock = new MockApi();
+ $mock->requireOnlyOneParameter(
+ [ "filename" => "foo.txt", "enablechunks" => 0 ],
+ "filename", "enablechunks"
+ );
+ }
+
+ /**
+ * @expectedException ApiUsageException
+ */
+ public function testRequireOnlyOneParameterTrue() {
+ $mock = new MockApi();
+ $mock->requireOnlyOneParameter(
+ [ "filename" => "foo.txt", "enablechunks" => true ],
+ "filename", "enablechunks"
+ );
+ }
+
+ public function testRequireOnlyOneParameterMissing() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'One of the parameters "foo" and "bar" is required.' );
+ $mock = new MockApi();
+ $mock->requireOnlyOneParameter(
+ [ "filename" => "foo.txt", "enablechunks" => false ],
+ "foo", "bar" );
+ }
+
+ public function testRequireMaxOneParameterZero() {
+ $mock = new MockApi();
+ $mock->requireMaxOneParameter(
+ [ 'foo' => 'bar', 'baz' => 'quz' ],
+ 'squirrel' );
+ $this->assertTrue( true );
+ }
+
+ public function testRequireMaxOneParameterOne() {
+ $mock = new MockApi();
+ $mock->requireMaxOneParameter(
+ [ 'foo' => 'bar', 'baz' => 'quz' ],
+ 'foo', 'squirrel' );
+ $this->assertTrue( true );
+ }
+
+ public function testRequireMaxOneParameterTwo() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The parameters "foo" and "baz" can not be used together.' );
+ $mock = new MockApi();
+ $mock->requireMaxOneParameter(
+ [ 'foo' => 'bar', 'baz' => 'quz' ],
+ 'foo', 'baz' );
+ }
+
+ public function testRequireAtLeastOneParameterZero() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'At least one of the parameters "foo" and "bar" is required.' );
+ $mock = new MockApi();
+ $mock->requireAtLeastOneParameter(
+ [ 'a' => 'b', 'c' => 'd' ],
+ 'foo', 'bar' );
+ }
+
+ public function testRequireAtLeastOneParameterOne() {
+ $mock = new MockApi();
+ $mock->requireAtLeastOneParameter(
+ [ 'a' => 'b', 'c' => 'd' ],
+ 'foo', 'a' );
+ $this->assertTrue( true );
+ }
+
+ public function testRequireAtLeastOneParameterTwo() {
+ $mock = new MockApi();
+ $mock->requireAtLeastOneParameter(
+ [ 'a' => 'b', 'c' => 'd' ],
+ 'a', 'c' );
+ $this->assertTrue( true );
+ }
+
+ public function testGetTitleOrPageIdBadParams() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The parameters "title" and "pageid" can not be used together.' );
+ $mock = new MockApi();
+ $mock->getTitleOrPageId( [ 'title' => 'a', 'pageid' => 7 ] );
+ }
+
+ public function testGetTitleOrPageIdTitle() {
+ $mock = new MockApi();
+ $result = $mock->getTitleOrPageId( [ 'title' => 'Foo' ] );
+ $this->assertInstanceOf( WikiPage::class, $result );
+ $this->assertSame( 'Foo', $result->getTitle()->getPrefixedText() );
+ }
+
+ public function testGetTitleOrPageIdInvalidTitle() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'Bad title "|".' );
+ $mock = new MockApi();
+ $mock->getTitleOrPageId( [ 'title' => '|' ] );
+ }
+
+ public function testGetTitleOrPageIdSpecialTitle() {
+ $this->setExpectedException( ApiUsageException::class,
+ "Namespace doesn't allow actual pages." );
+ $mock = new MockApi();
+ $mock->getTitleOrPageId( [ 'title' => 'Special:RandomPage' ] );
+ }
+
+ public function testGetTitleOrPageIdPageId() {
+ $result = ( new MockApi() )->getTitleOrPageId(
+ [ 'pageid' => Title::newFromText( 'UTPage' )->getArticleId() ] );
+ $this->assertInstanceOf( WikiPage::class, $result );
+ $this->assertSame( 'UTPage', $result->getTitle()->getPrefixedText() );
+ }
+
+ public function testGetTitleOrPageIdInvalidPageId() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'There is no page with ID 2147483648.' );
+ $mock = new MockApi();
+ $mock->getTitleOrPageId( [ 'pageid' => 2147483648 ] );
+ }
+
+ public function testGetTitleFromTitleOrPageIdBadParams() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The parameters "title" and "pageid" can not be used together.' );
+ $mock = new MockApi();
+ $mock->getTitleFromTitleOrPageId( [ 'title' => 'a', 'pageid' => 7 ] );
+ }
+
+ public function testGetTitleFromTitleOrPageIdTitle() {
+ $mock = new MockApi();
+ $result = $mock->getTitleFromTitleOrPageId( [ 'title' => 'Foo' ] );
+ $this->assertInstanceOf( Title::class, $result );
+ $this->assertSame( 'Foo', $result->getPrefixedText() );
+ }
+
+ public function testGetTitleFromTitleOrPageIdInvalidTitle() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'Bad title "|".' );
+ $mock = new MockApi();
+ $mock->getTitleFromTitleOrPageId( [ 'title' => '|' ] );
+ }
+
+ public function testGetTitleFromTitleOrPageIdPageId() {
+ $result = ( new MockApi() )->getTitleFromTitleOrPageId(
+ [ 'pageid' => Title::newFromText( 'UTPage' )->getArticleId() ] );
+ $this->assertInstanceOf( Title::class, $result );
+ $this->assertSame( 'UTPage', $result->getPrefixedText() );
+ }
+
+ public function testGetTitleFromTitleOrPageIdInvalidPageId() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'There is no page with ID 298401643.' );
+ $mock = new MockApi();
+ $mock->getTitleFromTitleOrPageId( [ 'pageid' => 298401643 ] );
+ }
+
+ /**
+ * @dataProvider provideGetParameterFromSettings
+ * @param string|null $input
+ * @param array $paramSettings
+ * @param mixed $expected
+ * @param array $options Key-value pairs:
+ * 'parseLimits': true|false
+ * 'apihighlimits': true|false
+ * 'internalmode': true|false
+ * @param string[] $warnings
+ */
+ public function testGetParameterFromSettings(
+ $input, $paramSettings, $expected, $warnings, $options = []
+ ) {
+ $mock = new MockApi();
+ $wrapper = TestingAccessWrapper::newFromObject( $mock );
+
+ $context = new DerivativeContext( $mock );
+ $context->setRequest( new FauxRequest(
+ $input !== null ? [ 'myParam' => $input ] : [] ) );
+ $wrapper->mMainModule = new ApiMain( $context );
+
+ $parseLimits = isset( $options['parseLimits'] ) ?
+ $options['parseLimits'] : true;
+
+ if ( !empty( $options['apihighlimits'] ) ) {
+ $context->setUser( self::$users['sysop']->getUser() );
+ }
+
+ if ( isset( $options['internalmode'] ) && !$options['internalmode'] ) {
+ $mainWrapper = TestingAccessWrapper::newFromObject( $wrapper->mMainModule );
+ $mainWrapper->mInternalMode = false;
+ }
+
+ // If we're testing tags, set up some tags
+ if ( isset( $paramSettings[ApiBase::PARAM_TYPE] ) &&
+ $paramSettings[ApiBase::PARAM_TYPE] === 'tags'
+ ) {
+ ChangeTags::defineTag( 'tag1' );
+ ChangeTags::defineTag( 'tag2' );
+ }
+
+ if ( $expected instanceof Exception ) {
+ try {
+ $wrapper->getParameterFromSettings( 'myParam', $paramSettings,
+ $parseLimits );
+ $this->fail( 'No exception thrown' );
+ } catch ( Exception $ex ) {
+ $this->assertEquals( $expected, $ex );
+ }
+ } else {
+ $result = $wrapper->getParameterFromSettings( 'myParam',
+ $paramSettings, $parseLimits );
+ if ( isset( $paramSettings[ApiBase::PARAM_TYPE] ) &&
+ $paramSettings[ApiBase::PARAM_TYPE] === 'timestamp' &&
+ $expected === 'now'
+ ) {
+ // Allow one second of fuzziness. Make sure the formats are
+ // correct!
+ $this->assertRegExp( '/^\d{14}$/', $result );
+ $this->assertLessThanOrEqual( 1,
+ abs( wfTimestamp( TS_UNIX, $result ) - time() ),
+ "Result $result differs from expected $expected by " .
+ 'more than one second' );
+ } else {
+ $this->assertSame( $expected, $result );
+ }
+ $actualWarnings = array_map( function ( $warn ) {
+ return $warn instanceof Message
+ ? array_merge( [ $warn->getKey() ], $warn->getParams() )
+ : $warn;
+ }, $mock->warnings );
+ $this->assertSame( $warnings, $actualWarnings );
+ }
+
+ if ( !empty( $paramSettings[ApiBase::PARAM_SENSITIVE] ) ||
+ ( isset( $paramSettings[ApiBase::PARAM_TYPE] ) &&
+ $paramSettings[ApiBase::PARAM_TYPE] === 'password' )
+ ) {
+ $mainWrapper = TestingAccessWrapper::newFromObject( $wrapper->getMain() );
+ $this->assertSame( [ 'myParam' ],
+ $mainWrapper->getSensitiveParams() );
+ }
+ }
+
+ public static function provideGetParameterFromSettings() {
+ $warnings = [
+ [ 'apiwarn-badutf8', 'myParam' ],
+ ];
+
+ $c0 = '';
+ $enc = '';
+ for ( $i = 0; $i < 32; $i++ ) {
+ $c0 .= chr( $i );
+ $enc .= ( $i === 9 || $i === 10 || $i === 13 )
+ ? chr( $i )
+ : '�';
+ }
+
+ $returnArray = [
+ 'Basic param' => [ 'bar', null, 'bar', [] ],
+ 'Basic param, C0 controls' => [ $c0, null, $enc, $warnings ],
+ 'String param' => [ 'bar', '', 'bar', [] ],
+ 'String param, defaulted' => [ null, '', '', [] ],
+ 'String param, empty' => [ '', 'default', '', [] ],
+ 'String param, required, empty' => [
+ '',
+ [ ApiBase::PARAM_DFLT => 'default', ApiBase::PARAM_REQUIRED => true ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-missingparam', 'myParam' ] ),
+ []
+ ],
+ 'Multi-valued parameter' => [
+ 'a|b|c',
+ [ ApiBase::PARAM_ISMULTI => true ],
+ [ 'a', 'b', 'c' ],
+ []
+ ],
+ 'Multi-valued parameter, alternative separator' => [
+ "\x1fa|b\x1fc|d",
+ [ ApiBase::PARAM_ISMULTI => true ],
+ [ 'a|b', 'c|d' ],
+ []
+ ],
+ 'Multi-valued parameter, other C0 controls' => [
+ $c0,
+ [ ApiBase::PARAM_ISMULTI => true ],
+ [ $enc ],
+ $warnings
+ ],
+ 'Multi-valued parameter, other C0 controls (2)' => [
+ "\x1f" . $c0,
+ [ ApiBase::PARAM_ISMULTI => true ],
+ [ substr( $enc, 0, -3 ), '' ],
+ $warnings
+ ],
+ 'Multi-valued parameter with limits' => [
+ 'a|b|c',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ISMULTI_LIMIT1 => 3,
+ ],
+ [ 'a', 'b', 'c' ],
+ [],
+ ],
+ 'Multi-valued parameter with exceeded limits' => [
+ 'a|b|c',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ISMULTI_LIMIT1 => 2,
+ ],
+ [ 'a', 'b' ],
+ [ [ 'apiwarn-toomanyvalues', 'myParam', 2 ] ],
+ ],
+ 'Multi-valued parameter with exceeded limits for non-bot' => [
+ 'a|b|c',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ISMULTI_LIMIT1 => 2,
+ ApiBase::PARAM_ISMULTI_LIMIT2 => 3,
+ ],
+ [ 'a', 'b' ],
+ [ [ 'apiwarn-toomanyvalues', 'myParam', 2 ] ],
+ ],
+ 'Multi-valued parameter with non-exceeded limits for bot' => [
+ 'a|b|c',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ISMULTI_LIMIT1 => 2,
+ ApiBase::PARAM_ISMULTI_LIMIT2 => 3,
+ ],
+ [ 'a', 'b', 'c' ],
+ [],
+ [ 'apihighlimits' => true ],
+ ],
+ 'Multi-valued parameter with prohibited duplicates' => [
+ 'a|b|a|c',
+ [ ApiBase::PARAM_ISMULTI => true ],
+ // Note that the keys are not sequential! This matches
+ // array_unique, but might be unexpected.
+ [ 0 => 'a', 1 => 'b', 3 => 'c' ],
+ [],
+ ],
+ 'Multi-valued parameter with allowed duplicates' => [
+ 'a|a',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ALLOW_DUPLICATES => true,
+ ],
+ [ 'a', 'a' ],
+ [],
+ ],
+ 'Empty boolean param' => [
+ '',
+ [ ApiBase::PARAM_TYPE => 'boolean' ],
+ true,
+ [],
+ ],
+ 'Boolean param 0' => [
+ '0',
+ [ ApiBase::PARAM_TYPE => 'boolean' ],
+ true,
+ [],
+ ],
+ 'Boolean param false' => [
+ 'false',
+ [ ApiBase::PARAM_TYPE => 'boolean' ],
+ true,
+ [],
+ ],
+ 'Boolean multi-param' => [
+ 'true|false',
+ [
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ 'Multi-values not supported for myParam'
+ ),
+ [],
+ ],
+ 'Empty boolean param with non-false default' => [
+ '',
+ [
+ ApiBase::PARAM_TYPE => 'boolean',
+ ApiBase::PARAM_DFLT => true,
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ "Boolean param myParam's default is set to '1'. " .
+ 'Boolean parameters must default to false.' ),
+ [],
+ ],
+ 'Deprecated parameter' => [
+ 'foo',
+ [ ApiBase::PARAM_DEPRECATED => true ],
+ 'foo',
+ [ [ 'apiwarn-deprecation-parameter', 'myParam' ] ],
+ ],
+ 'Deprecated parameter value' => [
+ 'a',
+ [ ApiBase::PARAM_DEPRECATED_VALUES => [ 'a' => true ] ],
+ 'a',
+ [ [ 'apiwarn-deprecation-parameter', 'myParam=a' ] ],
+ ],
+ 'Multiple deprecated parameter values' => [
+ 'a|b|c|d',
+ [ ApiBase::PARAM_DEPRECATED_VALUES =>
+ [ 'b' => true, 'd' => true ],
+ ApiBase::PARAM_ISMULTI => true ],
+ [ 'a', 'b', 'c', 'd' ],
+ [
+ [ 'apiwarn-deprecation-parameter', 'myParam=b' ],
+ [ 'apiwarn-deprecation-parameter', 'myParam=d' ],
+ ],
+ ],
+ 'Deprecated parameter value with custom warning' => [
+ 'a',
+ [ ApiBase::PARAM_DEPRECATED_VALUES => [ 'a' => 'my-msg' ] ],
+ 'a',
+ [ 'my-msg' ],
+ ],
+ '"*" when wildcard not allowed' => [
+ '*',
+ [ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ] ],
+ [],
+ [ [ 'apiwarn-unrecognizedvalues', 'myParam',
+ [ 'list' => [ '&#42;' ], 'type' => 'comma' ], 1 ] ],
+ ],
+ 'Wildcard "*"' => [
+ '*',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ],
+ ApiBase::PARAM_ALL => true,
+ ],
+ [ 'a', 'b', 'c' ],
+ [],
+ ],
+ 'Wildcard "*" with multiples not allowed' => [
+ '*',
+ [
+ ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ],
+ ApiBase::PARAM_ALL => true,
+ ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-unrecognizedvalue', 'myParam', '&#42;' ],
+ 'unknown_myParam' ),
+ [],
+ ],
+ 'Wildcard "*" with unrestricted type' => [
+ '*',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_ALL => true,
+ ],
+ [ '*' ],
+ [],
+ ],
+ 'Wildcard "x"' => [
+ 'x',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ],
+ ApiBase::PARAM_ALL => 'x',
+ ],
+ [ 'a', 'b', 'c' ],
+ [],
+ ],
+ 'Wildcard conflicting with allowed value' => [
+ 'a',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => [ 'a', 'b', 'c' ],
+ ApiBase::PARAM_ALL => 'a',
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ 'For param myParam, PARAM_ALL collides with a possible ' .
+ 'value' ),
+ [],
+ ],
+ 'Namespace with wildcard' => [
+ '*',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ],
+ MWNamespace::getValidNamespaces(),
+ [],
+ ],
+ // PARAM_ALL is ignored with namespace types.
+ 'Namespace with wildcard suppressed' => [
+ '*',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_ALL => false,
+ ],
+ MWNamespace::getValidNamespaces(),
+ [],
+ ],
+ 'Namespace with wildcard "x"' => [
+ 'x',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_ALL => 'x',
+ ],
+ [],
+ [ [ 'apiwarn-unrecognizedvalues', 'myParam',
+ [ 'list' => [ 'x' ], 'type' => 'comma' ], 1 ] ],
+ ],
+ 'Password' => [
+ 'dDy+G?e?txnr.1:(@[Ru',
+ [ ApiBase::PARAM_TYPE => 'password' ],
+ 'dDy+G?e?txnr.1:(@[Ru',
+ [],
+ ],
+ 'Sensitive field' => [
+ 'I am fond of pineapples',
+ [ ApiBase::PARAM_SENSITIVE => true ],
+ 'I am fond of pineapples',
+ [],
+ ],
+ 'Upload with default' => [
+ '',
+ [
+ ApiBase::PARAM_TYPE => 'upload',
+ ApiBase::PARAM_DFLT => '',
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ "File upload param myParam's default is set to ''. " .
+ 'File upload parameters may not have a default.' ),
+ [],
+ ],
+ 'Multiple upload' => [
+ '',
+ [
+ ApiBase::PARAM_TYPE => 'upload',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ 'Multi-values not supported for myParam' ),
+ [],
+ ],
+ // @todo Test actual upload
+ 'Namespace -1' => [
+ '-1',
+ [ ApiBase::PARAM_TYPE => 'namespace' ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-unrecognizedvalue', 'myParam', '-1' ],
+ 'unknown_myParam' ),
+ [],
+ ],
+ 'Extra namespace -1' => [
+ '-1',
+ [
+ ApiBase::PARAM_TYPE => 'namespace',
+ ApiBase::PARAM_EXTRA_NAMESPACES => [ '-1' ],
+ ],
+ '-1',
+ [],
+ ],
+ // @todo Test with PARAM_SUBMODULE_MAP unset, need
+ // getModuleManager() to return something real
+ 'Nonexistent module' => [
+ 'not-a-module-name',
+ [
+ ApiBase::PARAM_TYPE => 'submodule',
+ ApiBase::PARAM_SUBMODULE_MAP =>
+ [ 'foo' => 'foo', 'bar' => 'foo+bar' ],
+ ],
+ ApiUsageException::newWithMessage(
+ null,
+ [
+ 'apierror-unrecognizedvalue',
+ 'myParam',
+ 'not-a-module-name',
+ ],
+ 'unknown_myParam'
+ ),
+ [],
+ ],
+ '\\x1f with multiples not allowed' => [
+ "\x1f",
+ [],
+ ApiUsageException::newWithMessage( null,
+ 'apierror-badvalue-notmultivalue',
+ 'badvalue_notmultivalue' ),
+ [],
+ ],
+ 'Integer with unenforced min' => [
+ '-2',
+ [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => -1,
+ ],
+ -1,
+ [ [ 'apierror-integeroutofrange-belowminimum', 'myParam', -1,
+ -2 ] ],
+ ],
+ 'Integer with enforced min' => [
+ '-2',
+ [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => -1,
+ ApiBase::PARAM_RANGE_ENFORCE => true,
+ ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-integeroutofrange-belowminimum', 'myParam',
+ '-1', '-2' ], 'integeroutofrange',
+ [ 'min' => -1, 'max' => null, 'botMax' => null ] ),
+ [],
+ ],
+ 'Integer with unenforced max (internal mode)' => [
+ '8',
+ [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MAX => 7,
+ ],
+ 8,
+ [],
+ ],
+ 'Integer with enforced max (internal mode)' => [
+ '8',
+ [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MAX => 7,
+ ApiBase::PARAM_RANGE_ENFORCE => true,
+ ],
+ 8,
+ [],
+ ],
+ 'Integer with unenforced max (non-internal mode)' => [
+ '8',
+ [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MAX => 7,
+ ],
+ 7,
+ [ [ 'apierror-integeroutofrange-abovemax', 'myParam', 7, 8 ] ],
+ [ 'internalmode' => false ],
+ ],
+ 'Integer with enforced max (non-internal mode)' => [
+ '8',
+ [
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MAX => 7,
+ ApiBase::PARAM_RANGE_ENFORCE => true,
+ ],
+ ApiUsageException::newWithMessage(
+ null,
+ [ 'apierror-integeroutofrange-abovemax', 'myParam', '7', '8' ],
+ 'integeroutofrange',
+ [ 'min' => null, 'max' => 7, 'botMax' => 7 ]
+ ),
+ [],
+ [ 'internalmode' => false ],
+ ],
+ 'Array of integers' => [
+ '3|12|966|-1',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'integer',
+ ],
+ [ 3, 12, 966, -1 ],
+ [],
+ ],
+ 'Array of integers with unenforced min/max (internal mode)' => [
+ '3|12|966|-1',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => 0,
+ ApiBase::PARAM_MAX => 100,
+ ],
+ [ 3, 12, 966, 0 ],
+ [ [ 'apierror-integeroutofrange-belowminimum', 'myParam', 0, -1 ] ],
+ ],
+ 'Array of integers with enforced min/max (internal mode)' => [
+ '3|12|966|-1',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => 0,
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_RANGE_ENFORCE => true,
+ ],
+ ApiUsageException::newWithMessage(
+ null,
+ [ 'apierror-integeroutofrange-belowminimum', 'myParam', 0, -1 ],
+ 'integeroutofrange',
+ [ 'min' => 0, 'max' => 100, 'botMax' => 100 ]
+ ),
+ [],
+ ],
+ 'Array of integers with unenforced min/max (non-internal mode)' => [
+ '3|12|966|-1',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => 0,
+ ApiBase::PARAM_MAX => 100,
+ ],
+ [ 3, 12, 100, 0 ],
+ [
+ [ 'apierror-integeroutofrange-abovemax', 'myParam', 100, 966 ],
+ [ 'apierror-integeroutofrange-belowminimum', 'myParam', 0, -1 ]
+ ],
+ [ 'internalmode' => false ],
+ ],
+ 'Array of integers with enforced min/max (non-internal mode)' => [
+ '3|12|966|-1',
+ [
+ ApiBase::PARAM_ISMULTI => true,
+ ApiBase::PARAM_TYPE => 'integer',
+ ApiBase::PARAM_MIN => 0,
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_RANGE_ENFORCE => true,
+ ],
+ ApiUsageException::newWithMessage(
+ null,
+ [ 'apierror-integeroutofrange-abovemax', 'myParam', 100, 966 ],
+ 'integeroutofrange',
+ [ 'min' => 0, 'max' => 100, 'botMax' => 100 ]
+ ),
+ [],
+ [ 'internalmode' => false ],
+ ],
+ 'Limit with parseLimits false' => [
+ '100',
+ [ ApiBase::PARAM_TYPE => 'limit' ],
+ '100',
+ [],
+ [ 'parseLimits' => false ],
+ ],
+ 'Limit with no max' => [
+ '100',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX2 => 10,
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ 'MAX1 or MAX2 are not defined for the limit myParam' ),
+ [],
+ ],
+ 'Limit with no max2' => [
+ '100',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 10,
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ 'MAX1 or MAX2 are not defined for the limit myParam' ),
+ [],
+ ],
+ 'Limit with multi-value' => [
+ '100',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 10,
+ ApiBase::PARAM_MAX2 => 10,
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ 'Multi-values not supported for myParam' ),
+ [],
+ ],
+ 'Valid limit' => [
+ '100',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 100,
+ ],
+ 100,
+ [],
+ ],
+ 'Limit max' => [
+ 'max',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 100,
+ [],
+ ],
+ 'Limit max for apihighlimits' => [
+ 'max',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 101,
+ [],
+ [ 'apihighlimits' => true ],
+ ],
+ 'Limit too large (internal mode)' => [
+ '101',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 101,
+ [],
+ ],
+ 'Limit okay for apihighlimits (internal mode)' => [
+ '101',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 101,
+ [],
+ [ 'apihighlimits' => true ],
+ ],
+ 'Limit too large for apihighlimits (internal mode)' => [
+ '102',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 102,
+ [],
+ [ 'apihighlimits' => true ],
+ ],
+ 'Limit too large (non-internal mode)' => [
+ '101',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 100,
+ [ [ 'apierror-integeroutofrange-abovemax', 'myParam', 100, 101 ] ],
+ [ 'internalmode' => false ],
+ ],
+ 'Limit okay for apihighlimits (non-internal mode)' => [
+ '101',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 101,
+ [],
+ [ 'internalmode' => false, 'apihighlimits' => true ],
+ ],
+ 'Limit too large for apihighlimits (non-internal mode)' => [
+ '102',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 101,
+ ],
+ 101,
+ [ [ 'apierror-integeroutofrange-abovebotmax', 'myParam', 101, 102 ] ],
+ [ 'internalmode' => false, 'apihighlimits' => true ],
+ ],
+ 'Limit too small' => [
+ '-2',
+ [
+ ApiBase::PARAM_TYPE => 'limit',
+ ApiBase::PARAM_MIN => -1,
+ ApiBase::PARAM_MAX => 100,
+ ApiBase::PARAM_MAX2 => 100,
+ ],
+ -1,
+ [ [ 'apierror-integeroutofrange-belowminimum', 'myParam', -1,
+ -2 ] ],
+ ],
+ 'Timestamp' => [
+ wfTimestamp( TS_UNIX, '20211221122112' ),
+ [ ApiBase::PARAM_TYPE => 'timestamp' ],
+ '20211221122112',
+ [],
+ ],
+ 'Timestamp 0' => [
+ '0',
+ [ ApiBase::PARAM_TYPE => 'timestamp' ],
+ // Magic keyword
+ 'now',
+ [ [ 'apiwarn-unclearnowtimestamp', 'myParam', '0' ] ],
+ ],
+ 'Timestamp empty' => [
+ '',
+ [ ApiBase::PARAM_TYPE => 'timestamp' ],
+ 'now',
+ [ [ 'apiwarn-unclearnowtimestamp', 'myParam', '' ] ],
+ ],
+ // wfTimestamp() interprets this as Unix time
+ 'Timestamp 00' => [
+ '00',
+ [ ApiBase::PARAM_TYPE => 'timestamp' ],
+ '19700101000000',
+ [],
+ ],
+ 'Timestamp now' => [
+ 'now',
+ [ ApiBase::PARAM_TYPE => 'timestamp' ],
+ 'now',
+ [],
+ ],
+ 'Invalid timestamp' => [
+ 'a potato',
+ [ ApiBase::PARAM_TYPE => 'timestamp' ],
+ ApiUsageException::newWithMessage(
+ null,
+ [ 'apierror-badtimestamp', 'myParam', 'a potato' ],
+ 'badtimestamp_myParam'
+ ),
+ [],
+ ],
+ 'Timestamp array' => [
+ '100|101',
+ [
+ ApiBase::PARAM_TYPE => 'timestamp',
+ ApiBase::PARAM_ISMULTI => 1,
+ ],
+ [ wfTimestamp( TS_MW, 100 ), wfTimestamp( TS_MW, 101 ) ],
+ [],
+ ],
+ 'User' => [
+ 'foo_bar',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ 'Foo bar',
+ [],
+ ],
+ 'Invalid username "|"' => [
+ '|',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-baduser', 'myParam', '&#124;' ],
+ 'baduser_myParam' ),
+ [],
+ ],
+ 'Invalid username "300.300.300.300"' => [
+ '300.300.300.300',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-baduser', 'myParam', '300.300.300.300' ],
+ 'baduser_myParam' ),
+ [],
+ ],
+ 'IP range as username' => [
+ '10.0.0.0/8',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ '10.0.0.0/8',
+ [],
+ ],
+ 'IPv6 as username' => [
+ '::1',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ '0:0:0:0:0:0:0:1',
+ [],
+ ],
+ 'Obsolete cloaked usemod IP address as username' => [
+ '1.2.3.xxx',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ '1.2.3.xxx',
+ [],
+ ],
+ 'Invalid username containing IP address' => [
+ 'This is [not] valid 1.2.3.xxx, ha!',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ ApiUsageException::newWithMessage(
+ null,
+ [ 'apierror-baduser', 'myParam', 'This is &#91;not&#93; valid 1.2.3.xxx, ha!' ],
+ 'baduser_myParam'
+ ),
+ [],
+ ],
+ 'External username' => [
+ 'M>Foo bar',
+ [ ApiBase::PARAM_TYPE => 'user' ],
+ 'M>Foo bar',
+ [],
+ ],
+ 'Array of usernames' => [
+ 'foo|bar',
+ [
+ ApiBase::PARAM_TYPE => 'user',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ [ 'Foo', 'Bar' ],
+ [],
+ ],
+ 'tag' => [
+ 'tag1',
+ [ ApiBase::PARAM_TYPE => 'tags' ],
+ [ 'tag1' ],
+ [],
+ ],
+ 'Array of one tag' => [
+ 'tag1',
+ [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ [ 'tag1' ],
+ [],
+ ],
+ 'Array of tags' => [
+ 'tag1|tag2',
+ [
+ ApiBase::PARAM_TYPE => 'tags',
+ ApiBase::PARAM_ISMULTI => true,
+ ],
+ [ 'tag1', 'tag2' ],
+ [],
+ ],
+ 'Invalid tag' => [
+ 'invalid tag',
+ [ ApiBase::PARAM_TYPE => 'tags' ],
+ new ApiUsageException( null,
+ Status::newFatal( 'tags-apply-not-allowed-one',
+ 'invalid tag', 1 ) ),
+ [],
+ ],
+ 'Unrecognized type' => [
+ 'foo',
+ [ ApiBase::PARAM_TYPE => 'nonexistenttype' ],
+ new MWException(
+ 'Internal error in ApiBase::getParameterFromSettings: ' .
+ "Param myParam's type is unknown - nonexistenttype" ),
+ [],
+ ],
+ 'Too many bytes' => [
+ '1',
+ [
+ ApiBase::PARAM_MAX_BYTES => 0,
+ ApiBase::PARAM_MAX_CHARS => 0,
+ ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-maxbytes', 'myParam', 0 ] ),
+ [],
+ ],
+ 'Too many chars' => [
+ '§§',
+ [
+ ApiBase::PARAM_MAX_BYTES => 4,
+ ApiBase::PARAM_MAX_CHARS => 1,
+ ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-maxchars', 'myParam', 1 ] ),
+ [],
+ ],
+ 'Omitted required param' => [
+ null,
+ [ ApiBase::PARAM_REQUIRED => true ],
+ ApiUsageException::newWithMessage( null,
+ [ 'apierror-missingparam', 'myParam' ] ),
+ [],
+ ],
+ 'Empty multi-value' => [
+ '',
+ [ ApiBase::PARAM_ISMULTI => true ],
+ [],
+ [],
+ ],
+ 'Multi-value \x1f' => [
+ "\x1f",
+ [ ApiBase::PARAM_ISMULTI => true ],
+ [],
+ [],
+ ],
+ 'Allowed non-multi-value with "|"' => [
+ 'a|b',
+ [ ApiBase::PARAM_TYPE => [ 'a|b' ] ],
+ 'a|b',
+ [],
+ ],
+ 'Prohibited multi-value' => [
+ 'a|b',
+ [ ApiBase::PARAM_TYPE => [ 'a', 'b' ] ],
+ ApiUsageException::newWithMessage( null,
+ [
+ 'apierror-multival-only-one-of',
+ 'myParam',
+ Message::listParam( [ '<kbd>a</kbd>', '<kbd>b</kbd>' ] ),
+ 2
+ ],
+ 'multival_myParam'
+ ),
+ [],
+ ],
+ ];
+
+ // The following really just test PHP's string-to-int conversion.
+ $integerTests = [
+ [ '+1', 1 ],
+ [ '-1', -1 ],
+ [ '1.5', 1 ],
+ [ '-1.5', -1 ],
+ [ '1abc', 1 ],
+ [ ' 1', 1 ],
+ [ "\t1", 1, '\t1' ],
+ [ "\r1", 1, '\r1' ],
+ [ "\f1", 0, '\f1', 'badutf-8' ],
+ [ "\n1", 1, '\n1' ],
+ [ "\v1", 0, '\v1', 'badutf-8' ],
+ [ "\e1", 0, '\e1', 'badutf-8' ],
+ [ "\x001", 0, '\x001', 'badutf-8' ],
+ ];
+
+ foreach ( $integerTests as $test ) {
+ $desc = isset( $test[2] ) ? $test[2] : $test[0];
+ $warnings = isset( $test[3] ) ?
+ [ [ 'apiwarn-badutf8', 'myParam' ] ] : [];
+ $returnArray["\"$desc\" as integer"] = [
+ $test[0],
+ [ ApiBase::PARAM_TYPE => 'integer' ],
+ $test[1],
+ $warnings,
+ ];
+ }
+
+ return $returnArray;
+ }
+
+ public function testErrorArrayToStatus() {
+ $mock = new MockApi();
+
+ // Sanity check empty array
+ $expect = Status::newGood();
+ $this->assertEquals( $expect, $mock->errorArrayToStatus( [] ) );
+
+ // No blocked $user, so no special block handling
+ $expect = Status::newGood();
+ $expect->fatal( 'blockedtext' );
+ $expect->fatal( 'autoblockedtext' );
+ $expect->fatal( 'systemblockedtext' );
+ $expect->fatal( 'mainpage' );
+ $expect->fatal( 'parentheses', 'foobar' );
+ $this->assertEquals( $expect, $mock->errorArrayToStatus( [
+ [ 'blockedtext' ],
+ [ 'autoblockedtext' ],
+ [ 'systemblockedtext' ],
+ 'mainpage',
+ [ 'parentheses', 'foobar' ],
+ ] ) );
+
+ // Has a blocked $user, so special block handling
+ $user = $this->getMutableTestUser()->getUser();
+ $block = new \Block( [
+ 'address' => $user->getName(),
+ 'user' => $user->getID(),
+ 'by' => $this->getTestSysop()->getUser()->getId(),
+ 'reason' => __METHOD__,
+ 'expiry' => time() + 100500,
+ ] );
+ $block->insert();
+ $blockinfo = [ 'blockinfo' => ApiQueryUserInfo::getBlockInfo( $block ) ];
+
+ $expect = Status::newGood();
+ $expect->fatal( ApiMessage::create( 'apierror-blocked', 'blocked', $blockinfo ) );
+ $expect->fatal( ApiMessage::create( 'apierror-autoblocked', 'autoblocked', $blockinfo ) );
+ $expect->fatal( ApiMessage::create( 'apierror-systemblocked', 'blocked', $blockinfo ) );
+ $expect->fatal( 'mainpage' );
+ $expect->fatal( 'parentheses', 'foobar' );
+ $this->assertEquals( $expect, $mock->errorArrayToStatus( [
+ [ 'blockedtext' ],
+ [ 'autoblockedtext' ],
+ [ 'systemblockedtext' ],
+ 'mainpage',
+ [ 'parentheses', 'foobar' ],
+ ], $user ) );
+ }
+
+ public function testDieStatus() {
+ $mock = new MockApi();
+
+ $status = StatusValue::newGood();
+ $status->error( 'foo' );
+ $status->warning( 'bar' );
+ try {
+ $mock->dieStatus( $status );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'foo' ), 'Exception has "foo"' );
+ $this->assertFalse( ApiTestCase::apiExceptionHasCode( $ex, 'bar' ), 'Exception has "bar"' );
+ }
+
+ $status = StatusValue::newGood();
+ $status->warning( 'foo' );
+ $status->warning( 'bar' );
+ try {
+ $mock->dieStatus( $status );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'foo' ), 'Exception has "foo"' );
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'bar' ), 'Exception has "bar"' );
+ }
+
+ $status = StatusValue::newGood();
+ $status->setOk( false );
+ try {
+ $mock->dieStatus( $status );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'unknownerror-nocode' ),
+ 'Exception has "unknownerror-nocode"' );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiBlockTest.php b/www/wiki/tests/phpunit/includes/api/ApiBlockTest.php
new file mode 100644
index 00000000..efefc09d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiBlockTest.php
@@ -0,0 +1,252 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiBlock
+ */
+class ApiBlockTest extends ApiTestCase {
+ protected $mUser = null;
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->mUser = $this->getMutableTestUser()->getUser();
+ $this->setMwGlobals( 'wgBlockCIDRLimit', [
+ 'IPv4' => 16,
+ 'IPv6' => 19,
+ ] );
+ }
+
+ protected function tearDown() {
+ $block = Block::newFromTarget( $this->mUser->getName() );
+ if ( !is_null( $block ) ) {
+ $block->delete();
+ }
+ parent::tearDown();
+ }
+
+ protected function getTokens() {
+ return $this->getTokenList( self::$users['sysop'] );
+ }
+
+ /**
+ * @param array $extraParams Extra API parameters to pass to doApiRequest
+ * @param User $blocker User to do the blocking, null to pick
+ * arbitrarily
+ */
+ private function doBlock( array $extraParams = [], User $blocker = null ) {
+ if ( $blocker === null ) {
+ $blocker = self::$users['sysop']->getUser();
+ }
+
+ $tokens = $this->getTokens();
+
+ $this->assertNotNull( $this->mUser, 'Sanity check' );
+
+ $this->assertArrayHasKey( 'blocktoken', $tokens, 'Sanity check' );
+
+ $params = [
+ 'action' => 'block',
+ 'user' => $this->mUser->getName(),
+ 'reason' => 'Some reason',
+ 'token' => $tokens['blocktoken'],
+ ];
+ if ( array_key_exists( 'userid', $extraParams ) ) {
+ // Make sure we don't have both user and userid
+ unset( $params['user'] );
+ }
+ $ret = $this->doApiRequest( array_merge( $params, $extraParams ), null,
+ false, $blocker );
+
+ $block = Block::newFromTarget( $this->mUser->getName() );
+
+ $this->assertTrue( !is_null( $block ), 'Block is valid' );
+
+ $this->assertSame( $this->mUser->getName(), (string)$block->getTarget() );
+ $this->assertSame( 'Some reason', $block->mReason );
+
+ return $ret;
+ }
+
+ /**
+ * Block by username
+ */
+ public function testNormalBlock() {
+ $this->doBlock();
+ }
+
+ /**
+ * Block by user ID
+ */
+ public function testBlockById() {
+ $this->doBlock( [ 'userid' => $this->mUser->getId() ] );
+ }
+
+ /**
+ * A blocked user can't block
+ */
+ public function testBlockByBlockedUser() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'You cannot block or unblock other users because you are yourself blocked.' );
+
+ $blocked = $this->getMutableTestUser( [ 'sysop' ] )->getUser();
+ $block = new Block( [
+ 'address' => $blocked->getName(),
+ 'by' => self::$users['sysop']->getUser()->getId(),
+ 'reason' => 'Capriciousness',
+ 'timestamp' => '19370101000000',
+ 'expiry' => 'infinity',
+ ] );
+ $block->insert();
+
+ $this->doBlock( [], $blocked );
+ }
+
+ public function testBlockOfNonexistentUser() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'There is no user by the name "Nonexistent". Check your spelling.' );
+
+ $this->doBlock( [ 'user' => 'Nonexistent' ] );
+ }
+
+ public function testBlockOfNonexistentUserId() {
+ $id = 948206325;
+ $this->setExpectedException( ApiUsageException::class,
+ "There is no user with ID $id." );
+
+ $this->assertFalse( User::whoIs( $id ), 'Sanity check' );
+
+ $this->doBlock( [ 'userid' => $id ] );
+ }
+
+ public function testBlockWithTag() {
+ ChangeTags::defineTag( 'custom tag' );
+
+ $this->doBlock( [ 'tags' => 'custom tag' ] );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $this->assertSame( 'custom tag', $dbw->selectField(
+ [ 'change_tag', 'logging' ],
+ 'ct_tag',
+ [ 'log_type' => 'block' ],
+ __METHOD__,
+ [],
+ [ 'change_tag' => [ 'INNER JOIN', 'ct_log_id = log_id' ] ]
+ ) );
+ }
+
+ public function testBlockWithProhibitedTag() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'You do not have permission to apply change tags along with your changes.' );
+
+ ChangeTags::defineTag( 'custom tag' );
+
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'user' => [ 'applychangetags' => true ] ] );
+
+ $this->doBlock( [ 'tags' => 'custom tag' ] );
+ }
+
+ public function testBlockWithHide() {
+ global $wgGroupPermissions;
+ $newPermissions = $wgGroupPermissions['sysop'];
+ $newPermissions['hideuser'] = true;
+ $this->mergeMwGlobalArrayValue( 'wgGroupPermissions',
+ [ 'sysop' => $newPermissions ] );
+
+ $res = $this->doBlock( [ 'hidename' => '' ] );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $this->assertSame( '1', $dbw->selectField(
+ 'ipblocks',
+ 'ipb_deleted',
+ [ 'ipb_id' => $res[0]['block']['id'] ],
+ __METHOD__
+ ) );
+ }
+
+ public function testBlockWithProhibitedHide() {
+ $this->setExpectedException( ApiUsageException::class,
+ "You don't have permission to hide user names from the block log." );
+
+ $this->doBlock( [ 'hidename' => '' ] );
+ }
+
+ public function testBlockWithEmailBlock() {
+ $res = $this->doBlock( [ 'noemail' => '' ] );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $this->assertSame( '1', $dbw->selectField(
+ 'ipblocks',
+ 'ipb_block_email',
+ [ 'ipb_id' => $res[0]['block']['id'] ],
+ __METHOD__
+ ) );
+ }
+
+ public function testBlockWithProhibitedEmailBlock() {
+ $this->setExpectedException( ApiUsageException::class,
+ "You don't have permission to block users from sending email through the wiki." );
+
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'sysop' => [ 'blockemail' => true ] ] );
+
+ $this->doBlock( [ 'noemail' => '' ] );
+ }
+
+ public function testBlockWithExpiry() {
+ $res = $this->doBlock( [ 'expiry' => '1 day' ] );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $expiry = $dbw->selectField(
+ 'ipblocks',
+ 'ipb_expiry',
+ [ 'ipb_id' => $res[0]['block']['id'] ],
+ __METHOD__
+ );
+
+ // Allow flakiness up to one second
+ $this->assertLessThanOrEqual( 1,
+ abs( wfTimestamp( TS_UNIX, $expiry ) - ( time() + 86400 ) ) );
+ }
+
+ public function testBlockWithInvalidExpiry() {
+ $this->setExpectedException( ApiUsageException::class, "Expiry time invalid." );
+
+ $this->doBlock( [ 'expiry' => '' ] );
+ }
+
+ /**
+ * @expectedException ApiUsageException
+ * @expectedExceptionMessage The "token" parameter must be set
+ */
+ public function testBlockingActionWithNoToken() {
+ $this->doApiRequest(
+ [
+ 'action' => 'block',
+ 'user' => $this->mUser->getName(),
+ 'reason' => 'Some reason',
+ ],
+ null,
+ false,
+ self::$users['sysop']->getUser()
+ );
+ }
+
+ public function testRangeBlock() {
+ $this->mUser = User::newFromName( '128.0.0.0/16', false );
+ $this->doBlock();
+ }
+
+ /**
+ * @expectedException ApiUsageException
+ * @expectedExceptionMessage Range blocks larger than /16 are not allowed.
+ */
+ public function testVeryLargeRangeBlock() {
+ $this->mUser = User::newFromName( '128.0.0.0/1', false );
+ $this->doBlock();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiCheckTokenTest.php b/www/wiki/tests/phpunit/includes/api/ApiCheckTokenTest.php
new file mode 100644
index 00000000..f1d95d03
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiCheckTokenTest.php
@@ -0,0 +1,95 @@
+<?php
+
+use MediaWiki\Session\Token;
+
+/**
+ * @group API
+ * @group medium
+ * @covers ApiCheckToken
+ */
+class ApiCheckTokenTest extends ApiTestCase {
+
+ /**
+ * Test result of checking previously queried token (should be valid)
+ */
+ public function testCheckTokenValid() {
+ // Query token which will be checked later
+ $tokens = $this->doApiRequest( [
+ 'action' => 'query',
+ 'meta' => 'tokens',
+ ] );
+
+ $data = $this->doApiRequest( [
+ 'action' => 'checktoken',
+ 'type' => 'csrf',
+ 'token' => $tokens[0]['query']['tokens']['csrftoken'],
+ ], $tokens[1]->getSessionArray() );
+
+ $this->assertEquals( 'valid', $data[0]['checktoken']['result'] );
+ $this->assertArrayHasKey( 'generated', $data[0]['checktoken'] );
+ }
+
+ /**
+ * Test result of checking invalid token
+ */
+ public function testCheckTokenInvalid() {
+ $session = [];
+ $data = $this->doApiRequest( [
+ 'action' => 'checktoken',
+ 'type' => 'csrf',
+ 'token' => 'invalid_token',
+ ], $session );
+
+ $this->assertEquals( 'invalid', $data[0]['checktoken']['result'] );
+ }
+
+ /**
+ * Test result of checking token with negative max age (should be expired)
+ */
+ public function testCheckTokenExpired() {
+ // Query token which will be checked later
+ $tokens = $this->doApiRequest( [
+ 'action' => 'query',
+ 'meta' => 'tokens',
+ ] );
+
+ $data = $this->doApiRequest( [
+ 'action' => 'checktoken',
+ 'type' => 'csrf',
+ 'token' => $tokens[0]['query']['tokens']['csrftoken'],
+ 'maxtokenage' => -1,
+ ], $tokens[1]->getSessionArray() );
+
+ $this->assertEquals( 'expired', $data[0]['checktoken']['result'] );
+ $this->assertArrayHasKey( 'generated', $data[0]['checktoken'] );
+ }
+
+ /**
+ * Test if using token with incorrect suffix will produce a warning
+ */
+ public function testCheckTokenSuffixWarning() {
+ // Query token which will be checked later
+ $tokens = $this->doApiRequest( [
+ 'action' => 'query',
+ 'meta' => 'tokens',
+ ] );
+
+ // Get token and change the suffix
+ $token = $tokens[0]['query']['tokens']['csrftoken'];
+ $token = substr( $token, 0, -strlen( Token::SUFFIX ) ) . urldecode( Token::SUFFIX );
+
+ $data = $this->doApiRequest( [
+ 'action' => 'checktoken',
+ 'type' => 'csrf',
+ 'token' => $token,
+ 'errorformat' => 'raw',
+ ], $tokens[1]->getSessionArray() );
+
+ $this->assertEquals( 'invalid', $data[0]['checktoken']['result'] );
+ $this->assertArrayHasKey( 'warnings', $data[0] );
+ $this->assertCount( 1, $data[0]['warnings'] );
+ $this->assertEquals( 'checktoken', $data[0]['warnings'][0]['module'] );
+ $this->assertEquals( 'checktoken-percentencoding', $data[0]['warnings'][0]['code'] );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiClearHasMsgTest.php b/www/wiki/tests/phpunit/includes/api/ApiClearHasMsgTest.php
new file mode 100644
index 00000000..5b124074
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiClearHasMsgTest.php
@@ -0,0 +1,24 @@
+<?php
+
+/**
+ * @group API
+ * @group medium
+ * @covers ApiClearHasMsg
+ */
+class ApiClearHasMsgTest extends ApiTestCase {
+
+ /**
+ * Test clearing hasmsg flag for current user
+ */
+ public function testClearFlag() {
+ $user = self::$users['sysop']->getUser();
+ $user->setNewtalk( true );
+ $this->assertTrue( $user->getNewtalk(), 'sanity check' );
+
+ $data = $this->doApiRequest( [ 'action' => 'clearhasmsg' ], [] );
+
+ $this->assertEquals( 'success', $data[0]['clearhasmsg'] );
+ $this->assertFalse( $user->getNewtalk() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiComparePagesTest.php b/www/wiki/tests/phpunit/includes/api/ApiComparePagesTest.php
new file mode 100644
index 00000000..ea13a0d3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiComparePagesTest.php
@@ -0,0 +1,653 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiComparePages
+ */
+class ApiComparePagesTest extends ApiTestCase {
+
+ protected static $repl = [];
+
+ protected function setUp() {
+ parent::setUp();
+
+ // Set $wgExternalDiffEngine to something bogus to try to force use of
+ // the PHP engine rather than wikidiff2.
+ $this->setMwGlobals( [
+ 'wgExternalDiffEngine' => '/dev/null',
+ ] );
+ }
+
+ protected function addPage( $page, $text, $model = CONTENT_MODEL_WIKITEXT ) {
+ $title = Title::newFromText( 'ApiComparePagesTest ' . $page );
+ $content = ContentHandler::makeContent( $text, $title, $model );
+
+ $page = WikiPage::factory( $title );
+ $user = static::getTestSysop()->getUser();
+ $status = $page->doEditContent(
+ $content, 'Test for ApiComparePagesTest: ' . $text, 0, false, $user
+ );
+ if ( !$status->isOK() ) {
+ $this->fail( "Failed to create $title: " . $status->getWikiText( false, false, 'en' ) );
+ }
+ return $status->value['revision']->getId();
+ }
+
+ public function addDBDataOnce() {
+ $user = static::getTestSysop()->getUser();
+ self::$repl['creator'] = $user->getName();
+ self::$repl['creatorid'] = $user->getId();
+
+ self::$repl['revA1'] = $this->addPage( 'A', 'A 1' );
+ self::$repl['revA2'] = $this->addPage( 'A', 'A 2' );
+ self::$repl['revA3'] = $this->addPage( 'A', 'A 3' );
+ self::$repl['revA4'] = $this->addPage( 'A', 'A 4' );
+ self::$repl['pageA'] = Title::newFromText( 'ApiComparePagesTest A' )->getArticleId();
+
+ self::$repl['revB1'] = $this->addPage( 'B', 'B 1' );
+ self::$repl['revB2'] = $this->addPage( 'B', 'B 2' );
+ self::$repl['revB3'] = $this->addPage( 'B', 'B 3' );
+ self::$repl['revB4'] = $this->addPage( 'B', 'B 4' );
+ self::$repl['pageB'] = Title::newFromText( 'ApiComparePagesTest B' )->getArticleId();
+
+ self::$repl['revC1'] = $this->addPage( 'C', 'C 1' );
+ self::$repl['revC2'] = $this->addPage( 'C', 'C 2' );
+ self::$repl['revC3'] = $this->addPage( 'C', 'C 3' );
+ self::$repl['pageC'] = Title::newFromText( 'ApiComparePagesTest C' )->getArticleId();
+
+ $id = $this->addPage( 'D', 'D 1' );
+ self::$repl['pageD'] = Title::newFromText( 'ApiComparePagesTest D' )->getArticleId();
+ wfGetDB( DB_MASTER )->delete( 'revision', [ 'rev_id' => $id ] );
+
+ self::$repl['revE1'] = $this->addPage( 'E', 'E 1' );
+ self::$repl['revE2'] = $this->addPage( 'E', 'E 2' );
+ self::$repl['revE3'] = $this->addPage( 'E', 'E 3' );
+ self::$repl['revE4'] = $this->addPage( 'E', 'E 4' );
+ self::$repl['pageE'] = Title::newFromText( 'ApiComparePagesTest E' )->getArticleId();
+ wfGetDB( DB_MASTER )->update(
+ 'page', [ 'page_latest' => 0 ], [ 'page_id' => self::$repl['pageE'] ]
+ );
+
+ self::$repl['revF1'] = $this->addPage( 'F', "== Section 1 ==\nF 1.1\n\n== Section 2 ==\nF 1.2" );
+ self::$repl['pageF'] = Title::newFromText( 'ApiComparePagesTest F' )->getArticleId();
+
+ WikiPage::factory( Title::newFromText( 'ApiComparePagesTest C' ) )
+ ->doDeleteArticleReal( 'Test for ApiComparePagesTest' );
+
+ RevisionDeleter::createList(
+ 'revision',
+ RequestContext::getMain(),
+ Title::newFromText( 'ApiComparePagesTest B' ),
+ [ self::$repl['revB2'] ]
+ )->setVisibility( [
+ 'value' => [
+ Revision::DELETED_TEXT => 1,
+ Revision::DELETED_USER => 1,
+ Revision::DELETED_COMMENT => 1,
+ ],
+ 'comment' => 'Test for ApiComparePages',
+ ] );
+
+ RevisionDeleter::createList(
+ 'revision',
+ RequestContext::getMain(),
+ Title::newFromText( 'ApiComparePagesTest B' ),
+ [ self::$repl['revB3'] ]
+ )->setVisibility( [
+ 'value' => [
+ Revision::DELETED_USER => 1,
+ Revision::DELETED_COMMENT => 1,
+ Revision::DELETED_RESTRICTED => 1,
+ ],
+ 'comment' => 'Test for ApiComparePages',
+ ] );
+
+ Title::clearCaches(); // Otherwise it has the wrong latest revision for some reason
+ }
+
+ protected function doReplacements( &$value ) {
+ if ( is_string( $value ) ) {
+ if ( preg_match( '/^{{REPL:(.+?)}}$/', $value, $m ) ) {
+ $value = self::$repl[$m[1]];
+ } else {
+ $value = preg_replace_callback( '/{{REPL:(.+?)}}/', function ( $m ) {
+ return isset( self::$repl[$m[1]] ) ? self::$repl[$m[1]] : $m[0];
+ }, $value );
+ }
+ } elseif ( is_array( $value ) || is_object( $value ) ) {
+ foreach ( $value as &$v ) {
+ $this->doReplacements( $v );
+ }
+ unset( $v );
+ }
+ }
+
+ /**
+ * @dataProvider provideDiff
+ */
+ public function testDiff( $params, $expect, $exceptionCode = false, $sysop = false ) {
+ $this->doReplacements( $params );
+
+ $params += [
+ 'action' => 'compare',
+ ];
+
+ $user = $sysop
+ ? static::getTestSysop()->getUser()
+ : static::getTestUser()->getUser();
+ if ( $exceptionCode ) {
+ try {
+ $this->doApiRequest( $params, null, false, $user );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( $this->apiExceptionHasCode( $ex, $exceptionCode ),
+ "Exception with code $exceptionCode" );
+ }
+ } else {
+ $apiResult = $this->doApiRequest( $params, null, false, $user );
+ $apiResult = $apiResult[0];
+ $this->doReplacements( $expect );
+ $this->assertEquals( $expect, $apiResult );
+ }
+ }
+
+ public static function provideDiff() {
+ // phpcs:disable Generic.Files.LineLength.TooLong
+ return [
+ 'Basic diff, titles' => [
+ [
+ 'fromtitle' => 'ApiComparePagesTest A',
+ 'totitle' => 'ApiComparePagesTest B',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageA}}',
+ 'fromrevid' => '{{REPL:revA4}}',
+ 'fromns' => 0,
+ 'fromtitle' => 'ApiComparePagesTest A',
+ 'toid' => '{{REPL:pageB}}',
+ 'torevid' => '{{REPL:revB4}}',
+ 'tons' => 0,
+ 'totitle' => 'ApiComparePagesTest B',
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">A </del>4</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">B </ins>4</div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, page IDs' => [
+ [
+ 'fromid' => '{{REPL:pageA}}',
+ 'toid' => '{{REPL:pageB}}',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageA}}',
+ 'fromrevid' => '{{REPL:revA4}}',
+ 'fromns' => 0,
+ 'fromtitle' => 'ApiComparePagesTest A',
+ 'toid' => '{{REPL:pageB}}',
+ 'torevid' => '{{REPL:revB4}}',
+ 'tons' => 0,
+ 'totitle' => 'ApiComparePagesTest B',
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">A </del>4</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">B </ins>4</div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, revision IDs' => [
+ [
+ 'fromrev' => '{{REPL:revA2}}',
+ 'torev' => '{{REPL:revA3}}',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageA}}',
+ 'fromrevid' => '{{REPL:revA2}}',
+ 'fromns' => 0,
+ 'fromtitle' => 'ApiComparePagesTest A',
+ 'toid' => '{{REPL:pageA}}',
+ 'torevid' => '{{REPL:revA3}}',
+ 'tons' => 0,
+ 'totitle' => 'ApiComparePagesTest A',
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>A <del class="diffchange diffchange-inline">2</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>A <ins class="diffchange diffchange-inline">3</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, deleted revision ID as sysop' => [
+ [
+ 'fromrev' => '{{REPL:revA2}}',
+ 'torev' => '{{REPL:revC2}}',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageA}}',
+ 'fromrevid' => '{{REPL:revA2}}',
+ 'fromns' => 0,
+ 'fromtitle' => 'ApiComparePagesTest A',
+ 'toid' => 0,
+ 'torevid' => '{{REPL:revC2}}',
+ 'tons' => 0,
+ 'totitle' => 'ApiComparePagesTest C',
+ 'toarchive' => true,
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">A </del>2</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">C </ins>2</div></td></tr>' . "\n",
+ ]
+ ],
+ false, true
+ ],
+ 'Basic diff, revdel as sysop' => [
+ [
+ 'fromrev' => '{{REPL:revA2}}',
+ 'torev' => '{{REPL:revB2}}',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageA}}',
+ 'fromrevid' => '{{REPL:revA2}}',
+ 'fromns' => 0,
+ 'fromtitle' => 'ApiComparePagesTest A',
+ 'toid' => '{{REPL:pageB}}',
+ 'torevid' => '{{REPL:revB2}}',
+ 'tons' => 0,
+ 'totitle' => 'ApiComparePagesTest B',
+ 'totexthidden' => true,
+ 'touserhidden' => true,
+ 'tocommenthidden' => true,
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">A </del>2</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">B </ins>2</div></td></tr>' . "\n",
+ ]
+ ],
+ false, true
+ ],
+ 'Basic diff, text' => [
+ [
+ 'fromtext' => 'From text',
+ 'fromcontentmodel' => 'wikitext',
+ 'totext' => 'To text {{subst:PAGENAME}}',
+ 'tocontentmodel' => 'wikitext',
+ ],
+ [
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">{{subst:PAGENAME}}</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, text 2' => [
+ [
+ 'fromtext' => 'From text',
+ 'totext' => 'To text {{subst:PAGENAME}}',
+ 'tocontentmodel' => 'wikitext',
+ ],
+ [
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">{{subst:PAGENAME}}</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, guessed model' => [
+ [
+ 'fromtext' => 'From text',
+ 'totext' => 'To text',
+ ],
+ [
+ 'warnings' => [
+ 'compare' => [
+ 'warnings' => 'No content model could be determined, assuming wikitext.',
+ ],
+ ],
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text</div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, text with title and PST' => [
+ [
+ 'fromtext' => 'From text',
+ 'totitle' => 'Test',
+ 'totext' => 'To text {{subst:PAGENAME}}',
+ 'topst' => true,
+ ],
+ [
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">Test</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, text with page ID and PST' => [
+ [
+ 'fromtext' => 'From text',
+ 'toid' => '{{REPL:pageB}}',
+ 'totext' => 'To text {{subst:PAGENAME}}',
+ 'topst' => true,
+ ],
+ [
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest B</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, text with revision and PST' => [
+ [
+ 'fromtext' => 'From text',
+ 'torev' => '{{REPL:revB2}}',
+ 'totext' => 'To text {{subst:PAGENAME}}',
+ 'topst' => true,
+ ],
+ [
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest B</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Basic diff, text with deleted revision and PST' => [
+ [
+ 'fromtext' => 'From text',
+ 'torev' => '{{REPL:revC2}}',
+ 'totext' => 'To text {{subst:PAGENAME}}',
+ 'topst' => true,
+ ],
+ [
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest C</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ false, true
+ ],
+ 'Basic diff, test with sections' => [
+ [
+ 'fromtitle' => 'ApiComparePagesTest F',
+ 'fromsection' => 1,
+ 'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?",
+ 'tosection' => 2,
+ ],
+ [
+ 'compare' => [
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>== Section <del class="diffchange diffchange-inline">1 </del>==</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>== Section <ins class="diffchange diffchange-inline">2 </ins>==</div></td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">F 1.1</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To text?</ins></div></td></tr>' . "\n",
+ 'fromid' => '{{REPL:pageF}}',
+ 'fromrevid' => '{{REPL:revF1}}',
+ 'fromns' => '0',
+ 'fromtitle' => 'ApiComparePagesTest F',
+ ]
+ ],
+ ],
+ 'Diff with all props' => [
+ [
+ 'fromrev' => '{{REPL:revB1}}',
+ 'torev' => '{{REPL:revB3}}',
+ 'totitle' => 'ApiComparePagesTest B',
+ 'prop' => 'diff|diffsize|rel|ids|title|user|comment|parsedcomment|size'
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageB}}',
+ 'fromrevid' => '{{REPL:revB1}}',
+ 'fromns' => 0,
+ 'fromtitle' => 'ApiComparePagesTest B',
+ 'fromsize' => 3,
+ 'fromuser' => '{{REPL:creator}}',
+ 'fromuserid' => '{{REPL:creatorid}}',
+ 'fromcomment' => 'Test for ApiComparePagesTest: B 1',
+ 'fromparsedcomment' => 'Test for ApiComparePagesTest: B 1',
+ 'toid' => '{{REPL:pageB}}',
+ 'torevid' => '{{REPL:revB3}}',
+ 'tons' => 0,
+ 'totitle' => 'ApiComparePagesTest B',
+ 'tosize' => 3,
+ 'touserhidden' => true,
+ 'tocommenthidden' => true,
+ 'tosuppressed' => true,
+ 'next' => '{{REPL:revB4}}',
+ 'diffsize' => 391,
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>B <del class="diffchange diffchange-inline">1</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>B <ins class="diffchange diffchange-inline">3</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ ],
+ 'Diff with all props as sysop' => [
+ [
+ 'fromrev' => '{{REPL:revB2}}',
+ 'torev' => '{{REPL:revB3}}',
+ 'totitle' => 'ApiComparePagesTest B',
+ 'prop' => 'diff|diffsize|rel|ids|title|user|comment|parsedcomment|size'
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageB}}',
+ 'fromrevid' => '{{REPL:revB2}}',
+ 'fromns' => 0,
+ 'fromtitle' => 'ApiComparePagesTest B',
+ 'fromsize' => 3,
+ 'fromtexthidden' => true,
+ 'fromuserhidden' => true,
+ 'fromuser' => '{{REPL:creator}}',
+ 'fromuserid' => '{{REPL:creatorid}}',
+ 'fromcommenthidden' => true,
+ 'fromcomment' => 'Test for ApiComparePagesTest: B 2',
+ 'fromparsedcomment' => 'Test for ApiComparePagesTest: B 2',
+ 'toid' => '{{REPL:pageB}}',
+ 'torevid' => '{{REPL:revB3}}',
+ 'tons' => 0,
+ 'totitle' => 'ApiComparePagesTest B',
+ 'tosize' => 3,
+ 'touserhidden' => true,
+ 'tocommenthidden' => true,
+ 'tosuppressed' => true,
+ 'prev' => '{{REPL:revB1}}',
+ 'next' => '{{REPL:revB4}}',
+ 'diffsize' => 391,
+ 'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+ . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+ . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>B <del class="diffchange diffchange-inline">2</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>B <ins class="diffchange diffchange-inline">3</ins></div></td></tr>' . "\n",
+ ]
+ ],
+ false, true
+ ],
+ 'Relative diff, cur' => [
+ [
+ 'fromrev' => '{{REPL:revA2}}',
+ 'torelative' => 'cur',
+ 'prop' => 'ids',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageA}}',
+ 'fromrevid' => '{{REPL:revA2}}',
+ 'toid' => '{{REPL:pageA}}',
+ 'torevid' => '{{REPL:revA4}}',
+ ]
+ ],
+ ],
+ 'Relative diff, next' => [
+ [
+ 'fromrev' => '{{REPL:revE2}}',
+ 'torelative' => 'next',
+ 'prop' => 'ids|rel',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageE}}',
+ 'fromrevid' => '{{REPL:revE2}}',
+ 'toid' => '{{REPL:pageE}}',
+ 'torevid' => '{{REPL:revE3}}',
+ 'prev' => '{{REPL:revE1}}',
+ 'next' => '{{REPL:revE4}}',
+ ]
+ ],
+ ],
+ 'Relative diff, prev' => [
+ [
+ 'fromrev' => '{{REPL:revE3}}',
+ 'torelative' => 'prev',
+ 'prop' => 'ids|rel',
+ ],
+ [
+ 'compare' => [
+ 'fromid' => '{{REPL:pageE}}',
+ 'fromrevid' => '{{REPL:revE2}}',
+ 'toid' => '{{REPL:pageE}}',
+ 'torevid' => '{{REPL:revE3}}',
+ 'prev' => '{{REPL:revE1}}',
+ 'next' => '{{REPL:revE4}}',
+ ]
+ ],
+ ],
+
+ 'Error, missing title' => [
+ [
+ 'fromtitle' => 'ApiComparePagesTest X',
+ 'totitle' => 'ApiComparePagesTest B',
+ ],
+ [],
+ 'missingtitle',
+ ],
+ 'Error, invalid title' => [
+ [
+ 'fromtitle' => '<bad>',
+ 'totitle' => 'ApiComparePagesTest B',
+ ],
+ [],
+ 'invalidtitle',
+ ],
+ 'Error, missing page ID' => [
+ [
+ 'fromid' => 8817900,
+ 'totitle' => 'ApiComparePagesTest B',
+ ],
+ [],
+ 'nosuchpageid',
+ ],
+ 'Error, page with missing revision' => [
+ [
+ 'fromtitle' => 'ApiComparePagesTest D',
+ 'totitle' => 'ApiComparePagesTest B',
+ ],
+ [],
+ 'nosuchrevid',
+ ],
+ 'Error, page with no revision' => [
+ [
+ 'fromtitle' => 'ApiComparePagesTest E',
+ 'totitle' => 'ApiComparePagesTest B',
+ ],
+ [],
+ 'nosuchrevid',
+ ],
+ 'Error, bad rev ID' => [
+ [
+ 'fromrev' => 8817900,
+ 'totitle' => 'ApiComparePagesTest B',
+ ],
+ [],
+ 'nosuchrevid',
+ ],
+ 'Error, deleted revision ID, non-sysop' => [
+ [
+ 'fromrev' => '{{REPL:revA2}}',
+ 'torev' => '{{REPL:revC2}}',
+ ],
+ [],
+ 'nosuchrevid',
+ ],
+ 'Error, revision-deleted content' => [
+ [
+ 'fromrev' => '{{REPL:revA2}}',
+ 'torev' => '{{REPL:revB2}}',
+ ],
+ [],
+ 'missingcontent',
+ ],
+ 'Error, text with no title and PST' => [
+ [
+ 'fromtext' => 'From text',
+ 'totext' => 'To text {{subst:PAGENAME}}',
+ 'topst' => true,
+ ],
+ [],
+ 'compare-no-title',
+ ],
+ 'Error, test with invalid from section ID' => [
+ [
+ 'fromtitle' => 'ApiComparePagesTest F',
+ 'fromsection' => 5,
+ 'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?",
+ 'tosection' => 2,
+ ],
+ [],
+ 'nosuchfromsection',
+ ],
+ 'Error, test with invalid to section ID' => [
+ [
+ 'fromtitle' => 'ApiComparePagesTest F',
+ 'fromsection' => 1,
+ 'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?",
+ 'tosection' => 5,
+ ],
+ [],
+ 'nosuchtosection',
+ ],
+ 'Error, Relative diff, no from revision' => [
+ [
+ 'fromtext' => 'Foo',
+ 'torelative' => 'cur',
+ 'prop' => 'ids',
+ ],
+ [],
+ 'compare-relative-to-nothing'
+ ],
+ 'Error, Relative diff, cur with no current revision' => [
+ [
+ 'fromrev' => '{{REPL:revE2}}',
+ 'torelative' => 'cur',
+ 'prop' => 'ids',
+ ],
+ [],
+ 'nosuchrevid'
+ ],
+ 'Error, Relative diff, next revdeleted' => [
+ [
+ 'fromrev' => '{{REPL:revB1}}',
+ 'torelative' => 'next',
+ 'prop' => 'ids',
+ ],
+ [],
+ 'missingcontent'
+ ],
+ 'Error, Relative diff, prev revdeleted' => [
+ [
+ 'fromrev' => '{{REPL:revB3}}',
+ 'torelative' => 'prev',
+ 'prop' => 'ids',
+ ],
+ [],
+ 'missingcontent'
+ ],
+ ];
+ // phpcs:enable
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiContinuationManagerTest.php b/www/wiki/tests/phpunit/includes/api/ApiContinuationManagerTest.php
new file mode 100644
index 00000000..788d120c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiContinuationManagerTest.php
@@ -0,0 +1,198 @@
+<?php
+
+/**
+ * @covers ApiContinuationManager
+ * @group API
+ */
+class ApiContinuationManagerTest extends MediaWikiTestCase {
+
+ private static function getManager( $continue, $allModules, $generatedModules ) {
+ $context = new DerivativeContext( RequestContext::getMain() );
+ $context->setRequest( new FauxRequest( [ 'continue' => $continue ] ) );
+ $main = new ApiMain( $context );
+ return new ApiContinuationManager( $main, $allModules, $generatedModules );
+ }
+
+ public function testContinuation() {
+ $allModules = [
+ new MockApiQueryBase( 'mock1' ),
+ new MockApiQueryBase( 'mock2' ),
+ new MockApiQueryBase( 'mocklist' ),
+ ];
+ $generator = new MockApiQueryBase( 'generator' );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( ApiMain::class, $manager->getSource() );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] );
+ $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 );
+ $manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 );
+ $this->assertSame( [ [
+ 'mlcontinue' => 2,
+ 'm1continue' => '1|2',
+ 'continue' => '||mock2',
+ ], false ], $manager->getContinuation() );
+ $this->assertSame( [
+ 'mock1' => [ 'm1continue' => '1|2' ],
+ 'mocklist' => [ 'mlcontinue' => 2 ],
+ 'generator' => [ 'gcontinue' => 3 ],
+ ], $manager->getRawContinuation() );
+
+ $result = new ApiResult( 0 );
+ $manager->setContinuationIntoResult( $result );
+ $this->assertSame( [
+ 'mlcontinue' => 2,
+ 'm1continue' => '1|2',
+ 'continue' => '||mock2',
+ ], $result->getResultData( 'continue' ) );
+ $this->assertSame( null, $result->getResultData( 'batchcomplete' ) );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] );
+ $manager->addGeneratorContinueParam( $generator, 'gcontinue', [ 3, 4 ] );
+ $this->assertSame( [ [
+ 'm1continue' => '1|2',
+ 'continue' => '||mock2|mocklist',
+ ], false ], $manager->getContinuation() );
+ $this->assertSame( [
+ 'mock1' => [ 'm1continue' => '1|2' ],
+ 'generator' => [ 'gcontinue' => '3|4' ],
+ ], $manager->getRawContinuation() );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 );
+ $manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 );
+ $this->assertSame( [ [
+ 'mlcontinue' => 2,
+ 'gcontinue' => 3,
+ 'continue' => 'gcontinue||',
+ ], true ], $manager->getContinuation() );
+ $this->assertSame( [
+ 'mocklist' => [ 'mlcontinue' => 2 ],
+ 'generator' => [ 'gcontinue' => 3 ],
+ ], $manager->getRawContinuation() );
+
+ $result = new ApiResult( 0 );
+ $manager->setContinuationIntoResult( $result );
+ $this->assertSame( [
+ 'mlcontinue' => 2,
+ 'gcontinue' => 3,
+ 'continue' => 'gcontinue||',
+ ], $result->getResultData( 'continue' ) );
+ $this->assertSame( true, $result->getResultData( 'batchcomplete' ) );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $manager->addGeneratorContinueParam( $generator, 'gcontinue', 3 );
+ $this->assertSame( [ [
+ 'gcontinue' => 3,
+ 'continue' => 'gcontinue||mocklist',
+ ], true ], $manager->getContinuation() );
+ $this->assertSame( [
+ 'generator' => [ 'gcontinue' => 3 ],
+ ], $manager->getRawContinuation() );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] );
+ $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 );
+ $this->assertSame( [ [
+ 'mlcontinue' => 2,
+ 'm1continue' => '1|2',
+ 'continue' => '||mock2',
+ ], false ], $manager->getContinuation() );
+ $this->assertSame( [
+ 'mock1' => [ 'm1continue' => '1|2' ],
+ 'mocklist' => [ 'mlcontinue' => 2 ],
+ ], $manager->getRawContinuation() );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $manager->addContinueParam( $allModules[0], 'm1continue', [ 1, 2 ] );
+ $this->assertSame( [ [
+ 'm1continue' => '1|2',
+ 'continue' => '||mock2|mocklist',
+ ], false ], $manager->getContinuation() );
+ $this->assertSame( [
+ 'mock1' => [ 'm1continue' => '1|2' ],
+ ], $manager->getRawContinuation() );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $manager->addContinueParam( $allModules[2], 'mlcontinue', 2 );
+ $this->assertSame( [ [
+ 'mlcontinue' => 2,
+ 'continue' => '-||mock1|mock2',
+ ], true ], $manager->getContinuation() );
+ $this->assertSame( [
+ 'mocklist' => [ 'mlcontinue' => 2 ],
+ ], $manager->getRawContinuation() );
+
+ $manager = self::getManager( '', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame( $allModules, $manager->getRunModules() );
+ $this->assertSame( [ [], true ], $manager->getContinuation() );
+ $this->assertSame( [], $manager->getRawContinuation() );
+
+ $manager = self::getManager( '||mock2', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( false, $manager->isGeneratorDone() );
+ $this->assertSame(
+ array_values( array_diff_key( $allModules, [ 1 => 1 ] ) ),
+ $manager->getRunModules()
+ );
+
+ $manager = self::getManager( '-||', $allModules, [ 'mock1', 'mock2' ] );
+ $this->assertSame( true, $manager->isGeneratorDone() );
+ $this->assertSame(
+ array_values( array_diff_key( $allModules, [ 0 => 0, 1 => 1 ] ) ),
+ $manager->getRunModules()
+ );
+
+ try {
+ self::getManager( 'foo', $allModules, [ 'mock1', 'mock2' ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'badcontinue' ),
+ 'Expected exception'
+ );
+ }
+
+ $manager = self::getManager(
+ '||mock2',
+ array_slice( $allModules, 0, 2 ),
+ [ 'mock1', 'mock2' ]
+ );
+ try {
+ $manager->addContinueParam( $allModules[1], 'm2continue', 1 );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'Module \'mock2\' was not supposed to have been executed, ' .
+ 'but it was executed anyway',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $manager->addContinueParam( $allModules[2], 'mlcontinue', 1 );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'Module \'mocklist\' called ApiContinuationManager::addContinueParam ' .
+ 'but was not passed to ApiContinuationManager::__construct',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiDeleteTest.php b/www/wiki/tests/phpunit/includes/api/ApiDeleteTest.php
new file mode 100644
index 00000000..0f2bcc61
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiDeleteTest.php
@@ -0,0 +1,168 @@
+<?php
+
+/**
+ * Tests for MediaWiki api.php?action=delete.
+ *
+ * @author Yifei He
+ *
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiDelete
+ */
+class ApiDeleteTest extends ApiTestCase {
+ public function testDelete() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ // create new page
+ $this->editPage( $name, 'Some text' );
+
+ // test deletion
+ $apiResult = $this->doApiRequestWithToken( [
+ 'action' => 'delete',
+ 'title' => $name,
+ ] )[0];
+
+ $this->assertArrayHasKey( 'delete', $apiResult );
+ $this->assertArrayHasKey( 'title', $apiResult['delete'] );
+ $this->assertSame( $name, $apiResult['delete']['title'] );
+ $this->assertArrayHasKey( 'logid', $apiResult['delete'] );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+
+ public function testDeleteNonexistent() {
+ $this->setExpectedException( ApiUsageException::class,
+ "The page you specified doesn't exist." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'delete',
+ 'title' => 'This page deliberately left nonexistent',
+ ] );
+ }
+
+ public function testDeletionWithoutPermission() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The action you have requested is limited to users in the group:' );
+
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ // create new page
+ $this->editPage( $name, 'Some text' );
+
+ // test deletion without permission
+ try {
+ $user = new User();
+ $apiResult = $this->doApiRequest( [
+ 'action' => 'delete',
+ 'title' => $name,
+ 'token' => $user->getEditToken(),
+ ], null, null, $user );
+ } finally {
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testDeleteWithTag() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ ChangeTags::defineTag( 'custom tag' );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'delete',
+ 'title' => $name,
+ 'tags' => 'custom tag',
+ ] );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $this->assertSame( 'custom tag', $dbw->selectField(
+ [ 'change_tag', 'logging' ],
+ 'ct_tag',
+ [
+ 'log_namespace' => NS_HELP,
+ 'log_title' => ucfirst( __FUNCTION__ ),
+ ],
+ __METHOD__,
+ [],
+ [ 'change_tag' => [ 'INNER JOIN', 'ct_log_id = log_id' ] ]
+ ) );
+ }
+
+ public function testDeleteWithoutTagPermission() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'You do not have permission to apply change tags along with your changes.' );
+
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ ChangeTags::defineTag( 'custom tag' );
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'user' => [ 'applychangetags' => true ] ] );
+
+ $this->editPage( $name, 'Some text' );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'delete',
+ 'title' => $name,
+ 'tags' => 'custom tag',
+ ] );
+ } finally {
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testDeleteAbortedByHook() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'Deletion aborted by hook. It gave no explanation.' );
+
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->setTemporaryHook( 'ArticleDelete',
+ function () {
+ return false;
+ }
+ );
+
+ try {
+ $this->doApiRequestWithToken( [ 'action' => 'delete', 'title' => $name ] );
+ } finally {
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testDeleteWatch() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $user = self::$users['sysop']->getUser();
+
+ $this->editPage( $name, 'Some text' );
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ $this->assertFalse( $user->isWatched( Title::newFromText( $name ) ) );
+
+ $this->doApiRequestWithToken( [ 'action' => 'delete', 'title' => $name, 'watch' => '' ] );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ $this->assertTrue( $user->isWatched( Title::newFromText( $name ) ) );
+ }
+
+ public function testDeleteUnwatch() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $user = self::$users['sysop']->getUser();
+
+ $this->editPage( $name, 'Some text' );
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ $user->addWatch( Title::newFromText( $name ) );
+ $this->assertTrue( $user->isWatched( Title::newFromText( $name ) ) );
+
+ $this->doApiRequestWithToken( [ 'action' => 'delete', 'title' => $name, 'unwatch' => '' ] );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ $this->assertFalse( $user->isWatched( Title::newFromText( $name ) ) );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiDisabledTest.php b/www/wiki/tests/phpunit/includes/api/ApiDisabledTest.php
new file mode 100644
index 00000000..cfdd57b8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiDisabledTest.php
@@ -0,0 +1,19 @@
+<?php
+
+/**
+ * @group API
+ * @group medium
+ *
+ * @covers ApiDisabled
+ */
+class ApiDisabledTest extends ApiTestCase {
+ public function testDisabled() {
+ $this->mergeMwGlobalArrayValue( 'wgAPIModules',
+ [ 'login' => 'ApiDisabled' ] );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The "login" module has been disabled.' );
+
+ $this->doApiRequest( [ 'action' => 'login' ] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiEditPageTest.php b/www/wiki/tests/phpunit/includes/api/ApiEditPageTest.php
new file mode 100644
index 00000000..c1963389
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiEditPageTest.php
@@ -0,0 +1,1604 @@
+<?php
+
+/**
+ * Tests for MediaWiki api.php?action=edit.
+ *
+ * @author Daniel Kinzler
+ *
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiEditPage
+ */
+class ApiEditPageTest extends ApiTestCase {
+
+ protected function setUp() {
+ global $wgExtraNamespaces, $wgNamespaceContentModels, $wgContentHandlers, $wgContLang;
+
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgExtraNamespaces' => $wgExtraNamespaces,
+ 'wgNamespaceContentModels' => $wgNamespaceContentModels,
+ 'wgContentHandlers' => $wgContentHandlers,
+ 'wgContLang' => $wgContLang,
+ ] );
+
+ $wgExtraNamespaces[12312] = 'Dummy';
+ $wgExtraNamespaces[12313] = 'Dummy_talk';
+ $wgExtraNamespaces[12314] = 'DummyNonText';
+ $wgExtraNamespaces[12315] = 'DummyNonText_talk';
+
+ $wgNamespaceContentModels[12312] = "testing";
+ $wgNamespaceContentModels[12314] = "testing-nontext";
+
+ $wgContentHandlers["testing"] = 'DummyContentHandlerForTesting';
+ $wgContentHandlers["testing-nontext"] = 'DummyNonTextContentHandler';
+ $wgContentHandlers["testing-serialize-error"] =
+ 'DummySerializeErrorContentHandler';
+
+ MWNamespace::clearCaches();
+ $wgContLang->resetNamespaces(); # reset namespace cache
+ }
+
+ protected function tearDown() {
+ global $wgContLang;
+
+ MWNamespace::clearCaches();
+ $wgContLang->resetNamespaces(); # reset namespace cache
+
+ parent::tearDown();
+ }
+
+ public function testEdit() {
+ $name = 'Help:ApiEditPageTest_testEdit'; // assume Help namespace to default to wikitext
+
+ // -- test new page --------------------------------------------
+ $apiResult = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ ] );
+ $apiResult = $apiResult[0];
+
+ // Validate API result data
+ $this->assertArrayHasKey( 'edit', $apiResult );
+ $this->assertArrayHasKey( 'result', $apiResult['edit'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
+
+ $this->assertArrayHasKey( 'new', $apiResult['edit'] );
+ $this->assertArrayNotHasKey( 'nochange', $apiResult['edit'] );
+
+ $this->assertArrayHasKey( 'pageid', $apiResult['edit'] );
+
+ // -- test existing page, no change ----------------------------
+ $data = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ ] );
+
+ $this->assertSame( 'Success', $data[0]['edit']['result'] );
+
+ $this->assertArrayNotHasKey( 'new', $data[0]['edit'] );
+ $this->assertArrayHasKey( 'nochange', $data[0]['edit'] );
+
+ // -- test existing page, with change --------------------------
+ $data = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'different text'
+ ] );
+
+ $this->assertSame( 'Success', $data[0]['edit']['result'] );
+
+ $this->assertArrayNotHasKey( 'new', $data[0]['edit'] );
+ $this->assertArrayNotHasKey( 'nochange', $data[0]['edit'] );
+
+ $this->assertArrayHasKey( 'oldrevid', $data[0]['edit'] );
+ $this->assertArrayHasKey( 'newrevid', $data[0]['edit'] );
+ $this->assertNotEquals(
+ $data[0]['edit']['newrevid'],
+ $data[0]['edit']['oldrevid'],
+ "revision id should change after edit"
+ );
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideEditAppend() {
+ return [
+ [ # 0: append
+ 'foo', 'append', 'bar', "foobar"
+ ],
+ [ # 1: prepend
+ 'foo', 'prepend', 'bar', "barfoo"
+ ],
+ [ # 2: append to empty page
+ '', 'append', 'foo', "foo"
+ ],
+ [ # 3: prepend to empty page
+ '', 'prepend', 'foo', "foo"
+ ],
+ [ # 4: append to non-existing page
+ null, 'append', 'foo', "foo"
+ ],
+ [ # 5: prepend to non-existing page
+ null, 'prepend', 'foo', "foo"
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideEditAppend
+ */
+ public function testEditAppend( $text, $op, $append, $expected ) {
+ static $count = 0;
+ $count++;
+
+ // assume NS_HELP defaults to wikitext
+ $name = "Help:ApiEditPageTest_testEditAppend_$count";
+
+ // -- create page (or not) -----------------------------------------
+ if ( $text !== null ) {
+ list( $re ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => $text, ] );
+
+ $this->assertSame( 'Success', $re['edit']['result'] ); // sanity
+ }
+
+ // -- try append/prepend --------------------------------------------
+ list( $re ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ $op . 'text' => $append, ] );
+
+ $this->assertSame( 'Success', $re['edit']['result'] );
+
+ // -- validate -----------------------------------------------------
+ $page = new WikiPage( Title::newFromText( $name ) );
+ $content = $page->getContent();
+ $this->assertNotNull( $content, 'Page should have been created' );
+
+ $text = $content->getNativeData();
+
+ $this->assertSame( $expected, $text );
+ }
+
+ /**
+ * Test editing of sections
+ */
+ public function testEditSection() {
+ $name = 'Help:ApiEditPageTest_testEditSection';
+ $page = WikiPage::factory( Title::newFromText( $name ) );
+ $text = "==section 1==\ncontent 1\n==section 2==\ncontent2";
+ // Preload the page with some text
+ $page->doEditContent( ContentHandler::makeContent( $text, $page->getTitle() ), 'summary' );
+
+ list( $re ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'section' => '1',
+ 'text' => "==section 1==\nnew content 1",
+ ] );
+ $this->assertSame( 'Success', $re['edit']['result'] );
+ $newtext = WikiPage::factory( Title::newFromText( $name ) )
+ ->getContent( Revision::RAW )
+ ->getNativeData();
+ $this->assertSame( "==section 1==\nnew content 1\n\n==section 2==\ncontent2", $newtext );
+
+ // Test that we raise a 'nosuchsection' error
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'section' => '9999',
+ 'text' => 'text',
+ ] );
+ $this->fail( "Should have raised an ApiUsageException" );
+ } catch ( ApiUsageException $e ) {
+ $this->assertTrue( self::apiExceptionHasCode( $e, 'nosuchsection' ) );
+ }
+ }
+
+ /**
+ * Test action=edit&section=new
+ * Run it twice so we test adding a new section on a
+ * page that doesn't exist (T54830) and one that
+ * does exist
+ */
+ public function testEditNewSection() {
+ $name = 'Help:ApiEditPageTest_testEditNewSection';
+
+ // Test on a page that does not already exist
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ list( $re ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'section' => 'new',
+ 'text' => 'test',
+ 'summary' => 'header',
+ ] );
+
+ $this->assertSame( 'Success', $re['edit']['result'] );
+ // Check the page text is correct
+ $text = WikiPage::factory( Title::newFromText( $name ) )
+ ->getContent( Revision::RAW )
+ ->getNativeData();
+ $this->assertSame( "== header ==\n\ntest", $text );
+
+ // Now on one that does
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ list( $re2 ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'section' => 'new',
+ 'text' => 'test',
+ 'summary' => 'header',
+ ] );
+
+ $this->assertSame( 'Success', $re2['edit']['result'] );
+ $text = WikiPage::factory( Title::newFromText( $name ) )
+ ->getContent( Revision::RAW )
+ ->getNativeData();
+ $this->assertSame( "== header ==\n\ntest\n\n== header ==\n\ntest", $text );
+ }
+
+ /**
+ * Ensure we can edit through a redirect, if adding a section
+ */
+ public function testEdit_redirect() {
+ static $count = 0;
+ $count++;
+
+ // assume NS_HELP defaults to wikitext
+ $name = "Help:ApiEditPageTest_testEdit_redirect_$count";
+ $title = Title::newFromText( $name );
+ $page = WikiPage::factory( $title );
+
+ $rname = "Help:ApiEditPageTest_testEdit_redirect_r$count";
+ $rtitle = Title::newFromText( $rname );
+ $rpage = WikiPage::factory( $rtitle );
+
+ // base edit for content
+ $page->doEditContent( new WikitextContent( "Foo" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $page, '20120101000000' );
+ $baseTime = $page->getRevision()->getTimestamp();
+
+ // base edit for redirect
+ $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $rpage, '20120101000000' );
+
+ // conflicting edit to redirect
+ $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]\n\n[[Category:Test]]" ),
+ "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() );
+ $this->forceRevisionDate( $rpage, '20120101020202' );
+
+ // try to save edit, following the redirect
+ list( $re, , ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $rname,
+ 'text' => 'nix bar!',
+ 'basetimestamp' => $baseTime,
+ 'section' => 'new',
+ 'redirect' => true,
+ ] );
+
+ $this->assertSame( 'Success', $re['edit']['result'],
+ "no problems expected when following redirect" );
+ }
+
+ /**
+ * Ensure we cannot edit through a redirect, if attempting to overwrite content
+ */
+ public function testEdit_redirectText() {
+ static $count = 0;
+ $count++;
+
+ // assume NS_HELP defaults to wikitext
+ $name = "Help:ApiEditPageTest_testEdit_redirectText_$count";
+ $title = Title::newFromText( $name );
+ $page = WikiPage::factory( $title );
+
+ $rname = "Help:ApiEditPageTest_testEdit_redirectText_r$count";
+ $rtitle = Title::newFromText( $rname );
+ $rpage = WikiPage::factory( $rtitle );
+
+ // base edit for content
+ $page->doEditContent( new WikitextContent( "Foo" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $page, '20120101000000' );
+ $baseTime = $page->getRevision()->getTimestamp();
+
+ // base edit for redirect
+ $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $rpage, '20120101000000' );
+
+ // conflicting edit to redirect
+ $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]\n\n[[Category:Test]]" ),
+ "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() );
+ $this->forceRevisionDate( $rpage, '20120101020202' );
+
+ // try to save edit, following the redirect but without creating a section
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $rname,
+ 'text' => 'nix bar!',
+ 'basetimestamp' => $baseTime,
+ 'redirect' => true,
+ ] );
+
+ $this->fail( 'redirect-appendonly error expected' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( self::apiExceptionHasCode( $ex, 'redirect-appendonly' ) );
+ }
+ }
+
+ public function testEditConflict() {
+ static $count = 0;
+ $count++;
+
+ // assume NS_HELP defaults to wikitext
+ $name = "Help:ApiEditPageTest_testEditConflict_$count";
+ $title = Title::newFromText( $name );
+
+ $page = WikiPage::factory( $title );
+
+ // base edit
+ $page->doEditContent( new WikitextContent( "Foo" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $page, '20120101000000' );
+ $baseTime = $page->getRevision()->getTimestamp();
+
+ // conflicting edit
+ $page->doEditContent( new WikitextContent( "Foo bar" ),
+ "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() );
+ $this->forceRevisionDate( $page, '20120101020202' );
+
+ // try to save edit, expect conflict
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'nix bar!',
+ 'basetimestamp' => $baseTime,
+ ] );
+
+ $this->fail( 'edit conflict expected' );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( self::apiExceptionHasCode( $ex, 'editconflict' ) );
+ }
+ }
+
+ /**
+ * Ensure that editing using section=new will prevent simple conflicts
+ */
+ public function testEditConflict_newSection() {
+ static $count = 0;
+ $count++;
+
+ // assume NS_HELP defaults to wikitext
+ $name = "Help:ApiEditPageTest_testEditConflict_newSection_$count";
+ $title = Title::newFromText( $name );
+
+ $page = WikiPage::factory( $title );
+
+ // base edit
+ $page->doEditContent( new WikitextContent( "Foo" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $page, '20120101000000' );
+ $baseTime = $page->getRevision()->getTimestamp();
+
+ // conflicting edit
+ $page->doEditContent( new WikitextContent( "Foo bar" ),
+ "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() );
+ $this->forceRevisionDate( $page, '20120101020202' );
+
+ // try to save edit, expect no conflict
+ list( $re, , ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'nix bar!',
+ 'basetimestamp' => $baseTime,
+ 'section' => 'new',
+ ] );
+
+ $this->assertSame( 'Success', $re['edit']['result'],
+ "no edit conflict expected here" );
+ }
+
+ public function testEditConflict_bug41990() {
+ static $count = 0;
+ $count++;
+
+ /*
+ * T43990: if the target page has a newer revision than the redirect, then editing the
+ * redirect while specifying 'redirect' and *not* specifying 'basetimestamp' erroneously
+ * caused an edit conflict to be detected.
+ */
+
+ // assume NS_HELP defaults to wikitext
+ $name = "Help:ApiEditPageTest_testEditConflict_redirect_bug41990_$count";
+ $title = Title::newFromText( $name );
+ $page = WikiPage::factory( $title );
+
+ $rname = "Help:ApiEditPageTest_testEditConflict_redirect_bug41990_r$count";
+ $rtitle = Title::newFromText( $rname );
+ $rpage = WikiPage::factory( $rtitle );
+
+ // base edit for content
+ $page->doEditContent( new WikitextContent( "Foo" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $page, '20120101000000' );
+
+ // base edit for redirect
+ $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->getUser() );
+ $this->forceRevisionDate( $rpage, '20120101000000' );
+
+ // new edit to content
+ $page->doEditContent( new WikitextContent( "Foo bar" ),
+ "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->getUser() );
+ $this->forceRevisionDate( $rpage, '20120101020202' );
+
+ // try to save edit; should work, following the redirect.
+ list( $re, , ) = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $rname,
+ 'text' => 'nix bar!',
+ 'section' => 'new',
+ 'redirect' => true,
+ ] );
+
+ $this->assertSame( 'Success', $re['edit']['result'],
+ "no edit conflict expected here" );
+ }
+
+ /**
+ * @param WikiPage $page
+ * @param string|int $timestamp
+ */
+ protected function forceRevisionDate( WikiPage $page, $timestamp ) {
+ $dbw = wfGetDB( DB_MASTER );
+
+ $dbw->update( 'revision',
+ [ 'rev_timestamp' => $dbw->timestamp( $timestamp ) ],
+ [ 'rev_id' => $page->getLatest() ] );
+
+ $page->clear();
+ }
+
+ public function testCheckDirectApiEditingDisallowed_forNonTextContent() {
+ $this->setExpectedException(
+ ApiUsageException::class,
+ 'Direct editing via API is not supported for content model ' .
+ 'testing used by Dummy:ApiEditPageTest_nonTextPageEdit'
+ );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => 'Dummy:ApiEditPageTest_nonTextPageEdit',
+ 'text' => '{"animals":["kittens!"]}'
+ ] );
+ }
+
+ public function testSupportsDirectApiEditing_withContentHandlerOverride() {
+ $name = 'DummyNonText:ApiEditPageTest_testNonTextEdit';
+ $data = serialize( 'some bla bla text' );
+
+ $result = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => $data,
+ ] );
+
+ $apiResult = $result[0];
+
+ // Validate API result data
+ $this->assertArrayHasKey( 'edit', $apiResult );
+ $this->assertArrayHasKey( 'result', $apiResult['edit'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
+
+ $this->assertArrayHasKey( 'new', $apiResult['edit'] );
+ $this->assertArrayNotHasKey( 'nochange', $apiResult['edit'] );
+
+ $this->assertArrayHasKey( 'pageid', $apiResult['edit'] );
+
+ // validate resulting revision
+ $page = WikiPage::factory( Title::newFromText( $name ) );
+ $this->assertSame( "testing-nontext", $page->getContentModel() );
+ $this->assertSame( $data, $page->getContent()->serialize() );
+ }
+
+ /**
+ * This test verifies that after changing the content model
+ * of a page, undoing that edit via the API will also
+ * undo the content model change.
+ */
+ public function testUndoAfterContentModelChange() {
+ $name = 'Help:' . __FUNCTION__;
+ $uploader = self::$users['uploader']->getUser();
+ $sysop = self::$users['sysop']->getUser();
+
+ $apiResult = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ ], null, $sysop )[0];
+
+ // Check success
+ $this->assertArrayHasKey( 'edit', $apiResult );
+ $this->assertArrayHasKey( 'result', $apiResult['edit'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
+ $this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] );
+ // Content model is wikitext
+ $this->assertSame( 'wikitext', $apiResult['edit']['contentmodel'] );
+
+ // Convert the page to JSON
+ $apiResult = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => '{}',
+ 'contentmodel' => 'json',
+ ], null, $uploader )[0];
+
+ // Check success
+ $this->assertArrayHasKey( 'edit', $apiResult );
+ $this->assertArrayHasKey( 'result', $apiResult['edit'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
+ $this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] );
+ $this->assertSame( 'json', $apiResult['edit']['contentmodel'] );
+
+ $apiResult = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $apiResult['edit']['newrevid']
+ ], null, $sysop )[0];
+
+ // Check success
+ $this->assertArrayHasKey( 'edit', $apiResult );
+ $this->assertArrayHasKey( 'result', $apiResult['edit'] );
+ $this->assertSame( 'Success', $apiResult['edit']['result'] );
+ $this->assertArrayHasKey( 'contentmodel', $apiResult['edit'] );
+ // Check that the contentmodel is back to wikitext now.
+ $this->assertSame( 'wikitext', $apiResult['edit']['contentmodel'] );
+ }
+
+ // The tests below are mostly not commented because they do exactly what
+ // you'd expect from the name.
+
+ public function testCorrectContentFormat() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ 'contentmodel' => 'wikitext',
+ 'contentformat' => 'text/x-wiki',
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+
+ public function testUnsupportedContentFormat() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'Unrecognized value for parameter "contentformat": nonexistent format.' );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ 'contentformat' => 'nonexistent format',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testMismatchedContentFormat() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The requested format text/plain is not supported for content ' .
+ "model wikitext used by $name." );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ 'contentmodel' => 'wikitext',
+ 'contentformat' => 'text/plain',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testUndoToInvalidRev() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $revId = $this->editPage( $name, 'Some text' )->value['revision']
+ ->getId();
+ $revId++;
+
+ $this->setExpectedException( ApiUsageException::class,
+ "There is no revision with ID $revId." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId,
+ ] );
+ }
+
+ /**
+ * Tests what happens if the undo parameter is a valid revision, but
+ * the undoafter parameter doesn't refer to a revision that exists in the
+ * database.
+ */
+ public function testUndoAfterToInvalidRev() {
+ // We can't just pick a large number for undoafter (as in
+ // testUndoToInvalidRev above), because then MediaWiki will helpfully
+ // assume we switched around undo and undoafter and we'll test the code
+ // path for undo being invalid, not undoafter. So instead we delete
+ // the revision from the database. In real life this case could come
+ // up if a revision number was skipped, e.g., if two transactions try
+ // to insert new revision rows at once and the first one to succeed
+ // gets rolled back.
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $titleObj = Title::newFromText( $name );
+
+ $revId1 = $this->editPage( $name, '1' )->value['revision']->getId();
+ $revId2 = $this->editPage( $name, '2' )->value['revision']->getId();
+ $revId3 = $this->editPage( $name, '3' )->value['revision']->getId();
+
+ // Make the middle revision disappear
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->delete( 'revision', [ 'rev_id' => $revId2 ], __METHOD__ );
+ $dbw->update( 'revision', [ 'rev_parent_id' => $revId1 ],
+ [ 'rev_id' => $revId3 ], __METHOD__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "There is no revision with ID $revId2." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId3,
+ 'undoafter' => $revId2,
+ ] );
+ }
+
+ /**
+ * Tests what happens if the undo parameter is a valid revision, but
+ * undoafter is hidden (rev_deleted).
+ */
+ public function testUndoAfterToHiddenRev() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $titleObj = Title::newFromText( $name );
+
+ $this->editPage( $name, '0' );
+
+ $revId1 = $this->editPage( $name, '1' )->value['revision']->getId();
+
+ $revId2 = $this->editPage( $name, '2' )->value['revision']->getId();
+
+ // Hide the middle revision
+ $list = RevisionDeleter::createList( 'revision',
+ RequestContext::getMain(), $titleObj, [ $revId1 ] );
+ $list->setVisibility( [
+ 'value' => [ Revision::DELETED_TEXT => 1 ],
+ 'comment' => 'Bye-bye',
+ ] );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "There is no revision with ID $revId1." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId2,
+ 'undoafter' => $revId1,
+ ] );
+ }
+
+ /**
+ * Test undo when a revision with a higher id has an earlier timestamp.
+ * This can happen if importing an old revision.
+ */
+ public function testUndoWithSwappedRevisions() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $titleObj = Title::newFromText( $name );
+
+ $this->editPage( $name, '0' );
+
+ $revId2 = $this->editPage( $name, '2' )->value['revision']->getId();
+
+ $revId1 = $this->editPage( $name, '1' )->value['revision']->getId();
+
+ // Now monkey with the timestamp
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->update(
+ 'revision',
+ [ 'rev_timestamp' => wfTimestamp( TS_MW, time() - 86400 ) ],
+ [ 'rev_id' => $revId1 ],
+ __METHOD__
+ );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId2,
+ 'undoafter' => $revId1,
+ ] );
+
+ $text = ( new WikiPage( $titleObj ) )->getContent()->getNativeData();
+
+ // This is wrong! It should be 1. But let's test for our incorrect
+ // behavior for now, so if someone fixes it they'll fix the test as
+ // well to expect 1. If we disabled the test, it might stay disabled
+ // even once the bug is fixed, which would be a shame.
+ $this->assertSame( '2', $text );
+ }
+
+ public function testUndoWithConflicts() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The edit could not be undone due to conflicting intermediate edits.' );
+
+ $this->editPage( $name, '1' );
+
+ $revId = $this->editPage( $name, '2' )->value['revision']->getId();
+
+ $this->editPage( $name, '3' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId,
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )->getContent()
+ ->getNativeData();
+ $this->assertSame( '3', $text );
+ }
+
+ /**
+ * undoafter is supposed to be less than undo. If not, we reverse their
+ * meaning, so that the two are effectively interchangeable.
+ */
+ public function testReversedUndoAfter() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, '0' );
+ $revId1 = $this->editPage( $name, '1' )->value['revision']->getId();
+ $revId2 = $this->editPage( $name, '2' )->value['revision']->getId();
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'undo' => $revId1,
+ 'undoafter' => $revId2,
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )->getContent()
+ ->getNativeData();
+ $this->assertSame( '1', $text );
+ }
+
+ public function testUndoToRevFromDifferentPage() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( "$name-1", 'Some text' );
+ $revId = $this->editPage( "$name-1", 'Some more text' )
+ ->value['revision']->getId();
+
+ $this->editPage( "$name-2", 'Some text' );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "r$revId is not a revision of $name-2." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => "$name-2",
+ 'undo' => $revId,
+ ] );
+ }
+
+ public function testUndoAfterToRevFromDifferentPage() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $revId1 = $this->editPage( "$name-1", 'Some text' )
+ ->value['revision']->getId();
+
+ $revId2 = $this->editPage( "$name-2", 'Some text' )
+ ->value['revision']->getId();
+
+ $this->setExpectedException( ApiUsageException::class,
+ "r$revId1 is not a revision of $name-2." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => "$name-2",
+ 'undo' => $revId2,
+ 'undoafter' => $revId1,
+ ] );
+ }
+
+ public function testMd5Text() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'md5' => md5( 'Some text' ),
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+
+ public function testMd5PrependText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'prependtext' => 'Alert: ',
+ 'md5' => md5( 'Alert: ' ),
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+ $this->assertSame( 'Alert: Some text', $text );
+ }
+
+ public function testMd5AppendText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => ' is nice',
+ 'md5' => md5( ' is nice' ),
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+ $this->assertSame( 'Some text is nice', $text );
+ }
+
+ public function testMd5PrependAndAppendText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'prependtext' => 'Alert: ',
+ 'appendtext' => ' is nice',
+ 'md5' => md5( 'Alert: is nice' ),
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+ $this->assertSame( 'Alert: Some text is nice', $text );
+ }
+
+ public function testIncorrectMd5Text() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The supplied MD5 hash was incorrect.' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'md5' => md5( '' ),
+ ] );
+ }
+
+ public function testIncorrectMd5PrependText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The supplied MD5 hash was incorrect.' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'prependtext' => 'Some ',
+ 'appendtext' => 'text',
+ 'md5' => md5( 'Some ' ),
+ ] );
+ }
+
+ public function testIncorrectMd5AppendText() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The supplied MD5 hash was incorrect.' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'prependtext' => 'Some ',
+ 'appendtext' => 'text',
+ 'md5' => md5( 'text' ),
+ ] );
+ }
+
+ public function testCreateOnly() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The article you tried to create has been created already.' );
+
+ $this->editPage( $name, 'Some text' );
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some more text',
+ 'createonly' => '',
+ ] );
+ } finally {
+ // Validate that content was not changed
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( 'Some text', $text );
+ }
+ }
+
+ public function testNoCreate() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "The page you specified doesn't exist." );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'nocreate' => '',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ /**
+ * Appending/prepending is currently only supported for TextContent. We
+ * test this right now, and when support is added this test should be
+ * replaced by tests that the support is correct.
+ */
+ public function testAppendWithNonTextContentHandler() {
+ $name = 'MediaWiki:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "Can't append to pages using content model testing-nontext." );
+
+ $this->setTemporaryHook( 'ContentHandlerDefaultModelFor',
+ function ( Title $title, &$model ) use ( $name ) {
+ if ( $title->getPrefixedText() === $name ) {
+ $model = 'testing-nontext';
+ }
+ return true;
+ }
+ );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => 'Some text',
+ ] );
+ }
+
+ public function testAppendInMediaWikiNamespace() {
+ $name = 'MediaWiki:' . ucfirst( __FUNCTION__ );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => 'Some text',
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+
+ public function testAppendInMediaWikiNamespaceWithSerializationError() {
+ $name = 'MediaWiki:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'Content serialization failed: Could not unserialize content' );
+
+ $this->setTemporaryHook( 'ContentHandlerDefaultModelFor',
+ function ( Title $title, &$model ) use ( $name ) {
+ if ( $title->getPrefixedText() === $name ) {
+ $model = 'testing-serialize-error';
+ }
+ return true;
+ }
+ );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => 'Some text',
+ ] );
+ }
+
+ public function testAppendNewSection() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => '== New section ==',
+ 'section' => 'new',
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( "Initial content\n\n== New section ==", $text );
+ }
+
+ public function testAppendNewSectionWithInvalidContentModel() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'Sections are not supported for content model text.' );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => '== New section ==',
+ 'section' => 'new',
+ 'contentmodel' => 'text',
+ ] );
+ }
+
+ public function testAppendNewSectionWithTitle() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'sectiontitle' => 'My section',
+ 'appendtext' => 'More content',
+ 'section' => 'new',
+ ] );
+
+ $page = new WikiPage( Title::newFromText( $name ) );
+
+ $this->assertSame( "Initial content\n\n== My section ==\n\nMore content",
+ $page->getContent()->getNativeData() );
+ $this->assertSame( '/* My section */ new section',
+ $page->getRevision()->getComment() );
+ }
+
+ public function testAppendNewSectionWithSummary() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => 'More content',
+ 'section' => 'new',
+ 'summary' => 'Add new section',
+ ] );
+
+ $page = new WikiPage( Title::newFromText( $name ) );
+
+ $this->assertSame( "Initial content\n\n== Add new section ==\n\nMore content",
+ $page->getContent()->getNativeData() );
+ // EditPage actually assumes the summary is the section name here
+ $this->assertSame( '/* Add new section */ new section',
+ $page->getRevision()->getComment() );
+ }
+
+ public function testAppendNewSectionWithTitleAndSummary() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Initial content' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'sectiontitle' => 'My section',
+ 'appendtext' => 'More content',
+ 'section' => 'new',
+ 'summary' => 'Add new section',
+ ] );
+
+ $page = new WikiPage( Title::newFromText( $name ) );
+
+ $this->assertSame( "Initial content\n\n== My section ==\n\nMore content",
+ $page->getContent()->getNativeData() );
+ $this->assertSame( 'Add new section',
+ $page->getRevision()->getComment() );
+ }
+
+ public function testAppendToSection() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "== Section 1 ==\n\nContent\n\n" .
+ "== Section 2 ==\n\nFascinating!" );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => ' and more content',
+ 'section' => '1',
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( "== Section 1 ==\n\nContent and more content\n\n" .
+ "== Section 2 ==\n\nFascinating!", $text );
+ }
+
+ public function testAppendToFirstSection() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "Content\n\n== Section 1 ==\n\nFascinating!" );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => ' and more content',
+ 'section' => '0',
+ ] );
+
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( "Content and more content\n\n== Section 1 ==\n\n" .
+ "Fascinating!", $text );
+ }
+
+ public function testAppendToNonexistentSection() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class, 'There is no section 1.' );
+
+ $this->editPage( $name, 'Content' );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'appendtext' => ' and more content',
+ 'section' => '1',
+ ] );
+ } finally {
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( 'Content', $text );
+ }
+ }
+
+ public function testEditMalformedSection() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The "section" parameter must be a valid section ID or "new".' );
+ $this->editPage( $name, 'Content' );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Different content',
+ 'section' => 'It is unlikely that this is valid',
+ ] );
+ } finally {
+ $text = ( new WikiPage( Title::newFromText( $name ) ) )
+ ->getContent()->getNativeData();
+
+ $this->assertSame( 'Content', $text );
+ }
+ }
+
+ public function testEditWithStartTimestamp() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $this->setExpectedException( ApiUsageException::class,
+ 'The page has been deleted since you fetched its timestamp.' );
+
+ $startTime = MWTimestamp::convert( TS_MW, time() - 1 );
+
+ $this->editPage( $name, 'Some text' );
+
+ $pageObj = new WikiPage( Title::newFromText( $name ) );
+ $pageObj->doDeleteArticle( 'Bye-bye' );
+
+ $this->assertFalse( $pageObj->exists() );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Different text',
+ 'starttimestamp' => $startTime,
+ ] );
+ } finally {
+ $this->assertFalse( $pageObj->exists() );
+ }
+ }
+
+ public function testEditMinor() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, 'Some text' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Different text',
+ 'minor' => '',
+ ] );
+
+ $revisionStore = \MediaWiki\MediaWikiServices::getInstance()->getRevisionStore();
+ $revision = $revisionStore->getRevisionByTitle( Title::newFromText( $name ) );
+ $this->assertTrue( $revision->isMinor() );
+ }
+
+ public function testEditRecreate() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $startTime = MWTimestamp::convert( TS_MW, time() - 1 );
+
+ $this->editPage( $name, 'Some text' );
+
+ $pageObj = new WikiPage( Title::newFromText( $name ) );
+ $pageObj->doDeleteArticle( 'Bye-bye' );
+
+ $this->assertFalse( $pageObj->exists() );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Different text',
+ 'starttimestamp' => $startTime,
+ 'recreate' => '',
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ }
+
+ public function testEditWatch() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $user = self::$users['sysop']->getUser();
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'watch' => '',
+ ] );
+
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ $this->assertTrue( $user->isWatched( Title::newFromText( $name ) ) );
+ }
+
+ public function testEditUnwatch() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+ $user = self::$users['sysop']->getUser();
+ $titleObj = Title::newFromText( $name );
+
+ $user->addWatch( $titleObj );
+
+ $this->assertFalse( $titleObj->exists() );
+ $this->assertTrue( $user->isWatched( $titleObj ) );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'unwatch' => '',
+ ] );
+
+ $this->assertTrue( $titleObj->exists() );
+ $this->assertFalse( $user->isWatched( $titleObj ) );
+ }
+
+ public function testEditWithTag() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ ChangeTags::defineTag( 'custom tag' );
+
+ $revId = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'tags' => 'custom tag',
+ ] )[0]['edit']['newrevid'];
+
+ $dbw = wfGetDB( DB_MASTER );
+ $this->assertSame( 'custom tag', $dbw->selectField(
+ 'change_tag', 'ct_tag', [ 'ct_rev_id' => $revId ], __METHOD__ ) );
+ }
+
+ public function testEditWithoutTagPermission() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'You do not have permission to apply change tags along with your changes.' );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+
+ ChangeTags::defineTag( 'custom tag' );
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'user' => [ 'applychangetags' => true ] ] );
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'tags' => 'custom tag',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testEditAbortedByHook() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The modification you tried to make was aborted by an extension.' );
+
+ $this->hideDeprecated( 'APIEditBeforeSave hook (used in ' .
+ 'hook-APIEditBeforeSave-closure)' );
+
+ $this->setTemporaryHook( 'APIEditBeforeSave',
+ function () {
+ return false;
+ }
+ );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testEditAbortedByHookWithCustomOutput() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->hideDeprecated( 'APIEditBeforeSave hook (used in ' .
+ 'hook-APIEditBeforeSave-closure)' );
+
+ $this->setTemporaryHook( 'APIEditBeforeSave',
+ function ( $unused1, $unused2, &$r ) {
+ $r['msg'] = 'Some message';
+ return false;
+ } );
+
+ $result = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ Wikimedia\restoreWarnings();
+
+ $this->assertSame( [ 'msg' => 'Some message', 'result' => 'Failure' ],
+ $result[0]['edit'] );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+
+ public function testEditAbortedByEditPageHookWithResult() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setTemporaryHook( 'EditFilterMergedContent',
+ function ( $unused1, $unused2, Status $status ) {
+ $status->apiHookResult = [ 'msg' => 'A message for you!' ];
+ return false;
+ } );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ $this->assertSame( [ 'edit' => [ 'msg' => 'A message for you!',
+ 'result' => 'Failure' ] ], $res[0] );
+ }
+
+ public function testEditAbortedByEditPageHookWithNoResult() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The modification you tried to make was aborted by an extension.' );
+
+ $this->setTemporaryHook( 'EditFilterMergedContent',
+ function () {
+ return false;
+ }
+ );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ } finally {
+ $this->assertFalse( Title::newFromText( $name )->exists() );
+ }
+ }
+
+ public function testEditWhileBlocked() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'You have been blocked from editing.' );
+
+ $block = new Block( [
+ 'address' => self::$users['sysop']->getUser()->getName(),
+ 'by' => self::$users['sysop']->getUser()->getId(),
+ 'reason' => 'Capriciousness',
+ 'timestamp' => '19370101000000',
+ 'expiry' => 'infinity',
+ ] );
+ $block->insert();
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ } finally {
+ $block->delete();
+ self::$users['sysop']->getUser()->clearInstanceCache();
+ }
+ }
+
+ public function testEditWhileReadOnly() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The wiki is currently in read-only mode.' );
+
+ $svc = \MediaWiki\MediaWikiServices::getInstance()->getReadOnlyMode();
+ $svc->setReason( "Read-only for testing" );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ] );
+ } finally {
+ $svc->setReason( false );
+ }
+ }
+
+ public function testCreateImageRedirectAnon() {
+ $name = 'File:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "Anonymous users can't create image redirects." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => '#REDIRECT [[File:Other file.png]]',
+ ], null, new User() );
+ }
+
+ public function testCreateImageRedirectLoggedIn() {
+ $name = 'File:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "You don't have permission to create image redirects." );
+
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'user' => [ 'upload' => true ] ] );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => '#REDIRECT [[File:Other file.png]]',
+ ] );
+ }
+
+ public function testTooBigEdit() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The content you supplied exceeds the article size limit of 1 kilobyte.' );
+
+ $this->setMwGlobals( 'wgMaxArticleSize', 1 );
+
+ $text = str_repeat( '!', 1025 );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => $text,
+ ] );
+ }
+
+ public function testProhibitedAnonymousEdit() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ 'The action you have requested is limited to users in the group: ' );
+
+ $this->setMwGlobals( 'wgRevokePermissions', [ '*' => [ 'edit' => true ] ] );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ ], null, new User() );
+ }
+
+ public function testProhibitedChangeContentModel() {
+ $name = 'Help:' . ucfirst( __FUNCTION__ );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "You don't have permission to change the content model of a page." );
+
+ $this->setMwGlobals( 'wgRevokePermissions',
+ [ 'user' => [ 'editcontentmodel' => true ] ] );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'Some text',
+ 'contentmodel' => 'json',
+ ] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiErrorFormatterTest.php b/www/wiki/tests/phpunit/includes/api/ApiErrorFormatterTest.php
new file mode 100644
index 00000000..aa579ab0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiErrorFormatterTest.php
@@ -0,0 +1,642 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group API
+ */
+class ApiErrorFormatterTest extends MediaWikiLangTestCase {
+
+ /**
+ * @covers ApiErrorFormatter
+ */
+ public function testErrorFormatterBasics() {
+ $result = new ApiResult( 8388608 );
+ $formatter = new ApiErrorFormatter( $result, Language::factory( 'de' ), 'wikitext', false );
+ $this->assertSame( 'de', $formatter->getLanguage()->getCode() );
+
+ $formatter->addMessagesFromStatus( null, Status::newGood() );
+ $this->assertSame(
+ [ ApiResult::META_TYPE => 'assoc' ],
+ $result->getResultData()
+ );
+
+ $this->assertSame( [], $formatter->arrayFromStatus( Status::newGood() ) );
+
+ $wrappedFormatter = TestingAccessWrapper::newFromObject( $formatter );
+ $this->assertSame(
+ 'Blah "kbd" <X> 😊',
+ $wrappedFormatter->stripMarkup( 'Blah <kbd>kbd</kbd> <b>&lt;X&gt;</b> &#x1f60a;' ),
+ 'stripMarkup'
+ );
+ }
+
+ /**
+ * @covers ApiErrorFormatter
+ * @dataProvider provideErrorFormatter
+ */
+ public function testErrorFormatter( $format, $lang, $useDB,
+ $expect1, $expect2, $expect3
+ ) {
+ $result = new ApiResult( 8388608 );
+ $formatter = new ApiErrorFormatter( $result, Language::factory( $lang ), $format, $useDB );
+
+ // Add default type
+ $expect1[ApiResult::META_TYPE] = 'assoc';
+ $expect2[ApiResult::META_TYPE] = 'assoc';
+ $expect3[ApiResult::META_TYPE] = 'assoc';
+
+ $formatter->addWarning( 'string', 'mainpage' );
+ $formatter->addError( 'err', 'mainpage' );
+ $this->assertEquals( $expect1, $result->getResultData(), 'Simple test' );
+
+ $result->reset();
+ $formatter->addWarning( 'foo', 'mainpage' );
+ $formatter->addWarning( 'foo', 'mainpage' );
+ $formatter->addWarning( 'foo', [ 'parentheses', 'foobar' ] );
+ $msg1 = wfMessage( 'mainpage' );
+ $formatter->addWarning( 'message', $msg1 );
+ $msg2 = new ApiMessage( 'mainpage', 'overriddenCode', [ 'overriddenData' => true ] );
+ $formatter->addWarning( 'messageWithData', $msg2 );
+ $formatter->addError( 'errWithData', $msg2 );
+ $this->assertSame( $expect2, $result->getResultData(), 'Complex test' );
+
+ $this->assertEquals(
+ $this->removeModuleTag( $expect2['warnings'][2] ),
+ $formatter->formatMessage( $msg1 ),
+ 'formatMessage test 1'
+ );
+ $this->assertEquals(
+ $this->removeModuleTag( $expect2['warnings'][3] ),
+ $formatter->formatMessage( $msg2 ),
+ 'formatMessage test 2'
+ );
+
+ $result->reset();
+ $status = Status::newGood();
+ $status->warning( 'mainpage' );
+ $status->warning( 'parentheses', 'foobar' );
+ $status->warning( $msg1 );
+ $status->warning( $msg2 );
+ $status->error( 'mainpage' );
+ $status->error( 'parentheses', 'foobar' );
+ $formatter->addMessagesFromStatus( 'status', $status );
+ $this->assertSame( $expect3, $result->getResultData(), 'Status test' );
+
+ $this->assertSame(
+ array_map( [ $this, 'removeModuleTag' ], $expect3['errors'] ),
+ $formatter->arrayFromStatus( $status, 'error' ),
+ 'arrayFromStatus test for error'
+ );
+ $this->assertSame(
+ array_map( [ $this, 'removeModuleTag' ], $expect3['warnings'] ),
+ $formatter->arrayFromStatus( $status, 'warning' ),
+ 'arrayFromStatus test for warning'
+ );
+ }
+
+ private function removeModuleTag( $s ) {
+ if ( is_array( $s ) ) {
+ unset( $s['module'] );
+ }
+ return $s;
+ }
+
+ public static function provideErrorFormatter() {
+ $mainpageText = wfMessage( 'mainpage' )->inLanguage( 'de' )->useDatabase( false )->text();
+ $parensText = wfMessage( 'parentheses', 'foobar' )->inLanguage( 'de' )
+ ->useDatabase( false )->text();
+ $mainpageHTML = wfMessage( 'mainpage' )->inLanguage( 'en' )->parse();
+ $parensHTML = wfMessage( 'parentheses', 'foobar' )->inLanguage( 'en' )->parse();
+ $C = ApiResult::META_CONTENT;
+ $I = ApiResult::META_INDEXED_TAG_NAME;
+ $overriddenData = [ 'overriddenData' => true, ApiResult::META_TYPE => 'assoc' ];
+
+ return [
+ $tmp = [ 'wikitext', 'de', false,
+ [
+ 'errors' => [
+ [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'err', $C => 'text' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'string', $C => 'text' ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [ 'code' => 'overriddenCode', 'text' => $mainpageText,
+ 'data' => $overriddenData, 'module' => 'errWithData', $C => 'text' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'foo', $C => 'text' ],
+ [ 'code' => 'parentheses', 'text' => $parensText, 'module' => 'foo', $C => 'text' ],
+ [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'message', $C => 'text' ],
+ [ 'code' => 'overriddenCode', 'text' => $mainpageText,
+ 'data' => $overriddenData, 'module' => 'messageWithData', $C => 'text' ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'status', $C => 'text' ],
+ [ 'code' => 'parentheses', 'text' => $parensText, 'module' => 'status', $C => 'text' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'text' => $mainpageText, 'module' => 'status', $C => 'text' ],
+ [ 'code' => 'parentheses', 'text' => $parensText, 'module' => 'status', $C => 'text' ],
+ [ 'code' => 'overriddenCode', 'text' => $mainpageText,
+ 'data' => $overriddenData, 'module' => 'status', $C => 'text' ],
+ $I => 'warning',
+ ],
+ ],
+ ],
+ [ 'plaintext' ] + $tmp, // For these messages, plaintext and wikitext are the same
+ [ 'html', 'en', true,
+ [
+ 'errors' => [
+ [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'err', $C => 'html' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'string', $C => 'html' ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [ 'code' => 'overriddenCode', 'html' => $mainpageHTML,
+ 'data' => $overriddenData, 'module' => 'errWithData', $C => 'html' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'foo', $C => 'html' ],
+ [ 'code' => 'parentheses', 'html' => $parensHTML, 'module' => 'foo', $C => 'html' ],
+ [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'message', $C => 'html' ],
+ [ 'code' => 'overriddenCode', 'html' => $mainpageHTML,
+ 'data' => $overriddenData, 'module' => 'messageWithData', $C => 'html' ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'status', $C => 'html' ],
+ [ 'code' => 'parentheses', 'html' => $parensHTML, 'module' => 'status', $C => 'html' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'html' => $mainpageHTML, 'module' => 'status', $C => 'html' ],
+ [ 'code' => 'parentheses', 'html' => $parensHTML, 'module' => 'status', $C => 'html' ],
+ [ 'code' => 'overriddenCode', 'html' => $mainpageHTML,
+ 'data' => $overriddenData, 'module' => 'status', $C => 'html' ],
+ $I => 'warning',
+ ],
+ ],
+ ],
+ [ 'raw', 'fr', true,
+ [
+ 'errors' => [
+ [
+ 'code' => 'mainpage',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'module' => 'err',
+ ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [
+ 'code' => 'mainpage',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'module' => 'string',
+ ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [
+ 'code' => 'overriddenCode',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'data' => $overriddenData,
+ 'module' => 'errWithData',
+ ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [
+ 'code' => 'mainpage',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'module' => 'foo',
+ ],
+ [
+ 'code' => 'parentheses',
+ 'key' => 'parentheses',
+ 'params' => [ 'foobar', $I => 'param' ],
+ 'module' => 'foo',
+ ],
+ [
+ 'code' => 'mainpage',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'module' => 'message',
+ ],
+ [
+ 'code' => 'overriddenCode',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'data' => $overriddenData,
+ 'module' => 'messageWithData',
+ ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [
+ 'code' => 'mainpage',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'module' => 'status',
+ ],
+ [
+ 'code' => 'parentheses',
+ 'key' => 'parentheses',
+ 'params' => [ 'foobar', $I => 'param' ],
+ 'module' => 'status',
+ ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [
+ 'code' => 'mainpage',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'module' => 'status',
+ ],
+ [
+ 'code' => 'parentheses',
+ 'key' => 'parentheses',
+ 'params' => [ 'foobar', $I => 'param' ],
+ 'module' => 'status',
+ ],
+ [
+ 'code' => 'overriddenCode',
+ 'key' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'data' => $overriddenData,
+ 'module' => 'status',
+ ],
+ $I => 'warning',
+ ],
+ ],
+ ],
+ [ 'none', 'fr', true,
+ [
+ 'errors' => [
+ [ 'code' => 'mainpage', 'module' => 'err' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'module' => 'string' ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [ 'code' => 'overriddenCode', 'data' => $overriddenData,
+ 'module' => 'errWithData' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'module' => 'foo' ],
+ [ 'code' => 'parentheses', 'module' => 'foo' ],
+ [ 'code' => 'mainpage', 'module' => 'message' ],
+ [ 'code' => 'overriddenCode', 'data' => $overriddenData,
+ 'module' => 'messageWithData' ],
+ $I => 'warning',
+ ],
+ ],
+ [
+ 'errors' => [
+ [ 'code' => 'mainpage', 'module' => 'status' ],
+ [ 'code' => 'parentheses', 'module' => 'status' ],
+ $I => 'error',
+ ],
+ 'warnings' => [
+ [ 'code' => 'mainpage', 'module' => 'status' ],
+ [ 'code' => 'parentheses', 'module' => 'status' ],
+ [ 'code' => 'overriddenCode', 'data' => $overriddenData, 'module' => 'status' ],
+ $I => 'warning',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @covers ApiErrorFormatter_BackCompat
+ */
+ public function testErrorFormatterBC() {
+ $mainpagePlain = wfMessage( 'mainpage' )->useDatabase( false )->plain();
+ $parensPlain = wfMessage( 'parentheses', 'foobar' )->useDatabase( false )->plain();
+
+ $result = new ApiResult( 8388608 );
+ $formatter = new ApiErrorFormatter_BackCompat( $result );
+
+ $this->assertSame( 'en', $formatter->getLanguage()->getCode() );
+
+ $this->assertSame( [], $formatter->arrayFromStatus( Status::newGood() ) );
+
+ $formatter->addWarning( 'string', 'mainpage' );
+ $formatter->addWarning( 'raw',
+ new RawMessage( 'Blah <kbd>kbd</kbd> <b>&lt;X&gt;</b> &#x1f61e;' )
+ );
+ $formatter->addError( 'err', 'mainpage' );
+ $this->assertSame( [
+ 'error' => [
+ 'code' => 'mainpage',
+ 'info' => $mainpagePlain,
+ ],
+ 'warnings' => [
+ 'raw' => [
+ 'warnings' => 'Blah "kbd" <X> 😞',
+ ApiResult::META_CONTENT => 'warnings',
+ ],
+ 'string' => [
+ 'warnings' => $mainpagePlain,
+ ApiResult::META_CONTENT => 'warnings',
+ ],
+ ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData(), 'Simple test' );
+
+ $result->reset();
+ $formatter->addWarning( 'foo', 'mainpage' );
+ $formatter->addWarning( 'foo', 'mainpage' );
+ $formatter->addWarning( 'xxx+foo', [ 'parentheses', 'foobar' ] );
+ $msg1 = wfMessage( 'mainpage' );
+ $formatter->addWarning( 'message', $msg1 );
+ $msg2 = new ApiMessage( 'mainpage', 'overriddenCode', [ 'overriddenData' => true ] );
+ $formatter->addWarning( 'messageWithData', $msg2 );
+ $formatter->addError( 'errWithData', $msg2 );
+ $formatter->addWarning( null, 'mainpage' );
+ $this->assertSame( [
+ 'error' => [
+ 'code' => 'overriddenCode',
+ 'info' => $mainpagePlain,
+ 'overriddenData' => true,
+ ],
+ 'warnings' => [
+ 'unknown' => [
+ 'warnings' => $mainpagePlain,
+ ApiResult::META_CONTENT => 'warnings',
+ ],
+ 'messageWithData' => [
+ 'warnings' => $mainpagePlain,
+ ApiResult::META_CONTENT => 'warnings',
+ ],
+ 'message' => [
+ 'warnings' => $mainpagePlain,
+ ApiResult::META_CONTENT => 'warnings',
+ ],
+ 'foo' => [
+ 'warnings' => "$mainpagePlain\n$parensPlain",
+ ApiResult::META_CONTENT => 'warnings',
+ ],
+ ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData(), 'Complex test' );
+
+ $this->assertSame(
+ [
+ 'code' => 'mainpage',
+ 'info' => 'Main Page',
+ ],
+ $formatter->formatMessage( $msg1 )
+ );
+ $this->assertSame(
+ [
+ 'code' => 'overriddenCode',
+ 'info' => 'Main Page',
+ 'overriddenData' => true,
+ ],
+ $formatter->formatMessage( $msg2 )
+ );
+
+ $result->reset();
+ $status = Status::newGood();
+ $status->warning( 'mainpage' );
+ $status->warning( 'parentheses', 'foobar' );
+ $status->warning( $msg1 );
+ $status->warning( $msg2 );
+ $status->error( 'mainpage' );
+ $status->error( 'parentheses', 'foobar' );
+ $formatter->addMessagesFromStatus( 'status', $status );
+ $this->assertSame( [
+ 'error' => [
+ 'code' => 'mainpage',
+ 'info' => $mainpagePlain,
+ ],
+ 'warnings' => [
+ 'status' => [
+ 'warnings' => "$mainpagePlain\n$parensPlain",
+ ApiResult::META_CONTENT => 'warnings',
+ ],
+ ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData(), 'Status test' );
+
+ $I = ApiResult::META_INDEXED_TAG_NAME;
+ $this->assertSame(
+ [
+ [
+ 'message' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'code' => 'mainpage',
+ 'type' => 'error',
+ ],
+ [
+ 'message' => 'parentheses',
+ 'params' => [ 'foobar', $I => 'param' ],
+ 'code' => 'parentheses',
+ 'type' => 'error',
+ ],
+ $I => 'error',
+ ],
+ $formatter->arrayFromStatus( $status, 'error' ),
+ 'arrayFromStatus test for error'
+ );
+ $this->assertSame(
+ [
+ [
+ 'message' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'code' => 'mainpage',
+ 'type' => 'warning',
+ ],
+ [
+ 'message' => 'parentheses',
+ 'params' => [ 'foobar', $I => 'param' ],
+ 'code' => 'parentheses',
+ 'type' => 'warning',
+ ],
+ [
+ 'message' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'code' => 'mainpage',
+ 'type' => 'warning',
+ ],
+ [
+ 'message' => 'mainpage',
+ 'params' => [ $I => 'param' ],
+ 'code' => 'overriddenCode',
+ 'type' => 'warning',
+ ],
+ $I => 'warning',
+ ],
+ $formatter->arrayFromStatus( $status, 'warning' ),
+ 'arrayFromStatus test for warning'
+ );
+
+ $result->reset();
+ $result->addValue( null, 'error', [ 'bogus' ] );
+ $formatter->addError( 'err', 'mainpage' );
+ $this->assertSame( [
+ 'error' => [
+ 'code' => 'mainpage',
+ 'info' => $mainpagePlain,
+ ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData(), 'Overwrites bogus "error" value with real error' );
+ }
+
+ /**
+ * @dataProvider provideGetMessageFromException
+ * @covers ApiErrorFormatter::getMessageFromException
+ * @covers ApiErrorFormatter::formatException
+ * @param Exception $exception
+ * @param array $options
+ * @param array $expect
+ */
+ public function testGetMessageFromException( $exception, $options, $expect ) {
+ if ( $exception instanceof UsageException ) {
+ $this->hideDeprecated( 'UsageException::getMessageArray' );
+ }
+
+ $result = new ApiResult( 8388608 );
+ $formatter = new ApiErrorFormatter( $result, Language::factory( 'en' ), 'html', false );
+
+ $msg = $formatter->getMessageFromException( $exception, $options );
+ $this->assertInstanceOf( Message::class, $msg );
+ $this->assertInstanceOf( IApiMessage::class, $msg );
+ $this->assertSame( $expect, [
+ 'text' => $msg->parse(),
+ 'code' => $msg->getApiCode(),
+ 'data' => $msg->getApiData(),
+ ] );
+
+ $expectFormatted = $formatter->formatMessage( $msg );
+ $formatted = $formatter->formatException( $exception, $options );
+ $this->assertSame( $expectFormatted, $formatted );
+ }
+
+ /**
+ * @dataProvider provideGetMessageFromException
+ * @covers ApiErrorFormatter_BackCompat::formatException
+ * @param Exception $exception
+ * @param array $options
+ * @param array $expect
+ */
+ public function testGetMessageFromException_BC( $exception, $options, $expect ) {
+ $result = new ApiResult( 8388608 );
+ $formatter = new ApiErrorFormatter_BackCompat( $result );
+
+ $msg = $formatter->getMessageFromException( $exception, $options );
+ $this->assertInstanceOf( Message::class, $msg );
+ $this->assertInstanceOf( IApiMessage::class, $msg );
+ $this->assertSame( $expect, [
+ 'text' => $msg->parse(),
+ 'code' => $msg->getApiCode(),
+ 'data' => $msg->getApiData(),
+ ] );
+
+ $expectFormatted = $formatter->formatMessage( $msg );
+ $formatted = $formatter->formatException( $exception, $options );
+ $this->assertSame( $expectFormatted, $formatted );
+ $formatted = $formatter->formatException( $exception, $options + [ 'bc' => true ] );
+ $this->assertSame( $expectFormatted['info'], $formatted );
+ }
+
+ public static function provideGetMessageFromException() {
+ Wikimedia\suppressWarnings();
+ $usageException = new UsageException(
+ '<b>Something broke!</b>', 'ue-code', 0, [ 'xxx' => 'yyy', 'baz' => 23 ]
+ );
+ Wikimedia\restoreWarnings();
+
+ return [
+ 'Normal exception' => [
+ new RuntimeException( '<b>Something broke!</b>' ),
+ [],
+ [
+ 'text' => '&#60;b&#62;Something broke!&#60;/b&#62;',
+ 'code' => 'internal_api_error_RuntimeException',
+ 'data' => [],
+ ]
+ ],
+ 'Normal exception, wrapped' => [
+ new RuntimeException( '<b>Something broke!</b>' ),
+ [ 'wrap' => 'parentheses', 'code' => 'some-code', 'data' => [ 'foo' => 'bar', 'baz' => 42 ] ],
+ [
+ 'text' => '(&#60;b&#62;Something broke!&#60;/b&#62;)',
+ 'code' => 'some-code',
+ 'data' => [ 'foo' => 'bar', 'baz' => 42 ],
+ ]
+ ],
+ 'UsageException' => [
+ $usageException,
+ [],
+ [
+ 'text' => '&#60;b&#62;Something broke!&#60;/b&#62;',
+ 'code' => 'ue-code',
+ 'data' => [ 'xxx' => 'yyy', 'baz' => 23 ],
+ ]
+ ],
+ 'UsageException, wrapped' => [
+ $usageException,
+ [ 'wrap' => 'parentheses', 'code' => 'some-code', 'data' => [ 'foo' => 'bar', 'baz' => 42 ] ],
+ [
+ 'text' => '(&#60;b&#62;Something broke!&#60;/b&#62;)',
+ 'code' => 'some-code',
+ 'data' => [ 'xxx' => 'yyy', 'baz' => 42, 'foo' => 'bar' ],
+ ]
+ ],
+ 'LocalizedException' => [
+ new LocalizedException( [ 'returnto', '<b>FooBar</b>' ] ),
+ [],
+ [
+ 'text' => 'Return to <b>FooBar</b>.',
+ 'code' => 'returnto',
+ 'data' => [],
+ ]
+ ],
+ 'LocalizedException, wrapped' => [
+ new LocalizedException( [ 'returnto', '<b>FooBar</b>' ] ),
+ [ 'wrap' => 'parentheses', 'code' => 'some-code', 'data' => [ 'foo' => 'bar', 'baz' => 42 ] ],
+ [
+ 'text' => 'Return to <b>FooBar</b>.',
+ 'code' => 'some-code',
+ 'data' => [ 'foo' => 'bar', 'baz' => 42 ],
+ ]
+ ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiLoginTest.php b/www/wiki/tests/phpunit/includes/api/ApiLoginTest.php
new file mode 100644
index 00000000..d382c83c
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiLoginTest.php
@@ -0,0 +1,301 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiLogin
+ */
+class ApiLoginTest extends ApiTestCase {
+
+ /**
+ * Test result of attempted login with an empty username
+ */
+ public function testApiLoginNoName() {
+ $session = [
+ 'wsTokenSecrets' => [ 'login' => 'foobar' ],
+ ];
+ $data = $this->doApiRequest( [ 'action' => 'login',
+ 'lgname' => '', 'lgpassword' => self::$users['sysop']->getPassword(),
+ 'lgtoken' => (string)( new MediaWiki\Session\Token( 'foobar', '' ) )
+ ], $session );
+ $this->assertEquals( 'Failed', $data[0]['login']['result'] );
+ }
+
+ public function testApiLoginBadPass() {
+ global $wgServer;
+
+ $user = self::$users['sysop'];
+ $userName = $user->getUser()->getName();
+ $user->getUser()->logout();
+
+ if ( !isset( $wgServer ) ) {
+ $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
+ }
+ $ret = $this->doApiRequest( [
+ "action" => "login",
+ "lgname" => $userName,
+ "lgpassword" => "bad",
+ ] );
+
+ $result = $ret[0];
+
+ $this->assertNotInternalType( "bool", $result );
+ $a = $result["login"]["result"];
+ $this->assertEquals( "NeedToken", $a );
+
+ $token = $result["login"]["token"];
+
+ $ret = $this->doApiRequest(
+ [
+ "action" => "login",
+ "lgtoken" => $token,
+ "lgname" => $userName,
+ "lgpassword" => "badnowayinhell",
+ ],
+ $ret[2]
+ );
+
+ $result = $ret[0];
+
+ $this->assertNotInternalType( "bool", $result );
+ $a = $result["login"]["result"];
+
+ $this->assertEquals( 'Failed', $a );
+ }
+
+ public function testApiLoginGoodPass() {
+ global $wgServer;
+
+ if ( !isset( $wgServer ) ) {
+ $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
+ }
+
+ $user = self::$users['sysop'];
+ $userName = $user->getUser()->getName();
+ $password = $user->getPassword();
+ $user->getUser()->logout();
+
+ $ret = $this->doApiRequest( [
+ "action" => "login",
+ "lgname" => $userName,
+ "lgpassword" => $password,
+ ]
+ );
+
+ $result = $ret[0];
+ $this->assertNotInternalType( "bool", $result );
+ $this->assertNotInternalType( "null", $result["login"] );
+
+ $a = $result["login"]["result"];
+ $this->assertEquals( "NeedToken", $a );
+ $token = $result["login"]["token"];
+
+ $ret = $this->doApiRequest(
+ [
+ "action" => "login",
+ "lgtoken" => $token,
+ "lgname" => $userName,
+ "lgpassword" => $password,
+ ],
+ $ret[2]
+ );
+
+ $result = $ret[0];
+
+ $this->assertNotInternalType( "bool", $result );
+ $a = $result["login"]["result"];
+
+ $this->assertEquals( "Success", $a );
+ }
+
+ /**
+ * @group Broken
+ */
+ public function testApiLoginGotCookie() {
+ $this->markTestIncomplete( "The server can't do external HTTP requests, "
+ . "and the internal one won't give cookies" );
+
+ global $wgServer, $wgScriptPath;
+
+ if ( !isset( $wgServer ) ) {
+ $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
+ }
+ $user = self::$users['sysop'];
+ $userName = $user->getUser()->getName();
+ $password = $user->getPassword();
+
+ $req = MWHttpRequest::factory( self::$apiUrl . "?action=login&format=xml",
+ [ "method" => "POST",
+ "postData" => [
+ "lgname" => $userName,
+ "lgpassword" => $password
+ ]
+ ],
+ __METHOD__
+ );
+ $req->execute();
+
+ libxml_use_internal_errors( true );
+ $sxe = simplexml_load_string( $req->getContent() );
+ $this->assertNotInternalType( "bool", $sxe );
+ $this->assertThat( $sxe, $this->isInstanceOf( SimpleXMLElement::class ) );
+ $this->assertNotInternalType( "null", $sxe->login[0] );
+
+ $a = $sxe->login[0]->attributes()->result[0];
+ $this->assertEquals( ' result="NeedToken"', $a->asXML() );
+ $token = (string)$sxe->login[0]->attributes()->token;
+
+ $req->setData( [
+ "lgtoken" => $token,
+ "lgname" => $userName,
+ "lgpassword" => $password ] );
+ $req->execute();
+
+ $cj = $req->getCookieJar();
+ $serverName = parse_url( $wgServer, PHP_URL_HOST );
+ $this->assertNotEquals( false, $serverName );
+ $serializedCookie = $cj->serializeToHttpRequest( $wgScriptPath, $serverName );
+ $this->assertNotEquals( '', $serializedCookie );
+ $this->assertRegExp(
+ '/_session=[^;]*; .*UserID=[0-9]*; .*UserName=' . $user->userName . '; .*Token=/',
+ $serializedCookie
+ );
+ }
+
+ public function testRunLogin() {
+ $user = self::$users['sysop'];
+ $userName = $user->getUser()->getName();
+ $password = $user->getPassword();
+
+ $data = $this->doApiRequest( [
+ 'action' => 'login',
+ 'lgname' => $userName,
+ 'lgpassword' => $password ] );
+
+ $this->assertArrayHasKey( "login", $data[0] );
+ $this->assertArrayHasKey( "result", $data[0]['login'] );
+ $this->assertEquals( "NeedToken", $data[0]['login']['result'] );
+ $token = $data[0]['login']['token'];
+
+ $data = $this->doApiRequest( [
+ 'action' => 'login',
+ "lgtoken" => $token,
+ "lgname" => $userName,
+ "lgpassword" => $password ], $data[2] );
+
+ $this->assertArrayHasKey( "login", $data[0] );
+ $this->assertArrayHasKey( "result", $data[0]['login'] );
+ $this->assertEquals( "Success", $data[0]['login']['result'] );
+ }
+
+ public function testBotPassword() {
+ global $wgServer, $wgSessionProviders;
+
+ if ( !isset( $wgServer ) ) {
+ $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
+ }
+
+ $this->setMwGlobals( [
+ 'wgSessionProviders' => array_merge( $wgSessionProviders, [
+ [
+ 'class' => MediaWiki\Session\BotPasswordSessionProvider::class,
+ 'args' => [ [ 'priority' => 40 ] ],
+ ]
+ ] ),
+ 'wgEnableBotPasswords' => true,
+ 'wgBotPasswordsDatabase' => false,
+ 'wgCentralIdLookupProvider' => 'local',
+ 'wgGrantPermissions' => [
+ 'test' => [ 'read' => true ],
+ ],
+ ] );
+
+ // Make sure our session provider is present
+ $manager = TestingAccessWrapper::newFromObject( MediaWiki\Session\SessionManager::singleton() );
+ if ( !isset( $manager->sessionProviders[MediaWiki\Session\BotPasswordSessionProvider::class] ) ) {
+ $tmp = $manager->sessionProviders;
+ $manager->sessionProviders = null;
+ $manager->sessionProviders = $tmp + $manager->getProviders();
+ }
+ $this->assertNotNull(
+ MediaWiki\Session\SessionManager::singleton()->getProvider(
+ MediaWiki\Session\BotPasswordSessionProvider::class
+ ),
+ 'sanity check'
+ );
+
+ $user = self::$users['sysop'];
+ $centralId = CentralIdLookup::factory()->centralIdFromLocalUser( $user->getUser() );
+ $this->assertNotEquals( 0, $centralId, 'sanity check' );
+
+ $password = 'ngfhmjm64hv0854493hsj5nncjud2clk';
+ $passwordFactory = new PasswordFactory();
+ $passwordFactory->init( RequestContext::getMain()->getConfig() );
+ // A is unsalted MD5 (thus fast) ... we don't care about security here, this is test only
+ $passwordHash = $passwordFactory->newFromPlaintext( $password );
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->insert(
+ 'bot_passwords',
+ [
+ 'bp_user' => $centralId,
+ 'bp_app_id' => 'foo',
+ 'bp_password' => $passwordHash->toString(),
+ 'bp_token' => '',
+ 'bp_restrictions' => MWRestrictions::newDefault()->toJson(),
+ 'bp_grants' => '["test"]',
+ ],
+ __METHOD__
+ );
+
+ $lgName = $user->getUser()->getName() . BotPassword::getSeparator() . 'foo';
+
+ $ret = $this->doApiRequest( [
+ 'action' => 'login',
+ 'lgname' => $lgName,
+ 'lgpassword' => $password,
+ ] );
+
+ $result = $ret[0];
+ $this->assertNotInternalType( 'bool', $result );
+ $this->assertNotInternalType( 'null', $result['login'] );
+
+ $a = $result['login']['result'];
+ $this->assertEquals( 'NeedToken', $a );
+ $token = $result['login']['token'];
+
+ $ret = $this->doApiRequest( [
+ 'action' => 'login',
+ 'lgtoken' => $token,
+ 'lgname' => $lgName,
+ 'lgpassword' => $password,
+ ], $ret[2] );
+
+ $result = $ret[0];
+ $this->assertNotInternalType( 'bool', $result );
+ $a = $result['login']['result'];
+
+ $this->assertEquals( 'Success', $a );
+ }
+
+ public function testLoginWithNoSameOriginSecurity() {
+ $this->setTemporaryHook( 'RequestHasSameOriginSecurity',
+ function () {
+ return false;
+ }
+ );
+
+ $result = $this->doApiRequest( [
+ 'action' => 'login',
+ ] )[0]['login'];
+
+ $this->assertSame( [
+ 'result' => 'Aborted',
+ 'reason' => 'Cannot log in when the same-origin policy is not applied.',
+ ], $result );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiLogoutTest.php b/www/wiki/tests/phpunit/includes/api/ApiLogoutTest.php
new file mode 100644
index 00000000..8254fdba
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiLogoutTest.php
@@ -0,0 +1,75 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiLogout
+ */
+class ApiLogoutTest extends ApiTestCase {
+
+ protected function setUp() {
+ global $wgRequest, $wgUser;
+
+ parent::setUp();
+
+ // Link the user to the Session properly so User::doLogout() doesn't complain.
+ $wgRequest->getSession()->setUser( $wgUser );
+ $wgUser = User::newFromSession( $wgRequest );
+ $this->apiContext->setUser( $wgUser );
+ }
+
+ public function testUserLogoutBadToken() {
+ global $wgUser;
+
+ $this->setExpectedApiException( 'apierror-badtoken' );
+
+ try {
+ $token = 'invalid token';
+ $this->doUserLogout( $token );
+ } finally {
+ $this->assertTrue( $wgUser->isLoggedIn(), 'not logged out' );
+ }
+ }
+
+ public function testUserLogout() {
+ global $wgUser;
+
+ $this->assertTrue( $wgUser->isLoggedIn(), 'sanity check' );
+ $token = $this->getUserCsrfTokenFromApi();
+ $this->doUserLogout( $token );
+ $this->assertFalse( $wgUser->isLoggedIn() );
+ }
+
+ public function testUserLogoutWithWebToken() {
+ global $wgUser, $wgRequest;
+
+ $this->assertTrue( $wgUser->isLoggedIn(), 'sanity check' );
+
+ // Logic copied from SkinTemplate.
+ $token = $wgUser->getEditToken( 'logoutToken', $wgRequest );
+
+ $this->doUserLogout( $token );
+ $this->assertFalse( $wgUser->isLoggedIn() );
+ }
+
+ private function getUserCsrfTokenFromApi() {
+ $retToken = $this->doApiRequest( [
+ 'action' => 'query',
+ 'meta' => 'tokens',
+ 'type' => 'csrf'
+ ] );
+
+ $this->assertArrayNotHasKey( 'warnings', $retToken );
+
+ return $retToken[0]['query']['tokens']['csrftoken'];
+ }
+
+ private function doUserLogout( $logoutToken ) {
+ return $this->doApiRequest( [
+ 'action' => 'logout',
+ 'token' => $logoutToken
+ ] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiMainTest.php b/www/wiki/tests/phpunit/includes/api/ApiMainTest.php
new file mode 100644
index 00000000..d17334bb
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiMainTest.php
@@ -0,0 +1,1072 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiMain
+ */
+class ApiMainTest extends ApiTestCase {
+
+ /**
+ * Test that the API will accept a FauxRequest and execute.
+ */
+ public function testApi() {
+ $api = new ApiMain(
+ new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] )
+ );
+ $api->execute();
+ $data = $api->getResult()->getResultData();
+ $this->assertInternalType( 'array', $data );
+ $this->assertArrayHasKey( 'query', $data );
+ }
+
+ public function testApiNoParam() {
+ $api = new ApiMain();
+ $api->execute();
+ $data = $api->getResult()->getResultData();
+ $this->assertInternalType( 'array', $data );
+ }
+
+ /**
+ * ApiMain behaves differently if passed a FauxRequest (mInternalMode set
+ * to true) or a proper WebRequest (mInternalMode false). For most tests
+ * we can just set mInternalMode to false using TestingAccessWrapper, but
+ * this doesn't work for the constructor. This method returns an ApiMain
+ * that's been set up in non-internal mode.
+ *
+ * Note that calling execute() will print to the console. Wrap it in
+ * ob_start()/ob_end_clean() to prevent this.
+ *
+ * @param array $requestData Query parameters for the WebRequest
+ * @param array $headers Headers for the WebRequest
+ */
+ private function getNonInternalApiMain( array $requestData, array $headers = [] ) {
+ $req = $this->getMockBuilder( WebRequest::class )
+ ->setMethods( [ 'response', 'getRawIP' ] )
+ ->getMock();
+ $response = new FauxResponse();
+ $req->method( 'response' )->willReturn( $response );
+ $req->method( 'getRawIP' )->willReturn( '127.0.0.1' );
+
+ $wrapper = TestingAccessWrapper::newFromObject( $req );
+ $wrapper->data = $requestData;
+ if ( $headers ) {
+ $wrapper->headers = $headers;
+ }
+
+ return new ApiMain( $req );
+ }
+
+ public function testUselang() {
+ global $wgLang;
+
+ $api = $this->getNonInternalApiMain( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ 'uselang' => 'fr',
+ ] );
+
+ ob_start();
+ $api->execute();
+ ob_end_clean();
+
+ $this->assertSame( 'fr', $wgLang->getCode() );
+ }
+
+ public function testNonWhitelistedCorsWithCookies() {
+ $logFile = $this->getNewTempFile();
+
+ $this->mergeMwGlobalArrayValue( '_COOKIE', [ 'forceHTTPS' => '1' ] );
+ $logger = new TestLogger( true );
+ $this->setLogger( 'cors', $logger );
+
+ $api = $this->getNonInternalApiMain( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ // For some reason multiple origins (which are not allowed in the
+ // WHATWG Fetch spec that supersedes the RFC) are always considered to
+ // be problematic.
+ ], [ 'ORIGIN' => 'https://www.example.com https://www.com.example' ] );
+
+ $this->assertSame(
+ [ [ Psr\Log\LogLevel::WARNING, 'Non-whitelisted CORS request with session cookies' ] ],
+ $logger->getBuffer()
+ );
+ }
+
+ public function testSuppressedLogin() {
+ global $wgUser;
+ $origUser = $wgUser;
+
+ $api = $this->getNonInternalApiMain( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ 'origin' => '*',
+ ] );
+
+ ob_start();
+ $api->execute();
+ ob_end_clean();
+
+ $this->assertNotSame( $origUser, $wgUser );
+ $this->assertSame( 'true', $api->getContext()->getRequest()->response()
+ ->getHeader( 'MediaWiki-Login-Suppressed' ) );
+ }
+
+ public function testSetContinuationManager() {
+ $api = new ApiMain();
+ $manager = $this->createMock( ApiContinuationManager::class );
+ $api->setContinuationManager( $manager );
+ $this->assertTrue( true, 'No exception' );
+ return [ $api, $manager ];
+ }
+
+ /**
+ * @depends testSetContinuationManager
+ */
+ public function testSetContinuationManagerTwice( $args ) {
+ $this->setExpectedException( UnexpectedValueException::class,
+ 'ApiMain::setContinuationManager: tried to set manager from ' .
+ 'when a manager is already set from ' );
+
+ list( $api, $manager ) = $args;
+ $api->setContinuationManager( $manager );
+ }
+
+ public function testSetCacheModeUnrecognized() {
+ $api = new ApiMain();
+ $api->setCacheMode( 'unrecognized' );
+ $this->assertSame(
+ 'private',
+ TestingAccessWrapper::newFromObject( $api )->mCacheMode,
+ 'Unrecognized params must be silently ignored'
+ );
+ }
+
+ public function testSetCacheModePrivateWiki() {
+ $this->setGroupPermissions( '*', 'read', false );
+
+ $wrappedApi = TestingAccessWrapper::newFromObject( new ApiMain() );
+ $wrappedApi->setCacheMode( 'public' );
+ $this->assertSame( 'private', $wrappedApi->mCacheMode );
+ $wrappedApi->setCacheMode( 'anon-public-user-private' );
+ $this->assertSame( 'private', $wrappedApi->mCacheMode );
+ }
+
+ public function testAddRequestedFieldsRequestId() {
+ $req = new FauxRequest( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ 'requestid' => '123456',
+ ] );
+ $api = new ApiMain( $req );
+ $api->execute();
+ $this->assertSame( '123456', $api->getResult()->getResultData()['requestid'] );
+ }
+
+ public function testAddRequestedFieldsCurTimestamp() {
+ $req = new FauxRequest( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ 'curtimestamp' => '',
+ ] );
+ $api = new ApiMain( $req );
+ $api->execute();
+ $timestamp = $api->getResult()->getResultData()['curtimestamp'];
+ $this->assertLessThanOrEqual( 1, abs( strtotime( $timestamp ) - time() ) );
+ }
+
+ public function testAddRequestedFieldsResponseLangInfo() {
+ $req = new FauxRequest( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ // errorlang is ignored if errorformat is not specified
+ 'errorformat' => 'plaintext',
+ 'uselang' => 'FR',
+ 'errorlang' => 'ja',
+ 'responselanginfo' => '',
+ ] );
+ $api = new ApiMain( $req );
+ $api->execute();
+ $data = $api->getResult()->getResultData();
+ $this->assertSame( 'fr', $data['uselang'] );
+ $this->assertSame( 'ja', $data['errorlang'] );
+ }
+
+ public function testSetupModuleUnknown() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'Unrecognized value for parameter "action": unknownaction.' );
+
+ $req = new FauxRequest( [ 'action' => 'unknownaction' ] );
+ $api = new ApiMain( $req );
+ $api->execute();
+ }
+
+ public function testSetupModuleNoTokenProvided() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The "token" parameter must be set.' );
+
+ $req = new FauxRequest( [
+ 'action' => 'edit',
+ 'title' => 'New page',
+ 'text' => 'Some text',
+ ] );
+ $api = new ApiMain( $req );
+ $api->execute();
+ }
+
+ public function testSetupModuleInvalidTokenProvided() {
+ $this->setExpectedException( ApiUsageException::class, 'Invalid CSRF token.' );
+
+ $req = new FauxRequest( [
+ 'action' => 'edit',
+ 'title' => 'New page',
+ 'text' => 'Some text',
+ 'token' => "This isn't a real token!",
+ ] );
+ $api = new ApiMain( $req );
+ $api->execute();
+ }
+
+ public function testSetupModuleNeedsTokenTrue() {
+ $this->setExpectedException( MWException::class,
+ "Module 'testmodule' must be updated for the new token handling. " .
+ "See documentation for ApiBase::needsToken for details." );
+
+ $mock = $this->createMock( ApiBase::class );
+ $mock->method( 'getModuleName' )->willReturn( 'testmodule' );
+ $mock->method( 'needsToken' )->willReturn( true );
+
+ $api = new ApiMain( new FauxRequest( [ 'action' => 'testmodule' ] ) );
+ $api->getModuleManager()->addModule( 'testmodule', 'action', get_class( $mock ),
+ function () use ( $mock ) {
+ return $mock;
+ }
+ );
+ $api->execute();
+ }
+
+ public function testSetupModuleNeedsTokenNeedntBePosted() {
+ $this->setExpectedException( MWException::class,
+ "Module 'testmodule' must require POST to use tokens." );
+
+ $mock = $this->createMock( ApiBase::class );
+ $mock->method( 'getModuleName' )->willReturn( 'testmodule' );
+ $mock->method( 'needsToken' )->willReturn( 'csrf' );
+ $mock->method( 'mustBePosted' )->willReturn( false );
+
+ $api = new ApiMain( new FauxRequest( [ 'action' => 'testmodule' ] ) );
+ $api->getModuleManager()->addModule( 'testmodule', 'action', get_class( $mock ),
+ function () use ( $mock ) {
+ return $mock;
+ }
+ );
+ $api->execute();
+ }
+
+ public function testCheckMaxLagFailed() {
+ // It's hard to mock the LoadBalancer properly, so instead we'll mock
+ // checkMaxLag (which is tested directly in other tests below).
+ $req = new FauxRequest( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ ] );
+
+ $mock = $this->getMockBuilder( ApiMain::class )
+ ->setConstructorArgs( [ $req ] )
+ ->setMethods( [ 'checkMaxLag' ] )
+ ->getMock();
+ $mock->method( 'checkMaxLag' )->willReturn( false );
+
+ $mock->execute();
+
+ $this->assertArrayNotHasKey( 'query', $mock->getResult()->getResultData() );
+ }
+
+ public function testCheckConditionalRequestHeadersFailed() {
+ // The detailed checking of all cases of checkConditionalRequestHeaders
+ // is below in testCheckConditionalRequestHeaders(), which calls the
+ // method directly. Here we just check that it will stop execution if
+ // it does fail.
+ $now = time();
+
+ $this->setMwGlobals( 'wgCacheEpoch', '20030516000000' );
+
+ $mock = $this->createMock( ApiBase::class );
+ $mock->method( 'getModuleName' )->willReturn( 'testmodule' );
+ $mock->method( 'getConditionalRequestData' )
+ ->willReturn( wfTimestamp( TS_MW, $now - 3600 ) );
+ $mock->expects( $this->exactly( 0 ) )->method( 'execute' );
+
+ $req = new FauxRequest( [
+ 'action' => 'testmodule',
+ ] );
+ $req->setHeader( 'If-Modified-Since', wfTimestamp( TS_RFC2822, $now - 3600 ) );
+ $req->setRequestURL( "http://localhost" );
+
+ $api = new ApiMain( $req );
+ $api->getModuleManager()->addModule( 'testmodule', 'action', get_class( $mock ),
+ function () use ( $mock ) {
+ return $mock;
+ }
+ );
+
+ $wrapper = TestingAccessWrapper::newFromObject( $api );
+ $wrapper->mInternalMode = false;
+
+ ob_start();
+ $api->execute();
+ ob_end_clean();
+ }
+
+ private function doTestCheckMaxLag( $lag ) {
+ $mockLB = $this->getMockBuilder( LoadBalancer::class )
+ ->disableOriginalConstructor()
+ ->setMethods( [ 'getMaxLag', '__destruct' ] )
+ ->getMock();
+ $mockLB->method( 'getMaxLag' )->willReturn( [ 'somehost', $lag ] );
+ $this->setService( 'DBLoadBalancer', $mockLB );
+
+ $req = new FauxRequest();
+
+ $api = new ApiMain( $req );
+ $wrapper = TestingAccessWrapper::newFromObject( $api );
+
+ $mockModule = $this->createMock( ApiBase::class );
+ $mockModule->method( 'shouldCheckMaxLag' )->willReturn( true );
+
+ try {
+ $wrapper->checkMaxLag( $mockModule, [ 'maxlag' => 3 ] );
+ } finally {
+ if ( $lag > 3 ) {
+ $this->assertSame( '5', $req->response()->getHeader( 'Retry-After' ) );
+ $this->assertSame( (string)$lag, $req->response()->getHeader( 'X-Database-Lag' ) );
+ }
+ }
+ }
+
+ public function testCheckMaxLagOkay() {
+ $this->doTestCheckMaxLag( 3 );
+
+ // No exception, we're happy
+ $this->assertTrue( true );
+ }
+
+ public function testCheckMaxLagExceeded() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'Waiting for a database server: 4 seconds lagged.' );
+
+ $this->setMwGlobals( 'wgShowHostnames', false );
+
+ $this->doTestCheckMaxLag( 4 );
+ }
+
+ public function testCheckMaxLagExceededWithHostNames() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'Waiting for somehost: 4 seconds lagged.' );
+
+ $this->setMwGlobals( 'wgShowHostnames', true );
+
+ $this->doTestCheckMaxLag( 4 );
+ }
+
+ public static function provideAssert() {
+ return [
+ [ false, [], 'user', 'assertuserfailed' ],
+ [ true, [], 'user', false ],
+ [ true, [], 'bot', 'assertbotfailed' ],
+ [ true, [ 'bot' ], 'user', false ],
+ [ true, [ 'bot' ], 'bot', false ],
+ ];
+ }
+
+ /**
+ * Tests the assert={user|bot} functionality
+ *
+ * @dataProvider provideAssert
+ * @param bool $registered
+ * @param array $rights
+ * @param string $assert
+ * @param string|bool $error False if no error expected
+ */
+ public function testAssert( $registered, $rights, $assert, $error ) {
+ if ( $registered ) {
+ $user = $this->getMutableTestUser()->getUser();
+ $user->load(); // load before setting mRights
+ } else {
+ $user = new User();
+ }
+ $user->mRights = $rights;
+ try {
+ $this->doApiRequest( [
+ 'action' => 'query',
+ 'assert' => $assert,
+ ], null, null, $user );
+ $this->assertFalse( $error ); // That no error was expected
+ } catch ( ApiUsageException $e ) {
+ $this->assertTrue( self::apiExceptionHasCode( $e, $error ),
+ "Error '{$e->getMessage()}' matched expected '$error'" );
+ }
+ }
+
+ /**
+ * Tests the assertuser= functionality
+ */
+ public function testAssertUser() {
+ $user = $this->getTestUser()->getUser();
+ $this->doApiRequest( [
+ 'action' => 'query',
+ 'assertuser' => $user->getName(),
+ ], null, null, $user );
+
+ try {
+ $this->doApiRequest( [
+ 'action' => 'query',
+ 'assertuser' => $user->getName() . 'X',
+ ], null, null, $user );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $e ) {
+ $this->assertTrue( self::apiExceptionHasCode( $e, 'assertnameduserfailed' ) );
+ }
+ }
+
+ /**
+ * Test if all classes in the main module manager exists
+ */
+ public function testClassNamesInModuleManager() {
+ $api = new ApiMain(
+ new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] )
+ );
+ $modules = $api->getModuleManager()->getNamesWithClasses();
+
+ foreach ( $modules as $name => $class ) {
+ $this->assertTrue(
+ class_exists( $class ),
+ 'Class ' . $class . ' for api module ' . $name . ' does not exist (with exact case)'
+ );
+ }
+ }
+
+ /**
+ * Test HTTP precondition headers
+ *
+ * @dataProvider provideCheckConditionalRequestHeaders
+ * @param array $headers HTTP headers
+ * @param array $conditions Return data for ApiBase::getConditionalRequestData
+ * @param int $status Expected response status
+ * @param array $options Array of options:
+ * post => true Request is a POST
+ * cdn => true CDN is enabled ($wgUseSquid)
+ */
+ public function testCheckConditionalRequestHeaders(
+ $headers, $conditions, $status, $options = []
+ ) {
+ $request = new FauxRequest(
+ [ 'action' => 'query', 'meta' => 'siteinfo' ],
+ !empty( $options['post'] )
+ );
+ $request->setHeaders( $headers );
+ $request->response()->statusHeader( 200 ); // Why doesn't it default?
+
+ $context = $this->apiContext->newTestContext( $request, null );
+ $api = new ApiMain( $context );
+ $priv = TestingAccessWrapper::newFromObject( $api );
+ $priv->mInternalMode = false;
+
+ if ( !empty( $options['cdn'] ) ) {
+ $this->setMwGlobals( 'wgUseSquid', true );
+ }
+
+ // Can't do this in TestSetup.php because Setup.php will override it
+ $this->setMwGlobals( 'wgCacheEpoch', '20030516000000' );
+
+ $module = $this->getMockBuilder( ApiBase::class )
+ ->setConstructorArgs( [ $api, 'mock' ] )
+ ->setMethods( [ 'getConditionalRequestData' ] )
+ ->getMockForAbstractClass();
+ $module->expects( $this->any() )
+ ->method( 'getConditionalRequestData' )
+ ->will( $this->returnCallback( function ( $condition ) use ( $conditions ) {
+ return isset( $conditions[$condition] ) ? $conditions[$condition] : null;
+ } ) );
+
+ $ret = $priv->checkConditionalRequestHeaders( $module );
+
+ $this->assertSame( $status, $request->response()->getStatusCode() );
+ $this->assertSame( $status === 200, $ret );
+ }
+
+ public static function provideCheckConditionalRequestHeaders() {
+ global $wgSquidMaxage;
+ $now = time();
+
+ return [
+ // Non-existing from module is ignored
+ 'If-None-Match' => [ [ 'If-None-Match' => '"foo", "bar"' ], [], 200 ],
+ 'If-Modified-Since' =>
+ [ [ 'If-Modified-Since' => 'Tue, 18 Aug 2015 00:00:00 GMT' ], [], 200 ],
+
+ // No headers
+ 'No headers' => [ [], [ 'etag' => '""', 'last-modified' => '20150815000000', ], 200 ],
+
+ // Basic If-None-Match
+ 'If-None-Match with matching etag' =>
+ [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"bar"' ], 304 ],
+ 'If-None-Match with non-matching etag' =>
+ [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"baz"' ], 200 ],
+ 'Strong If-None-Match with weak matching etag' =>
+ [ [ 'If-None-Match' => '"foo"' ], [ 'etag' => 'W/"foo"' ], 304 ],
+ 'Weak If-None-Match with strong matching etag' =>
+ [ [ 'If-None-Match' => 'W/"foo"' ], [ 'etag' => '"foo"' ], 304 ],
+ 'Weak If-None-Match with weak matching etag' =>
+ [ [ 'If-None-Match' => 'W/"foo"' ], [ 'etag' => 'W/"foo"' ], 304 ],
+
+ // Pointless for GET, but supported
+ 'If-None-Match: *' => [ [ 'If-None-Match' => '*' ], [], 304 ],
+
+ // Basic If-Modified-Since
+ 'If-Modified-Since, modified one second earlier' =>
+ [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
+ 'If-Modified-Since, modified now' =>
+ [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now ) ], 304 ],
+ 'If-Modified-Since, modified one second later' =>
+ [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now + 1 ) ], 200 ],
+
+ // If-Modified-Since ignored when If-None-Match is given too
+ 'Non-matching If-None-Match and matching If-Modified-Since' =>
+ [ [ 'If-None-Match' => '""',
+ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
+ [ 'etag' => '"x"', 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ],
+ 'Non-matching If-None-Match and matching If-Modified-Since with no ETag' =>
+ [
+ [
+ 'If-None-Match' => '""',
+ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now )
+ ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ],
+ 304
+ ],
+
+ // Ignored for POST
+ 'Matching If-None-Match with POST' =>
+ [ [ 'If-None-Match' => '"foo", "bar"' ], [ 'etag' => '"bar"' ], 200,
+ [ 'post' => true ] ],
+ 'Matching If-Modified-Since with POST' =>
+ [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200,
+ [ 'post' => true ] ],
+
+ // Other date formats allowed by the RFC
+ 'If-Modified-Since with alternate date format 1' =>
+ [ [ 'If-Modified-Since' => gmdate( 'l, d-M-y H:i:s', $now ) . ' GMT' ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
+ 'If-Modified-Since with alternate date format 2' =>
+ [ [ 'If-Modified-Since' => gmdate( 'D M j H:i:s Y', $now ) ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
+
+ // Old browser extension to HTTP/1.0
+ 'If-Modified-Since with length' =>
+ [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now ) . '; length=123' ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 304 ],
+
+ // Invalid date formats should be ignored
+ 'If-Modified-Since with invalid date format' =>
+ [ [ 'If-Modified-Since' => gmdate( 'Y-m-d H:i:s', $now ) . ' GMT' ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ],
+ 'If-Modified-Since with entirely unparseable date' =>
+ [ [ 'If-Modified-Since' => 'a potato' ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - 1 ) ], 200 ],
+
+ // Anything before $wgSquidMaxage seconds ago should be considered
+ // expired.
+ 'If-Modified-Since with CDN post-expiry' =>
+ [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now - $wgSquidMaxage * 2 ) ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - $wgSquidMaxage * 3 ) ],
+ 200, [ 'cdn' => true ] ],
+ 'If-Modified-Since with CDN pre-expiry' =>
+ [ [ 'If-Modified-Since' => wfTimestamp( TS_RFC2822, $now - $wgSquidMaxage / 2 ) ],
+ [ 'last-modified' => wfTimestamp( TS_MW, $now - $wgSquidMaxage * 3 ) ],
+ 304, [ 'cdn' => true ] ],
+ ];
+ }
+
+ /**
+ * Test conditional headers output
+ * @dataProvider provideConditionalRequestHeadersOutput
+ * @param array $conditions Return data for ApiBase::getConditionalRequestData
+ * @param array $headers Expected output headers
+ * @param bool $isError $isError flag
+ * @param bool $post Request is a POST
+ */
+ public function testConditionalRequestHeadersOutput(
+ $conditions, $headers, $isError = false, $post = false
+ ) {
+ $request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ], $post );
+ $response = $request->response();
+
+ $api = new ApiMain( $request );
+ $priv = TestingAccessWrapper::newFromObject( $api );
+ $priv->mInternalMode = false;
+
+ $module = $this->getMockBuilder( ApiBase::class )
+ ->setConstructorArgs( [ $api, 'mock' ] )
+ ->setMethods( [ 'getConditionalRequestData' ] )
+ ->getMockForAbstractClass();
+ $module->expects( $this->any() )
+ ->method( 'getConditionalRequestData' )
+ ->will( $this->returnCallback( function ( $condition ) use ( $conditions ) {
+ return isset( $conditions[$condition] ) ? $conditions[$condition] : null;
+ } ) );
+ $priv->mModule = $module;
+
+ $priv->sendCacheHeaders( $isError );
+
+ foreach ( [ 'Last-Modified', 'ETag' ] as $header ) {
+ $this->assertEquals(
+ isset( $headers[$header] ) ? $headers[$header] : null,
+ $response->getHeader( $header ),
+ $header
+ );
+ }
+ }
+
+ public static function provideConditionalRequestHeadersOutput() {
+ return [
+ [
+ [],
+ []
+ ],
+ [
+ [ 'etag' => '"foo"' ],
+ [ 'ETag' => '"foo"' ]
+ ],
+ [
+ [ 'last-modified' => '20150818000102' ],
+ [ 'Last-Modified' => 'Tue, 18 Aug 2015 00:01:02 GMT' ]
+ ],
+ [
+ [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
+ [ 'ETag' => '"foo"', 'Last-Modified' => 'Tue, 18 Aug 2015 00:01:02 GMT' ]
+ ],
+ [
+ [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
+ [],
+ true,
+ ],
+ [
+ [ 'etag' => '"foo"', 'last-modified' => '20150818000102' ],
+ [],
+ false,
+ true,
+ ],
+ ];
+ }
+
+ public function testCheckExecutePermissionsReadProhibited() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'You need read permission to use this module.' );
+
+ $this->setGroupPermissions( '*', 'read', false );
+
+ $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
+ $main->execute();
+ }
+
+ public function testCheckExecutePermissionWriteDisabled() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'Editing of this wiki through the API is disabled. Make sure the ' .
+ '"$wgEnableWriteAPI=true;" statement is included in the wiki\'s ' .
+ '"LocalSettings.php" file.' );
+ $main = new ApiMain( new FauxRequest( [
+ 'action' => 'edit',
+ 'title' => 'Some page',
+ 'text' => 'Some text',
+ 'token' => '+\\',
+ ] ) );
+ $main->execute();
+ }
+
+ public function testCheckExecutePermissionWriteApiProhibited() {
+ $this->setExpectedException( ApiUsageException::class,
+ "You're not allowed to edit this wiki through the API." );
+ $this->setGroupPermissions( '*', 'writeapi', false );
+
+ $main = new ApiMain( new FauxRequest( [
+ 'action' => 'edit',
+ 'title' => 'Some page',
+ 'text' => 'Some text',
+ 'token' => '+\\',
+ ] ), /* enableWrite = */ true );
+ $main->execute();
+ }
+
+ public function testCheckExecutePermissionPromiseNonWrite() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The "Promise-Non-Write-API-Action" HTTP header cannot be sent ' .
+ 'to write-mode API modules.' );
+
+ $req = new FauxRequest( [
+ 'action' => 'edit',
+ 'title' => 'Some page',
+ 'text' => 'Some text',
+ 'token' => '+\\',
+ ] );
+ $req->setHeaders( [ 'Promise-Non-Write-API-Action' => '1' ] );
+ $main = new ApiMain( $req, /* enableWrite = */ true );
+ $main->execute();
+ }
+
+ public function testCheckExecutePermissionHookAbort() {
+ $this->setExpectedException( ApiUsageException::class, 'Main Page' );
+
+ $this->setTemporaryHook( 'ApiCheckCanExecute', function ( $unused1, $unused2, &$message ) {
+ $message = 'mainpage';
+ return false;
+ } );
+
+ $main = new ApiMain( new FauxRequest( [
+ 'action' => 'edit',
+ 'title' => 'Some page',
+ 'text' => 'Some text',
+ 'token' => '+\\',
+ ] ), /* enableWrite = */ true );
+ $main->execute();
+ }
+
+ public function testGetValUnsupportedArray() {
+ $main = new ApiMain( new FauxRequest( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ 'siprop' => [ 'general', 'namespaces' ],
+ ] ) );
+ $this->assertSame( 'myDefault', $main->getVal( 'siprop', 'myDefault' ) );
+ $main->execute();
+ $this->assertSame( 'Parameter "siprop" uses unsupported PHP array syntax.',
+ $main->getResult()->getResultData()['warnings']['main']['warnings'] );
+ }
+
+ public function testReportUnusedParams() {
+ $main = new ApiMain( new FauxRequest( [
+ 'action' => 'query',
+ 'meta' => 'siteinfo',
+ 'unusedparam' => 'unusedval',
+ 'anotherunusedparam' => 'anotherval',
+ ] ) );
+ $main->execute();
+ $this->assertSame( 'Unrecognized parameters: unusedparam, anotherunusedparam.',
+ $main->getResult()->getResultData()['warnings']['main']['warnings'] );
+ }
+
+ public function testLacksSameOriginSecurity() {
+ // Basic test
+ $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
+ $this->assertFalse( $main->lacksSameOriginSecurity(), 'Basic test, should have security' );
+
+ // JSONp
+ $main = new ApiMain(
+ new FauxRequest( [ 'action' => 'query', 'format' => 'xml', 'callback' => 'foo' ] )
+ );
+ $this->assertTrue( $main->lacksSameOriginSecurity(), 'JSONp, should lack security' );
+
+ // Header
+ $request = new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] );
+ $request->setHeader( 'TrEaT-As-UnTrUsTeD', '' ); // With falsey value!
+ $main = new ApiMain( $request );
+ $this->assertTrue( $main->lacksSameOriginSecurity(), 'Header supplied, should lack security' );
+
+ // Hook
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'RequestHasSameOriginSecurity' => [ function () {
+ return false;
+ } ]
+ ] );
+ $main = new ApiMain( new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] ) );
+ $this->assertTrue( $main->lacksSameOriginSecurity(), 'Hook, should lack security' );
+ }
+
+ /**
+ * Test proper creation of the ApiErrorFormatter
+ *
+ * @dataProvider provideApiErrorFormatterCreation
+ * @param array $request Request parameters
+ * @param array $expect Expected data
+ * - uselang: ApiMain language
+ * - class: ApiErrorFormatter class
+ * - lang: ApiErrorFormatter language
+ * - format: ApiErrorFormatter format
+ * - usedb: ApiErrorFormatter use-database flag
+ */
+ public function testApiErrorFormatterCreation( array $request, array $expect ) {
+ $context = new RequestContext();
+ $context->setRequest( new FauxRequest( $request ) );
+ $context->setLanguage( 'ru' );
+
+ $main = new ApiMain( $context );
+ $formatter = $main->getErrorFormatter();
+ $wrappedFormatter = TestingAccessWrapper::newFromObject( $formatter );
+
+ $this->assertSame( $expect['uselang'], $main->getLanguage()->getCode() );
+ $this->assertInstanceOf( $expect['class'], $formatter );
+ $this->assertSame( $expect['lang'], $formatter->getLanguage()->getCode() );
+ $this->assertSame( $expect['format'], $wrappedFormatter->format );
+ $this->assertSame( $expect['usedb'], $wrappedFormatter->useDB );
+ }
+
+ public static function provideApiErrorFormatterCreation() {
+ return [
+ 'Default (BC)' => [ [], [
+ 'uselang' => 'ru',
+ 'class' => ApiErrorFormatter_BackCompat::class,
+ 'lang' => 'en',
+ 'format' => 'none',
+ 'usedb' => false,
+ ] ],
+ 'BC ignores fields' => [ [ 'errorlang' => 'de', 'errorsuselocal' => 1 ], [
+ 'uselang' => 'ru',
+ 'class' => ApiErrorFormatter_BackCompat::class,
+ 'lang' => 'en',
+ 'format' => 'none',
+ 'usedb' => false,
+ ] ],
+ 'Explicit BC' => [ [ 'errorformat' => 'bc' ], [
+ 'uselang' => 'ru',
+ 'class' => ApiErrorFormatter_BackCompat::class,
+ 'lang' => 'en',
+ 'format' => 'none',
+ 'usedb' => false,
+ ] ],
+ 'Basic' => [ [ 'errorformat' => 'wikitext' ], [
+ 'uselang' => 'ru',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'ru',
+ 'format' => 'wikitext',
+ 'usedb' => false,
+ ] ],
+ 'Follows uselang' => [ [ 'uselang' => 'fr', 'errorformat' => 'plaintext' ], [
+ 'uselang' => 'fr',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'fr',
+ 'format' => 'plaintext',
+ 'usedb' => false,
+ ] ],
+ 'Explicitly follows uselang' => [
+ [ 'uselang' => 'fr', 'errorlang' => 'uselang', 'errorformat' => 'plaintext' ],
+ [
+ 'uselang' => 'fr',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'fr',
+ 'format' => 'plaintext',
+ 'usedb' => false,
+ ]
+ ],
+ 'uselang=content' => [
+ [ 'uselang' => 'content', 'errorformat' => 'plaintext' ],
+ [
+ 'uselang' => 'en',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'en',
+ 'format' => 'plaintext',
+ 'usedb' => false,
+ ]
+ ],
+ 'errorlang=content' => [
+ [ 'errorlang' => 'content', 'errorformat' => 'plaintext' ],
+ [
+ 'uselang' => 'ru',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'en',
+ 'format' => 'plaintext',
+ 'usedb' => false,
+ ]
+ ],
+ 'Explicit parameters' => [
+ [ 'errorlang' => 'de', 'errorformat' => 'html', 'errorsuselocal' => 1 ],
+ [
+ 'uselang' => 'ru',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'de',
+ 'format' => 'html',
+ 'usedb' => true,
+ ]
+ ],
+ 'Explicit parameters override uselang' => [
+ [ 'errorlang' => 'de', 'uselang' => 'fr', 'errorformat' => 'raw' ],
+ [
+ 'uselang' => 'fr',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'de',
+ 'format' => 'raw',
+ 'usedb' => false,
+ ]
+ ],
+ 'Bogus language doesn\'t explode' => [
+ [ 'errorlang' => '<bogus1>', 'uselang' => '<bogus2>', 'errorformat' => 'none' ],
+ [
+ 'uselang' => 'en',
+ 'class' => ApiErrorFormatter::class,
+ 'lang' => 'en',
+ 'format' => 'none',
+ 'usedb' => false,
+ ]
+ ],
+ 'Bogus format doesn\'t explode' => [ [ 'errorformat' => 'bogus' ], [
+ 'uselang' => 'ru',
+ 'class' => ApiErrorFormatter_BackCompat::class,
+ 'lang' => 'en',
+ 'format' => 'none',
+ 'usedb' => false,
+ ] ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideExceptionErrors
+ * @param Exception $exception
+ * @param array $expectReturn
+ * @param array $expectResult
+ */
+ public function testExceptionErrors( $error, $expectReturn, $expectResult ) {
+ $context = new RequestContext();
+ $context->setRequest( new FauxRequest( [ 'errorformat' => 'plaintext' ] ) );
+ $context->setLanguage( 'en' );
+ $context->setConfig( new MultiConfig( [
+ new HashConfig( [
+ 'ShowHostnames' => true, 'ShowSQLErrors' => false,
+ 'ShowExceptionDetails' => true, 'ShowDBErrorBacktrace' => true,
+ ] ),
+ $context->getConfig()
+ ] ) );
+
+ $main = new ApiMain( $context );
+ $main->addWarning( new RawMessage( 'existing warning' ), 'existing-warning' );
+ $main->addError( new RawMessage( 'existing error' ), 'existing-error' );
+
+ $ret = TestingAccessWrapper::newFromObject( $main )->substituteResultWithError( $error );
+ $this->assertSame( $expectReturn, $ret );
+
+ // PHPUnit sometimes adds some SplObjectStorage garbage to the arrays,
+ // so let's try ->assertEquals().
+ $this->assertEquals(
+ $expectResult,
+ $main->getResult()->getResultData( [], [ 'Strip' => 'all' ] )
+ );
+ }
+
+ // Not static so $this can be used
+ public function provideExceptionErrors() {
+ $reqId = WebRequest::getRequestId();
+ $doclink = wfExpandUrl( wfScript( 'api' ) );
+
+ $ex = new InvalidArgumentException( 'Random exception' );
+ $trace = wfMessage( 'api-exception-trace',
+ get_class( $ex ),
+ $ex->getFile(),
+ $ex->getLine(),
+ MWExceptionHandler::getRedactedTraceAsString( $ex )
+ )->inLanguage( 'en' )->useDatabase( false )->text();
+
+ $dbex = new DBQueryError(
+ $this->createMock( \Wikimedia\Rdbms\IDatabase::class ),
+ 'error', 1234, 'SELECT 1', __METHOD__ );
+ $dbtrace = wfMessage( 'api-exception-trace',
+ get_class( $dbex ),
+ $dbex->getFile(),
+ $dbex->getLine(),
+ MWExceptionHandler::getRedactedTraceAsString( $dbex )
+ )->inLanguage( 'en' )->useDatabase( false )->text();
+
+ Wikimedia\suppressWarnings();
+ $usageEx = new UsageException( 'Usage exception!', 'ue', 0, [ 'foo' => 'bar' ] );
+ Wikimedia\restoreWarnings();
+
+ $apiEx1 = new ApiUsageException( null,
+ StatusValue::newFatal( new ApiRawMessage( 'An error', 'sv-error1' ) ) );
+ TestingAccessWrapper::newFromObject( $apiEx1 )->modulePath = 'foo+bar';
+ $apiEx1->getStatusValue()->warning( new ApiRawMessage( 'A warning', 'sv-warn1' ) );
+ $apiEx1->getStatusValue()->warning( new ApiRawMessage( 'Another warning', 'sv-warn2' ) );
+ $apiEx1->getStatusValue()->fatal( new ApiRawMessage( 'Another error', 'sv-error2' ) );
+
+ return [
+ [
+ $ex,
+ [ 'existing-error', 'internal_api_error_InvalidArgumentException' ],
+ [
+ 'warnings' => [
+ [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
+ ],
+ 'errors' => [
+ [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
+ [
+ 'code' => 'internal_api_error_InvalidArgumentException',
+ 'text' => "[$reqId] Exception caught: Random exception",
+ ]
+ ],
+ 'trace' => $trace,
+ 'servedby' => wfHostname(),
+ ]
+ ],
+ [
+ $dbex,
+ [ 'existing-error', 'internal_api_error_DBQueryError' ],
+ [
+ 'warnings' => [
+ [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
+ ],
+ 'errors' => [
+ [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
+ [
+ 'code' => 'internal_api_error_DBQueryError',
+ 'text' => "[$reqId] Database query error.",
+ ]
+ ],
+ 'trace' => $dbtrace,
+ 'servedby' => wfHostname(),
+ ]
+ ],
+ [
+ $usageEx,
+ [ 'existing-error', 'ue' ],
+ [
+ 'warnings' => [
+ [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
+ ],
+ 'errors' => [
+ [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
+ [ 'code' => 'ue', 'text' => "Usage exception!", 'data' => [ 'foo' => 'bar' ] ]
+ ],
+ 'docref' => "See $doclink for API usage. Subscribe to the mediawiki-api-announce mailing " .
+ "list at &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; " .
+ "for notice of API deprecations and breaking changes.",
+ 'servedby' => wfHostname(),
+ ]
+ ],
+ [
+ $apiEx1,
+ [ 'existing-error', 'sv-error1', 'sv-error2' ],
+ [
+ 'warnings' => [
+ [ 'code' => 'existing-warning', 'text' => 'existing warning', 'module' => 'main' ],
+ [ 'code' => 'sv-warn1', 'text' => 'A warning', 'module' => 'foo+bar' ],
+ [ 'code' => 'sv-warn2', 'text' => 'Another warning', 'module' => 'foo+bar' ],
+ ],
+ 'errors' => [
+ [ 'code' => 'existing-error', 'text' => 'existing error', 'module' => 'main' ],
+ [ 'code' => 'sv-error1', 'text' => 'An error', 'module' => 'foo+bar' ],
+ [ 'code' => 'sv-error2', 'text' => 'Another error', 'module' => 'foo+bar' ],
+ ],
+ 'docref' => "See $doclink for API usage. Subscribe to the mediawiki-api-announce mailing " .
+ "list at &lt;https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce&gt; " .
+ "for notice of API deprecations and breaking changes.",
+ 'servedby' => wfHostname(),
+ ]
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiMessageTest.php b/www/wiki/tests/phpunit/includes/api/ApiMessageTest.php
new file mode 100644
index 00000000..c6f5a8e7
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiMessageTest.php
@@ -0,0 +1,189 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group API
+ */
+class ApiMessageTest extends MediaWikiTestCase {
+
+ private function compareMessages( Message $msg, Message $msg2 ) {
+ $this->assertSame( $msg->getKey(), $msg2->getKey(), 'getKey' );
+ $this->assertSame( $msg->getKeysToTry(), $msg2->getKeysToTry(), 'getKeysToTry' );
+ $this->assertSame( $msg->getParams(), $msg2->getParams(), 'getParams' );
+ $this->assertSame( $msg->getLanguage(), $msg2->getLanguage(), 'getLanguage' );
+
+ $msg = TestingAccessWrapper::newFromObject( $msg );
+ $msg2 = TestingAccessWrapper::newFromObject( $msg2 );
+ $this->assertSame( $msg->interface, $msg2->interface, 'interface' );
+ $this->assertSame( $msg->useDatabase, $msg2->useDatabase, 'useDatabase' );
+ $this->assertSame( $msg->format, $msg2->format, 'format' );
+ $this->assertSame(
+ $msg->title ? $msg->title->getFullText() : null,
+ $msg2->title ? $msg2->title->getFullText() : null,
+ 'title'
+ );
+ }
+
+ /**
+ * @covers ApiMessageTrait
+ */
+ public function testCodeDefaults() {
+ $msg = new ApiMessage( 'foo' );
+ $this->assertSame( 'foo', $msg->getApiCode() );
+
+ $msg = new ApiMessage( 'apierror-bar' );
+ $this->assertSame( 'bar', $msg->getApiCode() );
+
+ $msg = new ApiMessage( 'apiwarn-baz' );
+ $this->assertSame( 'baz', $msg->getApiCode() );
+
+ // BC case
+ $msg = new ApiMessage( 'actionthrottledtext' );
+ $this->assertSame( 'ratelimited', $msg->getApiCode() );
+
+ $msg = new ApiMessage( [ 'apierror-missingparam', 'param' ] );
+ $this->assertSame( 'noparam', $msg->getApiCode() );
+ }
+
+ /**
+ * @covers ApiMessageTrait
+ * @dataProvider provideInvalidCode
+ * @param mixed $code
+ */
+ public function testInvalidCode( $code ) {
+ $msg = new ApiMessage( 'foo' );
+ try {
+ $msg->setApiCode( $code );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertTrue( true );
+ }
+
+ try {
+ new ApiMessage( 'foo', $code );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertTrue( true );
+ }
+ }
+
+ public static function provideInvalidCode() {
+ return [
+ [ '' ],
+ [ 42 ],
+ ];
+ }
+
+ /**
+ * @covers ApiMessage
+ * @covers ApiMessageTrait
+ */
+ public function testApiMessage() {
+ $msg = new Message( [ 'foo', 'bar' ], [ 'baz' ] );
+ $msg->inLanguage( 'de' )->title( Title::newMainPage() );
+ $msg2 = new ApiMessage( $msg, 'code', [ 'data' ] );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+
+ $msg2 = unserialize( serialize( $msg2 ) );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+
+ $msg = new Message( [ 'foo', 'bar' ], [ 'baz' ] );
+ $msg2 = new ApiMessage( [ [ 'foo', 'bar' ], 'baz' ], 'code', [ 'data' ] );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+
+ $msg = new Message( 'foo' );
+ $msg2 = new ApiMessage( 'foo' );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'foo', $msg2->getApiCode() );
+ $this->assertEquals( [], $msg2->getApiData() );
+
+ $msg2->setApiCode( 'code', [ 'data' ] );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+ $msg2->setApiCode( null );
+ $this->assertEquals( 'foo', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+ $msg2->setApiData( [ 'data2' ] );
+ $this->assertEquals( [ 'data2' ], $msg2->getApiData() );
+ }
+
+ /**
+ * @covers ApiRawMessage
+ * @covers ApiMessageTrait
+ */
+ public function testApiRawMessage() {
+ $msg = new RawMessage( 'foo', [ 'baz' ] );
+ $msg->inLanguage( 'de' )->title( Title::newMainPage() );
+ $msg2 = new ApiRawMessage( $msg, 'code', [ 'data' ] );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+
+ $msg2 = unserialize( serialize( $msg2 ) );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+
+ $msg = new RawMessage( 'foo', [ 'baz' ] );
+ $msg2 = new ApiRawMessage( [ 'foo', 'baz' ], 'code', [ 'data' ] );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+
+ $msg = new RawMessage( 'foo' );
+ $msg2 = new ApiRawMessage( 'foo', 'code', [ 'data' ] );
+ $this->compareMessages( $msg, $msg2 );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+
+ $msg2->setApiCode( 'code', [ 'data' ] );
+ $this->assertEquals( 'code', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+ $msg2->setApiCode( null );
+ $this->assertEquals( 'foo', $msg2->getApiCode() );
+ $this->assertEquals( [ 'data' ], $msg2->getApiData() );
+ $msg2->setApiData( [ 'data2' ] );
+ $this->assertEquals( [ 'data2' ], $msg2->getApiData() );
+ }
+
+ /**
+ * @covers ApiMessage::create
+ */
+ public function testApiMessageCreate() {
+ $this->assertInstanceOf( ApiMessage::class, ApiMessage::create( new Message( 'mainpage' ) ) );
+ $this->assertInstanceOf(
+ ApiRawMessage::class, ApiMessage::create( new RawMessage( 'mainpage' ) )
+ );
+ $this->assertInstanceOf( ApiMessage::class, ApiMessage::create( 'mainpage' ) );
+
+ $msg = new ApiMessage( [ 'parentheses', 'foobar' ] );
+ $msg2 = new Message( 'parentheses', [ 'foobar' ] );
+
+ $this->assertSame( $msg, ApiMessage::create( $msg ) );
+ $this->assertEquals( $msg, ApiMessage::create( $msg2 ) );
+ $this->assertEquals( $msg, ApiMessage::create( [ 'parentheses', 'foobar' ] ) );
+ $this->assertEquals( $msg,
+ ApiMessage::create( [ 'message' => 'parentheses', 'params' => [ 'foobar' ] ] )
+ );
+ $this->assertSame( $msg,
+ ApiMessage::create( [ 'message' => $msg, 'params' => [ 'xxx' ] ] )
+ );
+ $this->assertEquals( $msg,
+ ApiMessage::create( [ 'message' => $msg2, 'params' => [ 'xxx' ] ] )
+ );
+ $this->assertSame( $msg,
+ ApiMessage::create( [ 'message' => $msg ] )
+ );
+
+ $msg = new ApiRawMessage( [ 'parentheses', 'foobar' ] );
+ $this->assertSame( $msg, ApiMessage::create( $msg ) );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiModuleManagerTest.php b/www/wiki/tests/phpunit/includes/api/ApiModuleManagerTest.php
new file mode 100644
index 00000000..b01b90e8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiModuleManagerTest.php
@@ -0,0 +1,330 @@
+<?php
+
+/**
+ * @covers ApiModuleManager
+ *
+ * @group API
+ * @group Database
+ * @group medium
+ */
+class ApiModuleManagerTest extends MediaWikiTestCase {
+
+ private function getModuleManager() {
+ $request = new FauxRequest();
+ $main = new ApiMain( $request );
+ return new ApiModuleManager( $main );
+ }
+
+ public function newApiLogin( $main, $action ) {
+ return new ApiLogin( $main, $action );
+ }
+
+ public function addModuleProvider() {
+ return [
+ 'plain class' => [
+ 'login',
+ 'action',
+ ApiLogin::class,
+ null,
+ ],
+
+ 'with factory' => [
+ 'login',
+ 'action',
+ ApiLogin::class,
+ [ $this, 'newApiLogin' ],
+ ],
+
+ 'with closure' => [
+ 'logout',
+ 'action',
+ ApiLogout::class,
+ function ( ApiMain $main, $action ) {
+ return new ApiLogout( $main, $action );
+ },
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider addModuleProvider
+ */
+ public function testAddModule( $name, $group, $class, $factory = null ) {
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModule( $name, $group, $class, $factory );
+
+ $this->assertTrue( $moduleManager->isDefined( $name, $group ), 'isDefined' );
+ $this->assertNotNull( $moduleManager->getModule( $name, $group, true ), 'getModule' );
+ }
+
+ public function addModulesProvider() {
+ return [
+ 'empty' => [
+ [],
+ 'action',
+ ],
+
+ 'simple' => [
+ [
+ 'login' => ApiLogin::class,
+ 'logout' => ApiLogout::class,
+ ],
+ 'action',
+ ],
+
+ 'with factories' => [
+ [
+ 'login' => [
+ 'class' => ApiLogin::class,
+ 'factory' => [ $this, 'newApiLogin' ],
+ ],
+ 'logout' => [
+ 'class' => ApiLogout::class,
+ 'factory' => function ( ApiMain $main, $action ) {
+ return new ApiLogout( $main, $action );
+ },
+ ],
+ ],
+ 'action',
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider addModulesProvider
+ */
+ public function testAddModules( array $modules, $group ) {
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $modules, $group );
+
+ foreach ( array_keys( $modules ) as $name ) {
+ $this->assertTrue( $moduleManager->isDefined( $name, $group ), 'isDefined' );
+ $this->assertNotNull( $moduleManager->getModule( $name, $group, true ), 'getModule' );
+ }
+
+ $this->assertTrue( true ); // Don't mark the test as risky if $modules is empty
+ }
+
+ public function getModuleProvider() {
+ $modules = [
+ 'feedrecentchanges' => ApiFeedRecentChanges::class,
+ 'feedcontributions' => [ 'class' => ApiFeedContributions::class ],
+ 'login' => [
+ 'class' => ApiLogin::class,
+ 'factory' => [ $this, 'newApiLogin' ],
+ ],
+ 'logout' => [
+ 'class' => ApiLogout::class,
+ 'factory' => function ( ApiMain $main, $action ) {
+ return new ApiLogout( $main, $action );
+ },
+ ],
+ ];
+
+ return [
+ 'legacy entry' => [
+ $modules,
+ 'feedrecentchanges',
+ ApiFeedRecentChanges::class,
+ ],
+
+ 'just a class' => [
+ $modules,
+ 'feedcontributions',
+ ApiFeedContributions::class,
+ ],
+
+ 'with factory' => [
+ $modules,
+ 'login',
+ ApiLogin::class,
+ ],
+
+ 'with closure' => [
+ $modules,
+ 'logout',
+ ApiLogout::class,
+ ],
+ ];
+ }
+
+ /**
+ * @covers ApiModuleManager::getModule
+ * @dataProvider getModuleProvider
+ */
+ public function testGetModule( $modules, $name, $expectedClass ) {
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $modules, 'test' );
+
+ // should return the right module
+ $module1 = $moduleManager->getModule( $name, null, false );
+ $this->assertInstanceOf( $expectedClass, $module1 );
+
+ // should pass group check (with caching disabled)
+ $module2 = $moduleManager->getModule( $name, 'test', true );
+ $this->assertNotNull( $module2 );
+
+ // should use cached instance
+ $module3 = $moduleManager->getModule( $name, null, false );
+ $this->assertSame( $module1, $module3 );
+
+ // should not use cached instance if caching is disabled
+ $module4 = $moduleManager->getModule( $name, null, true );
+ $this->assertNotSame( $module1, $module4 );
+ }
+
+ /**
+ * @covers ApiModuleManager::getModule
+ */
+ public function testGetModule_null() {
+ $modules = [
+ 'login' => ApiLogin::class,
+ 'logout' => ApiLogout::class,
+ ];
+
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $modules, 'test' );
+
+ $this->assertNull( $moduleManager->getModule( 'quux' ), 'unknown name' );
+ $this->assertNull( $moduleManager->getModule( 'login', 'bla' ), 'wrong group' );
+ }
+
+ /**
+ * @covers ApiModuleManager::getNames
+ */
+ public function testGetNames() {
+ $fooModules = [
+ 'login' => ApiLogin::class,
+ 'logout' => ApiLogout::class,
+ ];
+
+ $barModules = [
+ 'feedcontributions' => [ 'class' => ApiFeedContributions::class ],
+ 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ],
+ ];
+
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $fooModules, 'foo' );
+ $moduleManager->addModules( $barModules, 'bar' );
+
+ $fooNames = $moduleManager->getNames( 'foo' );
+ $this->assertArrayEquals( array_keys( $fooModules ), $fooNames );
+
+ $allNames = $moduleManager->getNames();
+ $allModules = array_merge( $fooModules, $barModules );
+ $this->assertArrayEquals( array_keys( $allModules ), $allNames );
+ }
+
+ /**
+ * @covers ApiModuleManager::getNamesWithClasses
+ */
+ public function testGetNamesWithClasses() {
+ $fooModules = [
+ 'login' => ApiLogin::class,
+ 'logout' => ApiLogout::class,
+ ];
+
+ $barModules = [
+ 'feedcontributions' => [ 'class' => ApiFeedContributions::class ],
+ 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ],
+ ];
+
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $fooModules, 'foo' );
+ $moduleManager->addModules( $barModules, 'bar' );
+
+ $fooNamesWithClasses = $moduleManager->getNamesWithClasses( 'foo' );
+ $this->assertArrayEquals( $fooModules, $fooNamesWithClasses );
+
+ $allNamesWithClasses = $moduleManager->getNamesWithClasses();
+ $allModules = array_merge( $fooModules, [
+ 'feedcontributions' => ApiFeedContributions::class,
+ 'feedrecentchanges' => ApiFeedRecentChanges::class,
+ ] );
+ $this->assertArrayEquals( $allModules, $allNamesWithClasses );
+ }
+
+ /**
+ * @covers ApiModuleManager::getModuleGroup
+ */
+ public function testGetModuleGroup() {
+ $fooModules = [
+ 'login' => ApiLogin::class,
+ 'logout' => ApiLogout::class,
+ ];
+
+ $barModules = [
+ 'feedcontributions' => [ 'class' => ApiFeedContributions::class ],
+ 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ],
+ ];
+
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $fooModules, 'foo' );
+ $moduleManager->addModules( $barModules, 'bar' );
+
+ $this->assertEquals( 'foo', $moduleManager->getModuleGroup( 'login' ) );
+ $this->assertEquals( 'bar', $moduleManager->getModuleGroup( 'feedrecentchanges' ) );
+ $this->assertNull( $moduleManager->getModuleGroup( 'quux' ) );
+ }
+
+ /**
+ * @covers ApiModuleManager::getGroups
+ */
+ public function testGetGroups() {
+ $fooModules = [
+ 'login' => ApiLogin::class,
+ 'logout' => ApiLogout::class,
+ ];
+
+ $barModules = [
+ 'feedcontributions' => [ 'class' => ApiFeedContributions::class ],
+ 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ],
+ ];
+
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $fooModules, 'foo' );
+ $moduleManager->addModules( $barModules, 'bar' );
+
+ $groups = $moduleManager->getGroups();
+ $this->assertArrayEquals( [ 'foo', 'bar' ], $groups );
+ }
+
+ /**
+ * @covers ApiModuleManager::getClassName
+ */
+ public function testGetClassName() {
+ $fooModules = [
+ 'login' => ApiLogin::class,
+ 'logout' => ApiLogout::class,
+ ];
+
+ $barModules = [
+ 'feedcontributions' => [ 'class' => ApiFeedContributions::class ],
+ 'feedrecentchanges' => [ 'class' => ApiFeedRecentChanges::class ],
+ ];
+
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $fooModules, 'foo' );
+ $moduleManager->addModules( $barModules, 'bar' );
+
+ $this->assertEquals(
+ ApiLogin::class,
+ $moduleManager->getClassName( 'login' )
+ );
+ $this->assertEquals(
+ ApiLogout::class,
+ $moduleManager->getClassName( 'logout' )
+ );
+ $this->assertEquals(
+ ApiFeedContributions::class,
+ $moduleManager->getClassName( 'feedcontributions' )
+ );
+ $this->assertEquals(
+ ApiFeedRecentChanges::class,
+ $moduleManager->getClassName( 'feedrecentchanges' )
+ );
+ $this->assertFalse(
+ $moduleManager->getClassName( 'nonexistentmodule' )
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiMoveTest.php b/www/wiki/tests/phpunit/includes/api/ApiMoveTest.php
new file mode 100644
index 00000000..fb697ffd
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiMoveTest.php
@@ -0,0 +1,393 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiMove
+ */
+class ApiMoveTest extends ApiTestCase {
+ /**
+ * @param string $from Prefixed name of source
+ * @param string $to Prefixed name of destination
+ * @param string $id Page id of the page to move
+ * @param array|string|null $opts Options: 'noredirect' to expect no redirect
+ */
+ protected function assertMoved( $from, $to, $id, $opts = null ) {
+ $opts = (array)$opts;
+
+ $fromTitle = Title::newFromText( $from );
+ $toTitle = Title::newFromText( $to );
+
+ $this->assertTrue( $toTitle->exists(),
+ "Destination {$toTitle->getPrefixedText()} does not exist" );
+
+ if ( in_array( 'noredirect', $opts ) ) {
+ $this->assertFalse( $fromTitle->exists(),
+ "Source {$fromTitle->getPrefixedText()} exists" );
+ } else {
+ $this->assertTrue( $fromTitle->exists(),
+ "Source {$fromTitle->getPrefixedText()} does not exist" );
+ $this->assertTrue( $fromTitle->isRedirect(),
+ "Source {$fromTitle->getPrefixedText()} is not a redirect" );
+
+ $target = Revision::newFromTitle( $fromTitle )->getContent()->getRedirectTarget();
+ $this->assertSame( $toTitle->getPrefixedText(), $target->getPrefixedText() );
+ }
+
+ $this->assertSame( $id, $toTitle->getArticleId() );
+ }
+
+ /**
+ * Shortcut function to create a page and return its id.
+ *
+ * @param string $name Page to create
+ * @return int ID of created page
+ */
+ protected function createPage( $name ) {
+ return $this->editPage( $name, 'Content' )->value['revision']->getPage();
+ }
+
+ public function testFromWithFromid() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The parameters "from" and "fromid" can not be used together.' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => 'Some page',
+ 'fromid' => 123,
+ 'to' => 'Some other page',
+ ] );
+ }
+
+ public function testMove() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->createPage( $name );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ ] );
+
+ $this->assertMoved( $name, "$name 2", $id );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testMoveById() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->createPage( $name );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'fromid' => $id,
+ 'to' => "$name 2",
+ ] );
+
+ $this->assertMoved( $name, "$name 2", $id );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testMoveNonexistent() {
+ $this->setExpectedException( ApiUsageException::class,
+ "The page you specified doesn't exist." );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => 'Nonexistent page',
+ 'to' => 'Different page'
+ ] );
+ }
+
+ public function testMoveNonexistentId() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'There is no page with ID 2147483647.' );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'fromid' => pow( 2, 31 ) - 1,
+ 'to' => 'Different page',
+ ] );
+ }
+
+ public function testMoveToInvalidPageName() {
+ $this->setExpectedException( ApiUsageException::class, 'Bad title "[".' );
+
+ $name = ucfirst( __FUNCTION__ );
+ $id = $this->createPage( $name );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => '[',
+ ] );
+ } finally {
+ $this->assertSame( $id, Title::newFromText( $name )->getArticleId() );
+ }
+ }
+
+ // @todo File moving
+
+ public function testPingLimiter() {
+ global $wgRateLimits;
+
+ $this->setExpectedException( ApiUsageException::class,
+ "You've exceeded your rate limit. Please wait some time and try again." );
+
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->setMwGlobals( 'wgMainCacheType', 'hash' );
+
+ $this->stashMwGlobals( 'wgRateLimits' );
+ $wgRateLimits['move'] = [ '&can-bypass' => false, 'user' => [ 1, 60 ] ];
+
+ $id = $this->createPage( $name );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ ] );
+
+ $this->assertMoved( $name, "$name 2", $id );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => "$name 2",
+ 'to' => "$name 3",
+ ] );
+ } finally {
+ $this->assertSame( $id, Title::newFromText( "$name 2" )->getArticleId() );
+ $this->assertFalse( Title::newFromText( "$name 3" )->exists(),
+ "\"$name 3\" should not exist" );
+ }
+ }
+
+ public function testTagsNoPermission() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'You do not have permission to apply change tags along with your changes.' );
+
+ $name = ucfirst( __FUNCTION__ );
+
+ ChangeTags::defineTag( 'custom tag' );
+
+ $this->setGroupPermissions( 'user', 'applychangetags', false );
+
+ $id = $this->createPage( $name );
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ 'tags' => 'custom tag',
+ ] );
+ } finally {
+ $this->assertSame( $id, Title::newFromText( $name )->getArticleId() );
+ $this->assertFalse( Title::newFromText( "$name 2" )->exists(),
+ "\"$name 2\" should not exist" );
+ }
+ }
+
+ public function testSelfMove() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The title is the same; cannot move a page over itself.' );
+
+ $name = ucfirst( __FUNCTION__ );
+ $this->createPage( $name );
+
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => $name,
+ ] );
+ }
+
+ public function testMoveTalk() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->createPage( $name );
+ $talkId = $this->createPage( "Talk:$name" );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ 'movetalk' => '',
+ ] );
+
+ $this->assertMoved( $name, "$name 2", $id );
+ $this->assertMoved( "Talk:$name", "Talk:$name 2", $talkId );
+
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testMoveTalkFailed() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->createPage( $name );
+ $talkId = $this->createPage( "Talk:$name" );
+ $talkDestinationId = $this->createPage( "Talk:$name 2" );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ 'movetalk' => '',
+ ] );
+
+ $this->assertMoved( $name, "$name 2", $id );
+ $this->assertSame( $talkId, Title::newFromText( "Talk:$name" )->getArticleId() );
+ $this->assertSame( $talkDestinationId,
+ Title::newFromText( "Talk:$name 2" )->getArticleId() );
+ $this->assertSame( [ [
+ 'message' => 'articleexists',
+ 'params' => [],
+ 'code' => 'articleexists',
+ 'type' => 'error',
+ ] ], $res[0]['move']['talkmove-errors'] );
+
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testMoveSubpages() {
+ global $wgNamespacesWithSubpages;
+
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->stashMwGlobals( 'wgNamespacesWithSubpages' );
+ $wgNamespacesWithSubpages[NS_MAIN] = true;
+
+ $pages = [ $name, "$name/1", "$name/2", "Talk:$name", "Talk:$name/1", "Talk:$name/3" ];
+ $ids = [];
+ foreach ( array_merge( $pages, [ "$name/error", "$name 2/error" ] ) as $page ) {
+ $ids[$page] = $this->createPage( $page );
+ }
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ 'movetalk' => '',
+ 'movesubpages' => '',
+ ] );
+
+ foreach ( $pages as $page ) {
+ $this->assertMoved( $page, str_replace( $name, "$name 2", $page ), $ids[$page] );
+ }
+
+ $this->assertSame( $ids["$name/error"],
+ Title::newFromText( "$name/error" )->getArticleId() );
+ $this->assertSame( $ids["$name 2/error"],
+ Title::newFromText( "$name 2/error" )->getArticleId() );
+
+ $results = array_merge( $res[0]['move']['subpages'], $res[0]['move']['subpages-talk'] );
+ foreach ( $results as $arr ) {
+ if ( $arr['from'] === "$name/error" ) {
+ $this->assertSame( [ [
+ 'message' => 'articleexists',
+ 'params' => [],
+ 'code' => 'articleexists',
+ 'type' => 'error'
+ ] ], $arr['errors'] );
+ } else {
+ $this->assertSame( str_replace( $name, "$name 2", $arr['from'] ), $arr['to'] );
+ }
+ $this->assertCount( 2, $arr );
+ }
+
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testMoveNoPermission() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'You must be a registered user and [[Special:UserLogin|logged in]] to move a page.' );
+
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->createPage( $name );
+
+ $user = new User();
+
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ ], null, $user );
+ } finally {
+ $this->assertSame( $id, Title::newFromText( "$name" )->getArticleId() );
+ $this->assertFalse( Title::newFromText( "$name 2" )->exists(),
+ "\"$name 2\" should not exist" );
+ }
+ }
+
+ public function testSuppressRedirect() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->createPage( $name );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ 'noredirect' => '',
+ ] );
+
+ $this->assertMoved( $name, "$name 2", $id, 'noredirect' );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testSuppressRedirectNoPermission() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->setGroupPermissions( 'sysop', 'suppressredirect', false );
+
+ $id = $this->createPage( $name );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => $name,
+ 'to' => "$name 2",
+ 'noredirect' => '',
+ ] );
+
+ $this->assertMoved( $name, "$name 2", $id );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testMoveSubpagesError() {
+ $name = ucfirst( __FUNCTION__ );
+
+ // Subpages are allowed in talk but not main
+ $idBase = $this->createPage( "Talk:$name" );
+ $idSub = $this->createPage( "Talk:$name/1" );
+
+ $res = $this->doApiRequestWithToken( [
+ 'action' => 'move',
+ 'from' => "Talk:$name",
+ 'to' => $name,
+ 'movesubpages' => '',
+ ] );
+
+ $this->assertMoved( "Talk:$name", $name, $idBase );
+ $this->assertSame( $idSub, Title::newFromText( "Talk:$name/1" )->getArticleId() );
+ $this->assertFalse( Title::newFromText( "$name/1" )->exists(),
+ "\"$name/1\" should not exist" );
+
+ $this->assertSame( [ 'errors' => [ [
+ 'message' => 'namespace-nosubpages',
+ 'params' => [ '' ],
+ 'code' => 'namespace-nosubpages',
+ 'type' => 'error',
+ ] ] ], $res[0]['move']['subpages'] );
+
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiOpenSearchTest.php b/www/wiki/tests/phpunit/includes/api/ApiOpenSearchTest.php
new file mode 100644
index 00000000..209ca07b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiOpenSearchTest.php
@@ -0,0 +1,67 @@
+<?php
+
+/**
+ * @covers ApiOpenSearch
+ */
+class ApiOpenSearchTest extends MediaWikiTestCase {
+ public function testGetAllowedParams() {
+ $config = $this->replaceSearchEngineConfig();
+ $config->expects( $this->any() )
+ ->method( 'getSearchTypes' )
+ ->will( $this->returnValue( [ 'the one ring' ] ) );
+
+ $api = $this->createApi();
+ $engine = $this->replaceSearchEngine();
+ $engine->expects( $this->any() )
+ ->method( 'getProfiles' )
+ ->will( $this->returnValueMap( [
+ [ SearchEngine::COMPLETION_PROFILE_TYPE, $api->getUser(), [
+ [
+ 'name' => 'normal',
+ 'desc-message' => 'normal-message',
+ 'default' => true,
+ ],
+ [
+ 'name' => 'strict',
+ 'desc-message' => 'strict-message',
+ ],
+ ] ],
+ ] ) );
+
+ $params = $api->getAllowedParams();
+
+ $this->assertArrayNotHasKey( 'offset', $params );
+ $this->assertArrayHasKey( 'profile', $params, print_r( $params, true ) );
+ $this->assertEquals( 'normal', $params['profile'][ApiBase::PARAM_DFLT] );
+ }
+
+ private function replaceSearchEngineConfig() {
+ $config = $this->getMockBuilder( SearchEngineConfig::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $this->setService( 'SearchEngineConfig', $config );
+
+ return $config;
+ }
+
+ private function replaceSearchEngine() {
+ $engine = $this->getMockBuilder( SearchEngine::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $engineFactory = $this->getMockBuilder( SearchEngineFactory::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+ $engineFactory->expects( $this->any() )
+ ->method( 'create' )
+ ->will( $this->returnValue( $engine ) );
+ $this->setService( 'SearchEngineFactory', $engineFactory );
+
+ return $engine;
+ }
+
+ private function createApi() {
+ $ctx = new RequestContext();
+ $apiMain = new ApiMain( $ctx );
+ return new ApiOpenSearch( $apiMain, 'opensearch', '' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiOptionsTest.php b/www/wiki/tests/phpunit/includes/api/ApiOptionsTest.php
new file mode 100644
index 00000000..c0fecf06
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiOptionsTest.php
@@ -0,0 +1,418 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiOptions
+ */
+class ApiOptionsTest extends MediaWikiLangTestCase {
+
+ /** @var PHPUnit_Framework_MockObject_MockObject */
+ private $mUserMock;
+ /** @var ApiOptions */
+ private $mTested;
+ private $mSession;
+ /** @var DerivativeContext */
+ private $mContext;
+
+ private static $Success = [ 'options' => 'success' ];
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->mUserMock = $this->getMockBuilder( User::class )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ // Set up groups and rights
+ $this->mUserMock->expects( $this->any() )
+ ->method( 'getEffectiveGroups' )->will( $this->returnValue( [ '*', 'user' ] ) );
+ $this->mUserMock->expects( $this->any() )
+ ->method( 'isAllowedAny' )->will( $this->returnValue( true ) );
+
+ // Set up callback for User::getOptionKinds
+ $this->mUserMock->expects( $this->any() )
+ ->method( 'getOptionKinds' )->will( $this->returnCallback( [ $this, 'getOptionKinds' ] ) );
+
+ // No actual DB data
+ $this->mUserMock->expects( $this->any() )
+ ->method( 'getInstanceForUpdate' )->will( $this->returnValue( $this->mUserMock ) );
+
+ // Create a new context
+ $this->mContext = new DerivativeContext( new RequestContext() );
+ $this->mContext->getContext()->setTitle( Title::newFromText( 'Test' ) );
+ $this->mContext->setUser( $this->mUserMock );
+
+ $main = new ApiMain( $this->mContext );
+
+ // Empty session
+ $this->mSession = [];
+
+ $this->mTested = new ApiOptions( $main, 'options' );
+
+ $this->mergeMwGlobalArrayValue( 'wgHooks', [
+ 'GetPreferences' => [
+ [ $this, 'hookGetPreferences' ]
+ ]
+ ] );
+ }
+
+ public function hookGetPreferences( $user, &$preferences ) {
+ $preferences = [];
+
+ foreach ( [ 'name', 'willBeNull', 'willBeEmpty', 'willBeHappy' ] as $k ) {
+ $preferences[$k] = [
+ 'type' => 'text',
+ 'section' => 'test',
+ 'label' => '&#160;',
+ ];
+ }
+
+ $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' => '&#160;',
+ 'prefix' => 'testmultiselect-',
+ 'default' => [],
+ ];
+
+ return true;
+ }
+
+ /**
+ * @param IContextSource $context
+ * @param array|null $options
+ *
+ * @return array
+ */
+ public function getOptionKinds( IContextSource $context, $options = null ) {
+ // Match with above.
+ $kinds = [
+ 'name' => 'registered',
+ 'willBeNull' => 'registered',
+ 'willBeEmpty' => 'registered',
+ 'willBeHappy' => '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 ( substr( $key, 0, 7 ) === '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->mTested->execute();
+
+ return $this->mTested->getResult()->getResultData( null, [ 'Strip' => 'all' ] );
+ }
+
+ /**
+ * @expectedException ApiUsageException
+ */
+ public function testNoToken() {
+ $request = $this->getSampleRequest( [ 'token' => null ] );
+
+ $this->executeQuery( $request );
+ }
+
+ public function testAnon() {
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'isAnon' )
+ ->will( $this->returnValue( true ) );
+
+ try {
+ $request = $this->getSampleRequest();
+
+ $this->executeQuery( $request );
+ } catch ( ApiUsageException $e ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $e, 'notloggedin' ) );
+ return;
+ }
+ $this->fail( "ApiUsageException was not thrown" );
+ }
+
+ public function testNoOptionname() {
+ try {
+ $request = $this->getSampleRequest( [ 'optionvalue' => '1' ] );
+
+ $this->executeQuery( $request );
+ } catch ( ApiUsageException $e ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $e, 'nooptionname' ) );
+ return;
+ }
+ $this->fail( "ApiUsageException was not thrown" );
+ }
+
+ public function testNoChanges() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'setOption' );
+
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'saveSettings' );
+
+ try {
+ $request = $this->getSampleRequest();
+
+ $this->executeQuery( $request );
+ } catch ( ApiUsageException $e ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $e, 'nochanges' ) );
+ return;
+ }
+ $this->fail( "ApiUsageException was not thrown" );
+ }
+
+ public function testReset() {
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'resetOptions' )
+ ->with( $this->equalTo( [ 'all' ] ) );
+
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'setOption' );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [ 'reset' => '' ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testResetKinds() {
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'resetOptions' )
+ ->with( $this->equalTo( [ 'registered' ] ) );
+
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'setOption' );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [ 'reset' => '', 'resetkinds' => 'registered' ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testOptionWithValue() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'name' ), $this->equalTo( 'value' ) );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [ 'optionname' => 'name', 'optionvalue' => 'value' ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testOptionResetValue() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'name' ), $this->identicalTo( null ) );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [ 'optionname' => 'name' ] );
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testChange() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->exactly( 3 ) )
+ ->method( 'setOption' )
+ ->withConsecutive(
+ [ $this->equalTo( 'willBeNull' ), $this->identicalTo( null ) ],
+ [ $this->equalTo( 'willBeEmpty' ), $this->equalTo( '' ) ],
+ [ $this->equalTo( 'willBeHappy' ), $this->equalTo( 'Happy' ) ]
+ );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [
+ 'change' => 'willBeNull|willBeEmpty=|willBeHappy=Happy'
+ ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testResetChangeOption() {
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->exactly( 2 ) )
+ ->method( 'setOption' )
+ ->withConsecutive(
+ [ $this->equalTo( 'willBeHappy' ), $this->equalTo( 'Happy' ) ],
+ [ $this->equalTo( 'name' ), $this->equalTo( 'value' ) ]
+ );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $args = [
+ 'reset' => '',
+ 'change' => 'willBeHappy=Happy',
+ 'optionname' => 'name',
+ 'optionvalue' => 'value'
+ ];
+
+ $response = $this->executeQuery( $this->getSampleRequest( $args ) );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testMultiSelect() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->exactly( 4 ) )
+ ->method( 'setOption' )
+ ->withConsecutive(
+ [ $this->equalTo( 'testmultiselect-opt1' ), $this->identicalTo( true ) ],
+ [ $this->equalTo( 'testmultiselect-opt2' ), $this->identicalTo( null ) ],
+ [ $this->equalTo( 'testmultiselect-opt3' ), $this->identicalTo( false ) ],
+ [ $this->equalTo( 'testmultiselect-opt4' ), $this->identicalTo( false ) ]
+ );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [
+ 'change' => 'testmultiselect-opt1=1|testmultiselect-opt2|'
+ . 'testmultiselect-opt3=|testmultiselect-opt4=0'
+ ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testSpecialOption() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [
+ 'change' => 'special=1'
+ ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( [
+ 'options' => 'success',
+ 'warnings' => [
+ 'options' => [
+ 'warnings' => "Validation error for \"special\": cannot be set by this module."
+ ]
+ ]
+ ], $response );
+ }
+
+ public function testUnknownOption() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [
+ 'change' => 'unknownOption=1'
+ ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( [
+ 'options' => 'success',
+ 'warnings' => [
+ 'options' => [
+ 'warnings' => "Validation error for \"unknownOption\": not a valid preference."
+ ]
+ ]
+ ], $response );
+ }
+
+ public function testUserjsOption() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'userjs-option' ), $this->equalTo( '1' ) );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( [
+ 'change' => 'userjs-option=1'
+ ] );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiPageSetTest.php b/www/wiki/tests/phpunit/includes/api/ApiPageSetTest.php
new file mode 100644
index 00000000..b9e4645d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiPageSetTest.php
@@ -0,0 +1,179 @@
+<?php
+
+/**
+ * @group API
+ * @group medium
+ * @group Database
+ * @covers ApiPageSet
+ */
+class ApiPageSetTest extends ApiTestCase {
+ public static function provideRedirectMergePolicy() {
+ return [
+ 'By default nothing is merged' => [
+ null,
+ []
+ ],
+
+ 'A simple merge policy adds the redirect data in' => [
+ function ( $current, $new ) {
+ if ( !isset( $current['index'] ) || $new['index'] < $current['index'] ) {
+ $current['index'] = $new['index'];
+ }
+ return $current;
+ },
+ [ 'index' => 1 ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideRedirectMergePolicy
+ */
+ public function testRedirectMergePolicyWithArrayResult( $mergePolicy, $expect ) {
+ list( $target, $pageSet ) = $this->createPageSetWithRedirect();
+ $pageSet->setRedirectMergePolicy( $mergePolicy );
+ $result = [
+ $target->getArticleID() => []
+ ];
+ $pageSet->populateGeneratorData( $result );
+ $this->assertEquals( $expect, $result[$target->getArticleID()] );
+ }
+
+ /**
+ * @dataProvider provideRedirectMergePolicy
+ */
+ public function testRedirectMergePolicyWithApiResult( $mergePolicy, $expect ) {
+ list( $target, $pageSet ) = $this->createPageSetWithRedirect();
+ $pageSet->setRedirectMergePolicy( $mergePolicy );
+ $result = new ApiResult( false );
+ $result->addValue( null, 'pages', [
+ $target->getArticleID() => []
+ ] );
+ $pageSet->populateGeneratorData( $result, [ 'pages' ] );
+ $this->assertEquals(
+ $expect,
+ $result->getResultData( [ 'pages', $target->getArticleID() ] )
+ );
+ }
+
+ protected function createPageSetWithRedirect() {
+ $target = Title::makeTitle( NS_MAIN, 'UTRedirectTarget' );
+ $sourceA = Title::makeTitle( NS_MAIN, 'UTRedirectSourceA' );
+ $sourceB = Title::makeTitle( NS_MAIN, 'UTRedirectSourceB' );
+ self::editPage( 'UTRedirectTarget', 'api page set test' );
+ self::editPage( 'UTRedirectSourceA', '#REDIRECT [[UTRedirectTarget]]' );
+ self::editPage( 'UTRedirectSourceB', '#REDIRECT [[UTRedirectTarget]]' );
+
+ $request = new FauxRequest( [ 'redirects' => 1 ] );
+ $context = new RequestContext();
+ $context->setRequest( $request );
+ $main = new ApiMain( $context );
+ $pageSet = new ApiPageSet( $main );
+
+ $pageSet->setGeneratorData( $sourceA, [ 'index' => 1 ] );
+ $pageSet->setGeneratorData( $sourceB, [ 'index' => 3 ] );
+ $pageSet->populateFromTitles( [ $sourceA, $sourceB ] );
+
+ return [ $target, $pageSet ];
+ }
+
+ public function testHandleNormalization() {
+ $context = new RequestContext();
+ $context->setRequest( new FauxRequest( [ 'titles' => "a|B|a\xcc\x8a" ] ) );
+ $main = new ApiMain( $context );
+ $pageSet = new ApiPageSet( $main );
+ $pageSet->execute();
+
+ $this->assertSame(
+ [ 0 => [ 'A' => -1, 'B' => -2, 'Å' => -3 ] ],
+ $pageSet->getAllTitlesByNamespace()
+ );
+ $this->assertSame(
+ [
+ [ 'fromencoded' => true, 'from' => 'a%CC%8A', 'to' => 'å' ],
+ [ 'fromencoded' => false, 'from' => 'a', 'to' => 'A' ],
+ [ 'fromencoded' => false, 'from' => 'å', 'to' => 'Å' ],
+ ],
+ $pageSet->getNormalizedTitlesAsResult()
+ );
+ }
+
+ public function testSpecialRedirects() {
+ $id1 = self::editPage( 'UTApiPageSet', 'UTApiPageSet in the default language' )
+ ->value['revision']->getTitle()->getArticleID();
+ $id2 = self::editPage( 'UTApiPageSet/de', 'UTApiPageSet in German' )
+ ->value['revision']->getTitle()->getArticleID();
+
+ $user = $this->getTestUser()->getUser();
+ $userName = $user->getName();
+ $userDbkey = str_replace( ' ', '_', $userName );
+ $request = new FauxRequest( [
+ 'titles' => implode( '|', [
+ 'Special:MyContributions',
+ 'Special:MyPage',
+ 'Special:MyTalk/subpage',
+ 'Special:MyLanguage/UTApiPageSet',
+ ] ),
+ ] );
+ $context = new RequestContext();
+ $context->setRequest( $request );
+ $context->setUser( $user );
+
+ $main = new ApiMain( $context );
+ $pageSet = new ApiPageSet( $main );
+ $pageSet->execute();
+
+ $this->assertEquals( [
+ ], $pageSet->getRedirectTitlesAsResult() );
+ $this->assertEquals( [
+ [ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ],
+ [ 'ns' => -1, 'title' => 'Special:MyPage', 'special' => true ],
+ [ 'ns' => -1, 'title' => 'Special:MyTalk/subpage', 'special' => true ],
+ [ 'ns' => -1, 'title' => 'Special:MyLanguage/UTApiPageSet', 'special' => true ],
+ ], $pageSet->getInvalidTitlesAndRevisions() );
+ $this->assertEquals( [
+ ], $pageSet->getAllTitlesByNamespace() );
+
+ $request->setVal( 'redirects', 1 );
+ $main = new ApiMain( $context );
+ $pageSet = new ApiPageSet( $main );
+ $pageSet->execute();
+
+ $this->assertEquals( [
+ [ 'from' => 'Special:MyPage', 'to' => "User:$userName" ],
+ [ 'from' => 'Special:MyTalk/subpage', 'to' => "User talk:$userName/subpage" ],
+ [ 'from' => 'Special:MyLanguage/UTApiPageSet', 'to' => 'UTApiPageSet' ],
+ ], $pageSet->getRedirectTitlesAsResult() );
+ $this->assertEquals( [
+ [ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ],
+ [ 'ns' => 2, 'title' => "User:$userName", 'missing' => true ],
+ [ 'ns' => 3, 'title' => "User talk:$userName/subpage", 'missing' => true ],
+ ], $pageSet->getInvalidTitlesAndRevisions() );
+ $this->assertEquals( [
+ 0 => [ 'UTApiPageSet' => $id1 ],
+ 2 => [ $userDbkey => -2 ],
+ 3 => [ "$userDbkey/subpage" => -3 ],
+ ], $pageSet->getAllTitlesByNamespace() );
+
+ $context->setLanguage( 'de' );
+ $main = new ApiMain( $context );
+ $pageSet = new ApiPageSet( $main );
+ $pageSet->execute();
+
+ $this->assertEquals( [
+ [ 'from' => 'Special:MyPage', 'to' => "User:$userName" ],
+ [ 'from' => 'Special:MyTalk/subpage', 'to' => "User talk:$userName/subpage" ],
+ [ 'from' => 'Special:MyLanguage/UTApiPageSet', 'to' => 'UTApiPageSet/de' ],
+ ], $pageSet->getRedirectTitlesAsResult() );
+ $this->assertEquals( [
+ [ 'ns' => -1, 'title' => 'Special:MyContributions', 'special' => true ],
+ [ 'ns' => 2, 'title' => "User:$userName", 'missing' => true ],
+ [ 'ns' => 3, 'title' => "User talk:$userName/subpage", 'missing' => true ],
+ ], $pageSet->getInvalidTitlesAndRevisions() );
+ $this->assertEquals( [
+ 0 => [ 'UTApiPageSet/de' => $id2 ],
+ 2 => [ $userDbkey => -2 ],
+ 3 => [ "$userDbkey/subpage" => -3 ],
+ ], $pageSet->getAllTitlesByNamespace() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiParseTest.php b/www/wiki/tests/phpunit/includes/api/ApiParseTest.php
new file mode 100644
index 00000000..a04271f6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiParseTest.php
@@ -0,0 +1,849 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiParse
+ */
+class ApiParseTest extends ApiTestCase {
+
+ protected static $pageId;
+ protected static $revIds = [];
+
+ public function addDBDataOnce() {
+ $title = Title::newFromText( __CLASS__ );
+
+ $status = $this->editPage( __CLASS__, 'Test for revdel' );
+ self::$pageId = $status->value['revision']->getPage();
+ self::$revIds['revdel'] = $status->value['revision']->getId();
+
+ $status = $this->editPage( __CLASS__, 'Test for suppressed' );
+ self::$revIds['suppressed'] = $status->value['revision']->getId();
+
+ $status = $this->editPage( __CLASS__, 'Test for oldid' );
+ self::$revIds['oldid'] = $status->value['revision']->getId();
+
+ $status = $this->editPage( __CLASS__, 'Test for latest' );
+ self::$revIds['latest'] = $status->value['revision']->getId();
+
+ $this->revisionDelete( self::$revIds['revdel'] );
+ $this->revisionDelete(
+ self::$revIds['suppressed'],
+ [ Revision::DELETED_TEXT => 1, Revision::DELETED_RESTRICTED => 1 ]
+ );
+
+ Title::clearCaches(); // Otherwise it has the wrong latest revision for some reason
+ }
+
+ /**
+ * Assert that the given result of calling $this->doApiRequest() with
+ * action=parse resulted in $html, accounting for the boilerplate that the
+ * parser adds around the parsed page. Also asserts that warnings match
+ * the provided $warning.
+ *
+ * @param string $html Expected HTML
+ * @param array $res Returned from doApiRequest()
+ * @param string|null $warnings Exact value of expected warnings, null for
+ * no warnings
+ */
+ protected function assertParsedTo( $expected, array $res, $warnings = null ) {
+ $this->doAssertParsedTo( $expected, $res, $warnings, [ $this, 'assertSame' ] );
+ }
+
+ /**
+ * Same as above, but asserts that the HTML matches a regexp instead of a
+ * literal string match.
+ *
+ * @param string $html Expected HTML
+ * @param array $res Returned from doApiRequest()
+ * @param string|null $warnings Exact value of expected warnings, null for
+ * no warnings
+ */
+ protected function assertParsedToRegExp( $expected, array $res, $warnings = null ) {
+ $this->doAssertParsedTo( $expected, $res, $warnings, [ $this, 'assertRegExp' ] );
+ }
+
+ private function doAssertParsedTo( $expected, array $res, $warnings, callable $callback ) {
+ $html = $res[0]['parse']['text'];
+
+ $expectedStart = '<div class="mw-parser-output">';
+ $this->assertSame( $expectedStart, substr( $html, 0, strlen( $expectedStart ) ) );
+
+ $html = substr( $html, strlen( $expectedStart ) );
+
+ if ( $res[1]->getBool( 'disablelimitreport' ) ) {
+ $expectedEnd = "</div>";
+ $this->assertSame( $expectedEnd, substr( $html, -strlen( $expectedEnd ) ) );
+
+ $html = substr( $html, 0, strlen( $html ) - strlen( $expectedEnd ) );
+ } else {
+ $expectedEnd = '#\n<!-- \nNewPP limit report\n(?>.+?\n-->)\n' .
+ '<!--\nTransclusion expansion time report \(%,ms,calls,template\)\n(?>.*?\n-->)\n' .
+ '</div>(\n<!-- Saved in parser cache (?>.*?\n -->)\n)?$#s';
+ $this->assertRegExp( $expectedEnd, $html );
+
+ $html = preg_replace( $expectedEnd, '', $html );
+ }
+
+ call_user_func( $callback, $expected, $html );
+
+ if ( $warnings === null ) {
+ $this->assertCount( 1, $res[0] );
+ } else {
+ $this->assertCount( 2, $res[0] );
+ // This deliberately fails if there are extra warnings
+ $this->assertSame( [ 'parse' => [ 'warnings' => $warnings ] ], $res[0]['warnings'] );
+ }
+ }
+
+ /**
+ * Set up an interwiki entry for testing.
+ */
+ protected function setupInterwiki() {
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->insert(
+ 'interwiki',
+ [
+ 'iw_prefix' => 'madeuplanguage',
+ 'iw_url' => "https://example.com/wiki/$1",
+ 'iw_api' => '',
+ 'iw_wikiid' => '',
+ 'iw_local' => false,
+ ],
+ __METHOD__,
+ 'IGNORE'
+ );
+
+ $this->setMwGlobals( 'wgExtraInterlanguageLinkPrefixes', [ 'madeuplanguage' ] );
+ $this->tablesUsed[] = 'interwiki';
+ }
+
+ /**
+ * Set up a skin for testing.
+ *
+ * @todo Should this code be in MediaWikiTestCase or something?
+ */
+ protected function setupSkin() {
+ $factory = new SkinFactory();
+ $factory->register( 'testing', 'Testing', function () {
+ $skin = $this->getMockBuilder( SkinFallback::class )
+ ->setMethods( [ 'getDefaultModules', 'setupSkinUserCss' ] )
+ ->getMock();
+ $skin->expects( $this->once() )->method( 'getDefaultModules' )
+ ->willReturn( [
+ 'core' => [ 'foo', 'bar' ],
+ 'content' => [ 'baz' ]
+ ] );
+ $skin->expects( $this->once() )->method( 'setupSkinUserCss' )
+ ->will( $this->returnCallback( function ( OutputPage $out ) {
+ $out->addModuleStyles( 'foo.styles' );
+ } ) );
+ return $skin;
+ } );
+ $this->setService( 'SkinFactory', $factory );
+ }
+
+ public function testParseByName() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => __CLASS__,
+ ] );
+ $this->assertParsedTo( "<p>Test for latest\n</p>", $res );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => __CLASS__,
+ 'disablelimitreport' => 1,
+ ] );
+ $this->assertParsedTo( "<p>Test for latest\n</p>", $res );
+ }
+
+ public function testParseById() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'pageid' => self::$pageId,
+ ] );
+ $this->assertParsedTo( "<p>Test for latest\n</p>", $res );
+ }
+
+ public function testParseByOldId() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'oldid' => self::$revIds['oldid'],
+ ] );
+ $this->assertParsedTo( "<p>Test for oldid\n</p>", $res );
+ $this->assertArrayNotHasKey( 'textdeleted', $res[0]['parse'] );
+ $this->assertArrayNotHasKey( 'textsuppressed', $res[0]['parse'] );
+ }
+
+ public function testRevDel() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'oldid' => self::$revIds['revdel'],
+ ] );
+
+ $this->assertParsedTo( "<p>Test for revdel\n</p>", $res );
+ $this->assertArrayHasKey( 'textdeleted', $res[0]['parse'] );
+ $this->assertArrayNotHasKey( 'textsuppressed', $res[0]['parse'] );
+ }
+
+ public function testRevDelNoPermission() {
+ $this->setExpectedException( ApiUsageException::class,
+ "You don't have permission to view deleted revision text." );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'oldid' => self::$revIds['revdel'],
+ ], null, null, static::getTestUser()->getUser() );
+ }
+
+ public function testSuppressed() {
+ $this->setGroupPermissions( 'sysop', 'viewsuppressed', true );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'oldid' => self::$revIds['suppressed']
+ ] );
+
+ $this->assertParsedTo( "<p>Test for suppressed\n</p>", $res );
+ $this->assertArrayHasKey( 'textsuppressed', $res[0]['parse'] );
+ $this->assertArrayHasKey( 'textdeleted', $res[0]['parse'] );
+ }
+
+ public function testNonexistentPage() {
+ try {
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => 'DoesNotExist',
+ ] );
+
+ $this->fail( "API did not return an error when parsing a nonexistent page" );
+ } catch ( ApiUsageException $ex ) {
+ $this->assertTrue( ApiTestCase::apiExceptionHasCode( $ex, 'missingtitle' ),
+ "Parse request for nonexistent page must give 'missingtitle' error: "
+ . var_export( self::getErrorFormatter()->arrayFromStatus( $ex->getStatusValue() ), true )
+ );
+ }
+ }
+
+ public function testTitleProvided() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => 'Some interesting page',
+ 'text' => '{{PAGENAME}} has attracted my attention',
+ ] );
+
+ $this->assertParsedTo( "<p>Some interesting page has attracted my attention\n</p>", $res );
+ }
+
+ public function testSection() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name,
+ "Intro\n\n== Section 1 ==\n\nContent 1\n\n== Section 2 ==\n\nContent 2" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ 'section' => 1,
+ ] );
+
+ $this->assertParsedToRegExp( '!<h2>.*Section 1.*</h2>\n<p>Content 1\n</p>!', $res );
+ }
+
+ public function testInvalidSection() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'The "section" parameter must be a valid section ID or "new".' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'section' => 'T-new',
+ ] );
+ }
+
+ public function testSectionNoContent() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $status = $this->editPage( $name,
+ "Intro\n\n== Section 1 ==\n\nContent 1\n\n== Section 2 ==\n\nContent 2" );
+
+ $this->setExpectedException( ApiUsageException::class,
+ "Missing content for page ID {$status->value['revision']->getPage()}." );
+
+ $this->db->delete( 'revision', [ 'rev_id' => $status->value['revision']->getId() ] );
+
+ // Suppress warning in WikiPage::getContentModel
+ Wikimedia\suppressWarnings();
+ try {
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ 'section' => 1,
+ ] );
+ } finally {
+ Wikimedia\restoreWarnings();
+ }
+ }
+
+ public function testNewSectionWithPage() {
+ $this->setExpectedException( ApiUsageException::class,
+ '"section=new" cannot be combined with the "oldid", "pageid" or "page" ' .
+ 'parameters. Please use "title" and "text".' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => __CLASS__,
+ 'section' => 'new',
+ ] );
+ }
+
+ public function testNonexistentOldId() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'There is no revision with ID 2147483647.' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'oldid' => pow( 2, 31 ) - 1,
+ ] );
+ }
+
+ public function testUnfollowedRedirect() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "#REDIRECT [[$name 2]]" );
+ $this->editPage( "$name 2", "Some ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ ] );
+
+ // Can't use assertParsedTo because the parser output is different for
+ // redirects
+ $this->assertRegExp( "/Redirect to:.*$name 2/", $res[0]['parse']['text'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testFollowedRedirect() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "#REDIRECT [[$name 2]]" );
+ $this->editPage( "$name 2", "Some ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ 'redirects' => true,
+ ] );
+
+ $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res );
+ }
+
+ public function testFollowedRedirectById() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $id = $this->editPage( $name, "#REDIRECT [[$name 2]]" )->value['revision']->getPage();
+ $this->editPage( "$name 2", "Some ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'pageid' => $id,
+ 'redirects' => true,
+ ] );
+
+ $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res );
+ }
+
+ public function testInvalidTitle() {
+ $this->setExpectedException( ApiUsageException::class, 'Bad title "|".' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => '|',
+ ] );
+ }
+
+ public function testTitleWithNonexistentRevId() {
+ $this->setExpectedException( ApiUsageException::class,
+ 'There is no revision with ID 2147483647.' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'revid' => pow( 2, 31 ) - 1,
+ ] );
+ }
+
+ public function testTitleWithNonMatchingRevId() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => $name,
+ 'revid' => self::$revIds['latest'],
+ 'text' => 'Some text',
+ ] );
+
+ $this->assertParsedTo( "<p>Some text\n</p>", $res,
+ 'r' . self::$revIds['latest'] . " is not a revision of $name." );
+ }
+
+ public function testRevId() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'revid' => self::$revIds['latest'],
+ 'text' => 'My revid is {{REVISIONID}}!',
+ ] );
+
+ $this->assertParsedTo( "<p>My revid is " . self::$revIds['latest'] . "!\n</p>", $res );
+ }
+
+ public function testTitleNoText() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => 'Special:AllPages',
+ ] );
+
+ $this->assertParsedTo( '', $res,
+ '"title" used without "text", and parsed page properties were requested. ' .
+ 'Did you mean to use "page" instead of "title"?' );
+ }
+
+ public function testRevidNoText() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'revid' => self::$revIds['latest'],
+ ] );
+
+ $this->assertParsedTo( '', $res,
+ '"revid" used without "text", and parsed page properties were requested. ' .
+ 'Did you mean to use "oldid" instead of "revid"?' );
+ }
+
+ public function testTextNoContentModel() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "Some ''text''",
+ ] );
+
+ $this->assertParsedTo( "<p>Some <i>text</i>\n</p>", $res,
+ 'No "title" or "contentmodel" was given, assuming wikitext.' );
+ }
+
+ public function testSerializationError() {
+ $this->setExpectedException( APIUsageException::class,
+ 'Content serialization failed: Could not unserialize content' );
+
+ $this->mergeMwGlobalArrayValue( 'wgContentHandlers',
+ [ 'testing-serialize-error' => 'DummySerializeErrorContentHandler' ] );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "Some ''text''",
+ 'contentmodel' => 'testing-serialize-error',
+ ] );
+ }
+
+ public function testNewSection() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'section' => 'new',
+ 'sectiontitle' => 'Title',
+ 'text' => 'Content',
+ ] );
+
+ $this->assertParsedToRegExp( '!<h2>.*Title.*</h2>\n<p>Content\n</p>!', $res );
+ }
+
+ public function testExistingSection() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'section' => 1,
+ 'text' => "Intro\n\n== Section 1 ==\n\nContent\n\n== Section 2 ==\n\nMore content",
+ ] );
+
+ $this->assertParsedToRegExp( '!<h2>.*Section 1.*</h2>\n<p>Content\n</p>!', $res );
+ }
+
+ public function testNoPst() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( "Template:$name", "Template ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "{{subst:$name}}",
+ 'contentmodel' => 'wikitext',
+ ] );
+
+ $this->assertParsedTo( "<p>{{subst:$name}}\n</p>", $res );
+ }
+
+ public function testPst() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( "Template:$name", "Template ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'pst' => '',
+ 'text' => "{{subst:$name}}",
+ 'contentmodel' => 'wikitext',
+ 'prop' => 'text|wikitext',
+ ] );
+
+ $this->assertParsedTo( "<p>Template <i>text</i>\n</p>", $res );
+ $this->assertSame( "{{subst:$name}}", $res[0]['parse']['wikitext'] );
+ }
+
+ public function testOnlyPst() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( "Template:$name", "Template ''text''" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'onlypst' => '',
+ 'text' => "{{subst:$name}}",
+ 'contentmodel' => 'wikitext',
+ 'prop' => 'text|wikitext',
+ 'summary' => 'Summary',
+ ] );
+
+ $this->assertSame(
+ [ 'parse' => [
+ 'text' => "Template ''text''",
+ 'wikitext' => "{{subst:$name}}",
+ 'parsedsummary' => 'Summary',
+ ] ],
+ $res[0]
+ );
+ }
+
+ public function testHeadHtml() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => __CLASS__,
+ 'prop' => 'headhtml',
+ ] );
+
+ // Just do a rough sanity check
+ $this->assertRegExp( '#<!DOCTYPE.*<html.*<head.*</head>.*<body#s',
+ $res[0]['parse']['headhtml'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testCategoriesHtml() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( $name, "[[Category:$name]]" );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'page' => $name,
+ 'prop' => 'categorieshtml',
+ ] );
+
+ $this->assertRegExp( "#Category.*Category:$name.*$name#",
+ $res[0]['parse']['categorieshtml'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testEffectiveLangLinks() {
+ $hookRan = false;
+ $this->setTemporaryHook( 'LanguageLinks',
+ function () use ( &$hookRan ) {
+ $hookRan = true;
+ }
+ );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => '[[zh:' . __CLASS__ . ']]',
+ 'effectivelanglinks' => '',
+ ] );
+
+ $this->assertTrue( $hookRan );
+ $this->assertSame( 'The parameter "effectivelanglinks" has been deprecated.',
+ $res[0]['warnings']['parse']['warnings'] );
+ }
+
+ /**
+ * @param array $arr Extra params to add to API request
+ */
+ private function doTestLangLinks( array $arr = [] ) {
+ $this->setupInterwiki();
+
+ $res = $this->doApiRequest( array_merge( [
+ 'action' => 'parse',
+ 'title' => 'Omelette',
+ 'text' => '[[madeuplanguage:Omelette]]',
+ 'prop' => 'langlinks',
+ ], $arr ) );
+
+ $langLinks = $res[0]['parse']['langlinks'];
+
+ $this->assertCount( 1, $langLinks );
+ $this->assertSame( 'madeuplanguage', $langLinks[0]['lang'] );
+ $this->assertSame( 'Omelette', $langLinks[0]['title'] );
+ $this->assertSame( 'https://example.com/wiki/Omelette', $langLinks[0]['url'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testLangLinks() {
+ $this->doTestLangLinks();
+ }
+
+ public function testLangLinksWithSkin() {
+ $this->setupSkin();
+ $this->doTestLangLinks( [ 'useskin' => 'testing' ] );
+ }
+
+ public function testHeadItems() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => '',
+ 'prop' => 'headitems',
+ ] );
+
+ $this->assertSame( [], $res[0]['parse']['headitems'] );
+ $this->assertSame(
+ '"prop=headitems" is deprecated since MediaWiki 1.28. ' .
+ 'Use "prop=headhtml" when creating new HTML documents, ' .
+ 'or "prop=modules|jsconfigvars" when updating a document client-side.',
+ $res[0]['warnings']['parse']['warnings']
+ );
+ }
+
+ public function testHeadItemsWithSkin() {
+ $this->setupSkin();
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => '',
+ 'prop' => 'headitems',
+ 'useskin' => 'testing',
+ ] );
+
+ $this->assertSame( [], $res[0]['parse']['headitems'] );
+ $this->assertSame(
+ '"prop=headitems" is deprecated since MediaWiki 1.28. ' .
+ 'Use "prop=headhtml" when creating new HTML documents, ' .
+ 'or "prop=modules|jsconfigvars" when updating a document client-side.',
+ $res[0]['warnings']['parse']['warnings']
+ );
+ }
+
+ public function testModules() {
+ $this->setTemporaryHook( 'ParserAfterParse',
+ function ( $parser ) {
+ $output = $parser->getOutput();
+ $output->addModules( [ 'foo', 'bar' ] );
+ $output->addModuleScripts( [ 'baz', 'quuz' ] );
+ $output->addModuleStyles( [ 'aaa', 'zzz' ] );
+ $output->addJsConfigVars( [ 'x' => 'y', 'z' => -3 ] );
+ }
+ );
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => 'Content',
+ 'prop' => 'modules|jsconfigvars|encodedjsconfigvars',
+ ] );
+
+ $this->assertSame( [ 'foo', 'bar' ], $res[0]['parse']['modules'] );
+ $this->assertSame( [ 'baz', 'quuz' ], $res[0]['parse']['modulescripts'] );
+ $this->assertSame( [ 'aaa', 'zzz' ], $res[0]['parse']['modulestyles'] );
+ $this->assertSame( [ 'x' => 'y', 'z' => -3 ], $res[0]['parse']['jsconfigvars'] );
+ $this->assertSame( '{"x":"y","z":-3}', $res[0]['parse']['encodedjsconfigvars'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testModulesWithSkin() {
+ $this->setupSkin();
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'pageid' => self::$pageId,
+ 'useskin' => 'testing',
+ 'prop' => 'modules',
+ ] );
+ $this->assertSame(
+ [ 'foo', 'bar', 'baz' ],
+ $res[0]['parse']['modules'],
+ 'resp.parse.modules'
+ );
+ $this->assertSame(
+ [],
+ $res[0]['parse']['modulescripts'],
+ 'resp.parse.modulescripts'
+ );
+ $this->assertSame(
+ [ 'foo.styles' ],
+ $res[0]['parse']['modulestyles'],
+ 'resp.parse.modulestyles'
+ );
+ $this->assertSame(
+ [ 'parse' =>
+ [ 'warnings' =>
+ 'Property "modules" was set but not "jsconfigvars" or ' .
+ '"encodedjsconfigvars". Configuration variables are necessary for ' .
+ 'proper module usage.'
+ ]
+ ],
+ $res[0]['warnings']
+ );
+ }
+
+ public function testIndicators() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' =>
+ '<indicator name="b">BBB!</indicator>Some text<indicator name="a">aaa</indicator>',
+ 'prop' => 'indicators',
+ ] );
+
+ $this->assertSame(
+ // It seems we return in markup order and not display order
+ [ 'b' => 'BBB!', 'a' => 'aaa' ],
+ $res[0]['parse']['indicators']
+ );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testIndicatorsWithSkin() {
+ $this->setupSkin();
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' =>
+ '<indicator name="b">BBB!</indicator>Some text<indicator name="a">aaa</indicator>',
+ 'prop' => 'indicators',
+ 'useskin' => 'testing',
+ ] );
+
+ $this->assertSame(
+ // Now we return in display order rather than markup order
+ [ 'a' => 'aaa', 'b' => 'BBB!' ],
+ $res[0]['parse']['indicators']
+ );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testIwlinks() {
+ $this->setupInterwiki();
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => 'Omelette',
+ 'text' => '[[:madeuplanguage:Omelette]][[madeuplanguage:Spaghetti]]',
+ 'prop' => 'iwlinks',
+ ] );
+
+ $iwlinks = $res[0]['parse']['iwlinks'];
+
+ $this->assertCount( 1, $iwlinks );
+ $this->assertSame( 'madeuplanguage', $iwlinks[0]['prefix'] );
+ $this->assertSame( 'https://example.com/wiki/Omelette', $iwlinks[0]['url'] );
+ $this->assertSame( 'madeuplanguage:Omelette', $iwlinks[0]['title'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testLimitReports() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'pageid' => self::$pageId,
+ 'prop' => 'limitreportdata|limitreporthtml',
+ ] );
+
+ // We don't bother testing the actual values here
+ $this->assertInternalType( 'array', $res[0]['parse']['limitreportdata'] );
+ $this->assertInternalType( 'string', $res[0]['parse']['limitreporthtml'] );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testParseTreeNonWikitext() {
+ $this->setExpectedException( ApiUsageException::class,
+ '"prop=parsetree" is only supported for wikitext content.' );
+
+ $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => '',
+ 'contentmodel' => 'json',
+ 'prop' => 'parsetree',
+ ] );
+ }
+
+ public function testParseTree() {
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "Some ''text'' is {{nice|to have|i=think}}",
+ 'contentmodel' => 'wikitext',
+ 'prop' => 'parsetree',
+ ] );
+
+ // Preprocessor_DOM and Preprocessor_Hash give different results here,
+ // so we'll accept either
+ $this->assertRegExp(
+ '#^<root>Some \'\'text\'\' is <template><title>nice</title>' .
+ '<part><name index="1"/><value>to have</value></part>' .
+ '<part><name>i</name>(?:<equals>)?=(?:</equals>)?<value>think</value></part>' .
+ '</template></root>$#',
+ $res[0]['parse']['parsetree']
+ );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ public function testDisableTidy() {
+ $this->setMwGlobals( 'wgTidyConfig', [ 'driver' => 'RemexHtml' ] );
+
+ // Check that disabletidy doesn't have an effect just because tidying
+ // doesn't work for some other reason
+ $res1 = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "<b>Mixed <i>up</b></i>",
+ 'contentmodel' => 'wikitext',
+ ] );
+ $this->assertParsedTo( "<p><b>Mixed <i>up</i></b>\n</p>", $res1 );
+
+ $res2 = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'text' => "<b>Mixed <i>up</b></i>",
+ 'contentmodel' => 'wikitext',
+ 'disabletidy' => '',
+ ] );
+
+ $this->assertParsedTo( "<p><b>Mixed <i>up</b></i>\n</p>", $res2 );
+ }
+
+ public function testFormatCategories() {
+ $name = ucfirst( __FUNCTION__ );
+
+ $this->editPage( "Category:$name", 'Content' );
+ $this->editPage( 'Category:Hidden', '__HIDDENCAT__' );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'parse',
+ 'title' => __CLASS__,
+ 'text' => "[[Category:$name]][[Category:Foo|Sort me]][[Category:Hidden]]",
+ 'prop' => 'categories',
+ ] );
+
+ $this->assertSame(
+ [ [ 'sortkey' => '', 'category' => $name ],
+ [ 'sortkey' => 'Sort me', 'category' => 'Foo', 'missing' => true ],
+ [ 'sortkey' => '', 'category' => 'Hidden', 'hidden' => true ] ],
+ $res[0]['parse']['categories']
+ );
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiPurgeTest.php b/www/wiki/tests/phpunit/includes/api/ApiPurgeTest.php
new file mode 100644
index 00000000..96d9a384
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiPurgeTest.php
@@ -0,0 +1,40 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiPurge
+ */
+class ApiPurgeTest extends ApiTestCase {
+
+ /**
+ * @group Broken
+ */
+ public function testPurgeMainPage() {
+ if ( !Title::newFromText( 'UTPage' )->exists() ) {
+ $this->markTestIncomplete( "The article [[UTPage]] does not exist" );
+ }
+
+ $somePage = mt_rand();
+
+ $data = $this->doApiRequest( [
+ 'action' => 'purge',
+ 'titles' => 'UTPage|' . $somePage . '|%5D' ] );
+
+ $this->assertArrayHasKey( 'purge', $data[0],
+ "Must receive a 'purge' result from API" );
+
+ $this->assertEquals(
+ 3,
+ count( $data[0]['purge'] ),
+ "Purge request for three articles should give back three results received: "
+ . var_export( $data[0]['purge'], true ) );
+
+ $pages = [ 'UTPage' => 'purged', $somePage => 'missing', '%5D' => 'invalid' ];
+ foreach ( $data[0]['purge'] as $v ) {
+ $this->assertArrayHasKey( $pages[$v['title']], $v );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiQueryAllPagesTest.php b/www/wiki/tests/phpunit/includes/api/ApiQueryAllPagesTest.php
new file mode 100644
index 00000000..2d89aa54
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiQueryAllPagesTest.php
@@ -0,0 +1,36 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiQueryAllPages
+ */
+class ApiQueryAllPagesTest extends ApiTestCase {
+ /**
+ * Test T27702
+ * Prefixes of API search requests are not handled with case sensitivity and may result
+ * in wrong search results
+ */
+ public function testPrefixNormalizationSearchBug() {
+ $title = Title::newFromText( 'Category:Template:xyz' );
+ $page = WikiPage::factory( $title );
+
+ $page->doEditContent(
+ ContentHandler::makeContent( 'Some text', $page->getTitle() ),
+ 'inserting content'
+ );
+
+ $result = $this->doApiRequest( [
+ 'action' => 'query',
+ 'list' => 'allpages',
+ 'apnamespace' => NS_CATEGORY,
+ 'apprefix' => 'Template:x' ] );
+
+ $this->assertArrayHasKey( 'query', $result[0] );
+ $this->assertArrayHasKey( 'allpages', $result[0]['query'] );
+ $this->assertNotEquals( 0, count( $result[0]['query']['allpages'] ),
+ 'allpages list does not contain page Category:Template:xyz' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php b/www/wiki/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php
new file mode 100644
index 00000000..5b43dd1b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiQueryRecentChangesIntegrationTest.php
@@ -0,0 +1,976 @@
+<?php
+
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiQueryRecentChanges
+ */
+class ApiQueryRecentChangesIntegrationTest extends ApiTestCase {
+
+ public function __construct( $name = null, array $data = [], $dataName = '' ) {
+ parent::__construct( $name, $data, $dataName );
+
+ $this->tablesUsed[] = 'recentchanges';
+ $this->tablesUsed[] = 'page';
+ }
+
+ protected function setUp() {
+ parent::setUp();
+
+ self::$users['ApiQueryRecentChangesIntegrationTestUser'] = $this->getMutableTestUser();
+ wfGetDB( DB_MASTER )->delete( 'recentchanges', '*', __METHOD__ );
+ }
+
+ private function getLoggedInTestUser() {
+ return self::$users['ApiQueryRecentChangesIntegrationTestUser']->getUser();
+ }
+
+ private function doPageEdit( User $user, LinkTarget $target, $summary ) {
+ static $i = 0;
+
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( __CLASS__ . $i++, $title ),
+ $summary,
+ 0,
+ false,
+ $user
+ );
+ }
+
+ private function doMinorPageEdit( User $user, LinkTarget $target, $summary ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( __CLASS__, $title ),
+ $summary,
+ EDIT_MINOR,
+ false,
+ $user
+ );
+ }
+
+ private function doBotPageEdit( User $user, LinkTarget $target, $summary ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( __CLASS__, $title ),
+ $summary,
+ EDIT_FORCE_BOT,
+ false,
+ $user
+ );
+ }
+
+ private function doAnonPageEdit( LinkTarget $target, $summary ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( __CLASS__, $title ),
+ $summary,
+ 0,
+ false,
+ User::newFromId( 0 )
+ );
+ }
+
+ private function deletePage( LinkTarget $target, $reason ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doDeleteArticleReal( $reason );
+ }
+
+ /**
+ * Performs a batch of page edits as a specified user
+ * @param User $user
+ * @param array $editData associative array, keys:
+ * - target => LinkTarget page to edit
+ * - summary => string edit summary
+ * - minorEdit => bool mark as minor edit if true (defaults to false)
+ * - botEdit => bool mark as bot edit if true (defaults to false)
+ */
+ private function doPageEdits( User $user, array $editData ) {
+ foreach ( $editData as $singleEditData ) {
+ if ( array_key_exists( 'minorEdit', $singleEditData ) && $singleEditData['minorEdit'] ) {
+ $this->doMinorPageEdit(
+ $user,
+ $singleEditData['target'],
+ $singleEditData['summary']
+ );
+ continue;
+ }
+ if ( array_key_exists( 'botEdit', $singleEditData ) && $singleEditData['botEdit'] ) {
+ $this->doBotPageEdit(
+ $user,
+ $singleEditData['target'],
+ $singleEditData['summary']
+ );
+ continue;
+ }
+ $this->doPageEdit(
+ $user,
+ $singleEditData['target'],
+ $singleEditData['summary']
+ );
+ }
+ }
+
+ private function doListRecentChangesRequest( array $params = [] ) {
+ return $this->doApiRequest(
+ array_merge(
+ [ 'action' => 'query', 'list' => 'recentchanges' ],
+ $params
+ ),
+ null,
+ false,
+ $this->getLoggedInTestUser()
+ );
+ }
+
+ private function doGeneratorRecentChangesRequest( array $params = [] ) {
+ return $this->doApiRequest(
+ array_merge(
+ [ 'action' => 'query', 'generator' => 'recentchanges' ],
+ $params
+ ),
+ null,
+ false,
+ $this->getLoggedInTestUser()
+ );
+ }
+
+ private function getItemsFromApiResponse( array $response ) {
+ return $response[0]['query']['recentchanges'];
+ }
+
+ private function getTitleFormatter() {
+ return new MediaWikiTitleCodec(
+ Language::factory( 'en' ),
+ MediaWikiServices::getInstance()->getGenderCache()
+ );
+ }
+
+ private function getPrefixedText( LinkTarget $target ) {
+ $formatter = $this->getTitleFormatter();
+ return $formatter->getPrefixedText( $target );
+ }
+
+ public function testListRecentChanges_returnsRCInfo() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );
+
+ $result = $this->doListRecentChangesRequest();
+
+ $this->assertArrayHasKey( 'query', $result[0] );
+ $this->assertArrayHasKey( 'recentchanges', $result[0]['query'] );
+
+ $items = $this->getItemsFromApiResponse( $result );
+ $this->assertCount( 1, $items );
+ $item = $items[0];
+ $this->assertArraySubset(
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ $item
+ );
+ $this->assertArrayNotHasKey( 'bot', $item );
+ $this->assertArrayNotHasKey( 'new', $item );
+ $this->assertArrayNotHasKey( 'minor', $item );
+ $this->assertArrayHasKey( 'pageid', $item );
+ $this->assertArrayHasKey( 'revid', $item );
+ $this->assertArrayHasKey( 'old_revid', $item );
+ }
+
+ public function testIdsPropParameter() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'ids', ] );
+ $items = $this->getItemsFromApiResponse( $result );
+
+ $this->assertCount( 1, $items );
+ $this->assertArrayHasKey( 'pageid', $items[0] );
+ $this->assertArrayHasKey( 'revid', $items[0] );
+ $this->assertArrayHasKey( 'old_revid', $items[0] );
+ $this->assertEquals( 'new', $items[0]['type'] );
+ }
+
+ public function testTitlePropParameter() {
+ $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $subjectTarget,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'summary' => 'Create Talk page',
+ ],
+ ]
+ );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testFlagsPropParameter() {
+ $normalEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $minorEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPageM' );
+ $botEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPageB' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $normalEditTarget,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $minorEditTarget,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $minorEditTarget,
+ 'summary' => 'Change content',
+ 'minorEdit' => true,
+ ],
+ [
+ 'target' => $botEditTarget,
+ 'summary' => 'Create the page with a bot',
+ 'botEdit' => true,
+ ],
+ ]
+ );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'flags', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'new' => true,
+ 'minor' => false,
+ 'bot' => true,
+ ],
+ [
+ 'type' => 'edit',
+ 'new' => false,
+ 'minor' => true,
+ 'bot' => false,
+ ],
+ [
+ 'type' => 'new',
+ 'new' => true,
+ 'minor' => false,
+ 'bot' => false,
+ ],
+ [
+ 'type' => 'new',
+ 'new' => true,
+ 'minor' => false,
+ 'bot' => false,
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testUserPropParameter() {
+ $userEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $anonEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPageA' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $userEditTarget, 'Create the page' );
+ $this->doAnonPageEdit( $anonEditTarget, 'Create the page' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'user', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'anon' => true,
+ 'user' => User::newFromId( 0 )->getName(),
+ ],
+ [
+ 'type' => 'new',
+ 'user' => $this->getLoggedInTestUser()->getName(),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testUserIdPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $userEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $anonEditTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPageA' );
+ $this->doPageEdit( $user, $userEditTarget, 'Create the page' );
+ $this->doAnonPageEdit( $anonEditTarget, 'Create the page' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'userid', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'anon' => true,
+ 'userid' => 0,
+ ],
+ [
+ 'type' => 'new',
+ 'userid' => $user->getId(),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testCommentPropParameter() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the <b>page</b>' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'comment', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'comment' => 'Create the <b>page</b>',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testParsedCommentPropParameter() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the <b>page</b>' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'parsedcomment', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'parsedcomment' => 'Create the &lt;b&gt;page&lt;/b&gt;',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testTimestampPropParameter() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'timestamp', ] );
+ $items = $this->getItemsFromApiResponse( $result );
+
+ $this->assertCount( 1, $items );
+ $this->assertArrayHasKey( 'timestamp', $items[0] );
+ $this->assertInternalType( 'string', $items[0]['timestamp'] );
+ }
+
+ public function testSizesPropParameter() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'sizes', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'oldlen' => 0,
+ 'newlen' => 38,
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ private function createPageAndDeleteIt( LinkTarget $target ) {
+ $this->doPageEdit( $this->getLoggedInTestUser(),
+ $target,
+ 'Create the page that will be deleted'
+ );
+ $this->deletePage( $target, 'Important Reason' );
+ }
+
+ public function testLoginfoPropParameter() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->createPageAndDeleteIt( $target );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'loginfo', ] );
+
+ $items = $this->getItemsFromApiResponse( $result );
+ $this->assertCount( 1, $items );
+ $this->assertArraySubset(
+ [
+ 'type' => 'log',
+ 'logtype' => 'delete',
+ 'logaction' => 'delete',
+ 'logparams' => [],
+ ],
+ $items[0]
+ );
+ $this->assertArrayHasKey( 'logid', $items[0] );
+ }
+
+ public function testEmptyPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $user, $target, 'Create the page' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => '', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ ]
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testNamespaceParam() {
+ $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $subjectTarget,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'summary' => 'Create the talk page',
+ ],
+ ]
+ );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcnamespace' => '0', ] );
+
+ $items = $this->getItemsFromApiResponse( $result );
+ $this->assertCount( 1, $items );
+ $this->assertArraySubset(
+ [
+ 'ns' => 0,
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ $items[0]
+ );
+ }
+
+ public function testShowAnonParams() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doAnonPageEdit( $target, 'Create the page' );
+
+ $resultAnon = $this->doListRecentChangesRequest( [
+ 'rcprop' => 'user',
+ 'rcshow' => WatchedItemQueryService::FILTER_ANON
+ ] );
+ $resultNotAnon = $this->doListRecentChangesRequest( [
+ 'rcprop' => 'user',
+ 'rcshow' => WatchedItemQueryService::FILTER_NOT_ANON
+ ] );
+
+ $items = $this->getItemsFromApiResponse( $resultAnon );
+ $this->assertCount( 1, $items );
+ $this->assertArraySubset( [ 'anon' => true ], $items[0] );
+ $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotAnon ) );
+ }
+
+ public function testNewAndEditTypeParameters() {
+ $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $subjectTarget,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $subjectTarget,
+ 'summary' => 'Change the content',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'summary' => 'Create Talk page',
+ ],
+ ]
+ );
+
+ $resultNew = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'new' ] );
+ $resultEdit = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'edit' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultNew )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'edit',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultEdit )
+ );
+ }
+
+ public function testLogTypeParameters() {
+ $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->createPageAndDeleteIt( $subjectTarget );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $talkTarget, 'Create Talk page' );
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'log' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'log',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ private function getExternalRC( LinkTarget $target ) {
+ $title = Title::newFromLinkTarget( $target );
+
+ $rc = new RecentChange;
+ $rc->mTitle = $title;
+ $rc->mAttribs = [
+ 'rc_timestamp' => wfTimestamp( TS_MW ),
+ 'rc_namespace' => $title->getNamespace(),
+ 'rc_title' => $title->getDBkey(),
+ 'rc_type' => RC_EXTERNAL,
+ 'rc_source' => 'foo',
+ 'rc_minor' => 0,
+ 'rc_cur_id' => $title->getArticleID(),
+ 'rc_user' => 0,
+ 'rc_user_text' => 'm>External User',
+ 'rc_comment' => '',
+ 'rc_comment_text' => '',
+ 'rc_comment_data' => null,
+ 'rc_this_oldid' => $title->getLatestRevID(),
+ 'rc_last_oldid' => $title->getLatestRevID(),
+ 'rc_bot' => 0,
+ 'rc_ip' => '',
+ 'rc_patrolled' => 0,
+ 'rc_new' => 0,
+ 'rc_old_len' => $title->getLength(),
+ 'rc_new_len' => $title->getLength(),
+ 'rc_deleted' => 0,
+ 'rc_logid' => 0,
+ 'rc_log_type' => null,
+ 'rc_log_action' => '',
+ 'rc_params' => '',
+ ];
+ $rc->mExtra = [
+ 'prefixedDBkey' => $title->getPrefixedDBkey(),
+ 'lastTimestamp' => 0,
+ 'oldSize' => $title->getLength(),
+ 'newSize' => $title->getLength(),
+ 'pageStatus' => 'changed'
+ ];
+
+ return $rc;
+ }
+
+ public function testExternalTypeParameters() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $user, $subjectTarget, 'Create the page' );
+ $this->doPageEdit( $user, $talkTarget, 'Create Talk page' );
+
+ $rc = $this->getExternalRC( $subjectTarget );
+ $rc->save();
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'external' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'external',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testCategorizeTypeParameter() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $categoryTarget = new TitleValue( NS_CATEGORY, 'ApiQueryRecentChangesIntegrationTestCategory' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $categoryTarget,
+ 'summary' => 'Create the category',
+ ],
+ [
+ 'target' => $subjectTarget,
+ 'summary' => 'Create the page and add it to the category',
+ ],
+ ]
+ );
+ $title = Title::newFromLinkTarget( $subjectTarget );
+ $revision = Revision::newFromTitle( $title );
+
+ $rc = RecentChange::newForCategorization(
+ $revision->getTimestamp(),
+ Title::newFromLinkTarget( $categoryTarget ),
+ $user,
+ $revision->getComment(),
+ $title,
+ 0,
+ $revision->getId(),
+ null,
+ false
+ );
+ $rc->save();
+
+ $result = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rctype' => 'categorize' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'categorize',
+ 'ns' => $categoryTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $categoryTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testLimitParam() {
+ $target1 = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $target2 = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $target3 = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage2' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $target1,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target2,
+ 'summary' => 'Create Talk page',
+ ],
+ [
+ 'target' => $target3,
+ 'summary' => 'Create the page',
+ ],
+ ]
+ );
+
+ $resultWithoutLimit = $this->doListRecentChangesRequest( [ 'rcprop' => 'title' ] );
+ $resultWithLimit = $this->doListRecentChangesRequest( [ 'rclimit' => 2, 'rcprop' => 'title' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target3->getNamespace(),
+ 'title' => $this->getPrefixedText( $target3 )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target2->getNamespace(),
+ 'title' => $this->getPrefixedText( $target2 )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target1->getNamespace(),
+ 'title' => $this->getPrefixedText( $target1 )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultWithoutLimit )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target3->getNamespace(),
+ 'title' => $this->getPrefixedText( $target3 )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target2->getNamespace(),
+ 'title' => $this->getPrefixedText( $target2 )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultWithLimit )
+ );
+ $this->assertArrayHasKey( 'continue', $resultWithLimit[0] );
+ $this->assertArrayHasKey( 'rccontinue', $resultWithLimit[0]['continue'] );
+ }
+
+ public function testAllRevParam() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $target,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target,
+ 'summary' => 'Change the content',
+ ],
+ ]
+ );
+
+ $resultAllRev = $this->doListRecentChangesRequest( [ 'rcprop' => 'title', 'rcallrev' => '', ] );
+ $resultNoAllRev = $this->doListRecentChangesRequest( [ 'rcprop' => 'title' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'edit',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultNoAllRev )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'edit',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultAllRev )
+ );
+ }
+
+ public function testDirParams() {
+ $subjectTarget = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $subjectTarget,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'summary' => 'Create Talk page',
+ ],
+ ]
+ );
+
+ $resultDirOlder = $this->doListRecentChangesRequest(
+ [ 'rcdir' => 'older', 'rcprop' => 'title' ]
+ );
+ $resultDirNewer = $this->doListRecentChangesRequest(
+ [ 'rcdir' => 'newer', 'rcprop' => 'title' ]
+ );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultDirOlder )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultDirNewer )
+ );
+ }
+
+ public function testStartEndParams() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );
+
+ $resultStart = $this->doListRecentChangesRequest( [
+ 'rcstart' => '20010115000000',
+ 'rcdir' => 'newer',
+ 'rcprop' => 'title',
+ ] );
+ $resultEnd = $this->doListRecentChangesRequest( [
+ 'rcend' => '20010115000000',
+ 'rcdir' => 'newer',
+ 'rcprop' => 'title',
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ]
+ ],
+ $this->getItemsFromApiResponse( $resultStart )
+ );
+ $this->assertEmpty( $this->getItemsFromApiResponse( $resultEnd ) );
+ }
+
+ public function testContinueParam() {
+ $target1 = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $target2 = new TitleValue( 1, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $target3 = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage2' );
+ $this->doPageEdits(
+ $this->getLoggedInTestUser(),
+ [
+ [
+ 'target' => $target1,
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target2,
+ 'summary' => 'Create Talk page',
+ ],
+ [
+ 'target' => $target3,
+ 'summary' => 'Create the page',
+ ],
+ ]
+ );
+
+ $firstResult = $this->doListRecentChangesRequest( [ 'rclimit' => 2, 'rcprop' => 'title' ] );
+ $this->assertArrayHasKey( 'continue', $firstResult[0] );
+ $this->assertArrayHasKey( 'rccontinue', $firstResult[0]['continue'] );
+
+ $continuationParam = $firstResult[0]['continue']['rccontinue'];
+
+ $continuedResult = $this->doListRecentChangesRequest(
+ [ 'rccontinue' => $continuationParam, 'rcprop' => 'title' ]
+ );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target3->getNamespace(),
+ 'title' => $this->getPrefixedText( $target3 ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target2->getNamespace(),
+ 'title' => $this->getPrefixedText( $target2 ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $firstResult )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target1->getNamespace(),
+ 'title' => $this->getPrefixedText( $target1 )
+ ]
+ ],
+ $this->getItemsFromApiResponse( $continuedResult )
+ );
+ }
+
+ public function testGeneratorRecentChangesPropInfo_returnsRCPages() {
+ $target = new TitleValue( 0, 'ApiQueryRecentChangesIntegrationTestPage' );
+ $this->doPageEdit( $this->getLoggedInTestUser(), $target, 'Create the page' );
+
+ $result = $this->doGeneratorRecentChangesRequest( [ 'prop' => 'info' ] );
+
+ $this->assertArrayHasKey( 'query', $result[0] );
+ $this->assertArrayHasKey( 'pages', $result[0]['query'] );
+
+ // $result[0]['query']['pages'] uses page ids as keys. Page ids don't matter here, so drop them
+ $pages = array_values( $result[0]['query']['pages'] );
+
+ $this->assertCount( 1, $pages );
+ $this->assertArraySubset(
+ [
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ 'new' => true,
+ ],
+ $pages[0]
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php b/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php
new file mode 100644
index 00000000..5f59d6fb
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistIntegrationTest.php
@@ -0,0 +1,1608 @@
+<?php
+
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @group medium
+ * @group API
+ * @group Database
+ *
+ * @covers ApiQueryWatchlist
+ */
+class ApiQueryWatchlistIntegrationTest extends ApiTestCase {
+
+ public function __construct( $name = null, array $data = [], $dataName = '' ) {
+ parent::__construct( $name, $data, $dataName );
+ $this->tablesUsed = array_unique(
+ array_merge( $this->tablesUsed, [ 'watchlist', 'recentchanges', 'page' ] )
+ );
+ }
+
+ protected function setUp() {
+ parent::setUp();
+ self::$users['ApiQueryWatchlistIntegrationTestUser'] = $this->getMutableTestUser();
+ self::$users['ApiQueryWatchlistIntegrationTestUser2'] = $this->getMutableTestUser();
+ }
+
+ private function getLoggedInTestUser() {
+ return self::$users['ApiQueryWatchlistIntegrationTestUser']->getUser();
+ }
+
+ private function getNonLoggedInTestUser() {
+ return self::$users['ApiQueryWatchlistIntegrationTestUser2']->getUser();
+ }
+
+ private function doPageEdit( User $user, LinkTarget $target, $content, $summary ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( $content, $title ),
+ $summary,
+ 0,
+ false,
+ $user
+ );
+ }
+
+ private function doMinorPageEdit( User $user, LinkTarget $target, $content, $summary ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( $content, $title ),
+ $summary,
+ EDIT_MINOR,
+ false,
+ $user
+ );
+ }
+
+ private function doBotPageEdit( User $user, LinkTarget $target, $content, $summary ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( $content, $title ),
+ $summary,
+ EDIT_FORCE_BOT,
+ false,
+ $user
+ );
+ }
+
+ private function doAnonPageEdit( LinkTarget $target, $content, $summary ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doEditContent(
+ ContentHandler::makeContent( $content, $title ),
+ $summary,
+ 0,
+ false,
+ User::newFromId( 0 )
+ );
+ }
+
+ private function doPatrolledPageEdit(
+ User $user,
+ LinkTarget $target,
+ $content,
+ $summary,
+ User $patrollingUser
+ ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $status = $page->doEditContent(
+ ContentHandler::makeContent( $content, $title ),
+ $summary,
+ 0,
+ false,
+ $user
+ );
+ /** @var Revision $rev */
+ $rev = $status->value['revision'];
+ $rc = $rev->getRecentChange();
+ $rc->doMarkPatrolled( $patrollingUser, false, [] );
+ }
+
+ private function deletePage( LinkTarget $target, $reason ) {
+ $title = Title::newFromLinkTarget( $target );
+ $page = WikiPage::factory( $title );
+ $page->doDeleteArticleReal( $reason );
+ }
+
+ /**
+ * Performs a batch of page edits as a specified user
+ * @param User $user
+ * @param array $editData associative array, keys:
+ * - target => LinkTarget page to edit
+ * - content => string new content
+ * - summary => string edit summary
+ * - minorEdit => bool mark as minor edit if true (defaults to false)
+ * - botEdit => bool mark as bot edit if true (defaults to false)
+ */
+ private function doPageEdits( User $user, array $editData ) {
+ foreach ( $editData as $singleEditData ) {
+ if ( array_key_exists( 'minorEdit', $singleEditData ) && $singleEditData['minorEdit'] ) {
+ $this->doMinorPageEdit(
+ $user,
+ $singleEditData['target'],
+ $singleEditData['content'],
+ $singleEditData['summary']
+ );
+ continue;
+ }
+ if ( array_key_exists( 'botEdit', $singleEditData ) && $singleEditData['botEdit'] ) {
+ $this->doBotPageEdit(
+ $user,
+ $singleEditData['target'],
+ $singleEditData['content'],
+ $singleEditData['summary']
+ );
+ continue;
+ }
+ $this->doPageEdit(
+ $user,
+ $singleEditData['target'],
+ $singleEditData['content'],
+ $singleEditData['summary']
+ );
+ }
+ }
+
+ private function getWatchedItemStore() {
+ return MediaWikiServices::getInstance()->getWatchedItemStore();
+ }
+
+ /**
+ * @param User $user
+ * @param LinkTarget[] $targets
+ */
+ private function watchPages( User $user, array $targets ) {
+ $store = $this->getWatchedItemStore();
+ $store->addWatchBatchForUser( $user, $targets );
+ }
+
+ private function doListWatchlistRequest( array $params = [], $user = null ) {
+ if ( $user === null ) {
+ $user = $this->getLoggedInTestUser();
+ }
+ return $this->doApiRequest(
+ array_merge(
+ [ 'action' => 'query', 'list' => 'watchlist' ],
+ $params
+ ), null, false, $user
+ );
+ }
+
+ private function doGeneratorWatchlistRequest( array $params = [] ) {
+ return $this->doApiRequest(
+ array_merge(
+ [ 'action' => 'query', 'generator' => 'watchlist' ],
+ $params
+ ), null, false, $this->getLoggedInTestUser()
+ );
+ }
+
+ private function getItemsFromApiResponse( array $response ) {
+ return $response[0]['query']['watchlist'];
+ }
+
+ /**
+ * Convenience method to assert that actual items array fetched from API is equal to the expected
+ * array, Unlike assertEquals this only checks if values of specified keys are equal in both
+ * arrays. This could be used e.g. not to compare IDs that could change between test run
+ * but only stable keys.
+ * Optionally this also checks that specified keys are present in the actual item without
+ * performing any checks on the related values.
+ *
+ * @param array $actualItems array of actual items (associative arrays)
+ * @param array $expectedItems array of expected items (associative arrays),
+ * those items have less keys than actual items
+ * @param array $keysUsedInValueComparison list of keys of the actual item that will be used
+ * in the comparison of values
+ * @param array $requiredKeys optional, list of keys that must be present in the
+ * actual items. Values of those keys are not checked.
+ */
+ private function assertArraySubsetsEqual(
+ array $actualItems,
+ array $expectedItems,
+ array $keysUsedInValueComparison,
+ array $requiredKeys = []
+ ) {
+ $this->assertCount( count( $expectedItems ), $actualItems );
+
+ // not checking values of all keys of the actual item, so removing unwanted keys from comparison
+ $actualItemsOnlyComparedValues = array_map(
+ function ( array $item ) use ( $keysUsedInValueComparison ) {
+ return array_intersect_key( $item, array_flip( $keysUsedInValueComparison ) );
+ },
+ $actualItems
+ );
+
+ $this->assertEquals(
+ $expectedItems,
+ $actualItemsOnlyComparedValues
+ );
+
+ // Check that each item in $actualItems contains all of keys specified in $requiredKeys
+ $actualItemsKeysOnly = array_map( 'array_keys', $actualItems );
+ foreach ( $actualItemsKeysOnly as $keysOfTheItem ) {
+ $this->assertEmpty( array_diff( $requiredKeys, $keysOfTheItem ) );
+ }
+ }
+
+ private function getTitleFormatter() {
+ return new MediaWikiTitleCodec(
+ Language::factory( 'en' ),
+ MediaWikiServices::getInstance()->getGenderCache()
+ );
+ }
+
+ private function getPrefixedText( LinkTarget $target ) {
+ $formatter = $this->getTitleFormatter();
+ return $formatter->getPrefixedText( $target );
+ }
+
+ private function cleanTestUsersWatchlist() {
+ $user = $this->getLoggedInTestUser();
+ $store = $this->getWatchedItemStore();
+ $items = $store->getWatchedItemsForUser( $user );
+ foreach ( $items as $item ) {
+ $store->removeWatch( $user, $item->getLinkTarget() );
+ }
+ }
+
+ public function testListWatchlist_returnsWatchedItemsWithRCInfo() {
+ // Clean up after previous tests that might have added something to the watchlist of
+ // the user with the same user ID as user used here as the test user
+ $this->cleanTestUsersWatchlist();
+
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doListWatchlistRequest();
+
+ $this->assertArrayHasKey( 'query', $result[0] );
+ $this->assertArrayHasKey( 'watchlist', $result[0]['query'] );
+
+ $this->assertArraySubsetsEqual(
+ $this->getItemsFromApiResponse( $result ),
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ 'bot' => false,
+ 'new' => true,
+ 'minor' => false,
+ ]
+ ],
+ [ 'type', 'ns', 'title', 'bot', 'new', 'minor' ],
+ [ 'pageid', 'revid', 'old_revid' ]
+ );
+ }
+
+ public function testIdsPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'ids', ] );
+ $items = $this->getItemsFromApiResponse( $result );
+
+ $this->assertCount( 1, $items );
+ $this->assertArrayHasKey( 'pageid', $items[0] );
+ $this->assertArrayHasKey( 'revid', $items[0] );
+ $this->assertArrayHasKey( 'old_revid', $items[0] );
+ $this->assertEquals( 'new', $items[0]['type'] );
+ }
+
+ public function testTitlePropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $subjectTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'content' => 'Some Talk Page Content',
+ 'summary' => 'Create Talk page',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testFlagsPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $normalEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $minorEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageM' );
+ $botEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageB' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $normalEditTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $minorEditTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $minorEditTarget,
+ 'content' => 'Slightly Better Content',
+ 'summary' => 'Change content',
+ 'minorEdit' => true,
+ ],
+ [
+ 'target' => $botEditTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page with a bot',
+ 'botEdit' => true,
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $normalEditTarget, $minorEditTarget, $botEditTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'flags', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'new' => true,
+ 'minor' => false,
+ 'bot' => true,
+ ],
+ [
+ 'type' => 'edit',
+ 'new' => false,
+ 'minor' => true,
+ 'bot' => false,
+ ],
+ [
+ 'type' => 'new',
+ 'new' => true,
+ 'minor' => false,
+ 'bot' => false,
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testUserPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $userEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $anonEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageA' );
+ $this->doPageEdit(
+ $user,
+ $userEditTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->doAnonPageEdit(
+ $anonEditTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $userEditTarget, $anonEditTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'user', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'anon' => true,
+ 'user' => User::newFromId( 0 )->getName(),
+ ],
+ [
+ 'type' => 'new',
+ 'user' => $user->getName(),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testUserIdPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $userEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $anonEditTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPageA' );
+ $this->doPageEdit(
+ $user,
+ $userEditTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->doAnonPageEdit(
+ $anonEditTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $userEditTarget, $anonEditTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'userid', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'anon' => true,
+ 'user' => 0,
+ 'userid' => 0,
+ ],
+ [
+ 'type' => 'new',
+ 'user' => $user->getId(),
+ 'userid' => $user->getId(),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testCommentPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the <b>page</b>'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'comment', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'comment' => 'Create the <b>page</b>',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testParsedCommentPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the <b>page</b>'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'parsedcomment', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'parsedcomment' => 'Create the &lt;b&gt;page&lt;/b&gt;',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testTimestampPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'timestamp', ] );
+ $items = $this->getItemsFromApiResponse( $result );
+
+ $this->assertCount( 1, $items );
+ $this->assertArrayHasKey( 'timestamp', $items[0] );
+ $this->assertInternalType( 'string', $items[0]['timestamp'] );
+ }
+
+ public function testSizesPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'sizes', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'oldlen' => 0,
+ 'newlen' => 12,
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testNotificationTimestampPropParameter() {
+ $otherUser = $this->getNonLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $otherUser,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $store = $this->getWatchedItemStore();
+ $store->addWatch( $this->getLoggedInTestUser(), $target );
+ $store->updateNotificationTimestamp(
+ $otherUser,
+ $target,
+ '20151212010101'
+ );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'notificationtimestamp', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'notificationtimestamp' => '2015-12-12T01:01:01Z',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ private function setupPatrolledSpecificFixtures( User $user ) {
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+
+ $this->doPatrolledPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page (this gets patrolled)',
+ $user
+ );
+
+ $this->watchPages( $user, [ $target ] );
+ }
+
+ public function testPatrolPropParameter() {
+ $testUser = static::getTestSysop();
+ $user = $testUser->getUser();
+ $this->setupPatrolledSpecificFixtures( $user );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'patrol', ], $user );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'patrolled' => true,
+ 'unpatrolled' => false,
+ 'autopatrolled' => false,
+ ]
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ private function createPageAndDeleteIt( LinkTarget $target ) {
+ $this->doPageEdit(
+ $this->getLoggedInTestUser(),
+ $target,
+ 'Some Content',
+ 'Create the page that will be deleted'
+ );
+ $this->deletePage( $target, 'Important Reason' );
+ }
+
+ public function testLoginfoPropParameter() {
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->createPageAndDeleteIt( $target );
+
+ $this->watchPages( $this->getLoggedInTestUser(), [ $target ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'loginfo', ] );
+
+ $this->assertArraySubsetsEqual(
+ $this->getItemsFromApiResponse( $result ),
+ [
+ [
+ 'type' => 'log',
+ 'logtype' => 'delete',
+ 'logaction' => 'delete',
+ 'logparams' => [],
+ ],
+ ],
+ [ 'type', 'logtype', 'logaction', 'logparams' ],
+ [ 'logid' ]
+ );
+ }
+
+ public function testEmptyPropParameter() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => '', ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ ]
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testNamespaceParam() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $subjectTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the talk page',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlnamespace' => '0', ] );
+
+ $this->assertArraySubsetsEqual(
+ $this->getItemsFromApiResponse( $result ),
+ [
+ [
+ 'ns' => 0,
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ [ 'ns', 'title' ]
+ );
+ }
+
+ public function testUserParam() {
+ $user = $this->getLoggedInTestUser();
+ $otherUser = $this->getNonLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $subjectTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->doPageEdit(
+ $otherUser,
+ $talkTarget,
+ 'What is this page about?',
+ 'Create the talk page'
+ );
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $result = $this->doListWatchlistRequest( [
+ 'wlprop' => 'user|title',
+ 'wluser' => $otherUser->getName(),
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget ),
+ 'user' => $otherUser->getName(),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testExcludeUserParam() {
+ $user = $this->getLoggedInTestUser();
+ $otherUser = $this->getNonLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $subjectTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->doPageEdit(
+ $otherUser,
+ $talkTarget,
+ 'What is this page about?',
+ 'Create the talk page'
+ );
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $result = $this->doListWatchlistRequest( [
+ 'wlprop' => 'user|title',
+ 'wlexcludeuser' => $otherUser->getName(),
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ 'user' => $user->getName(),
+ ]
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testShowMinorParams() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $target,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target,
+ 'content' => 'Slightly Better Content',
+ 'summary' => 'Change content',
+ 'minorEdit' => true,
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $resultMinor = $this->doListWatchlistRequest( [
+ 'wlshow' => WatchedItemQueryService::FILTER_MINOR,
+ 'wlprop' => 'flags'
+ ] );
+ $resultNotMinor = $this->doListWatchlistRequest( [
+ 'wlshow' => WatchedItemQueryService::FILTER_NOT_MINOR, 'wlprop' => 'flags'
+ ] );
+
+ $this->assertArraySubsetsEqual(
+ $this->getItemsFromApiResponse( $resultMinor ),
+ [
+ [ 'minor' => true, ]
+ ],
+ [ 'minor' ]
+ );
+ $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotMinor ) );
+ }
+
+ public function testShowBotParams() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doBotPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $resultBot = $this->doListWatchlistRequest( [
+ 'wlshow' => WatchedItemQueryService::FILTER_BOT
+ ] );
+ $resultNotBot = $this->doListWatchlistRequest( [
+ 'wlshow' => WatchedItemQueryService::FILTER_NOT_BOT
+ ] );
+
+ $this->assertArraySubsetsEqual(
+ $this->getItemsFromApiResponse( $resultBot ),
+ [
+ [ 'bot' => true ],
+ ],
+ [ 'bot' ]
+ );
+ $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotBot ) );
+ }
+
+ public function testShowAnonParams() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doAnonPageEdit(
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $resultAnon = $this->doListWatchlistRequest( [
+ 'wlprop' => 'user',
+ 'wlshow' => WatchedItemQueryService::FILTER_ANON
+ ] );
+ $resultNotAnon = $this->doListWatchlistRequest( [
+ 'wlprop' => 'user',
+ 'wlshow' => WatchedItemQueryService::FILTER_NOT_ANON
+ ] );
+
+ $this->assertArraySubsetsEqual(
+ $this->getItemsFromApiResponse( $resultAnon ),
+ [
+ [ 'anon' => true ],
+ ],
+ [ 'anon' ]
+ );
+ $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotAnon ) );
+ }
+
+ public function testShowUnreadParams() {
+ $user = $this->getLoggedInTestUser();
+ $otherUser = $this->getNonLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $subjectTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->doPageEdit(
+ $otherUser,
+ $talkTarget,
+ 'Some Content',
+ 'Create the talk page'
+ );
+ $store = $this->getWatchedItemStore();
+ $store->addWatchBatchForUser( $user, [ $subjectTarget, $talkTarget ] );
+ $store->updateNotificationTimestamp(
+ $otherUser,
+ $talkTarget,
+ '20151212010101'
+ );
+
+ $resultUnread = $this->doListWatchlistRequest( [
+ 'wlprop' => 'notificationtimestamp|title',
+ 'wlshow' => WatchedItemQueryService::FILTER_UNREAD
+ ] );
+ $resultNotUnread = $this->doListWatchlistRequest( [
+ 'wlprop' => 'notificationtimestamp|title',
+ 'wlshow' => WatchedItemQueryService::FILTER_NOT_UNREAD
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'notificationtimestamp' => '2015-12-12T01:01:01Z',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget )
+ ]
+ ],
+ $this->getItemsFromApiResponse( $resultUnread )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'notificationtimestamp' => '',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget )
+ ]
+ ],
+ $this->getItemsFromApiResponse( $resultNotUnread )
+ );
+ }
+
+ public function testShowPatrolledParams() {
+ $user = static::getTestSysop()->getUser();
+ $this->setupPatrolledSpecificFixtures( $user );
+
+ $resultPatrolled = $this->doListWatchlistRequest( [
+ 'wlprop' => 'patrol',
+ 'wlshow' => WatchedItemQueryService::FILTER_PATROLLED
+ ], $user );
+ $resultNotPatrolled = $this->doListWatchlistRequest( [
+ 'wlprop' => 'patrol',
+ 'wlshow' => WatchedItemQueryService::FILTER_NOT_PATROLLED
+ ], $user );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'patrolled' => true,
+ 'unpatrolled' => false,
+ 'autopatrolled' => false,
+ ]
+ ],
+ $this->getItemsFromApiResponse( $resultPatrolled )
+ );
+ $this->assertEmpty( $this->getItemsFromApiResponse( $resultNotPatrolled ) );
+ }
+
+ public function testNewAndEditTypeParameters() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $subjectTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $subjectTarget,
+ 'content' => 'Some Other Content',
+ 'summary' => 'Change the content',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'content' => 'Some Talk Page Content',
+ 'summary' => 'Create Talk page',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $resultNew = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'new' ] );
+ $resultEdit = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'edit' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultNew )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'edit',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultEdit )
+ );
+ }
+
+ public function testLogTypeParameters() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->createPageAndDeleteIt( $subjectTarget );
+ $this->doPageEdit(
+ $user,
+ $talkTarget,
+ 'Some Talk Page Content',
+ 'Create Talk page'
+ );
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'log' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'log',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ private function getExternalRC( LinkTarget $target ) {
+ $title = Title::newFromLinkTarget( $target );
+
+ $rc = new RecentChange;
+ $rc->mTitle = $title;
+ $rc->mAttribs = [
+ 'rc_timestamp' => wfTimestamp( TS_MW ),
+ 'rc_namespace' => $title->getNamespace(),
+ 'rc_title' => $title->getDBkey(),
+ 'rc_type' => RC_EXTERNAL,
+ 'rc_source' => 'foo',
+ 'rc_minor' => 0,
+ 'rc_cur_id' => $title->getArticleID(),
+ 'rc_user' => 0,
+ 'rc_user_text' => 'ext>External User',
+ 'rc_comment' => '',
+ 'rc_comment_text' => '',
+ 'rc_comment_data' => null,
+ 'rc_this_oldid' => $title->getLatestRevID(),
+ 'rc_last_oldid' => $title->getLatestRevID(),
+ 'rc_bot' => 0,
+ 'rc_ip' => '',
+ 'rc_patrolled' => 0,
+ 'rc_new' => 0,
+ 'rc_old_len' => $title->getLength(),
+ 'rc_new_len' => $title->getLength(),
+ 'rc_deleted' => 0,
+ 'rc_logid' => 0,
+ 'rc_log_type' => null,
+ 'rc_log_action' => '',
+ 'rc_params' => '',
+ ];
+ $rc->mExtra = [
+ 'prefixedDBkey' => $title->getPrefixedDBkey(),
+ 'lastTimestamp' => 0,
+ 'oldSize' => $title->getLength(),
+ 'newSize' => $title->getLength(),
+ 'pageStatus' => 'changed'
+ ];
+
+ return $rc;
+ }
+
+ public function testExternalTypeParameters() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $subjectTarget,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->doPageEdit(
+ $user,
+ $talkTarget,
+ 'Some Talk Page Content',
+ 'Create Talk page'
+ );
+
+ $rc = $this->getExternalRC( $subjectTarget );
+ $rc->save();
+
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'external' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'external',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testCategorizeTypeParameter() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $categoryTarget = new TitleValue( NS_CATEGORY, 'ApiQueryWatchlistIntegrationTestCategory' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $categoryTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the category',
+ ],
+ [
+ 'target' => $subjectTarget,
+ 'content' => 'Some Content [[Category:ApiQueryWatchlistIntegrationTestCategory]]t',
+ 'summary' => 'Create the page and add it to the category',
+ ],
+ ]
+ );
+ $title = Title::newFromLinkTarget( $subjectTarget );
+ $revision = Revision::newFromTitle( $title );
+
+ $rc = RecentChange::newForCategorization(
+ $revision->getTimestamp(),
+ Title::newFromLinkTarget( $categoryTarget ),
+ $user,
+ $revision->getComment(),
+ $title,
+ 0,
+ $revision->getId(),
+ null,
+ false
+ );
+ $rc->save();
+
+ $this->watchPages( $user, [ $subjectTarget, $categoryTarget ] );
+
+ $result = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wltype' => 'categorize' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'categorize',
+ 'ns' => $categoryTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $categoryTarget ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testLimitParam() {
+ $user = $this->getLoggedInTestUser();
+ $target1 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $target2 = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $target3 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage2' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $target1,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target2,
+ 'content' => 'Some Talk Page Content',
+ 'summary' => 'Create Talk page',
+ ],
+ [
+ 'target' => $target3,
+ 'content' => 'Some Other Content',
+ 'summary' => 'Create the page',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $target1, $target2, $target3 ] );
+
+ $resultWithoutLimit = $this->doListWatchlistRequest( [ 'wlprop' => 'title' ] );
+ $resultWithLimit = $this->doListWatchlistRequest( [ 'wllimit' => 2, 'wlprop' => 'title' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target3->getNamespace(),
+ 'title' => $this->getPrefixedText( $target3 )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target2->getNamespace(),
+ 'title' => $this->getPrefixedText( $target2 )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target1->getNamespace(),
+ 'title' => $this->getPrefixedText( $target1 )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultWithoutLimit )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target3->getNamespace(),
+ 'title' => $this->getPrefixedText( $target3 )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target2->getNamespace(),
+ 'title' => $this->getPrefixedText( $target2 )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultWithLimit )
+ );
+ $this->assertArrayHasKey( 'continue', $resultWithLimit[0] );
+ $this->assertArrayHasKey( 'wlcontinue', $resultWithLimit[0]['continue'] );
+ }
+
+ public function testAllRevParam() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $target,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target,
+ 'content' => 'Some Other Content',
+ 'summary' => 'Change the content',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $resultAllRev = $this->doListWatchlistRequest( [ 'wlprop' => 'title', 'wlallrev' => '', ] );
+ $resultNoAllRev = $this->doListWatchlistRequest( [ 'wlprop' => 'title' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'edit',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultNoAllRev )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'edit',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultAllRev )
+ );
+ }
+
+ public function testDirParams() {
+ $user = $this->getLoggedInTestUser();
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $subjectTarget,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $talkTarget,
+ 'content' => 'Some Talk Page Content',
+ 'summary' => 'Create Talk page',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $subjectTarget, $talkTarget ] );
+
+ $resultDirOlder = $this->doListWatchlistRequest( [ 'wldir' => 'older', 'wlprop' => 'title' ] );
+ $resultDirNewer = $this->doListWatchlistRequest( [ 'wldir' => 'newer', 'wlprop' => 'title' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultDirOlder )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $subjectTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $subjectTarget )
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $talkTarget->getNamespace(),
+ 'title' => $this->getPrefixedText( $talkTarget )
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultDirNewer )
+ );
+ }
+
+ public function testStartEndParams() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $resultStart = $this->doListWatchlistRequest( [
+ 'wlstart' => '20010115000000',
+ 'wldir' => 'newer',
+ 'wlprop' => 'title',
+ ] );
+ $resultEnd = $this->doListWatchlistRequest( [
+ 'wlend' => '20010115000000',
+ 'wldir' => 'newer',
+ 'wlprop' => 'title',
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ ]
+ ],
+ $this->getItemsFromApiResponse( $resultStart )
+ );
+ $this->assertEmpty( $this->getItemsFromApiResponse( $resultEnd ) );
+ }
+
+ public function testContinueParam() {
+ $user = $this->getLoggedInTestUser();
+ $target1 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $target2 = new TitleValue( 1, 'ApiQueryWatchlistIntegrationTestPage' );
+ $target3 = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage2' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $target1,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target2,
+ 'content' => 'Some Talk Page Content',
+ 'summary' => 'Create Talk page',
+ ],
+ [
+ 'target' => $target3,
+ 'content' => 'Some Other Content',
+ 'summary' => 'Create the page',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $target1, $target2, $target3 ] );
+
+ $firstResult = $this->doListWatchlistRequest( [ 'wllimit' => 2, 'wlprop' => 'title' ] );
+ $this->assertArrayHasKey( 'continue', $firstResult[0] );
+ $this->assertArrayHasKey( 'wlcontinue', $firstResult[0]['continue'] );
+
+ $continuationParam = $firstResult[0]['continue']['wlcontinue'];
+
+ $continuedResult = $this->doListWatchlistRequest(
+ [ 'wlcontinue' => $continuationParam, 'wlprop' => 'title' ]
+ );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target3->getNamespace(),
+ 'title' => $this->getPrefixedText( $target3 ),
+ ],
+ [
+ 'type' => 'new',
+ 'ns' => $target2->getNamespace(),
+ 'title' => $this->getPrefixedText( $target2 ),
+ ],
+ ],
+ $this->getItemsFromApiResponse( $firstResult )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target1->getNamespace(),
+ 'title' => $this->getPrefixedText( $target1 )
+ ]
+ ],
+ $this->getItemsFromApiResponse( $continuedResult )
+ );
+ }
+
+ public function testOwnerAndTokenParams() {
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $this->getLoggedInTestUser(),
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+
+ $otherUser = $this->getNonLoggedInTestUser();
+ $otherUser->setOption( 'watchlisttoken', '1234567890' );
+ $otherUser->saveSettings();
+
+ $this->watchPages( $otherUser, [ $target ] );
+
+ $reloadedUser = User::newFromName( $otherUser->getName() );
+ $this->assertEquals( '1234567890', $reloadedUser->getOption( 'watchlisttoken' ) );
+
+ $result = $this->doListWatchlistRequest( [
+ 'wlowner' => $otherUser->getName(),
+ 'wltoken' => '1234567890',
+ 'wlprop' => 'title',
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'type' => 'new',
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target )
+ ]
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testOwnerAndTokenParams_wrongToken() {
+ $otherUser = $this->getNonLoggedInTestUser();
+ $otherUser->setOption( 'watchlisttoken', '1234567890' );
+ $otherUser->saveSettings();
+
+ $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' );
+
+ $this->doListWatchlistRequest( [
+ 'wlowner' => $otherUser->getName(),
+ 'wltoken' => 'wrong-token',
+ ] );
+ }
+
+ public function testOwnerAndTokenParams_noWatchlistTokenSet() {
+ $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' );
+
+ $this->doListWatchlistRequest( [
+ 'wlowner' => $this->getNonLoggedInTestUser()->getName(),
+ 'wltoken' => 'some-token',
+ ] );
+ }
+
+ public function testGeneratorWatchlistPropInfo_returnsWatchedPages() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdit(
+ $user,
+ $target,
+ 'Some Content',
+ 'Create the page'
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doGeneratorWatchlistRequest( [ 'prop' => 'info' ] );
+
+ $this->assertArrayHasKey( 'query', $result[0] );
+ $this->assertArrayHasKey( 'pages', $result[0]['query'] );
+
+ // $result[0]['query']['pages'] uses page ids as keys. Page ids don't matter here, so drop them
+ $pages = array_values( $result[0]['query']['pages'] );
+
+ $this->assertArraySubsetsEqual(
+ $pages,
+ [
+ [
+ 'ns' => $target->getNamespace(),
+ 'title' => $this->getPrefixedText( $target ),
+ 'new' => true,
+ ]
+ ],
+ [ 'ns', 'title', 'new' ]
+ );
+ }
+
+ public function testGeneratorWatchlistPropRevisions_returnsWatchedItemsRevisions() {
+ $user = $this->getLoggedInTestUser();
+ $target = new TitleValue( 0, 'ApiQueryWatchlistIntegrationTestPage' );
+ $this->doPageEdits(
+ $user,
+ [
+ [
+ 'target' => $target,
+ 'content' => 'Some Content',
+ 'summary' => 'Create the page',
+ ],
+ [
+ 'target' => $target,
+ 'content' => 'Some Other Content',
+ 'summary' => 'Change the content',
+ ],
+ ]
+ );
+ $this->watchPages( $user, [ $target ] );
+
+ $result = $this->doGeneratorWatchlistRequest( [ 'prop' => 'revisions', 'gwlallrev' => '' ] );
+
+ $this->assertArrayHasKey( 'query', $result[0] );
+ $this->assertArrayHasKey( 'pages', $result[0]['query'] );
+
+ // $result[0]['query']['pages'] uses page ids as keys. Page ids don't matter here, so drop them
+ $pages = array_values( $result[0]['query']['pages'] );
+
+ $this->assertCount( 1, $pages );
+ $this->assertEquals( 0, $pages[0]['ns'] );
+ $this->assertEquals( $this->getPrefixedText( $target ), $pages[0]['title'] );
+ $this->assertArraySubsetsEqual(
+ $pages[0]['revisions'],
+ [
+ [
+ 'comment' => 'Create the page',
+ 'user' => $user->getName(),
+ 'minor' => false,
+ ],
+ [
+ 'comment' => 'Change the content',
+ 'user' => $user->getName(),
+ 'minor' => false,
+ ],
+ ],
+ [ 'comment', 'user', 'minor' ]
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php b/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php
new file mode 100644
index 00000000..2af63c49
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiQueryWatchlistRawIntegrationTest.php
@@ -0,0 +1,542 @@
+<?php
+
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiQueryWatchlistRaw
+ */
+class ApiQueryWatchlistRawIntegrationTest extends ApiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ self::$users['ApiQueryWatchlistRawIntegrationTestUser']
+ = $this->getMutableTestUser();
+ self::$users['ApiQueryWatchlistRawIntegrationTestUser2']
+ = $this->getMutableTestUser();
+ }
+
+ private function getLoggedInTestUser() {
+ return self::$users['ApiQueryWatchlistRawIntegrationTestUser']->getUser();
+ }
+
+ private function getNotLoggedInTestUser() {
+ return self::$users['ApiQueryWatchlistRawIntegrationTestUser2']->getUser();
+ }
+
+ private function getWatchedItemStore() {
+ return MediaWikiServices::getInstance()->getWatchedItemStore();
+ }
+
+ private function doListWatchlistRawRequest( array $params = [] ) {
+ return $this->doApiRequest( array_merge(
+ [ 'action' => 'query', 'list' => 'watchlistraw' ],
+ $params
+ ), null, false, $this->getLoggedInTestUser() );
+ }
+
+ private function doGeneratorWatchlistRawRequest( array $params = [] ) {
+ return $this->doApiRequest( array_merge(
+ [ 'action' => 'query', 'generator' => 'watchlistraw' ],
+ $params
+ ), null, false, $this->getLoggedInTestUser() );
+ }
+
+ private function getItemsFromApiResponse( array $response ) {
+ return $response[0]['watchlistraw'];
+ }
+
+ public function testListWatchlistRaw_returnsWatchedItems() {
+ $store = $this->getWatchedItemStore();
+ $store->addWatch(
+ $this->getLoggedInTestUser(),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' )
+ );
+
+ $result = $this->doListWatchlistRawRequest();
+
+ $this->assertArrayHasKey( 'watchlistraw', $result[0] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testPropChanged_addsNotificationTimestamp() {
+ $target = new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' );
+ $otherUser = $this->getNotLoggedInTestUser();
+
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatch( $this->getLoggedInTestUser(), $target );
+ $store->updateNotificationTimestamp(
+ $otherUser,
+ $target,
+ '20151212010101'
+ );
+
+ $result = $this->doListWatchlistRawRequest( [ 'wrprop' => 'changed' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage',
+ 'changed' => '2015-12-12T01:01:01Z',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testNamespaceParam() {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' ),
+ new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage' ),
+ ] );
+
+ $result = $this->doListWatchlistRawRequest( [ 'wrnamespace' => '0' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testShowChangedParams() {
+ $subjectTarget = new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' );
+ $talkTarget = new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage' );
+ $otherUser = $this->getNotLoggedInTestUser();
+
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ $subjectTarget,
+ $talkTarget,
+ ] );
+ $store->updateNotificationTimestamp(
+ $otherUser,
+ $subjectTarget,
+ '20151212010101'
+ );
+
+ $resultChanged = $this->doListWatchlistRawRequest(
+ [ 'wrprop' => 'changed', 'wrshow' => WatchedItemQueryService::FILTER_CHANGED ]
+ );
+ $resultNotChanged = $this->doListWatchlistRawRequest(
+ [ 'wrprop' => 'changed', 'wrshow' => WatchedItemQueryService::FILTER_NOT_CHANGED ]
+ );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage',
+ 'changed' => '2015-12-12T01:01:01Z',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultChanged )
+ );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 1,
+ 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultNotChanged )
+ );
+ }
+
+ public function testLimitParam() {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ ] );
+
+ $resultWithoutLimit = $this->doListWatchlistRawRequest();
+ $resultWithLimit = $this->doListWatchlistRawRequest( [ 'wrlimit' => 2 ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ [
+ 'ns' => 1,
+ 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultWithoutLimit )
+ );
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultWithLimit )
+ );
+
+ $this->assertArrayNotHasKey( 'continue', $resultWithoutLimit[0] );
+ $this->assertArrayHasKey( 'continue', $resultWithLimit[0] );
+ $this->assertArrayHasKey( 'wrcontinue', $resultWithLimit[0]['continue'] );
+ }
+
+ public function testDirParams() {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ ] );
+
+ $resultDirAsc = $this->doListWatchlistRawRequest( [ 'wrdir' => 'ascending' ] );
+ $resultDirDesc = $this->doListWatchlistRawRequest( [ 'wrdir' => 'descending' ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ [
+ 'ns' => 1,
+ 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultDirAsc )
+ );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 1,
+ 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $resultDirDesc )
+ );
+ }
+
+ public function testAscendingIsDefaultOrder() {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ ] );
+
+ $resultNoDir = $this->doListWatchlistRawRequest();
+ $resultAscDir = $this->doListWatchlistRawRequest( [ 'wrdir' => 'ascending' ] );
+
+ $this->assertEquals(
+ $this->getItemsFromApiResponse( $resultNoDir ),
+ $this->getItemsFromApiResponse( $resultAscDir )
+ );
+ }
+
+ public function testFromTitleParam() {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ),
+ ] );
+
+ $result = $this->doListWatchlistRawRequest( [
+ 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testToTitleParam() {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ),
+ ] );
+
+ $result = $this->doListWatchlistRawRequest( [
+ 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testContinueParam() {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ),
+ ] );
+
+ $firstResult = $this->doListWatchlistRawRequest( [ 'wrlimit' => 2 ] );
+ $continuationParam = $firstResult[0]['continue']['wrcontinue'];
+
+ $this->assertEquals( '0|ApiQueryWatchlistRawIntegrationTestPage3', $continuationParam );
+
+ $continuedResult = $this->doListWatchlistRawRequest( [ 'wrcontinue' => $continuationParam ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3',
+ ]
+ ],
+ $this->getItemsFromApiResponse( $continuedResult )
+ );
+ }
+
+ public function fromTitleToTitleContinueComboProvider() {
+ return [
+ [
+ [
+ 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ [
+ [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1' ],
+ [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2' ],
+ ],
+ ],
+ [
+ [
+ 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage3',
+ ],
+ [
+ [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ],
+ ],
+ ],
+ [
+ [
+ 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage3',
+ 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage2',
+ ],
+ [
+ [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage2' ],
+ [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ],
+ ],
+ ],
+ [
+ [
+ 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage3',
+ 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage3',
+ ],
+ [
+ [ 'ns' => 0, 'title' => 'ApiQueryWatchlistRawIntegrationTestPage3' ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider fromTitleToTitleContinueComboProvider
+ */
+ public function testFromTitleToTitleContinueCombo( array $params, array $expectedItems ) {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage3' ),
+ ] );
+
+ $result = $this->doListWatchlistRawRequest( $params );
+
+ $this->assertEquals( $expectedItems, $this->getItemsFromApiResponse( $result ) );
+ }
+
+ public function fromTitleToTitleContinueSelfContradictoryComboProvider() {
+ return [
+ [
+ [
+ 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ ]
+ ],
+ [
+ [
+ 'wrfromtitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage2',
+ 'wrdir' => 'descending',
+ ]
+ ],
+ [
+ [
+ 'wrtotitle' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ 'wrcontinue' => '0|ApiQueryWatchlistRawIntegrationTestPage2',
+ ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider fromTitleToTitleContinueSelfContradictoryComboProvider
+ */
+ public function testFromTitleToTitleContinueSelfContradictoryCombo( array $params ) {
+ $store = $this->getWatchedItemStore();
+
+ $store->addWatchBatchForUser( $this->getLoggedInTestUser(), [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage2' ),
+ ] );
+
+ $result = $this->doListWatchlistRawRequest( $params );
+
+ $this->assertEmpty( $this->getItemsFromApiResponse( $result ) );
+ $this->assertArrayNotHasKey( 'continue', $result[0] );
+ }
+
+ public function testOwnerAndTokenParams() {
+ $otherUser = $this->getNotLoggedInTestUser();
+ $otherUser->setOption( 'watchlisttoken', '1234567890' );
+ $otherUser->saveSettings();
+
+ $store = $this->getWatchedItemStore();
+ $store->addWatchBatchForUser( $otherUser, [
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ new TitleValue( 1, 'ApiQueryWatchlistRawIntegrationTestPage1' ),
+ ] );
+
+ ObjectCache::getMainWANInstance()->clearProcessCache();
+ $result = $this->doListWatchlistRawRequest( [
+ 'wrowner' => $otherUser->getName(),
+ 'wrtoken' => '1234567890',
+ ] );
+
+ $this->assertEquals(
+ [
+ [
+ 'ns' => 0,
+ 'title' => 'ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ [
+ 'ns' => 1,
+ 'title' => 'Talk:ApiQueryWatchlistRawIntegrationTestPage1',
+ ],
+ ],
+ $this->getItemsFromApiResponse( $result )
+ );
+ }
+
+ public function testOwnerAndTokenParams_wrongToken() {
+ $otherUser = $this->getNotLoggedInTestUser();
+ $otherUser->setOption( 'watchlisttoken', '1234567890' );
+ $otherUser->saveSettings();
+
+ $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' );
+
+ $this->doListWatchlistRawRequest( [
+ 'wrowner' => $otherUser->getName(),
+ 'wrtoken' => 'wrong-token',
+ ] );
+ }
+
+ public function testOwnerAndTokenParams_userHasNoWatchlistToken() {
+ $this->setExpectedException( ApiUsageException::class, 'Incorrect watchlist token provided' );
+
+ $this->doListWatchlistRawRequest( [
+ 'wrowner' => $this->getNotLoggedInTestUser()->getName(),
+ 'wrtoken' => 'some-watchlist-token',
+ ] );
+ }
+
+ public function testGeneratorWatchlistRawPropInfo_returnsWatchedItems() {
+ $store = $this->getWatchedItemStore();
+ $store->addWatch(
+ $this->getLoggedInTestUser(),
+ new TitleValue( 0, 'ApiQueryWatchlistRawIntegrationTestPage' )
+ );
+
+ $result = $this->doGeneratorWatchlistRawRequest( [ 'prop' => 'info' ] );
+
+ $this->assertArrayHasKey( 'query', $result[0] );
+ $this->assertArrayHasKey( 'pages', $result[0]['query'] );
+ $this->assertCount( 1, $result[0]['query']['pages'] );
+
+ // $result[0]['query']['pages'] uses page ids as keys
+ $item = array_values( $result[0]['query']['pages'] )[0];
+
+ $this->assertEquals( 0, $item['ns'] );
+ $this->assertEquals( 'ApiQueryWatchlistRawIntegrationTestPage', $item['title'] );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiResultTest.php b/www/wiki/tests/phpunit/includes/api/ApiResultTest.php
new file mode 100644
index 00000000..98e24fb6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiResultTest.php
@@ -0,0 +1,1410 @@
+<?php
+
+/**
+ * @covers ApiResult
+ * @group API
+ */
+class ApiResultTest extends MediaWikiTestCase {
+
+ /**
+ * @covers ApiResult
+ */
+ public function testStaticDataMethods() {
+ $arr = [];
+
+ ApiResult::setValue( $arr, 'setValue', '1' );
+
+ ApiResult::setValue( $arr, null, 'unnamed 1' );
+ ApiResult::setValue( $arr, null, 'unnamed 2' );
+
+ ApiResult::setValue( $arr, 'deleteValue', '2' );
+ ApiResult::unsetValue( $arr, 'deleteValue' );
+
+ ApiResult::setContentValue( $arr, 'setContentValue', '3' );
+
+ $this->assertSame( [
+ 'setValue' => '1',
+ 'unnamed 1',
+ 'unnamed 2',
+ ApiResult::META_CONTENT => 'setContentValue',
+ 'setContentValue' => '3',
+ ], $arr );
+
+ try {
+ ApiResult::setValue( $arr, 'setValue', '99' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame(
+ 'Attempting to add element setValue=99, existing value is 1',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ try {
+ ApiResult::setContentValue( $arr, 'setContentValue2', '99' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame(
+ 'Attempting to set content element as setContentValue2 when setContentValue ' .
+ 'is already set as the content element',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ ApiResult::setValue( $arr, 'setValue', '99', ApiResult::OVERRIDE );
+ $this->assertSame( '99', $arr['setValue'] );
+
+ ApiResult::setContentValue( $arr, 'setContentValue2', '99', ApiResult::OVERRIDE );
+ $this->assertSame( 'setContentValue2', $arr[ApiResult::META_CONTENT] );
+
+ $arr = [ 'foo' => 1, 'bar' => 1 ];
+ ApiResult::setValue( $arr, 'top', '2', ApiResult::ADD_ON_TOP );
+ ApiResult::setValue( $arr, null, '2', ApiResult::ADD_ON_TOP );
+ ApiResult::setValue( $arr, 'bottom', '2' );
+ ApiResult::setValue( $arr, 'foo', '2', ApiResult::OVERRIDE );
+ ApiResult::setValue( $arr, 'bar', '2', ApiResult::OVERRIDE | ApiResult::ADD_ON_TOP );
+ $this->assertSame( [ 0, 'top', 'foo', 'bar', 'bottom' ], array_keys( $arr ) );
+
+ $arr = [];
+ ApiResult::setValue( $arr, 'sub', [ 'foo' => 1 ] );
+ ApiResult::setValue( $arr, 'sub', [ 'bar' => 1 ] );
+ $this->assertSame( [ 'sub' => [ 'foo' => 1, 'bar' => 1 ] ], $arr );
+
+ try {
+ ApiResult::setValue( $arr, 'sub', [ 'foo' => 2, 'baz' => 2 ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame(
+ 'Conflicting keys (foo) when attempting to merge element sub',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ $arr = [];
+ $title = Title::newFromText( "MediaWiki:Foobar" );
+ $obj = new stdClass;
+ $obj->foo = 1;
+ $obj->bar = 2;
+ ApiResult::setValue( $arr, 'title', $title );
+ ApiResult::setValue( $arr, 'obj', $obj );
+ $this->assertSame( [
+ 'title' => (string)$title,
+ 'obj' => [ 'foo' => 1, 'bar' => 2, ApiResult::META_TYPE => 'assoc' ],
+ ], $arr );
+
+ $fh = tmpfile();
+ try {
+ ApiResult::setValue( $arr, 'file', $fh );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ ApiResult::setValue( $arr, null, $fh );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $obj->file = $fh;
+ ApiResult::setValue( $arr, 'sub', $obj );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $obj->file = $fh;
+ ApiResult::setValue( $arr, null, $obj );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ fclose( $fh );
+
+ try {
+ ApiResult::setValue( $arr, 'inf', INF );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ ApiResult::setValue( $arr, null, INF );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ ApiResult::setValue( $arr, 'nan', NAN );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ ApiResult::setValue( $arr, null, NAN );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ ApiResult::setValue( $arr, null, NAN, ApiResult::NO_VALIDATE );
+
+ try {
+ ApiResult::setValue( $arr, null, NAN, ApiResult::NO_SIZE_CHECK );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ $arr = [];
+ $result2 = new ApiResult( 8388608 );
+ $result2->addValue( null, 'foo', 'bar' );
+ ApiResult::setValue( $arr, 'baz', $result2 );
+ $this->assertSame( [
+ 'baz' => [
+ ApiResult::META_TYPE => 'assoc',
+ 'foo' => 'bar',
+ ]
+ ], $arr );
+
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', "foo\x80bar" );
+ ApiResult::setValue( $arr, 'bar', "a\xcc\x81" );
+ ApiResult::setValue( $arr, 'baz', 74 );
+ ApiResult::setValue( $arr, null, "foo\x80bar" );
+ ApiResult::setValue( $arr, null, "a\xcc\x81" );
+ $this->assertSame( [
+ 'foo' => "foo\xef\xbf\xbdbar",
+ 'bar' => "\xc3\xa1",
+ 'baz' => 74,
+ 0 => "foo\xef\xbf\xbdbar",
+ 1 => "\xc3\xa1",
+ ], $arr );
+
+ $obj = new stdClass;
+ $obj->{'1'} = 'one';
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', $obj );
+ $this->assertSame( [
+ 'foo' => [
+ 1 => 'one',
+ ApiResult::META_TYPE => 'assoc',
+ ]
+ ], $arr );
+ }
+
+ /**
+ * @covers ApiResult
+ */
+ public function testInstanceDataMethods() {
+ $result = new ApiResult( 8388608 );
+
+ $result->addValue( null, 'setValue', '1' );
+
+ $result->addValue( null, null, 'unnamed 1' );
+ $result->addValue( null, null, 'unnamed 2' );
+
+ $result->addValue( null, 'deleteValue', '2' );
+ $result->removeValue( null, 'deleteValue' );
+
+ $result->addValue( [ 'a', 'b' ], 'deleteValue', '3' );
+ $result->removeValue( [ 'a', 'b', 'deleteValue' ], null, '3' );
+
+ $result->addContentValue( null, 'setContentValue', '3' );
+
+ $this->assertSame( [
+ 'setValue' => '1',
+ 'unnamed 1',
+ 'unnamed 2',
+ 'a' => [ 'b' => [] ],
+ 'setContentValue' => '3',
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_CONTENT => 'setContentValue',
+ ], $result->getResultData() );
+ $this->assertSame( 20, $result->getSize() );
+
+ try {
+ $result->addValue( null, 'setValue', '99' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame(
+ 'Attempting to add element setValue=99, existing value is 1',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ try {
+ $result->addContentValue( null, 'setContentValue2', '99' );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame(
+ 'Attempting to set content element as setContentValue2 when setContentValue ' .
+ 'is already set as the content element',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ $result->addValue( null, 'setValue', '99', ApiResult::OVERRIDE );
+ $this->assertSame( '99', $result->getResultData( [ 'setValue' ] ) );
+
+ $result->addContentValue( null, 'setContentValue2', '99', ApiResult::OVERRIDE );
+ $this->assertSame( 'setContentValue2',
+ $result->getResultData( [ ApiResult::META_CONTENT ] ) );
+
+ $result->reset();
+ $this->assertSame( [
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+ $this->assertSame( 0, $result->getSize() );
+
+ $result->addValue( null, 'foo', 1 );
+ $result->addValue( null, 'bar', 1 );
+ $result->addValue( null, 'top', '2', ApiResult::ADD_ON_TOP );
+ $result->addValue( null, null, '2', ApiResult::ADD_ON_TOP );
+ $result->addValue( null, 'bottom', '2' );
+ $result->addValue( null, 'foo', '2', ApiResult::OVERRIDE );
+ $result->addValue( null, 'bar', '2', ApiResult::OVERRIDE | ApiResult::ADD_ON_TOP );
+ $this->assertSame( [ 0, 'top', 'foo', 'bar', 'bottom', ApiResult::META_TYPE ],
+ array_keys( $result->getResultData() ) );
+
+ $result->reset();
+ $result->addValue( null, 'foo', [ 'bar' => 1 ] );
+ $result->addValue( [ 'foo', 'top' ], 'x', 2, ApiResult::ADD_ON_TOP );
+ $result->addValue( [ 'foo', 'bottom' ], 'x', 2 );
+ $this->assertSame( [ 'top', 'bar', 'bottom' ],
+ array_keys( $result->getResultData( [ 'foo' ] ) ) );
+
+ $result->reset();
+ $result->addValue( null, 'sub', [ 'foo' => 1 ] );
+ $result->addValue( null, 'sub', [ 'bar' => 1 ] );
+ $this->assertSame( [
+ 'sub' => [ 'foo' => 1, 'bar' => 1 ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+
+ try {
+ $result->addValue( null, 'sub', [ 'foo' => 2, 'baz' => 2 ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( RuntimeException $ex ) {
+ $this->assertSame(
+ 'Conflicting keys (foo) when attempting to merge element sub',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ $result->reset();
+ $title = Title::newFromText( "MediaWiki:Foobar" );
+ $obj = new stdClass;
+ $obj->foo = 1;
+ $obj->bar = 2;
+ $result->addValue( null, 'title', $title );
+ $result->addValue( null, 'obj', $obj );
+ $this->assertSame( [
+ 'title' => (string)$title,
+ 'obj' => [ 'foo' => 1, 'bar' => 2, ApiResult::META_TYPE => 'assoc' ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+
+ $fh = tmpfile();
+ try {
+ $result->addValue( null, 'file', $fh );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $result->addValue( null, null, $fh );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $obj->file = $fh;
+ $result->addValue( null, 'sub', $obj );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $obj->file = $fh;
+ $result->addValue( null, null, $obj );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add resource(stream) to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ fclose( $fh );
+
+ try {
+ $result->addValue( null, 'inf', INF );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $result->addValue( null, null, INF );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $result->addValue( null, 'nan', NAN );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+ try {
+ $result->addValue( null, null, NAN );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ $result->addValue( null, null, NAN, ApiResult::NO_VALIDATE );
+
+ try {
+ $result->addValue( null, null, NAN, ApiResult::NO_SIZE_CHECK );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ $result->reset();
+ $result->addParsedLimit( 'foo', 12 );
+ $this->assertSame( [
+ 'limits' => [ 'foo' => 12 ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+ $result->addParsedLimit( 'foo', 13 );
+ $this->assertSame( [
+ 'limits' => [ 'foo' => 13 ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+ $this->assertSame( null, $result->getResultData( [ 'foo', 'bar', 'baz' ] ) );
+ $this->assertSame( 13, $result->getResultData( [ 'limits', 'foo' ] ) );
+ try {
+ $result->getResultData( [ 'limits', 'foo', 'bar' ] );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( InvalidArgumentException $ex ) {
+ $this->assertSame(
+ 'Path limits.foo is not an array',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ // Add two values and some metadata, but ensure metadata is not counted
+ $result = new ApiResult( 100 );
+ $obj = [ 'attr' => '12345' ];
+ ApiResult::setContentValue( $obj, 'content', '1234567890' );
+ $this->assertTrue( $result->addValue( null, 'foo', $obj ) );
+ $this->assertSame( 15, $result->getSize() );
+
+ $result = new ApiResult( 10 );
+ $formatter = new ApiErrorFormatter( $result, Language::factory( 'en' ), 'none', false );
+ $result->setErrorFormatter( $formatter );
+ $this->assertFalse( $result->addValue( null, 'foo', '12345678901' ) );
+ $this->assertTrue( $result->addValue( null, 'foo', '12345678901', ApiResult::NO_SIZE_CHECK ) );
+ $this->assertSame( 0, $result->getSize() );
+ $result->reset();
+ $this->assertTrue( $result->addValue( null, 'foo', '1234567890' ) );
+ $this->assertFalse( $result->addValue( null, 'foo', '1' ) );
+ $result->removeValue( null, 'foo' );
+ $this->assertTrue( $result->addValue( null, 'foo', '1' ) );
+
+ $result = new ApiResult( 10 );
+ $obj = new ApiResultTestSerializableObject( 'ok' );
+ $obj->foobar = 'foobaz';
+ $this->assertTrue( $result->addValue( null, 'foo', $obj ) );
+ $this->assertSame( 2, $result->getSize() );
+
+ $result = new ApiResult( 8388608 );
+ $result2 = new ApiResult( 8388608 );
+ $result2->addValue( null, 'foo', 'bar' );
+ $result->addValue( null, 'baz', $result2 );
+ $this->assertSame( [
+ 'baz' => [
+ 'foo' => 'bar',
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+
+ $result = new ApiResult( 8388608 );
+ $result->addValue( null, 'foo', "foo\x80bar" );
+ $result->addValue( null, 'bar', "a\xcc\x81" );
+ $result->addValue( null, 'baz', 74 );
+ $result->addValue( null, null, "foo\x80bar" );
+ $result->addValue( null, null, "a\xcc\x81" );
+ $this->assertSame( [
+ 'foo' => "foo\xef\xbf\xbdbar",
+ 'bar' => "\xc3\xa1",
+ 'baz' => 74,
+ 0 => "foo\xef\xbf\xbdbar",
+ 1 => "\xc3\xa1",
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+
+ $result = new ApiResult( 8388608 );
+ $obj = new stdClass;
+ $obj->{'1'} = 'one';
+ $arr = [];
+ $result->addValue( $arr, 'foo', $obj );
+ $this->assertSame( [
+ 'foo' => [
+ 1 => 'one',
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ ApiResult::META_TYPE => 'assoc',
+ ], $result->getResultData() );
+ }
+
+ /**
+ * @covers ApiResult
+ */
+ public function testMetadata() {
+ $arr = [ 'foo' => [ 'bar' => [] ] ];
+ $result = new ApiResult( 8388608 );
+ $result->addValue( null, 'foo', [ 'bar' => [] ] );
+
+ $expect = [
+ 'foo' => [
+ 'bar' => [
+ ApiResult::META_INDEXED_TAG_NAME => 'ritn',
+ ApiResult::META_TYPE => 'default',
+ ],
+ ApiResult::META_INDEXED_TAG_NAME => 'ritn',
+ ApiResult::META_TYPE => 'default',
+ ],
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar' ],
+ ApiResult::META_TYPE => 'array',
+ ];
+
+ ApiResult::setSubelementsList( $arr, 'foo' );
+ ApiResult::setSubelementsList( $arr, [ 'bar', 'baz' ] );
+ ApiResult::unsetSubelementsList( $arr, 'baz' );
+ ApiResult::setIndexedTagNameRecursive( $arr, 'ritn' );
+ ApiResult::setIndexedTagName( $arr, 'itn' );
+ ApiResult::setPreserveKeysList( $arr, 'foo' );
+ ApiResult::setPreserveKeysList( $arr, [ 'bar', 'baz' ] );
+ ApiResult::unsetPreserveKeysList( $arr, 'baz' );
+ ApiResult::setArrayTypeRecursive( $arr, 'default' );
+ ApiResult::setArrayType( $arr, 'array' );
+ $this->assertSame( $expect, $arr );
+
+ $result->addSubelementsList( null, 'foo' );
+ $result->addSubelementsList( null, [ 'bar', 'baz' ] );
+ $result->removeSubelementsList( null, 'baz' );
+ $result->addIndexedTagNameRecursive( null, 'ritn' );
+ $result->addIndexedTagName( null, 'itn' );
+ $result->addPreserveKeysList( null, 'foo' );
+ $result->addPreserveKeysList( null, [ 'bar', 'baz' ] );
+ $result->removePreserveKeysList( null, 'baz' );
+ $result->addArrayTypeRecursive( null, 'default' );
+ $result->addArrayType( null, 'array' );
+ $this->assertEquals( $expect, $result->getResultData() );
+
+ $arr = [ 'foo' => [ 'bar' => [] ] ];
+ $expect = [
+ 'foo' => [
+ 'bar' => [
+ ApiResult::META_TYPE => 'kvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ ApiResult::META_TYPE => 'kvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ ApiResult::META_TYPE => 'BCkvp',
+ ApiResult::META_KVP_KEY_NAME => 'bc',
+ ];
+ ApiResult::setArrayTypeRecursive( $arr, 'kvp', 'key' );
+ ApiResult::setArrayType( $arr, 'BCkvp', 'bc' );
+ $this->assertSame( $expect, $arr );
+ }
+
+ /**
+ * @covers ApiResult
+ */
+ public function testUtilityFunctions() {
+ $arr = [
+ 'foo' => [
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'bar2' => (object)[ '_dummy' => 'foobaz' ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ 'foo2' => (object)[
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'bar2' => (object)[ '_dummy' => 'foobaz' ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
+ ApiResult::META_TYPE => 'array',
+ '_dummy' => 'foobaz',
+ '_dummy2' => 'foobaz!',
+ ];
+ $this->assertEquals( [
+ 'foo' => [
+ 'bar' => [],
+ 'bar2' => (object)[],
+ 'x' => 'ok',
+ ],
+ 'foo2' => (object)[
+ 'bar' => [],
+ 'bar2' => (object)[],
+ 'x' => 'ok',
+ ],
+ '_dummy2' => 'foobaz!',
+ ], ApiResult::stripMetadata( $arr ), 'ApiResult::stripMetadata' );
+
+ $metadata = [];
+ $data = ApiResult::stripMetadataNonRecursive( $arr, $metadata );
+ $this->assertEquals( [
+ 'foo' => [
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'bar2' => (object)[ '_dummy' => 'foobaz' ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ 'foo2' => (object)[
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'bar2' => (object)[ '_dummy' => 'foobaz' ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ '_dummy2' => 'foobaz!',
+ ], $data, 'ApiResult::stripMetadataNonRecursive ($data)' );
+ $this->assertEquals( [
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
+ ApiResult::META_TYPE => 'array',
+ '_dummy' => 'foobaz',
+ ], $metadata, 'ApiResult::stripMetadataNonRecursive ($metadata)' );
+
+ $metadata = null;
+ $data = ApiResult::stripMetadataNonRecursive( (object)$arr, $metadata );
+ $this->assertEquals( (object)[
+ 'foo' => [
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'bar2' => (object)[ '_dummy' => 'foobaz' ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ 'foo2' => (object)[
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'bar2' => (object)[ '_dummy' => 'foobaz' ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ '_dummy2' => 'foobaz!',
+ ], $data, 'ApiResult::stripMetadataNonRecursive on object ($data)' );
+ $this->assertEquals( [
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
+ ApiResult::META_TYPE => 'array',
+ '_dummy' => 'foobaz',
+ ], $metadata, 'ApiResult::stripMetadataNonRecursive on object ($metadata)' );
+ }
+
+ /**
+ * @covers ApiResult
+ * @dataProvider provideTransformations
+ * @param string $label
+ * @param array $input
+ * @param array $transforms
+ * @param array|Exception $expect
+ */
+ public function testTransformations( $label, $input, $transforms, $expect ) {
+ $result = new ApiResult( false );
+ $result->addValue( null, 'test', $input );
+
+ if ( $expect instanceof Exception ) {
+ try {
+ $output = $result->getResultData( 'test', $transforms );
+ $this->fail( 'Expected exception not thrown', $label );
+ } catch ( Exception $ex ) {
+ $this->assertEquals( $ex, $expect, $label );
+ }
+ } else {
+ $output = $result->getResultData( 'test', $transforms );
+ $this->assertEquals( $expect, $output, $label );
+ }
+ }
+
+ public function provideTransformations() {
+ $kvp = function ( $keyKey, $key, $valKey, $value ) {
+ return [
+ $keyKey => $key,
+ $valKey => $value,
+ ApiResult::META_PRESERVE_KEYS => [ $keyKey ],
+ ApiResult::META_CONTENT => $valKey,
+ ApiResult::META_TYPE => 'assoc',
+ ];
+ };
+ $typeArr = [
+ 'defaultArray' => [ 2 => 'a', 0 => 'b', 1 => 'c' ],
+ 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c' ],
+ 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c' ],
+ 'array' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'array' ],
+ 'BCarray' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'BCarray' ],
+ 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'BCassoc' ],
+ 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'kvp' => [ 'x' => 'a', 'y' => 'b', 'z' => [ 'c' ], ApiResult::META_TYPE => 'kvp' ],
+ 'BCkvp' => [ 'x' => 'a', 'y' => 'b',
+ ApiResult::META_TYPE => 'BCkvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ 'kvpmerge' => [ 'x' => 'a', 'y' => [ 'b' ], 'z' => [ 'c' => 'd' ],
+ ApiResult::META_TYPE => 'kvp',
+ ApiResult::META_KVP_MERGE => true,
+ ],
+ 'emptyDefault' => [ '_dummy' => 1 ],
+ 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
+ '_dummy' => 1,
+ ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
+ ];
+ $stripArr = [
+ 'foo' => [
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'baz' => [
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
+ ApiResult::META_TYPE => 'array',
+ ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
+ ApiResult::META_TYPE => 'array',
+ '_dummy' => 'foobaz',
+ '_dummy2' => 'foobaz!',
+ ];
+
+ return [
+ [
+ 'BC: META_BC_BOOLS',
+ [
+ 'BCtrue' => true,
+ 'BCfalse' => false,
+ 'true' => true,
+ 'false' => false,
+ ApiResult::META_BC_BOOLS => [ 0, 'true', 'false' ],
+ ],
+ [ 'BC' => [] ],
+ [
+ 'BCtrue' => '',
+ 'true' => true,
+ 'false' => false,
+ ApiResult::META_BC_BOOLS => [ 0, 'true', 'false' ],
+ ]
+ ],
+ [
+ 'BC: META_BC_SUBELEMENTS',
+ [
+ 'bc' => 'foo',
+ 'nobc' => 'bar',
+ ApiResult::META_BC_SUBELEMENTS => [ 'bc' ],
+ ],
+ [ 'BC' => [] ],
+ [
+ 'bc' => [
+ '*' => 'foo',
+ ApiResult::META_CONTENT => '*',
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ 'nobc' => 'bar',
+ ApiResult::META_BC_SUBELEMENTS => [ 'bc' ],
+ ],
+ ],
+ [
+ 'BC: META_CONTENT',
+ [
+ 'content' => '!!!',
+ ApiResult::META_CONTENT => 'content',
+ ],
+ [ 'BC' => [] ],
+ [
+ '*' => '!!!',
+ ApiResult::META_CONTENT => '*',
+ ],
+ ],
+ [
+ 'BC: BCkvp type',
+ [
+ 'foo' => 'foo value',
+ 'bar' => 'bar value',
+ '_baz' => 'baz value',
+ ApiResult::META_TYPE => 'BCkvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [ '_baz' ],
+ ],
+ [ 'BC' => [] ],
+ [
+ $kvp( 'key', 'foo', '*', 'foo value' ),
+ $kvp( 'key', 'bar', '*', 'bar value' ),
+ $kvp( 'key', '_baz', '*', 'baz value' ),
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [ '_baz' ],
+ ],
+ ],
+ [
+ 'BC: BCarray type',
+ [
+ ApiResult::META_TYPE => 'BCarray',
+ ],
+ [ 'BC' => [] ],
+ [
+ ApiResult::META_TYPE => 'default',
+ ],
+ ],
+ [
+ 'BC: BCassoc type',
+ [
+ ApiResult::META_TYPE => 'BCassoc',
+ ],
+ [ 'BC' => [] ],
+ [
+ ApiResult::META_TYPE => 'default',
+ ],
+ ],
+ [
+ 'BC: BCkvp exception',
+ [
+ ApiResult::META_TYPE => 'BCkvp',
+ ],
+ [ 'BC' => [] ],
+ new UnexpectedValueException(
+ 'Type "BCkvp" used without setting ApiResult::META_KVP_KEY_NAME metadata item'
+ ),
+ ],
+ [
+ 'BC: nobool, no*, nosub',
+ [
+ 'true' => true,
+ 'false' => false,
+ 'content' => 'content',
+ ApiResult::META_CONTENT => 'content',
+ 'bc' => 'foo',
+ ApiResult::META_BC_SUBELEMENTS => [ 'bc' ],
+ 'BCarray' => [ ApiResult::META_TYPE => 'BCarray' ],
+ 'BCassoc' => [ ApiResult::META_TYPE => 'BCassoc' ],
+ 'BCkvp' => [
+ 'foo' => 'foo value',
+ 'bar' => 'bar value',
+ '_baz' => 'baz value',
+ ApiResult::META_TYPE => 'BCkvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [ '_baz' ],
+ ],
+ ],
+ [ 'BC' => [ 'nobool', 'no*', 'nosub' ] ],
+ [
+ 'true' => true,
+ 'false' => false,
+ 'content' => 'content',
+ 'bc' => 'foo',
+ 'BCarray' => [ ApiResult::META_TYPE => 'default' ],
+ 'BCassoc' => [ ApiResult::META_TYPE => 'default' ],
+ 'BCkvp' => [
+ $kvp( 'key', 'foo', '*', 'foo value' ),
+ $kvp( 'key', 'bar', '*', 'bar value' ),
+ $kvp( 'key', '_baz', '*', 'baz value' ),
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [ '_baz' ],
+ ],
+ ApiResult::META_CONTENT => 'content',
+ ApiResult::META_BC_SUBELEMENTS => [ 'bc' ],
+ ],
+ ],
+
+ [
+ 'Types: Normal transform',
+ $typeArr,
+ [ 'Types' => [] ],
+ [
+ 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
+ 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'kvp' => [ 'x' => 'a', 'y' => 'b',
+ 'z' => [ 'c', ApiResult::META_TYPE => 'array' ],
+ ApiResult::META_TYPE => 'assoc'
+ ],
+ 'BCkvp' => [ 'x' => 'a', 'y' => 'b',
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ 'kvpmerge' => [
+ 'x' => 'a',
+ 'y' => [ 'b', ApiResult::META_TYPE => 'array' ],
+ 'z' => [ 'c' => 'd', ApiResult::META_TYPE => 'assoc' ],
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_KVP_MERGE => true,
+ ],
+ 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
+ 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
+ '_dummy' => 1,
+ ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ ],
+ [
+ 'Types: AssocAsObject',
+ $typeArr,
+ [ 'Types' => [ 'AssocAsObject' => true ] ],
+ (object)[
+ 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
+ 'defaultAssoc' => (object)[ 'x' => 'a',
+ 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc'
+ ],
+ 'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b',
+ 0 => 'c', ApiResult::META_TYPE => 'assoc'
+ ],
+ 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCassoc' => (object)[ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'kvp' => (object)[ 'x' => 'a', 'y' => 'b',
+ 'z' => [ 'c', ApiResult::META_TYPE => 'array' ],
+ ApiResult::META_TYPE => 'assoc'
+ ],
+ 'BCkvp' => (object)[ 'x' => 'a', 'y' => 'b',
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ 'kvpmerge' => (object)[
+ 'x' => 'a',
+ 'y' => [ 'b', ApiResult::META_TYPE => 'array' ],
+ 'z' => (object)[ 'c' => 'd', ApiResult::META_TYPE => 'assoc' ],
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_KVP_MERGE => true,
+ ],
+ 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
+ 'emptyAssoc' => (object)[ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
+ '_dummy' => 1,
+ ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ ],
+ [
+ 'Types: ArmorKVP',
+ $typeArr,
+ [ 'Types' => [ 'ArmorKVP' => 'name' ] ],
+ [
+ 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
+ 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'kvp' => [
+ $kvp( 'name', 'x', 'value', 'a' ),
+ $kvp( 'name', 'y', 'value', 'b' ),
+ $kvp( 'name', 'z', 'value', [ 'c', ApiResult::META_TYPE => 'array' ] ),
+ ApiResult::META_TYPE => 'array'
+ ],
+ 'BCkvp' => [
+ $kvp( 'key', 'x', 'value', 'a' ),
+ $kvp( 'key', 'y', 'value', 'b' ),
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ 'kvpmerge' => [
+ $kvp( 'name', 'x', 'value', 'a' ),
+ $kvp( 'name', 'y', 'value', [ 'b', ApiResult::META_TYPE => 'array' ] ),
+ [
+ 'name' => 'z',
+ 'c' => 'd',
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_PRESERVE_KEYS => [ 'name' ]
+ ],
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_MERGE => true,
+ ],
+ 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
+ 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
+ '_dummy' => 1,
+ ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ ],
+ [
+ 'Types: ArmorKVP + BC',
+ $typeArr,
+ [ 'BC' => [], 'Types' => [ 'ArmorKVP' => 'name' ] ],
+ [
+ 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
+ 'defaultAssoc' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'defaultAssoc2' => [ 2 => 'a', 3 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCarray' => [ 'x' => 'a', 1 => 'b', 0 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'BCassoc' => [ 'a', 'b', 'c', ApiResult::META_TYPE => 'array' ],
+ 'assoc' => [ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'kvp' => [
+ $kvp( 'name', 'x', '*', 'a' ),
+ $kvp( 'name', 'y', '*', 'b' ),
+ $kvp( 'name', 'z', '*', [ 'c', ApiResult::META_TYPE => 'array' ] ),
+ ApiResult::META_TYPE => 'array'
+ ],
+ 'BCkvp' => [
+ $kvp( 'key', 'x', '*', 'a' ),
+ $kvp( 'key', 'y', '*', 'b' ),
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ 'kvpmerge' => [
+ $kvp( 'name', 'x', '*', 'a' ),
+ $kvp( 'name', 'y', '*', [ 'b', ApiResult::META_TYPE => 'array' ] ),
+ [
+ 'name' => 'z',
+ 'c' => 'd',
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_PRESERVE_KEYS => [ 'name' ] ],
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_MERGE => true,
+ ],
+ 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
+ 'emptyAssoc' => [ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
+ '_dummy' => 1,
+ ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ ],
+ [
+ 'Types: ArmorKVP + AssocAsObject',
+ $typeArr,
+ [ 'Types' => [ 'ArmorKVP' => 'name', 'AssocAsObject' => true ] ],
+ (object)[
+ 'defaultArray' => [ 'b', 'c', 'a', ApiResult::META_TYPE => 'array' ],
+ 'defaultAssoc' => (object)[ 'x' => 'a', 1 => 'b',
+ 0 => 'c', ApiResult::META_TYPE => 'assoc'
+ ],
+ 'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b',
+ 0 => 'c', ApiResult::META_TYPE => 'assoc'
+ ],
+ 'array' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCarray' => [ 'a', 'c', 'b', ApiResult::META_TYPE => 'array' ],
+ 'BCassoc' => (object)[ 'a', 'b', 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c', ApiResult::META_TYPE => 'assoc' ],
+ 'kvp' => [
+ (object)$kvp( 'name', 'x', 'value', 'a' ),
+ (object)$kvp( 'name', 'y', 'value', 'b' ),
+ (object)$kvp( 'name', 'z', 'value', [ 'c', ApiResult::META_TYPE => 'array' ] ),
+ ApiResult::META_TYPE => 'array'
+ ],
+ 'BCkvp' => [
+ (object)$kvp( 'key', 'x', 'value', 'a' ),
+ (object)$kvp( 'key', 'y', 'value', 'b' ),
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ],
+ 'kvpmerge' => [
+ (object)$kvp( 'name', 'x', 'value', 'a' ),
+ (object)$kvp( 'name', 'y', 'value', [ 'b', ApiResult::META_TYPE => 'array' ] ),
+ (object)[
+ 'name' => 'z',
+ 'c' => 'd',
+ ApiResult::META_TYPE => 'assoc',
+ ApiResult::META_PRESERVE_KEYS => [ 'name' ]
+ ],
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_KVP_MERGE => true,
+ ],
+ 'emptyDefault' => [ '_dummy' => 1, ApiResult::META_TYPE => 'array' ],
+ 'emptyAssoc' => (object)[ '_dummy' => 1, ApiResult::META_TYPE => 'assoc' ],
+ '_dummy' => 1,
+ ApiResult::META_PRESERVE_KEYS => [ '_dummy' ],
+ ApiResult::META_TYPE => 'assoc',
+ ],
+ ],
+ [
+ 'Types: BCkvp exception',
+ [
+ ApiResult::META_TYPE => 'BCkvp',
+ ],
+ [ 'Types' => [] ],
+ new UnexpectedValueException(
+ 'Type "BCkvp" used without setting ApiResult::META_KVP_KEY_NAME metadata item'
+ ),
+ ],
+
+ [
+ 'Strip: With ArmorKVP + AssocAsObject transforms',
+ $typeArr,
+ [ 'Types' => [ 'ArmorKVP' => 'name', 'AssocAsObject' => true ], 'Strip' => 'all' ],
+ (object)[
+ 'defaultArray' => [ 'b', 'c', 'a' ],
+ 'defaultAssoc' => (object)[ 'x' => 'a', 1 => 'b', 0 => 'c' ],
+ 'defaultAssoc2' => (object)[ 2 => 'a', 3 => 'b', 0 => 'c' ],
+ 'array' => [ 'a', 'c', 'b' ],
+ 'BCarray' => [ 'a', 'c', 'b' ],
+ 'BCassoc' => (object)[ 'a', 'b', 'c' ],
+ 'assoc' => (object)[ 2 => 'a', 0 => 'b', 1 => 'c' ],
+ 'kvp' => [
+ (object)[ 'name' => 'x', 'value' => 'a' ],
+ (object)[ 'name' => 'y', 'value' => 'b' ],
+ (object)[ 'name' => 'z', 'value' => [ 'c' ] ],
+ ],
+ 'BCkvp' => [
+ (object)[ 'key' => 'x', 'value' => 'a' ],
+ (object)[ 'key' => 'y', 'value' => 'b' ],
+ ],
+ 'kvpmerge' => [
+ (object)[ 'name' => 'x', 'value' => 'a' ],
+ (object)[ 'name' => 'y', 'value' => [ 'b' ] ],
+ (object)[ 'name' => 'z', 'c' => 'd' ],
+ ],
+ 'emptyDefault' => [],
+ 'emptyAssoc' => (object)[],
+ '_dummy' => 1,
+ ],
+ ],
+
+ [
+ 'Strip: all',
+ $stripArr,
+ [ 'Strip' => 'all' ],
+ [
+ 'foo' => [
+ 'bar' => [],
+ 'baz' => [],
+ 'x' => 'ok',
+ ],
+ '_dummy2' => 'foobaz!',
+ ],
+ ],
+ [
+ 'Strip: base',
+ $stripArr,
+ [ 'Strip' => 'base' ],
+ [
+ 'foo' => [
+ 'bar' => [ '_dummy' => 'foobaz' ],
+ 'baz' => [
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ApiResult::META_PRESERVE_KEYS => [ 'foo', 'bar', '_dummy2', 0 ],
+ ApiResult::META_TYPE => 'array',
+ ],
+ 'x' => 'ok',
+ '_dummy' => 'foobaz',
+ ],
+ '_dummy2' => 'foobaz!',
+ ],
+ ],
+ [
+ 'Strip: bc',
+ $stripArr,
+ [ 'Strip' => 'bc' ],
+ [
+ 'foo' => [
+ 'bar' => [],
+ 'baz' => [
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ],
+ 'x' => 'ok',
+ ],
+ '_dummy2' => 'foobaz!',
+ ApiResult::META_SUBELEMENTS => [ 'foo', 'bar' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'itn',
+ ],
+ ],
+
+ [
+ 'Custom transform',
+ [
+ 'foo' => '?',
+ 'bar' => '?',
+ '_dummy' => '?',
+ '_dummy2' => '?',
+ '_dummy3' => '?',
+ ApiResult::META_CONTENT => 'foo',
+ ApiResult::META_PRESERVE_KEYS => [ '_dummy2', '_dummy3' ],
+ ],
+ [
+ 'Custom' => [ $this, 'customTransform' ],
+ 'BC' => [],
+ 'Types' => [],
+ 'Strip' => 'all'
+ ],
+ [
+ '*' => 'FOO',
+ 'bar' => 'BAR',
+ 'baz' => [ 'a', 'b' ],
+ '_dummy2' => '_DUMMY2',
+ '_dummy3' => '_DUMMY3',
+ ApiResult::META_CONTENT => 'bar',
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * Custom transformer for testTransformations
+ * @param array &$data
+ * @param array &$metadata
+ */
+ public function customTransform( &$data, &$metadata ) {
+ // Prevent recursion
+ if ( isset( $metadata['_added'] ) ) {
+ $metadata[ApiResult::META_TYPE] = 'array';
+ return;
+ }
+
+ foreach ( $data as $k => $v ) {
+ $data[$k] = strtoupper( $k );
+ }
+ $data['baz'] = [ '_added' => 1, 'z' => 'b', 'y' => 'a' ];
+ $metadata[ApiResult::META_PRESERVE_KEYS][0] = '_dummy';
+ $data[ApiResult::META_CONTENT] = 'bar';
+ }
+
+ /**
+ * @covers ApiResult
+ */
+ public function testAddMetadataToResultVars() {
+ $arr = [
+ 'a' => "foo",
+ 'b' => false,
+ 'c' => 10,
+ 'sequential_numeric_keys' => [ 'a', 'b', 'c' ],
+ 'non_sequential_numeric_keys' => [ 'a', 'b', 4 => 'c' ],
+ 'string_keys' => [
+ 'one' => 1,
+ 'two' => 2
+ ],
+ 'object_sequential_keys' => (object)[ 'a', 'b', 'c' ],
+ '_type' => "should be overwritten in result",
+ ];
+ $this->assertSame( [
+ ApiResult::META_TYPE => 'kvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [
+ 'a', 'b', 'c',
+ 'sequential_numeric_keys', 'non_sequential_numeric_keys',
+ 'string_keys', 'object_sequential_keys'
+ ],
+ ApiResult::META_BC_BOOLS => [ 'b' ],
+ ApiResult::META_INDEXED_TAG_NAME => 'var',
+ 'a' => "foo",
+ 'b' => false,
+ 'c' => 10,
+ 'sequential_numeric_keys' => [
+ ApiResult::META_TYPE => 'array',
+ ApiResult::META_BC_BOOLS => [],
+ ApiResult::META_INDEXED_TAG_NAME => 'value',
+ 0 => 'a',
+ 1 => 'b',
+ 2 => 'c',
+ ],
+ 'non_sequential_numeric_keys' => [
+ ApiResult::META_TYPE => 'kvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [ 0, 1, 4 ],
+ ApiResult::META_BC_BOOLS => [],
+ ApiResult::META_INDEXED_TAG_NAME => 'var',
+ 0 => 'a',
+ 1 => 'b',
+ 4 => 'c',
+ ],
+ 'string_keys' => [
+ ApiResult::META_TYPE => 'kvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [ 'one', 'two' ],
+ ApiResult::META_BC_BOOLS => [],
+ ApiResult::META_INDEXED_TAG_NAME => 'var',
+ 'one' => 1,
+ 'two' => 2,
+ ],
+ 'object_sequential_keys' => [
+ ApiResult::META_TYPE => 'kvp',
+ ApiResult::META_KVP_KEY_NAME => 'key',
+ ApiResult::META_PRESERVE_KEYS => [ 0, 1, 2 ],
+ ApiResult::META_BC_BOOLS => [],
+ ApiResult::META_INDEXED_TAG_NAME => 'var',
+ 0 => 'a',
+ 1 => 'b',
+ 2 => 'c',
+ ],
+ ], ApiResult::addMetadataToResultVars( $arr ) );
+ }
+
+ public function testObjectSerialization() {
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', (object)[ 'a' => 1, 'b' => 2 ] );
+ $this->assertSame( [
+ 'a' => 1,
+ 'b' => 2,
+ ApiResult::META_TYPE => 'assoc',
+ ], $arr['foo'] );
+
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', new ApiResultTestStringifiableObject() );
+ $this->assertSame( 'Ok', $arr['foo'] );
+
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject( 'Ok' ) );
+ $this->assertSame( 'Ok', $arr['foo'] );
+
+ try {
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject(
+ new ApiResultTestStringifiableObject()
+ ) );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'ApiResultTestSerializableObject::serializeForApiResult() ' .
+ 'returned an object of class ApiResultTestStringifiableObject',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ try {
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject( NAN ) );
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( UnexpectedValueException $ex ) {
+ $this->assertSame(
+ 'ApiResultTestSerializableObject::serializeForApiResult() ' .
+ 'returned an invalid value: Cannot add non-finite floats to ApiResult',
+ $ex->getMessage(),
+ 'Expected exception'
+ );
+ }
+
+ $arr = [];
+ ApiResult::setValue( $arr, 'foo', new ApiResultTestSerializableObject(
+ [
+ 'one' => new ApiResultTestStringifiableObject( '1' ),
+ 'two' => new ApiResultTestSerializableObject( 2 ),
+ ]
+ ) );
+ $this->assertSame( [
+ 'one' => '1',
+ 'two' => 2,
+ ], $arr['foo'] );
+ }
+}
+
+class ApiResultTestStringifiableObject {
+ private $ret;
+
+ public function __construct( $ret = 'Ok' ) {
+ $this->ret = $ret;
+ }
+
+ public function __toString() {
+ return $this->ret;
+ }
+}
+
+class ApiResultTestSerializableObject {
+ private $ret;
+
+ public function __construct( $ret ) {
+ $this->ret = $ret;
+ }
+
+ public function __toString() {
+ return "Fail";
+ }
+
+ public function serializeForApiResult() {
+ return $this->ret;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiRevisionDeleteTest.php b/www/wiki/tests/phpunit/includes/api/ApiRevisionDeleteTest.php
new file mode 100644
index 00000000..a4ca8a10
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiRevisionDeleteTest.php
@@ -0,0 +1,117 @@
+<?php
+
+/**
+ * Tests for action=revisiondelete
+ * @covers APIRevisionDelete
+ * @group API
+ * @group medium
+ * @group Database
+ */
+class ApiRevisionDeleteTest extends ApiTestCase {
+
+ public static $page = 'Help:ApiRevDel_test';
+ public $revs = [];
+
+ protected function setUp() {
+ // Needs to be before setup since this gets cached
+ $this->mergeMwGlobalArrayValue(
+ 'wgGroupPermissions',
+ [ 'sysop' => [ 'deleterevision' => true ] ]
+ );
+ parent::setUp();
+ // Make a few edits for us to play with
+ for ( $i = 1; $i <= 5; $i++ ) {
+ self::editPage( self::$page, MWCryptRand::generateHex( 10 ), 'summary' );
+ $this->revs[] = Title::newFromText( self::$page )
+ ->getLatestRevID( Title::GAID_FOR_UPDATE );
+ }
+ }
+
+ public function testHidingRevisions() {
+ $user = self::$users['sysop']->getUser();
+ $revid = array_shift( $this->revs );
+ $out = $this->doApiRequest( [
+ 'action' => 'revisiondelete',
+ 'type' => 'revision',
+ 'target' => self::$page,
+ 'ids' => $revid,
+ 'hide' => 'content|user|comment',
+ 'token' => $user->getEditToken(),
+ ] );
+ // Check the output
+ $out = $out[0]['revisiondelete'];
+ $this->assertEquals( $out['status'], 'Success' );
+ $this->assertArrayHasKey( 'items', $out );
+ $item = $out['items'][0];
+ $this->assertTrue( $item['userhidden'], 'userhidden' );
+ $this->assertTrue( $item['commenthidden'], 'commenthidden' );
+ $this->assertTrue( $item['texthidden'], 'texthidden' );
+ $this->assertEquals( $item['id'], $revid );
+
+ // Now check that that revision was actually hidden
+ $rev = Revision::newFromId( $revid );
+ $this->assertEquals( $rev->getContent( Revision::FOR_PUBLIC ), null );
+ $this->assertEquals( $rev->getComment( Revision::FOR_PUBLIC ), '' );
+ $this->assertEquals( $rev->getUser( Revision::FOR_PUBLIC ), 0 );
+
+ // Now test unhiding!
+ $out2 = $this->doApiRequest( [
+ 'action' => 'revisiondelete',
+ 'type' => 'revision',
+ 'target' => self::$page,
+ 'ids' => $revid,
+ 'show' => 'content|user|comment',
+ 'token' => $user->getEditToken(),
+ ] );
+
+ // Check the output
+ $out2 = $out2[0]['revisiondelete'];
+ $this->assertEquals( $out2['status'], 'Success' );
+ $this->assertArrayHasKey( 'items', $out2 );
+ $item = $out2['items'][0];
+
+ $this->assertFalse( $item['userhidden'], 'userhidden' );
+ $this->assertFalse( $item['commenthidden'], 'commenthidden' );
+ $this->assertFalse( $item['texthidden'], 'texthidden' );
+
+ $this->assertEquals( $item['id'], $revid );
+
+ $rev = Revision::newFromId( $revid );
+ $this->assertNotEquals( $rev->getContent( Revision::FOR_PUBLIC ), null );
+ $this->assertNotEquals( $rev->getComment( Revision::FOR_PUBLIC ), '' );
+ $this->assertNotEquals( $rev->getUser( Revision::FOR_PUBLIC ), 0 );
+ }
+
+ public function testUnhidingOutput() {
+ $user = self::$users['sysop']->getUser();
+ $revid = array_shift( $this->revs );
+ // Hide revisions
+ $this->doApiRequest( [
+ 'action' => 'revisiondelete',
+ 'type' => 'revision',
+ 'target' => self::$page,
+ 'ids' => $revid,
+ 'hide' => 'content|user|comment',
+ 'token' => $user->getEditToken(),
+ ] );
+
+ $out = $this->doApiRequest( [
+ 'action' => 'revisiondelete',
+ 'type' => 'revision',
+ 'target' => self::$page,
+ 'ids' => $revid,
+ 'show' => 'comment',
+ 'token' => $user->getEditToken(),
+ ] );
+ $out = $out[0]['revisiondelete'];
+ $this->assertEquals( $out['status'], 'Success' );
+ $this->assertArrayHasKey( 'items', $out );
+ $item = $out['items'][0];
+ // Check it has userhidden & texthidden
+ // but not commenthidden
+ $this->assertTrue( $item['userhidden'], 'userhidden' );
+ $this->assertFalse( $item['commenthidden'], 'commenthidden' );
+ $this->assertTrue( $item['texthidden'], 'texthidden' );
+ $this->assertEquals( $item['id'], $revid );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php b/www/wiki/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php
new file mode 100644
index 00000000..dacd48f6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiSetNotificationTimestampIntegrationTest.php
@@ -0,0 +1,51 @@
+<?php
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @author Addshore
+ * @covers ApiSetNotificationTimestamp
+ * @group API
+ * @group medium
+ * @group Database
+ */
+class ApiSetNotificationTimestampIntegrationTest extends ApiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ self::$users[__CLASS__] = new TestUser( __CLASS__ );
+ }
+
+ public function testStuff() {
+ $user = self::$users[__CLASS__]->getUser();
+ $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+
+ $user->addWatch( $page->getTitle() );
+
+ $result = $this->doApiRequestWithToken(
+ [
+ 'action' => 'setnotificationtimestamp',
+ 'timestamp' => '20160101020202',
+ 'pageids' => $page->getId(),
+ ],
+ null,
+ $user
+ );
+
+ $this->assertEquals(
+ [
+ 'batchcomplete' => true,
+ 'setnotificationtimestamp' => [
+ [ 'ns' => 0, 'title' => 'UTPage', 'notificationtimestamp' => '2016-01-01T02:02:02Z' ]
+ ],
+ ],
+ $result[0]
+ );
+
+ $watchedItemStore = MediaWikiServices::getInstance()->getWatchedItemStore();
+ $this->assertEquals(
+ $watchedItemStore->getNotificationTimestampsBatch( $user, [ $page->getTitle() ] ),
+ [ [ 'UTPage' => '20160101020202' ] ]
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiStashEditTest.php b/www/wiki/tests/phpunit/includes/api/ApiStashEditTest.php
new file mode 100644
index 00000000..60cda090
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiStashEditTest.php
@@ -0,0 +1,27 @@
+<?php
+
+/**
+ * @covers ApiStashEdit
+ * @group API
+ * @group medium
+ * @group Database
+ */
+class ApiStashEditTest extends ApiTestCase {
+
+ public function testBasicEdit() {
+ $apiResult = $this->doApiRequestWithToken(
+ [
+ 'action' => 'stashedit',
+ 'title' => 'ApistashEdit_Page',
+ 'contentmodel' => 'wikitext',
+ 'contentformat' => 'text/x-wiki',
+ 'text' => 'Text for ' . __METHOD__ . ' page',
+ 'baserevid' => 0,
+ ]
+ );
+ $apiResult = $apiResult[0];
+ $this->assertArrayHasKey( 'stashedit', $apiResult );
+ $this->assertEquals( 'stashed', $apiResult['stashedit']['status'] );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiTestCase.php b/www/wiki/tests/phpunit/includes/api/ApiTestCase.php
new file mode 100644
index 00000000..974e9a2d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiTestCase.php
@@ -0,0 +1,260 @@
+<?php
+
+use MediaWiki\Session\SessionManager;
+
+abstract class ApiTestCase extends MediaWikiLangTestCase {
+ protected static $apiUrl;
+
+ protected static $errorFormatter = null;
+
+ /**
+ * @var ApiTestContext
+ */
+ protected $apiContext;
+
+ protected function setUp() {
+ global $wgServer;
+
+ parent::setUp();
+ self::$apiUrl = $wgServer . wfScript( 'api' );
+
+ ApiQueryInfo::resetTokenCache(); // tokens are invalid because we cleared the session
+
+ self::$users = [
+ 'sysop' => static::getTestSysop(),
+ 'uploader' => static::getTestUser(),
+ ];
+
+ $this->setMwGlobals( [
+ 'wgAuth' => new MediaWiki\Auth\AuthManagerAuthPlugin,
+ 'wgRequest' => new FauxRequest( [] ),
+ 'wgUser' => self::$users['sysop']->getUser(),
+ ] );
+
+ $this->apiContext = new ApiTestContext();
+ }
+
+ protected function tearDown() {
+ // Avoid leaking session over tests
+ MediaWiki\Session\SessionManager::getGlobalSession()->clear();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Edits or creates a page/revision
+ * @param string $pageName Page title
+ * @param string $text Content of the page
+ * @param string $summary Optional summary string for the revision
+ * @param int $defaultNs Optional namespace id
+ * @return array Array as returned by WikiPage::doEditContent()
+ */
+ protected function editPage( $pageName, $text, $summary = '', $defaultNs = NS_MAIN ) {
+ $title = Title::newFromText( $pageName, $defaultNs );
+ $page = WikiPage::factory( $title );
+
+ return $page->doEditContent( ContentHandler::makeContent( $text, $title ), $summary );
+ }
+
+ /**
+ * Revision-deletes a revision.
+ *
+ * @param Revision|int $rev Revision to delete
+ * @param array $value Keys are Revision::DELETED_* flags. Values are 1 to set the bit, 0 to
+ * clear, -1 to leave alone. (All other values also clear the bit.)
+ * @param string $comment Deletion comment
+ */
+ protected function revisionDelete(
+ $rev, array $value = [ Revision::DELETED_TEXT => 1 ], $comment = ''
+ ) {
+ if ( is_int( $rev ) ) {
+ $rev = Revision::newFromId( $rev );
+ }
+ RevisionDeleter::createList(
+ 'revision', RequestContext::getMain(), $rev->getTitle(), [ $rev->getId() ]
+ )->setVisibility( [
+ 'value' => $value,
+ 'comment' => $comment,
+ ] );
+ }
+
+ /**
+ * Does the API request and returns the result.
+ *
+ * The returned value is an array containing
+ * - the result data (array)
+ * - the request (WebRequest)
+ * - the session data of the request (array)
+ * - if $appendModule is true, the Api module $module
+ *
+ * @param array $params
+ * @param array|null $session
+ * @param bool $appendModule
+ * @param User|null $user
+ * @param string|null $tokenType Set to a string like 'csrf' to send an
+ * appropriate token
+ *
+ * @throws ApiUsageException
+ * @return array
+ */
+ protected function doApiRequest( array $params, array $session = null,
+ $appendModule = false, User $user = null, $tokenType = null
+ ) {
+ global $wgRequest, $wgUser;
+
+ if ( is_null( $session ) ) {
+ // re-use existing global session by default
+ $session = $wgRequest->getSessionArray();
+ }
+
+ $sessionObj = SessionManager::singleton()->getEmptySession();
+
+ if ( $session !== null ) {
+ foreach ( $session as $key => $value ) {
+ $sessionObj->set( $key, $value );
+ }
+ }
+
+ // set up global environment
+ if ( $user ) {
+ $wgUser = $user;
+ }
+
+ if ( $tokenType !== null ) {
+ if ( $tokenType === 'auto' ) {
+ $tokenType = ( new ApiMain() )->getModuleManager()
+ ->getModule( $params['action'], 'action' )->needsToken();
+ }
+ $params['token'] = ApiQueryTokens::getToken(
+ $wgUser, $sessionObj, ApiQueryTokens::getTokenTypeSalts()[$tokenType]
+ )->toString();
+ }
+
+ $wgRequest = new FauxRequest( $params, true, $sessionObj );
+ RequestContext::getMain()->setRequest( $wgRequest );
+ RequestContext::getMain()->setUser( $wgUser );
+ MediaWiki\Auth\AuthManager::resetCache();
+
+ // set up local environment
+ $context = $this->apiContext->newTestContext( $wgRequest, $wgUser );
+
+ $module = new ApiMain( $context, true );
+
+ // run it!
+ $module->execute();
+
+ // construct result
+ $results = [
+ $module->getResult()->getResultData( null, [ 'Strip' => 'all' ] ),
+ $context->getRequest(),
+ $context->getRequest()->getSessionArray()
+ ];
+
+ if ( $appendModule ) {
+ $results[] = $module;
+ }
+
+ return $results;
+ }
+
+ /**
+ * Convenience function to access the token parameter of doApiRequest()
+ * more succinctly.
+ *
+ * @param array $params Key-value API params
+ * @param array|null $session Session array
+ * @param User|null $user A User object for the context
+ * @param string $tokenType Which token type to pass
+ * @return array Result of the API call
+ */
+ protected function doApiRequestWithToken( array $params, array $session = null,
+ User $user = null, $tokenType = 'auto'
+ ) {
+ return $this->doApiRequest( $params, $session, false, $user, $tokenType );
+ }
+
+ /**
+ * Previously this would do API requests to log in, as well as setting $wgUser and the request
+ * context's user. The API requests are unnecessary, and the global-setting is unwanted, so
+ * this method should not be called. Instead, pass appropriate User values directly to
+ * functions that need them. For functions that still rely on $wgUser, set that directly. If
+ * you just want to log in the test sysop user, don't do anything -- that's the default.
+ *
+ * @param TestUser|string $testUser Object, or key to self::$users such as 'sysop' or 'uploader'
+ * @deprecated since 1.31
+ */
+ protected function doLogin( $testUser = null ) {
+ global $wgUser;
+
+ if ( $testUser === null ) {
+ $testUser = static::getTestSysop();
+ } elseif ( is_string( $testUser ) && array_key_exists( $testUser, self::$users ) ) {
+ $testUser = self::$users[$testUser];
+ } elseif ( !$testUser instanceof TestUser ) {
+ throw new MWException( "Can't log in to undefined user $testUser" );
+ }
+
+ $wgUser = $testUser->getUser();
+ RequestContext::getMain()->setUser( $wgUser );
+ }
+
+ protected function getTokenList( TestUser $user, $session = null ) {
+ $data = $this->doApiRequest( [
+ 'action' => 'tokens',
+ 'type' => 'edit|delete|protect|move|block|unblock|watch'
+ ], $session, false, $user->getUser() );
+
+ if ( !array_key_exists( 'tokens', $data[0] ) ) {
+ throw new MWException( 'Api failed to return a token list' );
+ }
+
+ return $data[0]['tokens'];
+ }
+
+ protected static function getErrorFormatter() {
+ if ( self::$errorFormatter === null ) {
+ self::$errorFormatter = new ApiErrorFormatter(
+ new ApiResult( false ),
+ Language::factory( 'en' ),
+ 'none'
+ );
+ }
+ return self::$errorFormatter;
+ }
+
+ public static function apiExceptionHasCode( ApiUsageException $ex, $code ) {
+ return (bool)array_filter(
+ self::getErrorFormatter()->arrayFromStatus( $ex->getStatusValue() ),
+ function ( $e ) use ( $code ) {
+ return is_array( $e ) && $e['code'] === $code;
+ }
+ );
+ }
+
+ /**
+ * @coversNothing
+ */
+ public function testApiTestGroup() {
+ $groups = PHPUnit_Util_Test::getGroups( static::class );
+ $constraint = PHPUnit_Framework_Assert::logicalOr(
+ $this->contains( 'medium' ),
+ $this->contains( 'large' )
+ );
+ $this->assertThat( $groups, $constraint,
+ 'ApiTestCase::setUp can be slow, tests must be "medium" or "large"'
+ );
+ }
+
+ /**
+ * Expect an ApiUsageException to be thrown with the given parameters, which are the same as
+ * ApiUsageException::newWithMessage()'s parameters. This allows checking for an exception
+ * whose text is given by a message key instead of text, so as not to hard-code the message's
+ * text into test code.
+ */
+ protected function setExpectedApiException(
+ $msg, $code = null, array $data = null, $httpCode = 0
+ ) {
+ $expected = ApiUsageException::newWithMessage( null, $msg, $code, $data, $httpCode );
+ $this->setExpectedException( ApiUsageException::class, $expected->getMessage() );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiTestCaseUpload.php b/www/wiki/tests/phpunit/includes/api/ApiTestCaseUpload.php
new file mode 100644
index 00000000..3670fad8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiTestCaseUpload.php
@@ -0,0 +1,8 @@
+<?php
+
+/**
+ * For backward compatibility since 1.31
+ */
+abstract class ApiTestCaseUpload extends ApiUploadTestCase {
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiTestContext.php b/www/wiki/tests/phpunit/includes/api/ApiTestContext.php
new file mode 100644
index 00000000..17dad1fa
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiTestContext.php
@@ -0,0 +1,21 @@
+<?php
+
+class ApiTestContext extends RequestContext {
+
+ /**
+ * Returns a DerivativeContext with the request variables in place
+ *
+ * @param WebRequest $request WebRequest request object including parameters and session
+ * @param User|null $user User or null
+ * @return DerivativeContext
+ */
+ public function newTestContext( WebRequest $request, User $user = null ) {
+ $context = new DerivativeContext( $this );
+ $context->setRequest( $request );
+ if ( $user !== null ) {
+ $context->setUser( $user );
+ }
+
+ return $context;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiTokensTest.php b/www/wiki/tests/phpunit/includes/api/ApiTokensTest.php
new file mode 100644
index 00000000..1f7c00b0
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiTokensTest.php
@@ -0,0 +1,40 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiTokens
+ */
+class ApiTokensTest extends ApiTestCase {
+
+ public function testGettingToken() {
+ foreach ( self::$users as $user ) {
+ $this->runTokenTest( $user );
+ }
+ }
+
+ protected function runTokenTest( TestUser $user ) {
+ $tokens = $this->getTokenList( $user );
+
+ $rights = $user->getUser()->getRights();
+
+ $this->assertArrayHasKey( 'edittoken', $tokens );
+ $this->assertArrayHasKey( 'movetoken', $tokens );
+
+ if ( isset( $rights['delete'] ) ) {
+ $this->assertArrayHasKey( 'deletetoken', $tokens );
+ }
+
+ if ( isset( $rights['block'] ) ) {
+ $this->assertArrayHasKey( 'blocktoken', $tokens );
+ $this->assertArrayHasKey( 'unblocktoken', $tokens );
+ }
+
+ if ( isset( $rights['protect'] ) ) {
+ $this->assertArrayHasKey( 'protecttoken', $tokens );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiUnblockTest.php b/www/wiki/tests/phpunit/includes/api/ApiUnblockTest.php
new file mode 100644
index 00000000..d20de0dc
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiUnblockTest.php
@@ -0,0 +1,23 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiUnblock
+ */
+class ApiUnblockTest extends ApiTestCase {
+ /**
+ * @expectedException ApiUsageException
+ */
+ public function testWithNoToken() {
+ $this->doApiRequest(
+ [
+ 'action' => 'unblock',
+ 'user' => 'UTApiBlockee',
+ 'reason' => 'Some reason',
+ ]
+ );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiUploadTest.php b/www/wiki/tests/phpunit/includes/api/ApiUploadTest.php
new file mode 100644
index 00000000..41c9aed4
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiUploadTest.php
@@ -0,0 +1,560 @@
+<?php
+/**
+ * n.b. Ensure that you can write to the images/ directory as the
+ * user that will run tests.
+ *
+ * Note for reviewers: this intentionally duplicates functionality already in
+ * "ApiSetup" and so on. This framework works better IMO and has less
+ * strangeness (such as test cases inheriting from "ApiSetup"...) (and in the
+ * case of the other Upload tests, this flat out just actually works... )
+ *
+ * @todo Port the other Upload tests, and other API tests to this framework
+ *
+ * @todo Broken test, reports false errors from time to time.
+ * See https://phabricator.wikimedia.org/T28169
+ *
+ * @todo This is pretty sucky... needs to be prettified.
+ *
+ * @group API
+ * @group Database
+ * @group medium
+ * @group Broken
+ *
+ * @covers ApiUpload
+ */
+class ApiUploadTest extends ApiUploadTestCase {
+ /**
+ * Testing login
+ * XXX this is a funny way of getting session context
+ */
+ public function testLogin() {
+ $user = self::$users['uploader'];
+ $userName = $user->getUser()->getName();
+ $password = $user->getPassword();
+
+ $params = [
+ 'action' => 'login',
+ 'lgname' => $userName,
+ 'lgpassword' => $password
+ ];
+ list( $result, , $session ) = $this->doApiRequest( $params );
+ $this->assertArrayHasKey( "login", $result );
+ $this->assertArrayHasKey( "result", $result['login'] );
+ $this->assertEquals( "NeedToken", $result['login']['result'] );
+ $token = $result['login']['token'];
+
+ $params = [
+ 'action' => 'login',
+ 'lgtoken' => $token,
+ 'lgname' => $userName,
+ 'lgpassword' => $password
+ ];
+ list( $result, , $session ) = $this->doApiRequest( $params, $session );
+ $this->assertArrayHasKey( "login", $result );
+ $this->assertArrayHasKey( "result", $result['login'] );
+ $this->assertEquals( "Success", $result['login']['result'] );
+
+ $this->assertNotEmpty( $session, 'API Login must return a session' );
+
+ return $session;
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadRequiresToken( $session ) {
+ $exception = false;
+ try {
+ $this->doApiRequest( [
+ 'action' => 'upload'
+ ] );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ $this->assertContains( 'The "token" parameter must be set', $e->getMessage() );
+ }
+ $this->assertTrue( $exception, "Got exception" );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadMissingParams( $session ) {
+ $exception = false;
+ try {
+ $this->doApiRequestWithToken( [
+ 'action' => 'upload',
+ ], $session, self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ $this->assertEquals(
+ 'One of the parameters "filekey", "file" and "url" is required.',
+ $e->getMessage()
+ );
+ }
+ $this->assertTrue( $exception, "Got exception" );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUpload( $session ) {
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ $filePaths = $randomImageGenerator->writeImages( 1, $extension, $this->getNewTempDirectory() );
+ } catch ( Exception $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+
+ /** @var array $filePaths */
+ $filePath = $filePaths[0];
+ $fileSize = filesize( $filePath );
+ $fileName = basename( $filePath );
+
+ $this->deleteFileByFileName( $fileName );
+ $this->deleteFileByContent( $filePath );
+
+ if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $params = [
+ 'action' => 'upload',
+ 'filename' => $fileName,
+ 'file' => 'dummy content',
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for $fileName",
+ ];
+
+ $exception = false;
+ try {
+ list( $result, , ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertEquals( $fileSize, (int)$result['upload']['imageinfo']['size'] );
+ $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] );
+ $this->assertFalse( $exception );
+
+ // clean up
+ $this->deleteFileByFileName( $fileName );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadZeroLength( $session ) {
+ $mimeType = 'image/png';
+
+ $filePath = $this->getNewTempFile();
+ $fileName = "apiTestUploadZeroLength.png";
+
+ $this->deleteFileByFileName( $fileName );
+
+ if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $params = [
+ 'action' => 'upload',
+ 'filename' => $fileName,
+ 'file' => 'dummy content',
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for $fileName",
+ ];
+
+ $exception = false;
+ try {
+ $this->doApiRequestWithToken( $params, $session, self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $this->assertContains( 'The file you submitted was empty', $e->getMessage() );
+ $exception = true;
+ }
+ $this->assertTrue( $exception );
+
+ // clean up
+ $this->deleteFileByFileName( $fileName );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadSameFileName( $session ) {
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ $filePaths = $randomImageGenerator->writeImages( 2, $extension, $this->getNewTempDirectory() );
+ } catch ( Exception $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+
+ // we'll reuse this filename
+ /** @var array $filePaths */
+ $fileName = basename( $filePaths[0] );
+
+ // clear any other files with the same name
+ $this->deleteFileByFileName( $fileName );
+
+ // we reuse these params
+ $params = [
+ 'action' => 'upload',
+ 'filename' => $fileName,
+ 'file' => 'dummy content',
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for $fileName",
+ ];
+
+ // first upload .... should succeed
+
+ if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePaths[0] ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $exception = false;
+ try {
+ list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertFalse( $exception );
+
+ // second upload with the same name (but different content)
+
+ if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePaths[1] ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $exception = false;
+ try {
+ list( $result, , ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() ); // FIXME: leaks a temporary file
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Warning', $result['upload']['result'] );
+ $this->assertTrue( isset( $result['upload']['warnings'] ) );
+ $this->assertTrue( isset( $result['upload']['warnings']['exists'] ) );
+ $this->assertFalse( $exception );
+
+ // clean up
+ $this->deleteFileByFileName( $fileName );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadSameContent( $session ) {
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ $filePaths = $randomImageGenerator->writeImages( 1, $extension, $this->getNewTempDirectory() );
+ } catch ( Exception $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+
+ /** @var array $filePaths */
+ $fileNames[0] = basename( $filePaths[0] );
+ $fileNames[1] = "SameContentAs" . $fileNames[0];
+
+ // clear any other files with the same name or content
+ $this->deleteFileByContent( $filePaths[0] );
+ $this->deleteFileByFileName( $fileNames[0] );
+ $this->deleteFileByFileName( $fileNames[1] );
+
+ // first upload .... should succeed
+
+ $params = [
+ 'action' => 'upload',
+ 'filename' => $fileNames[0],
+ 'file' => 'dummy content',
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for " . $fileNames[0],
+ ];
+
+ if ( !$this->fakeUploadFile( 'file', $fileNames[0], $mimeType, $filePaths[0] ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $exception = false;
+ try {
+ list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertFalse( $exception );
+
+ // second upload with the same content (but different name)
+
+ if ( !$this->fakeUploadFile( 'file', $fileNames[1], $mimeType, $filePaths[0] ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $params = [
+ 'action' => 'upload',
+ 'filename' => $fileNames[1],
+ 'file' => 'dummy content',
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for " . $fileNames[1],
+ ];
+
+ $exception = false;
+ try {
+ list( $result ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() ); // FIXME: leaks a temporary file
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Warning', $result['upload']['result'] );
+ $this->assertTrue( isset( $result['upload']['warnings'] ) );
+ $this->assertTrue( isset( $result['upload']['warnings']['duplicate'] ) );
+ $this->assertFalse( $exception );
+
+ // clean up
+ $this->deleteFileByFileName( $fileNames[0] );
+ $this->deleteFileByFileName( $fileNames[1] );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadStash( $session ) {
+ $this->setMwGlobals( [
+ 'wgUser' => self::$users['uploader']->getUser(), // @todo FIXME: still used somewhere
+ ] );
+
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ $filePaths = $randomImageGenerator->writeImages( 1, $extension, $this->getNewTempDirectory() );
+ } catch ( Exception $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+
+ /** @var array $filePaths */
+ $filePath = $filePaths[0];
+ $fileSize = filesize( $filePath );
+ $fileName = basename( $filePath );
+
+ $this->deleteFileByFileName( $fileName );
+ $this->deleteFileByContent( $filePath );
+
+ if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $params = [
+ 'action' => 'upload',
+ 'stash' => 1,
+ 'filename' => $fileName,
+ 'file' => 'dummy content',
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for $fileName",
+ ];
+
+ $exception = false;
+ try {
+ list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() ); // FIXME: leaks a temporary file
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertFalse( $exception );
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertEquals( $fileSize, (int)$result['upload']['imageinfo']['size'] );
+ $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] );
+ $this->assertTrue( isset( $result['upload']['filekey'] ) );
+ $this->assertEquals( $result['upload']['sessionkey'], $result['upload']['filekey'] );
+ $filekey = $result['upload']['filekey'];
+
+ // it should be visible from Special:UploadStash
+ // XXX ...but how to test this, with a fake WebRequest with the session?
+
+ // now we should try to release the file from stash
+ $params = [
+ 'action' => 'upload',
+ 'filekey' => $filekey,
+ 'filename' => $fileName,
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for $fileName, altered",
+ ];
+
+ $this->clearFakeUploads();
+ $exception = false;
+ try {
+ list( $result ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertFalse( $exception, "No ApiUsageException exception." );
+
+ // clean up
+ $this->deleteFileByFileName( $fileName );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadChunks( $session ) {
+ $this->setMwGlobals( [
+ // @todo FIXME: still used somewhere
+ 'wgUser' => self::$users['uploader']->getUser(),
+ ] );
+
+ $chunkSize = 1048576;
+ // Download a large image file
+ // (using RandomImageGenerator for large files is not stable)
+ // @todo Don't download files from wikimedia.org
+ $mimeType = 'image/jpeg';
+ $url = 'http://upload.wikimedia.org/wikipedia/commons/'
+ . 'e/ed/Oberaargletscher_from_Oberaar%2C_2010_07.JPG';
+ $filePath = $this->getNewTempDirectory() . '/Oberaargletscher_from_Oberaar.jpg';
+ try {
+ copy( $url, $filePath );
+ } catch ( Exception $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+
+ $fileSize = filesize( $filePath );
+ $fileName = basename( $filePath );
+
+ $this->deleteFileByFileName( $fileName );
+ $this->deleteFileByContent( $filePath );
+
+ // Base upload params:
+ $params = [
+ 'action' => 'upload',
+ 'stash' => 1,
+ 'filename' => $fileName,
+ 'filesize' => $fileSize,
+ 'offset' => 0,
+ ];
+
+ // Upload chunks
+ $chunkSessionKey = false;
+ $resultOffset = 0;
+ // Open the file:
+ Wikimedia\suppressWarnings();
+ $handle = fopen( $filePath, "r" );
+ Wikimedia\restoreWarnings();
+
+ if ( $handle === false ) {
+ $this->markTestIncomplete( "could not open file: $filePath" );
+ }
+
+ while ( !feof( $handle ) ) {
+ // Get the current chunk
+ Wikimedia\suppressWarnings();
+ $chunkData = fread( $handle, $chunkSize );
+ Wikimedia\restoreWarnings();
+
+ // Upload the current chunk into the $_FILE object:
+ $this->fakeUploadChunk( 'chunk', 'blob', $mimeType, $chunkData );
+
+ // Check for chunkSessionKey
+ if ( !$chunkSessionKey ) {
+ // Upload fist chunk ( and get the session key )
+ try {
+ list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+ // Make sure we got a valid chunk continue:
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertTrue( isset( $result['upload']['filekey'] ) );
+ // If we don't get a session key mark test incomplete.
+ if ( !isset( $result['upload']['filekey'] ) ) {
+ $this->markTestIncomplete( "no filekey provided" );
+ }
+ $chunkSessionKey = $result['upload']['filekey'];
+ $this->assertEquals( 'Continue', $result['upload']['result'] );
+ // First chunk should have chunkSize == offset
+ $this->assertEquals( $chunkSize, $result['upload']['offset'] );
+ $resultOffset = $result['upload']['offset'];
+ continue;
+ }
+ // Filekey set to chunk session
+ $params['filekey'] = $chunkSessionKey;
+ // Update the offset ( always add chunkSize for subquent chunks
+ // should be in-sync with $result['upload']['offset'] )
+ $params['offset'] += $chunkSize;
+ // Make sure param offset is insync with resultOffset:
+ $this->assertEquals( $resultOffset, $params['offset'] );
+ // Upload current chunk
+ try {
+ list( $result, , $session ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+ // Make sure we got a valid chunk continue:
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertTrue( isset( $result['upload']['filekey'] ) );
+
+ // Check if we were on the last chunk:
+ if ( $params['offset'] + $chunkSize >= $fileSize ) {
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ break;
+ } else {
+ $this->assertEquals( 'Continue', $result['upload']['result'] );
+ // update $resultOffset
+ $resultOffset = $result['upload']['offset'];
+ }
+ }
+ fclose( $handle );
+
+ // Check that we got a valid file result:
+ wfDebug( __METHOD__
+ . " hohoh filesize {$fileSize} info {$result['upload']['imageinfo']['size']}\n\n" );
+ $this->assertEquals( $fileSize, $result['upload']['imageinfo']['size'] );
+ $this->assertEquals( $mimeType, $result['upload']['imageinfo']['mime'] );
+ $this->assertTrue( isset( $result['upload']['filekey'] ) );
+ $filekey = $result['upload']['filekey'];
+
+ // Now we should try to release the file from stash
+ $params = [
+ 'action' => 'upload',
+ 'filekey' => $filekey,
+ 'filename' => $fileName,
+ 'comment' => 'dummy comment',
+ 'text' => "This is the page text for $fileName, altered",
+ ];
+ $this->clearFakeUploads();
+ $exception = false;
+ try {
+ list( $result ) = $this->doApiRequestWithToken( $params, $session,
+ self::$users['uploader']->getUser() );
+ } catch ( ApiUsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertFalse( $exception );
+
+ // clean up
+ $this->deleteFileByFileName( $fileName );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiUploadTestCase.php b/www/wiki/tests/phpunit/includes/api/ApiUploadTestCase.php
new file mode 100644
index 00000000..3c7efd57
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiUploadTestCase.php
@@ -0,0 +1,153 @@
+<?php
+
+/**
+ * Abstract class to support upload tests
+ */
+abstract class ApiUploadTestCase extends ApiTestCase {
+ /**
+ * Fixture -- run before every test
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( [
+ 'wgEnableUploads' => true,
+ 'wgEnableAPI' => true,
+ ] );
+
+ $this->clearFakeUploads();
+ }
+
+ /**
+ * Helper function -- remove files and associated articles by Title
+ *
+ * @param Title $title Title to be removed
+ *
+ * @return bool
+ */
+ public function deleteFileByTitle( $title ) {
+ if ( $title->exists() ) {
+ $file = wfFindFile( $title, [ 'ignoreRedirect' => true ] );
+ $noOldArchive = ""; // yes this really needs to be set this way
+ $comment = "removing for test";
+ $restrictDeletedVersions = false;
+ $status = FileDeleteForm::doDelete(
+ $title,
+ $file,
+ $noOldArchive,
+ $comment,
+ $restrictDeletedVersions
+ );
+
+ if ( !$status->isGood() ) {
+ return false;
+ }
+
+ $page = WikiPage::factory( $title );
+ $page->doDeleteArticle( "removing for test" );
+
+ // see if it now doesn't exist; reload
+ $title = Title::newFromText( $title->getText(), NS_FILE );
+ }
+
+ return !( $title && $title instanceof Title && $title->exists() );
+ }
+
+ /**
+ * Helper function -- remove files and associated articles with a particular filename
+ *
+ * @param string $fileName Filename to be removed
+ *
+ * @return bool
+ */
+ public function deleteFileByFileName( $fileName ) {
+ return $this->deleteFileByTitle( Title::newFromText( $fileName, NS_FILE ) );
+ }
+
+ /**
+ * Helper function -- given a file on the filesystem, find matching
+ * content in the db (and associated articles) and remove them.
+ *
+ * @param string $filePath Path to file on the filesystem
+ *
+ * @return bool
+ */
+ public function deleteFileByContent( $filePath ) {
+ $hash = FSFile::getSha1Base36FromPath( $filePath );
+ $dupes = RepoGroup::singleton()->findBySha1( $hash );
+ $success = true;
+ foreach ( $dupes as $dupe ) {
+ $success &= $this->deleteFileByTitle( $dupe->getTitle() );
+ }
+
+ return $success;
+ }
+
+ /**
+ * Fake an upload by dumping the file into temp space, and adding info to $_FILES.
+ * (This is what PHP would normally do).
+ *
+ * @param string $fieldName Name this would have in the upload form
+ * @param string $fileName Name to title this
+ * @param string $type MIME type
+ * @param string $filePath Path where to find file contents
+ *
+ * @throws Exception
+ * @return bool
+ */
+ function fakeUploadFile( $fieldName, $fileName, $type, $filePath ) {
+ $tmpName = $this->getNewTempFile();
+ if ( !file_exists( $filePath ) ) {
+ throw new Exception( "$filePath doesn't exist!" );
+ }
+
+ if ( !copy( $filePath, $tmpName ) ) {
+ throw new Exception( "couldn't copy $filePath to $tmpName" );
+ }
+
+ clearstatcache();
+ $size = filesize( $tmpName );
+ if ( $size === false ) {
+ throw new Exception( "couldn't stat $tmpName" );
+ }
+
+ $_FILES[$fieldName] = [
+ 'name' => $fileName,
+ 'type' => $type,
+ 'tmp_name' => $tmpName,
+ 'size' => $size,
+ 'error' => null
+ ];
+
+ return true;
+ }
+
+ function fakeUploadChunk( $fieldName, $fileName, $type, & $chunkData ) {
+ $tmpName = $this->getNewTempFile();
+ // copy the chunk data to temp location:
+ if ( !file_put_contents( $tmpName, $chunkData ) ) {
+ throw new Exception( "couldn't copy chunk data to $tmpName" );
+ }
+
+ clearstatcache();
+ $size = filesize( $tmpName );
+ if ( $size === false ) {
+ throw new Exception( "couldn't stat $tmpName" );
+ }
+
+ $_FILES[$fieldName] = [
+ 'name' => $fileName,
+ 'type' => $type,
+ 'tmp_name' => $tmpName,
+ 'size' => $size,
+ 'error' => null
+ ];
+ }
+
+ /**
+ * Remove traces of previous fake uploads
+ */
+ function clearFakeUploads() {
+ $_FILES = [];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiUsageExceptionTest.php b/www/wiki/tests/phpunit/includes/api/ApiUsageExceptionTest.php
new file mode 100644
index 00000000..bb720211
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiUsageExceptionTest.php
@@ -0,0 +1,44 @@
+<?php
+
+/**
+ * @covers ApiUsageException
+ */
+class ApiUsageExceptionTest extends MediaWikiTestCase {
+
+ public function testCreateWithStatusValue_CanGetAMessageObject() {
+ $messageKey = 'some-message-key';
+ $messageParameter = 'some-parameter';
+ $statusValue = new StatusValue();
+ $statusValue->fatal( $messageKey, $messageParameter );
+
+ $apiUsageException = new ApiUsageException( null, $statusValue );
+ /** @var \Message $gotMessage */
+ $gotMessage = $apiUsageException->getMessageObject();
+
+ $this->assertInstanceOf( \Message::class, $gotMessage );
+ $this->assertEquals( $messageKey, $gotMessage->getKey() );
+ $this->assertEquals( [ $messageParameter ], $gotMessage->getParams() );
+ }
+
+ public function testNewWithMessage_ThenGetMessageObject_ReturnsApiMessageWithProvidedData() {
+ $expectedMessage = new Message( 'some-message-key', [ 'some message parameter' ] );
+ $expectedCode = 'some-error-code';
+ $expectedData = [ 'some-error-data' ];
+
+ $apiUsageException = ApiUsageException::newWithMessage(
+ null,
+ $expectedMessage,
+ $expectedCode,
+ $expectedData
+ );
+ /** @var \ApiMessage $gotMessage */
+ $gotMessage = $apiUsageException->getMessageObject();
+
+ $this->assertInstanceOf( \ApiMessage::class, $gotMessage );
+ $this->assertEquals( $expectedMessage->getKey(), $gotMessage->getKey() );
+ $this->assertEquals( $expectedMessage->getParams(), $gotMessage->getParams() );
+ $this->assertEquals( $expectedCode, $gotMessage->getApiCode() );
+ $this->assertEquals( $expectedData, $gotMessage->getApiData() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiUserrightsTest.php b/www/wiki/tests/phpunit/includes/api/ApiUserrightsTest.php
new file mode 100644
index 00000000..0229e767
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiUserrightsTest.php
@@ -0,0 +1,358 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiUserrights
+ */
+class ApiUserrightsTest extends ApiTestCase {
+ /**
+ * Unsets $wgGroupPermissions['bureaucrat']['userrights'], and sets
+ * $wgAddGroups['bureaucrat'] and $wgRemoveGroups['bureaucrat'] to the
+ * specified values.
+ *
+ * @param array|bool $add Groups bureaucrats should be allowed to add, true for all
+ * @param array|bool $remove Groups bureaucrats should be allowed to remove, true for all
+ */
+ protected function setPermissions( $add = [], $remove = [] ) {
+ global $wgAddGroups, $wgRemoveGroups;
+
+ $this->setGroupPermissions( 'bureaucrat', 'userrights', false );
+
+ if ( $add ) {
+ $this->stashMwGlobals( 'wgAddGroups' );
+ $wgAddGroups['bureaucrat'] = $add;
+ }
+ if ( $remove ) {
+ $this->stashMwGlobals( 'wgRemoveGroups' );
+ $wgRemoveGroups['bureaucrat'] = $remove;
+ }
+ }
+
+ /**
+ * Perform an API userrights request that's expected to be successful.
+ *
+ * @param array|string $expectedGroups Group(s) that the user is expected
+ * to have after the API request
+ * @param array $params Array to pass to doApiRequestWithToken(). 'action'
+ * => 'userrights' is implicit. If no 'user' or 'userid' is specified,
+ * we add a 'user' parameter. If no 'add' or 'remove' is specified, we
+ * add 'add' => 'sysop'.
+ * @param User|null $user The user that we're modifying. The user must be
+ * mutable, because we're going to change its groups! null means that
+ * we'll make up our own user to modify, and doesn't make sense if 'user'
+ * or 'userid' is specified in $params.
+ */
+ protected function doSuccessfulRightsChange(
+ $expectedGroups = 'sysop', array $params = [], User $user = null
+ ) {
+ $expectedGroups = (array)$expectedGroups;
+ $params['action'] = 'userrights';
+
+ if ( !$user ) {
+ $user = $this->getMutableTestUser()->getUser();
+ }
+
+ $this->assertTrue( TestUserRegistry::isMutable( $user ),
+ 'Immutable user passed to doSuccessfulRightsChange!' );
+
+ if ( !isset( $params['user'] ) && !isset( $params['userid'] ) ) {
+ $params['user'] = $user->getName();
+ }
+ if ( !isset( $params['add'] ) && !isset( $params['remove'] ) ) {
+ $params['add'] = 'sysop';
+ }
+
+ $res = $this->doApiRequestWithToken( $params );
+
+ $user->clearInstanceCache();
+ $this->assertSame( $expectedGroups, $user->getGroups() );
+
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ /**
+ * Perform an API userrights request that's expected to fail.
+ *
+ * @param string $expectedException Expected exception text
+ * @param array $params As for doSuccessfulRightsChange()
+ * @param User|null $user As for doSuccessfulRightsChange(). If there's no
+ * user who will possibly be affected (such as if an invalid username is
+ * provided in $params), pass null.
+ */
+ protected function doFailedRightsChange(
+ $expectedException, array $params = [], User $user = null
+ ) {
+ $params['action'] = 'userrights';
+
+ $this->setExpectedException( ApiUsageException::class, $expectedException );
+
+ if ( !$user ) {
+ // If 'user' or 'userid' is specified and $user was not specified,
+ // the user we're creating now will have nothing to do with the API
+ // request, but that's okay, since we're just testing that it has
+ // no groups.
+ $user = $this->getMutableTestUser()->getUser();
+ }
+
+ $this->assertTrue( TestUserRegistry::isMutable( $user ),
+ 'Immutable user passed to doFailedRightsChange!' );
+
+ if ( !isset( $params['user'] ) && !isset( $params['userid'] ) ) {
+ $params['user'] = $user->getName();
+ }
+ if ( !isset( $params['add'] ) && !isset( $params['remove'] ) ) {
+ $params['add'] = 'sysop';
+ }
+ $expectedGroups = $user->getGroups();
+
+ try {
+ $this->doApiRequestWithToken( $params );
+ } finally {
+ $user->clearInstanceCache();
+ $this->assertSame( $expectedGroups, $user->getGroups() );
+ }
+ }
+
+ public function testAdd() {
+ $this->doSuccessfulRightsChange();
+ }
+
+ public function testBlockedWithUserrights() {
+ global $wgUser;
+
+ $block = new Block( [ 'address' => $wgUser, 'by' => $wgUser->getId(), ] );
+ $block->insert();
+
+ try {
+ $this->doSuccessfulRightsChange();
+ } finally {
+ $block->delete();
+ $wgUser->clearInstanceCache();
+ }
+ }
+
+ public function testBlockedWithoutUserrights() {
+ $user = $this->getTestSysop()->getUser();
+
+ $this->setPermissions( true, true );
+
+ $block = new Block( [ 'address' => $user, 'by' => $user->getId() ] );
+ $block->insert();
+
+ try {
+ $this->doFailedRightsChange( 'You have been blocked from editing.' );
+ } finally {
+ $block->delete();
+ $user->clearInstanceCache();
+ }
+ }
+
+ public function testAddMultiple() {
+ $this->doSuccessfulRightsChange(
+ [ 'bureaucrat', 'sysop' ],
+ [ 'add' => 'bureaucrat|sysop' ]
+ );
+ }
+
+ public function testTooFewExpiries() {
+ $this->doFailedRightsChange(
+ '2 expiry timestamps were provided where 3 were needed.',
+ [ 'add' => 'sysop|bureaucrat|bot', 'expiry' => 'infinity|tomorrow' ]
+ );
+ }
+
+ public function testTooManyExpiries() {
+ $this->doFailedRightsChange(
+ '3 expiry timestamps were provided where 2 were needed.',
+ [ 'add' => 'sysop|bureaucrat', 'expiry' => 'infinity|tomorrow|never' ]
+ );
+ }
+
+ public function testInvalidExpiry() {
+ $this->doFailedRightsChange( 'Invalid expiry time', [ 'expiry' => 'yummy lollipops!' ] );
+ }
+
+ public function testMultipleInvalidExpiries() {
+ $this->doFailedRightsChange(
+ 'Invalid expiry time "foo".',
+ [ 'add' => 'sysop|bureaucrat', 'expiry' => 'foo|bar' ]
+ );
+ }
+
+ public function testWithTag() {
+ ChangeTags::defineTag( 'custom tag' );
+
+ $user = $this->getMutableTestUser()->getUser();
+
+ $this->doSuccessfulRightsChange( 'sysop', [ 'tags' => 'custom tag' ], $user );
+
+ $dbr = wfGetDB( DB_REPLICA );
+ $this->assertSame(
+ 'custom tag',
+ $dbr->selectField(
+ [ 'change_tag', 'logging' ],
+ 'ct_tag',
+ [
+ 'ct_log_id = log_id',
+ 'log_namespace' => NS_USER,
+ 'log_title' => strtr( $user->getName(), ' ', '_' )
+ ],
+ __METHOD__
+ )
+ );
+ }
+
+ public function testWithoutTagPermission() {
+ global $wgGroupPermissions;
+
+ ChangeTags::defineTag( 'custom tag' );
+
+ $this->stashMwGlobals( 'wgGroupPermissions' );
+ $wgGroupPermissions['user']['applychangetags'] = false;
+
+ $this->doFailedRightsChange(
+ 'You do not have permission to apply change tags along with your changes.',
+ [ 'tags' => 'custom tag' ]
+ );
+ }
+
+ public function testNonexistentUser() {
+ $this->doFailedRightsChange(
+ 'There is no user by the name "Nonexistent user". Check your spelling.',
+ [ 'user' => 'Nonexistent user' ]
+ );
+ }
+
+ public function testWebToken() {
+ $sysop = $this->getTestSysop()->getUser();
+ $user = $this->getMutableTestUser()->getUser();
+
+ $token = $sysop->getEditToken( $user->getName() );
+
+ $res = $this->doApiRequest( [
+ 'action' => 'userrights',
+ 'user' => $user->getName(),
+ 'add' => 'sysop',
+ 'token' => $token,
+ ] );
+
+ $user->clearInstanceCache();
+ $this->assertSame( [ 'sysop' ], $user->getGroups() );
+
+ $this->assertArrayNotHasKey( 'warnings', $res[0] );
+ }
+
+ /**
+ * Helper for testCanProcessExpiries that returns a mock ApiUserrights that either can or cannot
+ * process expiries. Although the regular page can process expiries, we use a mock here to
+ * ensure that it's the result of canProcessExpiries() that makes a difference, and not some
+ * error in the way we construct the mock.
+ *
+ * @param bool $canProcessExpiries
+ */
+ private function getMockForProcessingExpiries( $canProcessExpiries ) {
+ $sysop = $this->getTestSysop()->getUser();
+ $user = $this->getMutableTestUser()->getUser();
+
+ $token = $sysop->getEditToken( 'userrights' );
+
+ $main = new ApiMain( new FauxRequest( [
+ 'action' => 'userrights',
+ 'user' => $user->getName(),
+ 'add' => 'sysop',
+ 'token' => $token,
+ ] ) );
+
+ $mockUserRightsPage = $this->getMockBuilder( UserrightsPage::class )
+ ->setMethods( [ 'canProcessExpiries' ] )
+ ->getMock();
+ $mockUserRightsPage->method( 'canProcessExpiries' )->willReturn( $canProcessExpiries );
+
+ $mockApi = $this->getMockBuilder( ApiUserrights::class )
+ ->setConstructorArgs( [ $main, 'userrights' ] )
+ ->setMethods( [ 'getUserRightsPage' ] )
+ ->getMock();
+ $mockApi->method( 'getUserRightsPage' )->willReturn( $mockUserRightsPage );
+
+ return $mockApi;
+ }
+
+ public function testCanProcessExpiries() {
+ $mock1 = $this->getMockForProcessingExpiries( true );
+ $this->assertArrayHasKey( 'expiry', $mock1->getAllowedParams() );
+
+ $mock2 = $this->getMockForProcessingExpiries( false );
+ $this->assertArrayNotHasKey( 'expiry', $mock2->getAllowedParams() );
+ }
+
+ /**
+ * Tests adding and removing various groups with various permissions.
+ *
+ * @dataProvider addAndRemoveGroupsProvider
+ * @param array|null $permissions [ [ $wgAddGroups, $wgRemoveGroups ] ] or null for 'userrights'
+ * to be set in $wgGroupPermissions
+ * @param array $groupsToChange [ [ groups to add ], [ groups to remove ] ]
+ * @param array $expectedGroups Array of expected groups
+ */
+ public function testAddAndRemoveGroups(
+ array $permissions = null, array $groupsToChange, array $expectedGroups
+ ) {
+ if ( $permissions !== null ) {
+ $this->setPermissions( $permissions[0], $permissions[1] );
+ }
+
+ $params = [
+ 'add' => implode( '|', $groupsToChange[0] ),
+ 'remove' => implode( '|', $groupsToChange[1] ),
+ ];
+
+ // We'll take a bot so we have a group to remove
+ $user = $this->getMutableTestUser( [ 'bot' ] )->getUser();
+
+ $this->doSuccessfulRightsChange( $expectedGroups, $params, $user );
+ }
+
+ public function addAndRemoveGroupsProvider() {
+ return [
+ 'Simple add' => [
+ [ [ 'sysop' ], [] ],
+ [ [ 'sysop' ], [] ],
+ [ 'bot', 'sysop' ]
+ ], 'Add with only remove permission' => [
+ [ [], [ 'sysop' ] ],
+ [ [ 'sysop' ], [] ],
+ [ 'bot' ],
+ ], 'Add with global remove permission' => [
+ [ [], true ],
+ [ [ 'sysop' ], [] ],
+ [ 'bot' ],
+ ], 'Simple remove' => [
+ [ [], [ 'bot' ] ],
+ [ [], [ 'bot' ] ],
+ [],
+ ], 'Remove with only add permission' => [
+ [ [ 'bot' ], [] ],
+ [ [], [ 'bot' ] ],
+ [ 'bot' ],
+ ], 'Remove with global add permission' => [
+ [ true, [] ],
+ [ [], [ 'bot' ] ],
+ [ 'bot' ],
+ ], 'Add and remove same new group' => [
+ null,
+ [ [ 'sysop' ], [ 'sysop' ] ],
+ // The userrights code does removals before adds, so it doesn't remove the sysop
+ // group here and only adds it.
+ [ 'bot', 'sysop' ],
+ ], 'Add and remove same existing group' => [
+ null,
+ [ [ 'bot' ], [ 'bot' ] ],
+ // But here it first removes the existing group and then re-adds it.
+ [ 'bot' ],
+ ],
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/ApiWatchTest.php b/www/wiki/tests/phpunit/includes/api/ApiWatchTest.php
new file mode 100644
index 00000000..6d64a178
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/ApiWatchTest.php
@@ -0,0 +1,148 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @todo This test suite is severly broken and need a full review
+ *
+ * @covers ApiWatch
+ */
+class ApiWatchTest extends ApiTestCase {
+ function getTokens() {
+ return $this->getTokenList( self::$users['sysop'] );
+ }
+
+ public function testWatchEdit() {
+ $tokens = $this->getTokens();
+
+ $data = $this->doApiRequest( [
+ 'action' => 'edit',
+ 'title' => 'Help:UTPage', // Help namespace is hopefully wikitext
+ 'text' => 'new text',
+ 'token' => $tokens['edittoken'],
+ 'watchlist' => 'watch' ] );
+ $this->assertArrayHasKey( 'edit', $data[0] );
+ $this->assertArrayHasKey( 'result', $data[0]['edit'] );
+ $this->assertEquals( 'Success', $data[0]['edit']['result'] );
+
+ return $data;
+ }
+
+ /**
+ * @depends testWatchEdit
+ */
+ public function testWatchClear() {
+ $tokens = $this->getTokens();
+
+ $data = $this->doApiRequest( [
+ 'action' => 'query',
+ 'wllimit' => 'max',
+ 'list' => 'watchlist' ] );
+
+ if ( isset( $data[0]['query']['watchlist'] ) ) {
+ $wl = $data[0]['query']['watchlist'];
+
+ foreach ( $wl as $page ) {
+ $data = $this->doApiRequest( [
+ 'action' => 'watch',
+ 'title' => $page['title'],
+ 'unwatch' => true,
+ 'token' => $tokens['watchtoken'] ] );
+ }
+ }
+ $data = $this->doApiRequest( [
+ 'action' => 'query',
+ 'list' => 'watchlist' ], $data );
+ $this->assertArrayHasKey( 'query', $data[0] );
+ $this->assertArrayHasKey( 'watchlist', $data[0]['query'] );
+ foreach ( $data[0]['query']['watchlist'] as $index => $item ) {
+ // Previous tests may insert an invalid title
+ // like ":ApiEditPageTest testNonTextEdit", which
+ // can't be cleared.
+ if ( strpos( $item['title'], ':' ) === 0 ) {
+ unset( $data[0]['query']['watchlist'][$index] );
+ }
+ }
+ $this->assertEquals( 0, count( $data[0]['query']['watchlist'] ) );
+
+ return $data;
+ }
+
+ public function testWatchProtect() {
+ $tokens = $this->getTokens();
+
+ $data = $this->doApiRequest( [
+ 'action' => 'protect',
+ 'token' => $tokens['protecttoken'],
+ 'title' => 'Help:UTPage',
+ 'protections' => 'edit=sysop',
+ 'watchlist' => 'unwatch' ] );
+
+ $this->assertArrayHasKey( 'protect', $data[0] );
+ $this->assertArrayHasKey( 'protections', $data[0]['protect'] );
+ $this->assertEquals( 1, count( $data[0]['protect']['protections'] ) );
+ $this->assertArrayHasKey( 'edit', $data[0]['protect']['protections'][0] );
+ }
+
+ public function testGetRollbackToken() {
+ $this->getTokens();
+
+ if ( !Title::newFromText( 'Help:UTPage' )->exists() ) {
+ $this->markTestSkipped( "The article [[Help:UTPage]] does not exist" ); // TODO: just create it?
+ }
+
+ $data = $this->doApiRequest( [
+ 'action' => 'query',
+ 'prop' => 'revisions',
+ 'titles' => 'Help:UTPage',
+ 'rvtoken' => 'rollback' ] );
+
+ $this->assertArrayHasKey( 'query', $data[0] );
+ $this->assertArrayHasKey( 'pages', $data[0]['query'] );
+ $keys = array_keys( $data[0]['query']['pages'] );
+ $key = array_pop( $keys );
+
+ if ( isset( $data[0]['query']['pages'][$key]['missing'] ) ) {
+ $this->markTestSkipped( "Target page (Help:UTPage) doesn't exist" );
+ }
+
+ $this->assertArrayHasKey( 'pageid', $data[0]['query']['pages'][$key] );
+ $this->assertArrayHasKey( 'revisions', $data[0]['query']['pages'][$key] );
+ $this->assertArrayHasKey( 0, $data[0]['query']['pages'][$key]['revisions'] );
+ $this->assertArrayHasKey( 'rollbacktoken', $data[0]['query']['pages'][$key]['revisions'][0] );
+
+ return $data;
+ }
+
+ /**
+ * @group Broken
+ * Broken because there is currently no revision info in the $pageinfo
+ *
+ * @depends testGetRollbackToken
+ */
+ public function testWatchRollback( $data ) {
+ $keys = array_keys( $data[0]['query']['pages'] );
+ $key = array_pop( $keys );
+ $pageinfo = $data[0]['query']['pages'][$key];
+ $revinfo = $pageinfo['revisions'][0];
+
+ try {
+ $data = $this->doApiRequest( [
+ 'action' => 'rollback',
+ 'title' => 'Help:UTPage',
+ 'user' => $revinfo['user'],
+ 'token' => $pageinfo['rollbacktoken'],
+ 'watchlist' => 'watch' ] );
+
+ $this->assertArrayHasKey( 'rollback', $data[0] );
+ $this->assertArrayHasKey( 'title', $data[0]['rollback'] );
+ } catch ( ApiUsageException $ue ) {
+ if ( self::apiExceptionHasCode( $ue, 'onlyauthor' ) ) {
+ $this->markTestIncomplete( "Only one author to 'Help:UTPage', cannot test rollback" );
+ } else {
+ $this->fail( "Received error '" . $ue->getMessage() . "'" );
+ }
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/MockApi.php b/www/wiki/tests/phpunit/includes/api/MockApi.php
new file mode 100644
index 00000000..1407c10d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/MockApi.php
@@ -0,0 +1,27 @@
+<?php
+
+class MockApi extends ApiBase {
+ public $warnings = [];
+
+ public function execute() {
+ }
+
+ public function __construct() {
+ }
+
+ public function getModulePath() {
+ return $this->getModuleName();
+ }
+
+ public function addWarning( $warning, $code = null, $data = null ) {
+ $this->warnings[] = $warning;
+ }
+
+ public function getAllowedParams() {
+ return [
+ 'filename' => null,
+ 'enablechunks' => false,
+ 'sessionkey' => null,
+ ];
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/MockApiQueryBase.php b/www/wiki/tests/phpunit/includes/api/MockApiQueryBase.php
new file mode 100644
index 00000000..9915a38d
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/MockApiQueryBase.php
@@ -0,0 +1,19 @@
+<?php
+class MockApiQueryBase extends ApiQueryBase {
+ private $name;
+
+ public function execute() {
+ }
+
+ public function __construct( $name = 'mock' ) {
+ $this->name = $name;
+ }
+
+ public function getModuleName() {
+ return $this->name;
+ }
+
+ public function getModulePath() {
+ return 'query+' . $this->getModuleName();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/PrefixUniquenessTest.php b/www/wiki/tests/phpunit/includes/api/PrefixUniquenessTest.php
new file mode 100644
index 00000000..d125a7d5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/PrefixUniquenessTest.php
@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * Checks that all API query modules, core and extensions, have unique prefixes.
+ *
+ * @group API
+ */
+class PrefixUniquenessTest extends MediaWikiTestCase {
+
+ public function testPrefixes() {
+ $main = new ApiMain( new FauxRequest() );
+ $query = new ApiQuery( $main, 'foo', 'bar' );
+ $moduleManager = $query->getModuleManager();
+
+ $modules = $moduleManager->getNames();
+ $prefixes = [];
+
+ foreach ( $modules as $name ) {
+ $module = $moduleManager->getModule( $name );
+ $class = get_class( $module );
+
+ $prefix = $module->getModulePrefix();
+ if ( $prefix !== '' && isset( $prefixes[$prefix] ) ) {
+ $this->fail( "Module prefix '{$prefix}' is shared between {$class} and {$prefixes[$prefix]}" );
+ }
+ $prefixes[$module->getModulePrefix()] = $class;
+ }
+ $this->assertTrue( true ); // dummy call to make this test non-incomplete
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/RandomImageGenerator.php b/www/wiki/tests/phpunit/includes/api/RandomImageGenerator.php
new file mode 100644
index 00000000..50a59f97
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/RandomImageGenerator.php
@@ -0,0 +1,497 @@
+<?php
+/**
+ * RandomImageGenerator -- does what it says on the tin.
+ * Requires Imagick, the ImageMagick library for PHP, or the command line
+ * equivalent (usually 'convert').
+ *
+ * Because MediaWiki tests the uniqueness of media upload content, and
+ * filenames, it is sometimes useful to generate files that are guaranteed (or
+ * at least very likely) to be unique in both those ways. This generates a
+ * number of filenames with random names and random content (colored triangles).
+ *
+ * It is also useful to have fresh content because our tests currently run in a
+ * "destructive" mode, and don't create a fresh new wiki for each test run.
+ * Consequently, if we just had a few static files we kept re-uploading, we'd
+ * get lots of warnings about matching content or filenames, and even if we
+ * deleted those files, we'd get warnings about archived files.
+ *
+ * This can also be used with a cronjob to generate random files all the time.
+ * I use it to have a constant, never ending supply when I'm testing
+ * interactively.
+ *
+ * @file
+ * @author Neil Kandalgaonkar <neilk@wikimedia.org>
+ */
+
+/**
+ * RandomImageGenerator: does what it says on the tin.
+ * Can fetch a random image, or also write a number of them to disk with random filenames.
+ */
+class RandomImageGenerator {
+ private $dictionaryFile;
+ private $minWidth = 400;
+ private $maxWidth = 800;
+ private $minHeight = 400;
+ private $maxHeight = 800;
+ private $shapesToDraw = 5;
+
+ /**
+ * Orientations: 0th row, 0th column, Exif orientation code, rotation 2x2
+ * matrix that is opposite of orientation. N.b. we do not handle the
+ * 'flipped' orientations, which is why there is no entry for 2, 4, 5, or 7.
+ * Those seem to be rare in real images anyway (we also would need a
+ * non-symmetric shape for the images to test those, like a letter F).
+ */
+ private static $orientations = [
+ [
+ '0thRow' => 'top',
+ '0thCol' => 'left',
+ 'exifCode' => 1,
+ 'counterRotation' => [ [ 1, 0 ], [ 0, 1 ] ]
+ ],
+ [
+ '0thRow' => 'bottom',
+ '0thCol' => 'right',
+ 'exifCode' => 3,
+ 'counterRotation' => [ [ -1, 0 ], [ 0, -1 ] ]
+ ],
+ [
+ '0thRow' => 'right',
+ '0thCol' => 'top',
+ 'exifCode' => 6,
+ 'counterRotation' => [ [ 0, 1 ], [ 1, 0 ] ]
+ ],
+ [
+ '0thRow' => 'left',
+ '0thCol' => 'bottom',
+ 'exifCode' => 8,
+ 'counterRotation' => [ [ 0, -1 ], [ -1, 0 ] ]
+ ]
+ ];
+
+ public function __construct( $options = [] ) {
+ foreach ( [ 'dictionaryFile', 'minWidth', 'minHeight',
+ 'maxWidth', 'maxHeight', 'shapesToDraw' ] as $property
+ ) {
+ if ( isset( $options[$property] ) ) {
+ $this->$property = $options[$property];
+ }
+ }
+
+ // find the dictionary file, to generate random names
+ if ( !isset( $this->dictionaryFile ) ) {
+ foreach (
+ [
+ '/usr/share/dict/words',
+ '/usr/dict/words',
+ __DIR__ . '/words.txt'
+ ] as $dictionaryFile
+ ) {
+ if ( is_file( $dictionaryFile ) && is_readable( $dictionaryFile ) ) {
+ $this->dictionaryFile = $dictionaryFile;
+ break;
+ }
+ }
+ }
+ if ( !isset( $this->dictionaryFile ) ) {
+ throw new Exception( "RandomImageGenerator: dictionary file not "
+ . "found or not specified properly" );
+ }
+ }
+
+ /**
+ * Writes random images with random filenames to disk in the directory you
+ * specify, or current working directory.
+ *
+ * @param int $number Number of filenames to write
+ * @param string $format Optional, must be understood by ImageMagick, such as 'jpg' or 'gif'
+ * @param string $dir Directory, optional (will default to current working directory)
+ * @return array Filenames we just wrote
+ */
+ function writeImages( $number, $format = 'jpg', $dir = null ) {
+ $filenames = $this->getRandomFilenames( $number, $format, $dir );
+ $imageWriteMethod = $this->getImageWriteMethod( $format );
+ foreach ( $filenames as $filename ) {
+ $this->{$imageWriteMethod}( $this->getImageSpec(), $format, $filename );
+ }
+
+ return $filenames;
+ }
+
+ /**
+ * Figure out how we write images. This is a factor of both format and the local system
+ *
+ * @param string $format (a typical extension like 'svg', 'jpg', etc.)
+ *
+ * @throws Exception
+ * @return string
+ */
+ function getImageWriteMethod( $format ) {
+ global $wgUseImageMagick, $wgImageMagickConvertCommand;
+ if ( $format === 'svg' ) {
+ return 'writeSvg';
+ } else {
+ // figure out how to write images
+ global $wgExiv2Command;
+ if ( class_exists( 'Imagick' ) && $wgExiv2Command && is_executable( $wgExiv2Command ) ) {
+ return 'writeImageWithApi';
+ } elseif ( $wgUseImageMagick
+ && $wgImageMagickConvertCommand
+ && is_executable( $wgImageMagickConvertCommand )
+ ) {
+ return 'writeImageWithCommandLine';
+ }
+ }
+ throw new Exception( "RandomImageGenerator: could not find a suitable "
+ . "method to write images in '$format' format" );
+ }
+
+ /**
+ * Return a number of randomly-generated filenames
+ * Each filename uses two words randomly drawn from the dictionary, like elephantine_spatula.jpg
+ *
+ * @param int $number Number of filenames to generate
+ * @param string $extension Optional, defaults to 'jpg'
+ * @param string $dir Optional, defaults to current working directory
+ * @return array Array of filenames
+ */
+ private function getRandomFilenames( $number, $extension = 'jpg', $dir = null ) {
+ if ( is_null( $dir ) ) {
+ $dir = getcwd();
+ }
+ $filenames = [];
+ foreach ( $this->getRandomWordPairs( $number ) as $pair ) {
+ $basename = $pair[0] . '_' . $pair[1];
+ if ( !is_null( $extension ) ) {
+ $basename .= '.' . $extension;
+ }
+ $basename = preg_replace( '/\s+/', '', $basename );
+ $filenames[] = "$dir/$basename";
+ }
+
+ return $filenames;
+ }
+
+ /**
+ * Generate data representing an image of random size (within limits),
+ * consisting of randomly colored and sized upward pointing triangles
+ * against a random background color. (This data is used in the
+ * writeImage* methods).
+ *
+ * @return mixed
+ */
+ public function getImageSpec() {
+ $spec = [];
+
+ $spec['width'] = mt_rand( $this->minWidth, $this->maxWidth );
+ $spec['height'] = mt_rand( $this->minHeight, $this->maxHeight );
+ $spec['fill'] = $this->getRandomColor();
+
+ $diagonalLength = sqrt( pow( $spec['width'], 2 ) + pow( $spec['height'], 2 ) );
+
+ $draws = [];
+ for ( $i = 0; $i <= $this->shapesToDraw; $i++ ) {
+ $radius = mt_rand( 0, $diagonalLength / 4 );
+ if ( $radius == 0 ) {
+ continue;
+ }
+ $originX = mt_rand( -1 * $radius, $spec['width'] + $radius );
+ $originY = mt_rand( -1 * $radius, $spec['height'] + $radius );
+ $angle = mt_rand( 0, ( 3.141592 / 2 ) * $radius ) / $radius;
+ $legDeltaX = round( $radius * sin( $angle ) );
+ $legDeltaY = round( $radius * cos( $angle ) );
+
+ $draw = [];
+ $draw['fill'] = $this->getRandomColor();
+ $draw['shape'] = [
+ [ 'x' => $originX, 'y' => $originY - $radius ],
+ [ 'x' => $originX + $legDeltaX, 'y' => $originY + $legDeltaY ],
+ [ 'x' => $originX - $legDeltaX, 'y' => $originY + $legDeltaY ],
+ [ 'x' => $originX, 'y' => $originY - $radius ]
+ ];
+ $draws[] = $draw;
+ }
+
+ $spec['draws'] = $draws;
+
+ return $spec;
+ }
+
+ /**
+ * Given [ [ 'x' => 10, 'y' => 20 ], [ 'x' => 30, y=> 5 ] ]
+ * returns "10,20 30,5"
+ * Useful for SVG and imagemagick command line arguments
+ * @param array $shape Array of arrays, each array containing x & y keys mapped to numeric values
+ * @return string
+ */
+ static function shapePointsToString( $shape ) {
+ $points = [];
+ foreach ( $shape as $point ) {
+ $points[] = $point['x'] . ',' . $point['y'];
+ }
+
+ return implode( " ", $points );
+ }
+
+ /**
+ * Based on image specification, write a very simple SVG file to disk.
+ * Ignores the background spec because transparency is cool. :)
+ *
+ * @param array $spec Spec describing background and shapes to draw
+ * @param string $format File format to write (which is obviously always svg here)
+ * @param string $filename Filename to write to
+ *
+ * @throws Exception
+ */
+ public function writeSvg( $spec, $format, $filename ) {
+ $svg = new SimpleXmlElement( '<svg/>' );
+ $svg->addAttribute( 'xmlns', 'http://www.w3.org/2000/svg' );
+ $svg->addAttribute( 'version', '1.1' );
+ $svg->addAttribute( 'width', $spec['width'] );
+ $svg->addAttribute( 'height', $spec['height'] );
+ $g = $svg->addChild( 'g' );
+ foreach ( $spec['draws'] as $drawSpec ) {
+ $shape = $g->addChild( 'polygon' );
+ $shape->addAttribute( 'fill', $drawSpec['fill'] );
+ $shape->addAttribute( 'points', self::shapePointsToString( $drawSpec['shape'] ) );
+ }
+
+ $fh = fopen( $filename, 'w' );
+ if ( !$fh ) {
+ throw new Exception( "couldn't open $filename for writing" );
+ }
+ fwrite( $fh, $svg->asXML() );
+ if ( !fclose( $fh ) ) {
+ throw new Exception( "couldn't close $filename" );
+ }
+ }
+
+ /**
+ * Based on an image specification, write such an image to disk, using Imagick PHP extension
+ * @param array $spec Spec describing background and circles to draw
+ * @param string $format File format to write
+ * @param string $filename Filename to write to
+ */
+ public function writeImageWithApi( $spec, $format, $filename ) {
+ // this is a hack because I can't get setImageOrientation() to work. See below.
+ global $wgExiv2Command;
+
+ $image = new Imagick();
+ /**
+ * If the format is 'jpg', will also add a random orientation -- the
+ * image will be drawn rotated with triangle points facing in some
+ * direction (0, 90, 180 or 270 degrees) and a countering rotation
+ * should turn the triangle points upward again.
+ */
+ $orientation = self::$orientations[0]; // default is normal orientation
+ if ( $format == 'jpg' ) {
+ $orientation = self::$orientations[array_rand( self::$orientations )];
+ $spec = self::rotateImageSpec( $spec, $orientation['counterRotation'] );
+ }
+
+ $image->newImage( $spec['width'], $spec['height'], new ImagickPixel( $spec['fill'] ) );
+
+ foreach ( $spec['draws'] as $drawSpec ) {
+ $draw = new ImagickDraw();
+ $draw->setFillColor( $drawSpec['fill'] );
+ $draw->polygon( $drawSpec['shape'] );
+ $image->drawImage( $draw );
+ }
+
+ $image->setImageFormat( $format );
+
+ // this doesn't work, even though it's documented to do so...
+ // $image->setImageOrientation( $orientation['exifCode'] );
+
+ $image->writeImage( $filename );
+
+ // because the above setImageOrientation call doesn't work... nor can I
+ // get an external imagemagick binary to do this either... Hacking this
+ // for now (only works if you have exiv2 installed, a program to read
+ // and manipulate exif).
+ if ( $wgExiv2Command ) {
+ $cmd = wfEscapeShellArg( $wgExiv2Command )
+ . " -M "
+ . wfEscapeShellArg( "set Exif.Image.Orientation " . $orientation['exifCode'] )
+ . " "
+ . wfEscapeShellArg( $filename );
+
+ $retval = 0;
+ $err = wfShellExec( $cmd, $retval );
+ if ( $retval !== 0 ) {
+ print "Error with $cmd: $retval, $err\n";
+ }
+ }
+ }
+
+ /**
+ * Given an image specification, produce rotated version
+ * This is used when simulating a rotated image capture with Exif orientation
+ * @param array $spec Returned by getImageSpec
+ * @param array $matrix 2x2 transformation matrix
+ * @return array Transformed Spec
+ */
+ private static function rotateImageSpec( &$spec, $matrix ) {
+ $tSpec = [];
+ $dims = self::matrixMultiply2x2( $matrix, $spec['width'], $spec['height'] );
+ $correctionX = 0;
+ $correctionY = 0;
+ if ( $dims['x'] < 0 ) {
+ $correctionX = abs( $dims['x'] );
+ }
+ if ( $dims['y'] < 0 ) {
+ $correctionY = abs( $dims['y'] );
+ }
+ $tSpec['width'] = abs( $dims['x'] );
+ $tSpec['height'] = abs( $dims['y'] );
+ $tSpec['fill'] = $spec['fill'];
+ $tSpec['draws'] = [];
+ foreach ( $spec['draws'] as $draw ) {
+ $tDraw = [
+ 'fill' => $draw['fill'],
+ 'shape' => []
+ ];
+ foreach ( $draw['shape'] as $point ) {
+ $tPoint = self::matrixMultiply2x2( $matrix, $point['x'], $point['y'] );
+ $tPoint['x'] += $correctionX;
+ $tPoint['y'] += $correctionY;
+ $tDraw['shape'][] = $tPoint;
+ }
+ $tSpec['draws'][] = $tDraw;
+ }
+
+ return $tSpec;
+ }
+
+ /**
+ * Given a matrix and a pair of images, return new position
+ * @param array $matrix 2x2 rotation matrix
+ * @param int $x The x-coordinate number
+ * @param int $y The y-coordinate number
+ * @return array Transformed with properties x, y
+ */
+ private static function matrixMultiply2x2( $matrix, $x, $y ) {
+ return [
+ 'x' => $x * $matrix[0][0] + $y * $matrix[0][1],
+ 'y' => $x * $matrix[1][0] + $y * $matrix[1][1]
+ ];
+ }
+
+ /**
+ * Based on an image specification, write such an image to disk, using the
+ * command line ImageMagick program ('convert').
+ *
+ * Sample command line:
+ * $ convert -size 100x60 xc:rgb(90,87,45) \
+ * -draw 'fill rgb(12,34,56) polygon 41,39 44,57 50,57 41,39' \
+ * -draw 'fill rgb(99,123,231) circle 59,39 56,57' \
+ * -draw 'fill rgb(240,12,32) circle 50,21 50,3' filename.png
+ *
+ * @param array $spec Spec describing background and shapes to draw
+ * @param string $format File format to write (unused by this method but
+ * kept so it has the same signature as writeImageWithApi).
+ * @param string $filename Filename to write to
+ *
+ * @return bool
+ */
+ public function writeImageWithCommandLine( $spec, $format, $filename ) {
+ global $wgImageMagickConvertCommand;
+ $args = [];
+ $args[] = "-size " . wfEscapeShellArg( $spec['width'] . 'x' . $spec['height'] );
+ $args[] = wfEscapeShellArg( "xc:" . $spec['fill'] );
+ foreach ( $spec['draws'] as $draw ) {
+ $fill = $draw['fill'];
+ $polygon = self::shapePointsToString( $draw['shape'] );
+ $drawCommand = "fill $fill polygon $polygon";
+ $args[] = '-draw ' . wfEscapeShellArg( $drawCommand );
+ }
+ $args[] = wfEscapeShellArg( $filename );
+
+ $command = wfEscapeShellArg( $wgImageMagickConvertCommand ) . " " . implode( " ", $args );
+ $retval = null;
+ wfShellExec( $command, $retval );
+
+ return ( $retval === 0 );
+ }
+
+ /**
+ * Generate a string of random colors for ImageMagick or SVG, like "rgb(12, 37, 98)"
+ *
+ * @return string
+ */
+ public function getRandomColor() {
+ $components = [];
+ for ( $i = 0; $i <= 2; $i++ ) {
+ $components[] = mt_rand( 0, 255 );
+ }
+
+ return 'rgb(' . implode( ', ', $components ) . ')';
+ }
+
+ /**
+ * Get an array of random pairs of random words, like
+ * [ [ 'foo', 'bar' ], [ 'quux', 'baz' ] ];
+ *
+ * @param int $number Number of pairs
+ * @return array Two-element arrays
+ */
+ private function getRandomWordPairs( $number ) {
+ $lines = $this->getRandomLines( $number * 2 );
+ // construct pairs of words
+ $pairs = [];
+ $count = count( $lines );
+ for ( $i = 0; $i < $count; $i += 2 ) {
+ $pairs[] = [ $lines[$i], $lines[$i + 1] ];
+ }
+
+ return $pairs;
+ }
+
+ /**
+ * Return N random lines from a file
+ *
+ * Will throw exception if the file could not be read or if it had fewer lines than requested.
+ *
+ * @param int $number_desired Number of lines desired
+ *
+ * @throws Exception
+ * @return array Array of exactly n elements, drawn randomly from lines the file
+ */
+ private function getRandomLines( $number_desired ) {
+ $filepath = $this->dictionaryFile;
+
+ // initialize array of lines
+ $lines = [];
+ for ( $i = 0; $i < $number_desired; $i++ ) {
+ $lines[] = null;
+ }
+
+ /*
+ * This algorithm obtains N random lines from a file in one single pass.
+ * It does this by replacing elements of a fixed-size array of lines,
+ * less and less frequently as it reads the file.
+ */
+ $fh = fopen( $filepath, "r" );
+ if ( !$fh ) {
+ throw new Exception( "couldn't open $filepath" );
+ }
+ $line_number = 0;
+ $max_index = $number_desired - 1;
+ while ( !feof( $fh ) ) {
+ $line = fgets( $fh );
+ if ( $line !== false ) {
+ $line_number++;
+ $line = trim( $line );
+ if ( mt_rand( 0, $line_number ) <= $max_index ) {
+ $lines[mt_rand( 0, $max_index )] = $line;
+ }
+ }
+ }
+ fclose( $fh );
+ if ( $line_number < $number_desired ) {
+ throw new Exception( "not enough lines in $filepath" );
+ }
+
+ return $lines;
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/UserWrapper.php b/www/wiki/tests/phpunit/includes/api/UserWrapper.php
new file mode 100644
index 00000000..9942a0f2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/UserWrapper.php
@@ -0,0 +1,25 @@
+<?php
+
+class UserWrapper {
+ public $userName;
+ public $password;
+ public $user;
+
+ public function __construct( $userName, $password, $group = '' ) {
+ $this->userName = $userName;
+ $this->password = $password;
+
+ $this->user = User::newFromName( $this->userName );
+ if ( !$this->user->getId() ) {
+ $this->user = User::createNew( $this->userName, [
+ "email" => "test@example.com",
+ "real_name" => "Test User" ] );
+ }
+ TestUser::setPasswordForUser( $this->user, $this->password );
+
+ if ( $group !== '' ) {
+ $this->user->addGroup( $group );
+ }
+ $this->user->saveSettings();
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatBaseTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatBaseTest.php
new file mode 100644
index 00000000..55f760f6
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatBaseTest.php
@@ -0,0 +1,388 @@
+<?php
+
+use Wikimedia\TestingAccessWrapper;
+
+/**
+ * @group API
+ * @covers ApiFormatBase
+ */
+class ApiFormatBaseTest extends ApiFormatTestBase {
+
+ protected $printerName = 'mockbase';
+
+ public function getMockFormatter( ApiMain $main = null, $format, $methods = [] ) {
+ if ( $main === null ) {
+ $context = new RequestContext;
+ $context->setRequest( new FauxRequest( [], true ) );
+ $main = new ApiMain( $context );
+ }
+
+ $mock = $this->getMockBuilder( ApiFormatBase::class )
+ ->setConstructorArgs( [ $main, $format ] )
+ ->setMethods( array_unique( array_merge( $methods, [ 'getMimeType', 'execute' ] ) ) )
+ ->getMock();
+ if ( !in_array( 'getMimeType', $methods, true ) ) {
+ $mock->method( 'getMimeType' )->willReturn( 'text/x-mock' );
+ }
+ return $mock;
+ }
+
+ protected function encodeData( array $params, array $data, $options = [] ) {
+ $options += [
+ 'name' => 'mock',
+ 'class' => ApiFormatBase::class,
+ 'factory' => function ( ApiMain $main, $format ) use ( $options ) {
+ $mock = $this->getMockFormatter( $main, $format );
+ $mock->expects( $this->once() )->method( 'execute' )
+ ->willReturnCallback( function () use ( $mock ) {
+ $mock->printText( "Format {$mock->getFormat()}: " );
+ $mock->printText( "<b>ok</b>" );
+ } );
+
+ if ( isset( $options['status'] ) ) {
+ $mock->setHttpStatus( $options['status'] );
+ }
+
+ return $mock;
+ },
+ 'returnPrinter' => true,
+ ];
+
+ $this->setMwGlobals( [
+ 'wgApiFrameOptions' => 'DENY',
+ ] );
+
+ $ret = parent::encodeData( $params, $data, $options );
+ $printer = TestingAccessWrapper::newFromObject( $ret['printer'] );
+ $text = $ret['text'];
+
+ if ( $options['name'] !== 'mockfm' ) {
+ $ct = 'text/x-mock';
+ $file = 'api-result.mock';
+ $status = isset( $options['status'] ) ? $options['status'] : null;
+ } elseif ( isset( $params['wrappedhtml'] ) ) {
+ $ct = 'text/mediawiki-api-prettyprint-wrapped';
+ $file = 'api-result-wrapped.json';
+ $status = null;
+
+ // Replace varying field
+ $text = preg_replace( '/"time":\d+/', '"time":1234', $text );
+ } else {
+ $ct = 'text/html';
+ $file = 'api-result.html';
+ $status = null;
+
+ // Strip OutputPage-generated HTML
+ if ( preg_match( '!<pre class="api-pretty-content">.*</pre>!s', $text, $m ) ) {
+ $text = $m[0];
+ }
+ }
+
+ $response = $printer->getMain()->getRequest()->response();
+ $this->assertSame( "$ct; charset=utf-8", strtolower( $response->getHeader( 'Content-Type' ) ) );
+ $this->assertSame( 'DENY', $response->getHeader( 'X-Frame-Options' ) );
+ $this->assertSame( $file, $printer->getFilename() );
+ $this->assertSame( "inline; filename=$file", $response->getHeader( 'Content-Disposition' ) );
+ $this->assertSame( $status, $response->getStatusCode() );
+
+ return $text;
+ }
+
+ public static function provideGeneralEncoding() {
+ return [
+ 'normal' => [
+ [],
+ "Format MOCK: <b>ok</b>",
+ [],
+ [ 'name' => 'mock' ]
+ ],
+ 'normal ignores wrappedhtml' => [
+ [],
+ "Format MOCK: <b>ok</b>",
+ [ 'wrappedhtml' => 1 ],
+ [ 'name' => 'mock' ]
+ ],
+ 'HTML format' => [
+ [],
+ '<pre class="api-pretty-content">Format MOCK: &lt;b>ok&lt;/b></pre>',
+ [],
+ [ 'name' => 'mockfm' ]
+ ],
+ 'wrapped HTML format' => [
+ [],
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ '{"status":200,"statustext":"OK","html":"<pre class=\"api-pretty-content\">Format MOCK: &lt;b>ok&lt;/b></pre>","modules":["mediawiki.apipretty"],"continue":null,"time":1234}',
+ [ 'wrappedhtml' => 1 ],
+ [ 'name' => 'mockfm' ]
+ ],
+ 'normal, with set status' => [
+ [],
+ "Format MOCK: <b>ok</b>",
+ [],
+ [ 'name' => 'mock', 'status' => 400 ]
+ ],
+ 'HTML format, with set status' => [
+ [],
+ '<pre class="api-pretty-content">Format MOCK: &lt;b>ok&lt;/b></pre>',
+ [],
+ [ 'name' => 'mockfm', 'status' => 400 ]
+ ],
+ 'wrapped HTML format, with set status' => [
+ [],
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ '{"status":400,"statustext":"Bad Request","html":"<pre class=\"api-pretty-content\">Format MOCK: &lt;b>ok&lt;/b></pre>","modules":["mediawiki.apipretty"],"continue":null,"time":1234}',
+ [ 'wrappedhtml' => 1 ],
+ [ 'name' => 'mockfm', 'status' => 400 ]
+ ],
+ 'wrapped HTML format, cross-domain-policy' => [
+ [ 'continue' => '< CrOsS-DoMaIn-PoLiCy >' ],
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ '{"status":200,"statustext":"OK","html":"<pre class=\"api-pretty-content\">Format MOCK: &lt;b>ok&lt;/b></pre>","modules":["mediawiki.apipretty"],"continue":"\u003C CrOsS-DoMaIn-PoLiCy \u003E","time":1234}',
+ [ 'wrappedhtml' => 1 ],
+ [ 'name' => 'mockfm' ]
+ ],
+ ];
+ }
+
+ /**
+ * @dataProvider provideFilenameEncoding
+ */
+ public function testFilenameEncoding( $filename, $expect ) {
+ $ret = parent::encodeData( [], [], [
+ 'name' => 'mock',
+ 'class' => ApiFormatBase::class,
+ 'factory' => function ( ApiMain $main, $format ) use ( $filename ) {
+ $mock = $this->getMockFormatter( $main, $format, [ 'getFilename' ] );
+ $mock->method( 'getFilename' )->willReturn( $filename );
+ return $mock;
+ },
+ 'returnPrinter' => true,
+ ] );
+ $response = $ret['printer']->getMain()->getRequest()->response();
+
+ $this->assertSame( "inline; $expect", $response->getHeader( 'Content-Disposition' ) );
+ }
+
+ public static function provideFilenameEncoding() {
+ return [
+ 'something simple' => [
+ 'foo.xyz', 'filename=foo.xyz'
+ ],
+ 'more complicated, but still simple' => [
+ 'foo.!#$%&\'*+-^_`|~', 'filename=foo.!#$%&\'*+-^_`|~'
+ ],
+ 'Needs quoting' => [
+ 'foo\\bar.xyz', 'filename="foo\\\\bar.xyz"'
+ ],
+ 'Needs quoting (2)' => [
+ 'foo (bar).xyz', 'filename="foo (bar).xyz"'
+ ],
+ 'Needs quoting (3)' => [
+ "foo\t\"b\x5car\"\0.xyz", "filename=\"foo\x5c\t\x5c\"b\x5c\x5car\x5c\"\x5c\0.xyz\""
+ ],
+ 'Non-ASCII characters' => [
+ 'fóo bár.🙌!',
+ "filename=\"f\xF3o b\xE1r.?!\"; filename*=UTF-8''f%C3%B3o%20b%C3%A1r.%F0%9F%99%8C!"
+ ]
+ ];
+ }
+
+ public function testBasics() {
+ $printer = $this->getMockFormatter( null, 'mock' );
+ $this->assertTrue( $printer->canPrintErrors() );
+ $this->assertSame(
+ 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Data_formats',
+ $printer->getHelpUrls()
+ );
+ }
+
+ public function testDisable() {
+ $this->setMwGlobals( [
+ 'wgApiFrameOptions' => 'DENY',
+ ] );
+
+ $printer = $this->getMockFormatter( null, 'mock' );
+ $printer->method( 'execute' )->willReturnCallback( function () use ( $printer ) {
+ $printer->printText( 'Foo' );
+ } );
+ $this->assertFalse( $printer->isDisabled() );
+ $printer->disable();
+ $this->assertTrue( $printer->isDisabled() );
+
+ $printer->setHttpStatus( 400 );
+ $printer->initPrinter();
+ $printer->execute();
+ ob_start();
+ $printer->closePrinter();
+ $this->assertSame( '', ob_get_clean() );
+ $response = $printer->getMain()->getRequest()->response();
+ $this->assertNull( $response->getHeader( 'Content-Type' ) );
+ $this->assertNull( $response->getHeader( 'X-Frame-Options' ) );
+ $this->assertNull( $response->getHeader( 'Content-Disposition' ) );
+ $this->assertNull( $response->getStatusCode() );
+ }
+
+ public function testNullMimeType() {
+ $this->setMwGlobals( [
+ 'wgApiFrameOptions' => 'DENY',
+ ] );
+
+ $printer = $this->getMockFormatter( null, 'mock', [ 'getMimeType' ] );
+ $printer->method( 'execute' )->willReturnCallback( function () use ( $printer ) {
+ $printer->printText( 'Foo' );
+ } );
+ $printer->method( 'getMimeType' )->willReturn( null );
+ $this->assertNull( $printer->getMimeType(), 'sanity check' );
+
+ $printer->initPrinter();
+ $printer->execute();
+ ob_start();
+ $printer->closePrinter();
+ $this->assertSame( 'Foo', ob_get_clean() );
+ $response = $printer->getMain()->getRequest()->response();
+ $this->assertNull( $response->getHeader( 'Content-Type' ) );
+ $this->assertNull( $response->getHeader( 'X-Frame-Options' ) );
+ $this->assertNull( $response->getHeader( 'Content-Disposition' ) );
+
+ $printer = $this->getMockFormatter( null, 'mockfm', [ 'getMimeType' ] );
+ $printer->method( 'execute' )->willReturnCallback( function () use ( $printer ) {
+ $printer->printText( 'Foo' );
+ } );
+ $printer->method( 'getMimeType' )->willReturn( null );
+ $this->assertNull( $printer->getMimeType(), 'sanity check' );
+ $this->assertTrue( $printer->getIsHtml(), 'sanity check' );
+
+ $printer->initPrinter();
+ $printer->execute();
+ ob_start();
+ $printer->closePrinter();
+ $this->assertSame( 'Foo', ob_get_clean() );
+ $response = $printer->getMain()->getRequest()->response();
+ $this->assertSame(
+ 'text/html; charset=utf-8', strtolower( $response->getHeader( 'Content-Type' ) )
+ );
+ $this->assertSame( 'DENY', $response->getHeader( 'X-Frame-Options' ) );
+ $this->assertSame(
+ 'inline; filename=api-result.html', $response->getHeader( 'Content-Disposition' )
+ );
+ }
+
+ public function testApiFrameOptions() {
+ $this->setMwGlobals( [ 'wgApiFrameOptions' => 'DENY' ] );
+ $printer = $this->getMockFormatter( null, 'mock' );
+ $printer->initPrinter();
+ $this->assertSame(
+ 'DENY',
+ $printer->getMain()->getRequest()->response()->getHeader( 'X-Frame-Options' )
+ );
+
+ $this->setMwGlobals( [ 'wgApiFrameOptions' => 'SAMEORIGIN' ] );
+ $printer = $this->getMockFormatter( null, 'mock' );
+ $printer->initPrinter();
+ $this->assertSame(
+ 'SAMEORIGIN',
+ $printer->getMain()->getRequest()->response()->getHeader( 'X-Frame-Options' )
+ );
+
+ $this->setMwGlobals( [ 'wgApiFrameOptions' => false ] );
+ $printer = $this->getMockFormatter( null, 'mock' );
+ $printer->initPrinter();
+ $this->assertNull(
+ $printer->getMain()->getRequest()->response()->getHeader( 'X-Frame-Options' )
+ );
+ }
+
+ public function testForceDefaultParams() {
+ $context = new RequestContext;
+ $context->setRequest( new FauxRequest( [ 'foo' => '1', 'bar' => '2', 'baz' => '3' ], true ) );
+ $main = new ApiMain( $context );
+ $allowedParams = [
+ 'foo' => [],
+ 'bar' => [ ApiBase::PARAM_DFLT => 'bar?' ],
+ 'baz' => 'baz!',
+ ];
+
+ $printer = $this->getMockFormatter( $main, 'mock', [ 'getAllowedParams' ] );
+ $printer->method( 'getAllowedParams' )->willReturn( $allowedParams );
+ $this->assertEquals(
+ [ 'foo' => '1', 'bar' => '2', 'baz' => '3' ],
+ $printer->extractRequestParams(),
+ 'sanity check'
+ );
+
+ $printer = $this->getMockFormatter( $main, 'mock', [ 'getAllowedParams' ] );
+ $printer->method( 'getAllowedParams' )->willReturn( $allowedParams );
+ $printer->forceDefaultParams();
+ $this->assertEquals(
+ [ 'foo' => null, 'bar' => 'bar?', 'baz' => 'baz!' ],
+ $printer->extractRequestParams()
+ );
+ }
+
+ public function testGetAllowedParams() {
+ $printer = $this->getMockFormatter( null, 'mock' );
+ $this->assertSame( [], $printer->getAllowedParams() );
+
+ $printer = $this->getMockFormatter( null, 'mockfm' );
+ $this->assertSame( [
+ 'wrappedhtml' => [
+ ApiBase::PARAM_DFLT => false,
+ ApiBase::PARAM_HELP_MSG => 'apihelp-format-param-wrappedhtml',
+ ]
+ ], $printer->getAllowedParams() );
+ }
+
+ public function testGetExamplesMessages() {
+ $printer = TestingAccessWrapper::newFromObject( $this->getMockFormatter( null, 'mock' ) );
+ $this->assertSame( [
+ 'action=query&meta=siteinfo&siprop=namespaces&format=mock'
+ => [ 'apihelp-format-example-generic', 'MOCK' ]
+ ], $printer->getExamplesMessages() );
+
+ $printer = TestingAccessWrapper::newFromObject( $this->getMockFormatter( null, 'mockfm' ) );
+ $this->assertSame( [
+ 'action=query&meta=siteinfo&siprop=namespaces&format=mockfm'
+ => [ 'apihelp-format-example-generic', 'MOCK' ]
+ ], $printer->getExamplesMessages() );
+ }
+
+ /**
+ * @dataProvider provideHtmlHeader
+ */
+ public function testHtmlHeader( $post, $registerNonHtml, $expect ) {
+ $context = new RequestContext;
+ $request = new FauxRequest( [ 'a' => 1, 'b' => 2 ], $post );
+ $request->setRequestURL( 'http://example.org/wx/api.php' );
+ $context->setRequest( $request );
+ $context->setLanguage( 'qqx' );
+ $main = new ApiMain( $context );
+ $printer = $this->getMockFormatter( $main, 'mockfm' );
+ $mm = $printer->getMain()->getModuleManager();
+ $mm->addModule( 'mockfm', 'format', ApiFormatBase::class, function () {
+ return $mock;
+ } );
+ if ( $registerNonHtml ) {
+ $mm->addModule( 'mock', 'format', ApiFormatBase::class, function () {
+ return $mock;
+ } );
+ }
+
+ $printer->initPrinter();
+ $printer->execute();
+ ob_start();
+ $printer->closePrinter();
+ $text = ob_get_clean();
+ $this->assertContains( $expect, $text );
+ }
+
+ public static function provideHtmlHeader() {
+ return [
+ [ false, false, '(api-format-prettyprint-header-only-html: MOCK)' ],
+ [ true, false, '(api-format-prettyprint-header-only-html: MOCK)' ],
+ // phpcs:ignore Generic.Files.LineLength.TooLong
+ [ false, true, '(api-format-prettyprint-header-hyperlinked: MOCK, mock, <a rel="nofollow" class="external free" href="http://example.org/wx/api.php?a=1&amp;b=2&amp;format=mock">http://example.org/wx/api.php?a=1&amp;b=2&amp;format=mock</a>)' ],
+ [ true, true, '(api-format-prettyprint-header: MOCK, mock)' ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatJsonTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatJsonTest.php
new file mode 100644
index 00000000..7eb2a35e
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatJsonTest.php
@@ -0,0 +1,129 @@
+<?php
+
+/**
+ * @group API
+ * @covers ApiFormatJson
+ */
+class ApiFormatJsonTest extends ApiFormatTestBase {
+
+ protected $printerName = 'json';
+
+ private static function addFormatVersion( $format, $arr ) {
+ foreach ( $arr as &$p ) {
+ if ( !isset( $p[2] ) ) {
+ $p[2] = [ 'formatversion' => $format ];
+ } else {
+ $p[2]['formatversion'] = $format;
+ }
+ }
+ return $arr;
+ }
+
+ public static function provideGeneralEncoding() {
+ return array_merge(
+ self::addFormatVersion( 1, [
+ // Basic types
+ [ [ null ], '[null]' ],
+ [ [ true ], '[""]' ],
+ [ [ false ], '[]' ],
+ [ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ], '[true]' ],
+ [ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ], '[false]' ],
+ [ [ 42 ], '[42]' ],
+ [ [ 42.5 ], '[42.5]' ],
+ [ [ 1e42 ], '[1.0e+42]' ],
+ [ [ 'foo' ], '["foo"]' ],
+ [ [ 'fóo' ], '["f\u00f3o"]' ],
+ [ [ 'fóo' ], '["fóo"]', [ 'utf8' => 1 ] ],
+
+ // Arrays and objects
+ [ [ [] ], '[[]]' ],
+ [ [ [ 1 ] ], '[[1]]' ],
+ [ [ [ 'x' => 1 ] ], '[{"x":1}]' ],
+ [ [ [ 2 => 1 ] ], '[{"2":1}]' ],
+ [ [ (object)[] ], '[{}]' ],
+ [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '[{"0":1}]' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '[[1]]' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], '[{"x":1}]' ],
+ [
+ [ [
+ 'x' => 1,
+ ApiResult::META_TYPE => 'BCkvp',
+ ApiResult::META_KVP_KEY_NAME => 'key'
+ ] ],
+ '[[{"key":"x","*":1}]]'
+ ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '[{"x":1}]' ],
+ [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], '[["a","b"]]' ],
+
+ // Content
+ [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
+ '{"*":"foo"}' ],
+
+ // BC Subelements
+ [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
+ '{"foo":{"*":"foo"}}' ],
+
+ // Callbacks
+ [ [ 1 ], '/**/myCallback([1])', [ 'callback' => 'myCallback' ] ],
+
+ // Cross-domain mangling
+ [ [ '< Cross-Domain-Policy >' ], '["\u003C Cross-Domain-Policy >"]' ],
+ ] ),
+ self::addFormatVersion( 2, [
+ // Basic types
+ [ [ null ], '[null]' ],
+ [ [ true ], '[true]' ],
+ [ [ false ], '[false]' ],
+ [ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ], '[true]' ],
+ [ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ], '[false]' ],
+ [ [ 42 ], '[42]' ],
+ [ [ 42.5 ], '[42.5]' ],
+ [ [ 1e42 ], '[1.0e+42]' ],
+ [ [ 'foo' ], '["foo"]' ],
+ [ [ 'fóo' ], '["fóo"]' ],
+ [ [ 'fóo' ], '["f\u00f3o"]', [ 'ascii' => 1 ] ],
+
+ // Arrays and objects
+ [ [ [] ], '[[]]' ],
+ [ [ [ 'x' => 1 ] ], '[{"x":1}]' ],
+ [ [ [ 2 => 1 ] ], '[{"2":1}]' ],
+ [ [ (object)[] ], '[{}]' ],
+ [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '[{"0":1}]' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '[[1]]' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], '[{"x":1}]' ],
+ [
+ [ [
+ 'x' => 1,
+ ApiResult::META_TYPE => 'BCkvp',
+ ApiResult::META_KVP_KEY_NAME => 'key'
+ ] ],
+ '[{"x":1}]'
+ ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '[[1]]' ],
+ [
+ [ [
+ 'a',
+ 'b',
+ ApiResult::META_TYPE => 'BCassoc'
+ ] ],
+ '[{"0":"a","1":"b"}]'
+ ],
+
+ // Content
+ [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
+ '{"content":"foo"}' ],
+
+ // BC Subelements
+ [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
+ '{"foo":"foo"}' ],
+
+ // Callbacks
+ [ [ 1 ], '/**/myCallback([1])', [ 'callback' => 'myCallback' ] ],
+
+ // Cross-domain mangling
+ [ [ '< Cross-Domain-Policy >' ], '["\u003C Cross-Domain-Policy >"]' ],
+ ] )
+ );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatNoneTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatNoneTest.php
new file mode 100644
index 00000000..87e36703
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatNoneTest.php
@@ -0,0 +1,51 @@
+<?php
+
+/**
+ * @group API
+ * @covers ApiFormatNone
+ */
+class ApiFormatNoneTest extends ApiFormatTestBase {
+
+ protected $printerName = 'none';
+
+ public static function provideGeneralEncoding() {
+ return [
+ // Basic types
+ [ [ null ], '' ],
+ [ [ true ], '' ],
+ [ [ false ], '' ],
+ [ [ 42 ], '' ],
+ [ [ 42.5 ], '' ],
+ [ [ 1e42 ], '' ],
+ [ [ 'foo' ], '' ],
+ [ [ 'fóo' ], '' ],
+
+ // Arrays and objects
+ [ [ [] ], '' ],
+ [ [ [ 1 ] ], '' ],
+ [ [ [ 'x' => 1 ] ], '' ],
+ [ [ [ 2 => 1 ] ], '' ],
+ [ [ (object)[] ], '' ],
+ [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], '' ],
+ [
+ [ [
+ 'x' => 1,
+ ApiResult::META_TYPE => 'BCkvp',
+ ApiResult::META_KVP_KEY_NAME => 'key'
+ ] ],
+ ''
+ ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '' ],
+ [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], '' ],
+
+ // Content
+ [ [ '*' => 'foo' ], '' ],
+
+ // BC Subelements
+ [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ], '' ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatPhpTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatPhpTest.php
new file mode 100644
index 00000000..66e620e8
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatPhpTest.php
@@ -0,0 +1,139 @@
+<?php
+
+/**
+ * @group API
+ * @covers ApiFormatPhp
+ */
+class ApiFormatPhpTest extends ApiFormatTestBase {
+
+ protected $printerName = 'php';
+
+ private static function addFormatVersion( $format, $arr ) {
+ foreach ( $arr as &$p ) {
+ if ( !isset( $p[2] ) ) {
+ $p[2] = [ 'formatversion' => $format ];
+ } else {
+ $p[2]['formatversion'] = $format;
+ }
+ }
+ return $arr;
+ }
+
+ public static function provideGeneralEncoding() {
+ // phpcs:disable Generic.Files.LineLength
+ return array_merge(
+ self::addFormatVersion( 1, [
+ // Basic types
+ [ [ null ], 'a:1:{i:0;N;}' ],
+ [ [ true ], 'a:1:{i:0;s:0:"";}' ],
+ [ [ false ], 'a:0:{}' ],
+ [ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ],
+ 'a:1:{i:0;b:1;}' ],
+ [ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ],
+ 'a:1:{i:0;b:0;}' ],
+ [ [ 42 ], 'a:1:{i:0;i:42;}' ],
+ [ [ 42.5 ], 'a:1:{i:0;d:42.5;}' ],
+ [ [ 1e42 ], 'a:1:{i:0;d:1.0E+42;}' ],
+ [ [ 'foo' ], 'a:1:{i:0;s:3:"foo";}' ],
+ [ [ 'fóo' ], 'a:1:{i:0;s:4:"fóo";}' ],
+
+ // Arrays and objects
+ [ [ [] ], 'a:1:{i:0;a:0:{}}' ],
+ [ [ [ 1 ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
+ [ [ [ 'x' => 1 ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
+ [ [ [ 2 => 1 ] ], 'a:1:{i:0;a:1:{i:2;i:1;}}' ],
+ [ [ (object)[] ], 'a:1:{i:0;a:0:{}}' ],
+ [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCkvp', ApiResult::META_KVP_KEY_NAME => 'key' ] ],
+ 'a:1:{i:0;a:1:{i:0;a:2:{s:3:"key";s:1:"x";s:1:"*";i:1;}}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
+ [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], 'a:1:{i:0;a:2:{i:0;s:1:"a";i:1;s:1:"b";}}' ],
+
+ // Content
+ [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
+ 'a:1:{s:1:"*";s:3:"foo";}' ],
+
+ // BC Subelements
+ [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
+ 'a:1:{s:3:"foo";a:1:{s:1:"*";s:3:"foo";}}' ],
+ ] ),
+ self::addFormatVersion( 2, [
+ // Basic types
+ [ [ null ], 'a:1:{i:0;N;}' ],
+ [ [ true ], 'a:1:{i:0;b:1;}' ],
+ [ [ false ], 'a:1:{i:0;b:0;}' ],
+ [ [ true, ApiResult::META_BC_BOOLS => [ 0 ] ],
+ 'a:1:{i:0;b:1;}' ],
+ [ [ false, ApiResult::META_BC_BOOLS => [ 0 ] ],
+ 'a:1:{i:0;b:0;}' ],
+ [ [ 42 ], 'a:1:{i:0;i:42;}' ],
+ [ [ 42.5 ], 'a:1:{i:0;d:42.5;}' ],
+ [ [ 1e42 ], 'a:1:{i:0;d:1.0E+42;}' ],
+ [ [ 'foo' ], 'a:1:{i:0;s:3:"foo";}' ],
+ [ [ 'fóo' ], 'a:1:{i:0;s:4:"fóo";}' ],
+
+ // Arrays and objects
+ [ [ [] ], 'a:1:{i:0;a:0:{}}' ],
+ [ [ [ 1 ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
+ [ [ [ 'x' => 1 ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
+ [ [ [ 2 => 1 ] ], 'a:1:{i:0;a:1:{i:2;i:1;}}' ],
+ [ [ (object)[] ], 'a:1:{i:0;a:0:{}}' ],
+ [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp' ] ], 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCkvp', ApiResult::META_KVP_KEY_NAME => 'key' ] ],
+ 'a:1:{i:0;a:1:{s:1:"x";i:1;}}' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], 'a:1:{i:0;a:1:{i:0;i:1;}}' ],
+ [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], 'a:1:{i:0;a:2:{i:0;s:1:"a";i:1;s:1:"b";}}' ],
+
+ // Content
+ [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
+ 'a:1:{s:7:"content";s:3:"foo";}' ],
+
+ // BC Subelements
+ [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
+ 'a:1:{s:3:"foo";s:3:"foo";}' ],
+ ] )
+ );
+ // phpcs:enable
+ }
+
+ public function testCrossDomainMangling() {
+ $config = new HashConfig( [ 'MangleFlashPolicy' => false ] );
+ $context = new RequestContext;
+ $context->setConfig( new MultiConfig( [
+ $config,
+ $context->getConfig(),
+ ] ) );
+ $main = new ApiMain( $context );
+ $main->getResult()->addValue( null, null, '< Cross-Domain-Policy >' );
+
+ $printer = $main->createPrinterByName( 'php' );
+ ob_start( 'MediaWiki\\OutputHandler::handle' );
+ $printer->initPrinter();
+ $printer->execute();
+ $printer->closePrinter();
+ $ret = ob_get_clean();
+ $this->assertSame( 'a:1:{i:0;s:23:"< Cross-Domain-Policy >";}', $ret );
+
+ $config->set( 'MangleFlashPolicy', true );
+ $printer = $main->createPrinterByName( 'php' );
+ ob_start( 'MediaWiki\\OutputHandler::handle' );
+ try {
+ $printer->initPrinter();
+ $printer->execute();
+ $printer->closePrinter();
+ ob_end_clean();
+ $this->fail( 'Expected exception not thrown' );
+ } catch ( ApiUsageException $ex ) {
+ ob_end_clean();
+ $this->assertTrue(
+ $ex->getStatusValue()->hasMessage( 'apierror-formatphp' ),
+ 'Expected exception'
+ );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatRawTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatRawTest.php
new file mode 100644
index 00000000..f64af6d3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatRawTest.php
@@ -0,0 +1,120 @@
+<?php
+
+/**
+ * @group API
+ * @covers ApiFormatRaw
+ */
+class ApiFormatRawTest extends ApiFormatTestBase {
+
+ protected $printerName = 'raw';
+
+ /**
+ * Test basic encoding and missing mime and text exceptions
+ * @return array datasets
+ */
+ public static function provideGeneralEncoding() {
+ $options = [
+ 'class' => ApiFormatRaw::class,
+ 'factory' => function ( ApiMain $main ) {
+ return new ApiFormatRaw( $main, new ApiFormatJson( $main, 'json' ) );
+ }
+ ];
+
+ return [
+ [
+ [ 'mime' => 'text/plain', 'text' => 'foo' ],
+ 'foo',
+ [],
+ $options
+ ],
+ [
+ [ 'mime' => 'text/plain', 'text' => 'fóo' ],
+ 'fóo',
+ [],
+ $options
+ ],
+ [
+ [ 'text' => 'some text' ],
+ new MWException( 'No MIME type set for raw formatter' ),
+ [],
+ $options
+ ],
+ [
+ [ 'mime' => 'text/plain' ],
+ new MWException( 'No text given for raw formatter' ),
+ [],
+ $options
+ ],
+ 'test error fallback' => [
+ [ 'mime' => 'text/plain', 'text' => 'some text', 'error' => 'some error' ],
+ '{"mime":"text/plain","text":"some text","error":"some error"}',
+ [],
+ $options
+ ]
+ ];
+ }
+
+ /**
+ * Test specifying filename
+ */
+ public function testFilename() {
+ $printer = new ApiFormatRaw( new ApiMain );
+ $printer->getResult()->addValue( null, 'filename', 'whatever.raw' );
+ $this->assertSame( 'whatever.raw', $printer->getFilename() );
+ }
+
+ /**
+ * Test specifying filename with error fallback printer
+ */
+ public function testErrorFallbackFilename() {
+ $apiMain = new ApiMain;
+ $printer = new ApiFormatRaw( $apiMain, new ApiFormatJson( $apiMain, 'json' ) );
+ $printer->getResult()->addValue( null, 'error', 'some error' );
+ $printer->getResult()->addValue( null, 'filename', 'whatever.raw' );
+ $this->assertSame( 'api-result.json', $printer->getFilename() );
+ }
+
+ /**
+ * Test specifying mime
+ */
+ public function testMime() {
+ $printer = new ApiFormatRaw( new ApiMain );
+ $printer->getResult()->addValue( null, 'mime', 'text/plain' );
+ $this->assertSame( 'text/plain', $printer->getMimeType() );
+ }
+
+ /**
+ * Test specifying mime with error fallback printer
+ */
+ public function testErrorFallbackMime() {
+ $apiMain = new ApiMain;
+ $printer = new ApiFormatRaw( $apiMain, new ApiFormatJson( $apiMain, 'json' ) );
+ $printer->getResult()->addValue( null, 'error', 'some error' );
+ $printer->getResult()->addValue( null, 'mime', 'text/plain' );
+ $this->assertSame( 'application/json', $printer->getMimeType() );
+ }
+
+ /**
+ * Check that setting failWithHTTPError to true will result in 400 response status code
+ */
+ public function testFailWithHTTPError() {
+ $apiMain = null;
+
+ $this->testGeneralEncoding(
+ [ 'mime' => 'text/plain', 'text' => 'some text', 'error' => 'some error' ],
+ '{"mime":"text/plain","text":"some text","error":"some error"}',
+ [],
+ [
+ 'class' => ApiFormatRaw::class,
+ 'factory' => function ( ApiMain $main ) use ( &$apiMain ) {
+ $apiMain = $main;
+ $printer = new ApiFormatRaw( $main, new ApiFormatJson( $main, 'json' ) );
+ $printer->setFailWithHTTPError( true );
+ return $printer;
+ }
+ ]
+ );
+ $this->assertEquals( 400, $apiMain->getRequest()->response()->getStatusCode() );
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatTestBase.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatTestBase.php
new file mode 100644
index 00000000..4169dab2
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatTestBase.php
@@ -0,0 +1,93 @@
+<?php
+
+abstract class ApiFormatTestBase extends MediaWikiTestCase {
+
+ /**
+ * Name of the formatter being tested
+ * @var string
+ */
+ protected $printerName;
+
+ /**
+ * Return general data to be encoded for testing
+ * @return array See self::testGeneralEncoding
+ * @throws BadMethodCallException
+ */
+ public static function provideGeneralEncoding() {
+ throw new BadMethodCallException( static::class . ' must implement ' . __METHOD__ );
+ }
+
+ /**
+ * Get the formatter output for the given input data
+ * @param array $params Query parameters
+ * @param array $data Data to encode
+ * @param array $options Options. If passed a string, the string is treated
+ * as the 'class' option.
+ * - name: Format name, rather than $this->printerName
+ * - class: If set, register 'name' with this class (and 'factory', if that's set)
+ * - factory: Used with 'class' to register at runtime
+ * - returnPrinter: Return the printer object
+ * @param callable|null $factory Factory to use instead of the normal one
+ * @return string|array The string if $options['returnPrinter'] isn't set, or an array if it is:
+ * - text: Output text string
+ * - printer: ApiFormatBase
+ * @throws Exception
+ */
+ protected function encodeData( array $params, array $data, $options = [] ) {
+ if ( is_string( $options ) ) {
+ $options = [ 'class' => $options ];
+ }
+ $printerName = isset( $options['name'] ) ? $options['name'] : $this->printerName;
+
+ $context = new RequestContext;
+ $context->setRequest( new FauxRequest( $params, true ) );
+ $main = new ApiMain( $context );
+ if ( isset( $options['class'] ) ) {
+ $factory = isset( $options['factory'] ) ? $options['factory'] : null;
+ $main->getModuleManager()->addModule( $printerName, 'format', $options['class'], $factory );
+ }
+ $result = $main->getResult();
+ $result->addArrayType( null, 'default' );
+ foreach ( $data as $k => $v ) {
+ $result->addValue( null, $k, $v );
+ }
+
+ $ret = [];
+ $printer = $main->createPrinterByName( $printerName );
+ $printer->initPrinter();
+ $printer->execute();
+ ob_start();
+ try {
+ $printer->closePrinter();
+ $ret['text'] = ob_get_clean();
+ } catch ( Exception $ex ) {
+ ob_end_clean();
+ throw $ex;
+ }
+
+ if ( !empty( $options['returnPrinter'] ) ) {
+ $ret['printer'] = $printer;
+ }
+
+ return count( $ret ) === 1 ? $ret['text'] : $ret;
+ }
+
+ /**
+ * @dataProvider provideGeneralEncoding
+ * @param array $data Data to be encoded
+ * @param string|Exception $expect String to expect, or exception expected to be thrown
+ * @param array $params Query parameters to set in the FauxRequest
+ * @param array $options Options to pass to self::encodeData()
+ */
+ public function testGeneralEncoding(
+ array $data, $expect, array $params = [], array $options = []
+ ) {
+ if ( $expect instanceof Exception ) {
+ $this->setExpectedException( get_class( $expect ), $expect->getMessage() );
+ $this->encodeData( $params, $data, $options ); // Should throw
+ } else {
+ $this->assertSame( $expect, $this->encodeData( $params, $data, $options ) );
+ }
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/format/ApiFormatXmlTest.php b/www/wiki/tests/phpunit/includes/api/format/ApiFormatXmlTest.php
new file mode 100644
index 00000000..915fb5c5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/format/ApiFormatXmlTest.php
@@ -0,0 +1,123 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @covers ApiFormatXml
+ */
+class ApiFormatXmlTest extends ApiFormatTestBase {
+
+ protected $printerName = 'xml';
+
+ public static function setUpBeforeClass() {
+ parent::setUpBeforeClass();
+ $page = WikiPage::factory( Title::newFromText( 'MediaWiki:ApiFormatXmlTest.xsl' ) );
+ // phpcs:disable Generic.Files.LineLength
+ $page->doEditContent( new WikitextContent(
+ '<?xml version="1.0"?><xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" />'
+ ), 'Summary' );
+ // phpcs:enable
+ $page = WikiPage::factory( Title::newFromText( 'MediaWiki:ApiFormatXmlTest' ) );
+ $page->doEditContent( new WikitextContent( 'Bogus' ), 'Summary' );
+ $page = WikiPage::factory( Title::newFromText( 'ApiFormatXmlTest' ) );
+ $page->doEditContent( new WikitextContent( 'Bogus' ), 'Summary' );
+ }
+
+ public static function provideGeneralEncoding() {
+ // phpcs:disable Generic.Files.LineLength
+ return [
+ // Basic types
+ [ [ null, 'a' => null ], '<?xml version="1.0"?><api><_v _idx="0" /></api>' ],
+ [ [ true, 'a' => true ], '<?xml version="1.0"?><api a=""><_v _idx="0">true</_v></api>' ],
+ [ [ false, 'a' => false ], '<?xml version="1.0"?><api><_v _idx="0">false</_v></api>' ],
+ [ [ true, 'a' => true, ApiResult::META_BC_BOOLS => [ 0, 'a' ] ],
+ '<?xml version="1.0"?><api a=""><_v _idx="0">1</_v></api>' ],
+ [ [ false, 'a' => false, ApiResult::META_BC_BOOLS => [ 0, 'a' ] ],
+ '<?xml version="1.0"?><api><_v _idx="0"></_v></api>' ],
+ [ [ 42, 'a' => 42 ], '<?xml version="1.0"?><api a="42"><_v _idx="0">42</_v></api>' ],
+ [ [ 42.5, 'a' => 42.5 ], '<?xml version="1.0"?><api a="42.5"><_v _idx="0">42.5</_v></api>' ],
+ [ [ 1e42, 'a' => 1e42 ], '<?xml version="1.0"?><api a="1.0E+42"><_v _idx="0">1.0E+42</_v></api>' ],
+ [ [ 'foo', 'a' => 'foo' ], '<?xml version="1.0"?><api a="foo"><_v _idx="0">foo</_v></api>' ],
+ [ [ 'fóo', 'a' => 'fóo' ], '<?xml version="1.0"?><api a="fóo"><_v _idx="0">fóo</_v></api>' ],
+
+ // Arrays and objects
+ [ [ [] ], '<?xml version="1.0"?><api><_v /></api>' ],
+ [ [ [ 'x' => 1 ] ], '<?xml version="1.0"?><api><_v x="1" /></api>' ],
+ [ [ [ 2 => 1 ] ], '<?xml version="1.0"?><api><_v><_v _idx="2">1</_v></_v></api>' ],
+ [ [ (object)[] ], '<?xml version="1.0"?><api><_v /></api>' ],
+ [ [ [ 1, ApiResult::META_TYPE => 'assoc' ] ], '<?xml version="1.0"?><api><_v><_v _idx="0">1</_v></_v></api>' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'array' ] ], '<?xml version="1.0"?><api><_v><_v>1</_v></_v></api>' ],
+ [ [ [ 'x' => 1, 'y' => [ 'z' => 1 ], ApiResult::META_TYPE => 'kvp' ] ],
+ '<?xml version="1.0"?><api><_v><_v _name="x" xml:space="preserve">1</_v><_v _name="y"><z xml:space="preserve">1</z></_v></_v></api>' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'kvp', ApiResult::META_INDEXED_TAG_NAME => 'i', ApiResult::META_KVP_KEY_NAME => 'key' ] ],
+ '<?xml version="1.0"?><api><_v><i key="x" xml:space="preserve">1</i></_v></api>' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCkvp', ApiResult::META_KVP_KEY_NAME => 'key' ] ],
+ '<?xml version="1.0"?><api><_v><_v key="x" xml:space="preserve">1</_v></_v></api>' ],
+ [ [ [ 'x' => 1, ApiResult::META_TYPE => 'BCarray' ] ], '<?xml version="1.0"?><api><_v x="1" /></api>' ],
+ [ [ [ 'a', 'b', ApiResult::META_TYPE => 'BCassoc' ] ], '<?xml version="1.0"?><api><_v><_v _idx="0">a</_v><_v _idx="1">b</_v></_v></api>' ],
+
+ // Content
+ [ [ 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
+ '<?xml version="1.0"?><api xml:space="preserve">foo</api>' ],
+
+ // Specified element name
+ [ [ 'foo', 'bar', ApiResult::META_INDEXED_TAG_NAME => 'itn' ],
+ '<?xml version="1.0"?><api><itn>foo</itn><itn>bar</itn></api>' ],
+
+ // Subelements
+ [ [ 'a' => 1, 's' => 1, '_subelements' => [ 's' ] ],
+ '<?xml version="1.0"?><api a="1"><s xml:space="preserve">1</s></api>' ],
+
+ // Content and subelement
+ [ [ 'a' => 1, 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
+ '<?xml version="1.0"?><api a="1" xml:space="preserve">foo</api>' ],
+ [ [ 's' => [], 'content' => 'foo', ApiResult::META_CONTENT => 'content' ],
+ '<?xml version="1.0"?><api><s /><content xml:space="preserve">foo</content></api>' ],
+ [
+ [
+ 's' => 1,
+ 'content' => 'foo',
+ ApiResult::META_CONTENT => 'content',
+ ApiResult::META_SUBELEMENTS => [ 's' ]
+ ],
+ '<?xml version="1.0"?><api><s xml:space="preserve">1</s><content xml:space="preserve">foo</content></api>'
+ ],
+
+ // BC Subelements
+ [ [ 'foo' => 'foo', ApiResult::META_BC_SUBELEMENTS => [ 'foo' ] ],
+ '<?xml version="1.0"?><api><foo xml:space="preserve">foo</foo></api>' ],
+
+ // Name mangling
+ [ [ 'foo.bar' => 1 ], '<?xml version="1.0"?><api foo.bar="1" />' ],
+ [ [ '' => 1 ], '<?xml version="1.0"?><api _="1" />' ],
+ [ [ 'foo bar' => 1 ], '<?xml version="1.0"?><api _foo.20.bar="1" />' ],
+ [ [ 'foo:bar' => 1 ], '<?xml version="1.0"?><api _foo.3A.bar="1" />' ],
+ [ [ 'foo%.bar' => 1 ], '<?xml version="1.0"?><api _foo.25..2E.bar="1" />' ],
+ [ [ '4foo' => 1, 'foo4' => 1 ], '<?xml version="1.0"?><api _4foo="1" foo4="1" />' ],
+ [ [ "foo\xe3\x80\x80bar" => 1 ], '<?xml version="1.0"?><api _foo.3000.bar="1" />' ],
+ [ [ 'foo:bar' => 1, ApiResult::META_PRESERVE_KEYS => [ 'foo:bar' ] ],
+ '<?xml version="1.0"?><api foo:bar="1" />' ],
+ [ [ 'a', 'b', ApiResult::META_INDEXED_TAG_NAME => 'foo bar' ],
+ '<?xml version="1.0"?><api><_foo.20.bar>a</_foo.20.bar><_foo.20.bar>b</_foo.20.bar></api>' ],
+
+ // includenamespace param
+ [ [ 'x' => 'foo' ], '<?xml version="1.0"?><api x="foo" xmlns="http://www.mediawiki.org/xml/api/" />',
+ [ 'includexmlnamespace' => 1 ] ],
+
+ // xslt param
+ [ [], '<?xml version="1.0"?><api><warnings><xml xml:space="preserve">Invalid or non-existent stylesheet specified.</xml></warnings></api>',
+ [ 'xslt' => 'DoesNotExist' ] ],
+ [ [], '<?xml version="1.0"?><api><warnings><xml xml:space="preserve">Stylesheet should be in the MediaWiki namespace.</xml></warnings></api>',
+ [ 'xslt' => 'ApiFormatXmlTest' ] ],
+ [ [], '<?xml version="1.0"?><api><warnings><xml xml:space="preserve">Stylesheet should have &quot;.xsl&quot; extension.</xml></warnings></api>',
+ [ 'xslt' => 'MediaWiki:ApiFormatXmlTest' ] ],
+ [ [],
+ '<?xml version="1.0"?><?xml-stylesheet href="' .
+ htmlspecialchars( Title::newFromText( 'MediaWiki:ApiFormatXmlTest.xsl' )->getLocalURL( 'action=raw' ) ) .
+ '" type="text/xsl" ?><api />',
+ [ 'xslt' => 'MediaWiki:ApiFormatXmlTest.xsl' ] ],
+ ];
+ // phpcs:enable
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/generateRandomImages.php b/www/wiki/tests/phpunit/includes/api/generateRandomImages.php
new file mode 100644
index 00000000..d4a5acff
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/generateRandomImages.php
@@ -0,0 +1,45 @@
+<?php
+/**
+ * Bootstrapping for test image file generation
+ *
+ * @file
+ */
+
+// Start up MediaWiki in command-line mode
+require_once __DIR__ . "/../../../../maintenance/Maintenance.php";
+require __DIR__ . "/RandomImageGenerator.php";
+
+class GenerateRandomImages extends Maintenance {
+
+ public function getDbType() {
+ return Maintenance::DB_NONE;
+ }
+
+ public function execute() {
+ $getOptSpec = [
+ 'dictionaryFile::',
+ 'minWidth::',
+ 'maxWidth::',
+ 'minHeight::',
+ 'maxHeight::',
+ 'shapesToDraw::',
+ 'shape::',
+
+ 'number::',
+ 'format::'
+ ];
+ $options = getopt( null, $getOptSpec );
+
+ $format = isset( $options['format'] ) ? $options['format'] : 'jpg';
+ unset( $options['format'] );
+
+ $number = isset( $options['number'] ) ? intval( $options['number'] ) : 10;
+ unset( $options['number'] );
+
+ $randomImageGenerator = new RandomImageGenerator( $options );
+ $randomImageGenerator->writeImages( $number, $format );
+ }
+}
+
+$maintClass = 'GenerateRandomImages';
+require RUN_MAINTENANCE_IF_MAIN;
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryBasicTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryBasicTest.php
new file mode 100644
index 00000000..e49e1d8b
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryBasicTest.php
@@ -0,0 +1,346 @@
+<?php
+/**
+ * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * These tests validate basic functionality of the api query module
+ *
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiQuery
+ */
+class ApiQueryBasicTest extends ApiQueryTestBase {
+ protected $exceptionFromAddDBData;
+
+ /**
+ * Create a set of pages. These must not change, otherwise the tests might give wrong results.
+ *
+*@see MediaWikiTestCase::addDBDataOnce()
+ */
+ function addDBDataOnce() {
+ try {
+ if ( Title::newFromText( 'AQBT-All' )->exists() ) {
+ return;
+ }
+
+ // Ordering is important, as it will be returned in the same order as stored in the index
+ $this->editPage( 'AQBT-All', '[[Category:AQBT-Cat]] [[AQBT-Links]] {{AQBT-T}}' );
+ $this->editPage( 'AQBT-Categories', '[[Category:AQBT-Cat]]' );
+ $this->editPage( 'AQBT-Links', '[[AQBT-All]] [[AQBT-Categories]] [[AQBT-Templates]]' );
+ $this->editPage( 'AQBT-Templates', '{{AQBT-T}}' );
+ $this->editPage( 'AQBT-T', 'Content', '', NS_TEMPLATE );
+
+ // Refresh due to the bug with listing transclusions as links if they don't exist
+ $this->editPage( 'AQBT-All', '[[Category:AQBT-Cat]] [[AQBT-Links]] {{AQBT-T}}' );
+ $this->editPage( 'AQBT-Templates', '{{AQBT-T}}' );
+ } catch ( Exception $e ) {
+ $this->exceptionFromAddDBData = $e;
+ }
+ }
+
+ private static $links = [
+ [ 'prop' => 'links', 'titles' => 'AQBT-All' ],
+ [ 'pages' => [
+ '1' => [
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All',
+ 'links' => [
+ [ 'ns' => 0, 'title' => 'AQBT-Links' ],
+ ]
+ ]
+ ] ]
+ ];
+
+ private static $templates = [
+ [ 'prop' => 'templates', 'titles' => 'AQBT-All' ],
+ [ 'pages' => [
+ '1' => [
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All',
+ 'templates' => [
+ [ 'ns' => 10, 'title' => 'Template:AQBT-T' ],
+ ]
+ ]
+ ] ]
+ ];
+
+ private static $categories = [
+ [ 'prop' => 'categories', 'titles' => 'AQBT-All' ],
+ [ 'pages' => [
+ '1' => [
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All',
+ 'categories' => [
+ [ 'ns' => 14, 'title' => 'Category:AQBT-Cat' ],
+ ]
+ ]
+ ] ]
+ ];
+
+ private static $allpages = [
+ [ 'list' => 'allpages', 'apprefix' => 'AQBT-' ],
+ [ 'allpages' => [
+ [ 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ],
+ [ 'pageid' => 2, 'ns' => 0, 'title' => 'AQBT-Categories' ],
+ [ 'pageid' => 3, 'ns' => 0, 'title' => 'AQBT-Links' ],
+ [ 'pageid' => 4, 'ns' => 0, 'title' => 'AQBT-Templates' ],
+ ] ]
+ ];
+
+ private static $alllinks = [
+ [ 'list' => 'alllinks', 'alprefix' => 'AQBT-' ],
+ [ 'alllinks' => [
+ [ 'ns' => 0, 'title' => 'AQBT-All' ],
+ [ 'ns' => 0, 'title' => 'AQBT-Categories' ],
+ [ 'ns' => 0, 'title' => 'AQBT-Links' ],
+ [ 'ns' => 0, 'title' => 'AQBT-Templates' ],
+ ] ]
+ ];
+
+ private static $alltransclusions = [
+ [ 'list' => 'alltransclusions', 'atprefix' => 'AQBT-' ],
+ [ 'alltransclusions' => [
+ [ 'ns' => 10, 'title' => 'Template:AQBT-T' ],
+ [ 'ns' => 10, 'title' => 'Template:AQBT-T' ],
+ ] ]
+ ];
+
+ // Although this appears to have no use it is used by testLists()
+ private static $allcategories = [
+ [ 'list' => 'allcategories', 'acprefix' => 'AQBT-' ],
+ [ 'allcategories' => [
+ [ 'category' => 'AQBT-Cat' ],
+ ] ]
+ ];
+
+ private static $backlinks = [
+ [ 'list' => 'backlinks', 'bltitle' => 'AQBT-Links' ],
+ [ 'backlinks' => [
+ [ 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ],
+ ] ]
+ ];
+
+ private static $embeddedin = [
+ [ 'list' => 'embeddedin', 'eititle' => 'Template:AQBT-T' ],
+ [ 'embeddedin' => [
+ [ 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ],
+ [ 'pageid' => 4, 'ns' => 0, 'title' => 'AQBT-Templates' ],
+ ] ]
+ ];
+
+ private static $categorymembers = [
+ [ 'list' => 'categorymembers', 'cmtitle' => 'Category:AQBT-Cat' ],
+ [ 'categorymembers' => [
+ [ 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ],
+ [ 'pageid' => 2, 'ns' => 0, 'title' => 'AQBT-Categories' ],
+ ] ]
+ ];
+
+ private static $generatorAllpages = [
+ [ 'generator' => 'allpages', 'gapprefix' => 'AQBT-' ],
+ [ 'pages' => [
+ '1' => [
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All' ],
+ '2' => [
+ 'pageid' => 2,
+ 'ns' => 0,
+ 'title' => 'AQBT-Categories' ],
+ '3' => [
+ 'pageid' => 3,
+ 'ns' => 0,
+ 'title' => 'AQBT-Links' ],
+ '4' => [
+ 'pageid' => 4,
+ 'ns' => 0,
+ 'title' => 'AQBT-Templates' ],
+ ] ]
+ ];
+
+ private static $generatorLinks = [
+ [ 'generator' => 'links', 'titles' => 'AQBT-Links' ],
+ [ 'pages' => [
+ '1' => [
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All' ],
+ '2' => [
+ 'pageid' => 2,
+ 'ns' => 0,
+ 'title' => 'AQBT-Categories' ],
+ '4' => [
+ 'pageid' => 4,
+ 'ns' => 0,
+ 'title' => 'AQBT-Templates' ],
+ ] ]
+ ];
+
+ private static $generatorLinksPropLinks = [
+ [ 'prop' => 'links' ],
+ [ 'pages' => [
+ '1' => [ 'links' => [
+ [ 'ns' => 0, 'title' => 'AQBT-Links' ],
+ ] ]
+ ] ]
+ ];
+
+ private static $generatorLinksPropTemplates = [
+ [ 'prop' => 'templates' ],
+ [ 'pages' => [
+ '1' => [ 'templates' => [
+ [ 'ns' => 10, 'title' => 'Template:AQBT-T' ] ] ],
+ '4' => [ 'templates' => [
+ [ 'ns' => 10, 'title' => 'Template:AQBT-T' ] ] ],
+ ] ]
+ ];
+
+ /**
+ * Test basic props
+ */
+ public function testProps() {
+ $this->check( self::$links );
+ $this->check( self::$templates );
+ $this->check( self::$categories );
+ }
+
+ /**
+ * Test basic lists
+ */
+ public function testLists() {
+ $this->check( self::$allpages );
+ $this->check( self::$alllinks );
+ $this->check( self::$alltransclusions );
+ $this->check( self::$allcategories );
+ $this->check( self::$backlinks );
+ $this->check( self::$embeddedin );
+ $this->check( self::$categorymembers );
+ }
+
+ /**
+ * Test basic lists
+ */
+ public function testAllTogether() {
+ // All props together
+ $this->check( $this->merge(
+ self::$links,
+ self::$templates,
+ self::$categories
+ ) );
+
+ // All lists together
+ $this->check( $this->merge(
+ self::$allpages,
+ self::$alllinks,
+ self::$alltransclusions,
+ // This test is temporarily disabled until a sqlite bug is fixed
+ // self::$allcategories,
+ self::$backlinks,
+ self::$embeddedin,
+ self::$categorymembers
+ ) );
+
+ // All props+lists together
+ $this->check( $this->merge(
+ self::$links,
+ self::$templates,
+ self::$categories,
+ self::$allpages,
+ self::$alllinks,
+ self::$alltransclusions,
+ // This test is temporarily disabled until a sqlite bug is fixed
+ // self::$allcategories,
+ self::$backlinks,
+ self::$embeddedin,
+ self::$categorymembers
+ ) );
+ }
+
+ /**
+ * Test basic lists
+ */
+ public function testGenerator() {
+ // generator=allpages
+ $this->check( self::$generatorAllpages );
+ // generator=allpages & list=allpages
+ $this->check( $this->merge(
+ self::$generatorAllpages,
+ self::$allpages ) );
+ // generator=links
+ $this->check( self::$generatorLinks );
+ // generator=links & prop=links
+ $this->check( $this->merge(
+ self::$generatorLinks,
+ self::$generatorLinksPropLinks ) );
+ // generator=links & prop=templates
+ $this->check( $this->merge(
+ self::$generatorLinks,
+ self::$generatorLinksPropTemplates ) );
+ // generator=links & prop=links|templates
+ $this->check( $this->merge(
+ self::$generatorLinks,
+ self::$generatorLinksPropLinks,
+ self::$generatorLinksPropTemplates ) );
+ // generator=links & prop=links|templates & list=allpages|...
+ $this->check( $this->merge(
+ self::$generatorLinks,
+ self::$generatorLinksPropLinks,
+ self::$generatorLinksPropTemplates,
+ self::$allpages,
+ self::$alllinks,
+ self::$alltransclusions,
+ // This test is temporarily disabled until a sqlite bug is fixed
+ // self::$allcategories,
+ self::$backlinks,
+ self::$embeddedin,
+ self::$categorymembers ) );
+ }
+
+ /**
+ * Test T53821
+ */
+ public function testGeneratorRedirects() {
+ $this->editPage( 'AQBT-Target', 'test' );
+ $this->editPage( 'AQBT-Redir', '#REDIRECT [[AQBT-Target]]' );
+ $this->check( [
+ [ 'generator' => 'backlinks', 'gbltitle' => 'AQBT-Target', 'redirects' => '1' ],
+ [
+ 'redirects' => [
+ [
+ 'from' => 'AQBT-Redir',
+ 'to' => 'AQBT-Target',
+ ]
+ ],
+ 'pages' => [
+ '6' => [
+ 'pageid' => 6,
+ 'ns' => 0,
+ 'title' => 'AQBT-Target',
+ ]
+ ],
+ ]
+ ] );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php
new file mode 100644
index 00000000..334fd5da
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiQuery
+ */
+class ApiQueryContinue2Test extends ApiQueryContinueTestBase {
+ protected $exceptionFromAddDBData;
+
+ /**
+ * Create a set of pages. These must not change, otherwise the tests might give wrong results.
+ *
+*@see MediaWikiTestCase::addDBDataOnce()
+ */
+ function addDBDataOnce() {
+ try {
+ $this->editPage( 'AQCT73462-A', '**AQCT73462-A** [[AQCT73462-B]] [[AQCT73462-C]]' );
+ $this->editPage( 'AQCT73462-B', '[[AQCT73462-A]] **AQCT73462-B** [[AQCT73462-C]]' );
+ $this->editPage( 'AQCT73462-C', '[[AQCT73462-A]] [[AQCT73462-B]] **AQCT73462-C**' );
+ $this->editPage( 'AQCT73462-A', '**AQCT73462-A** [[AQCT73462-B]] [[AQCT73462-C]]' );
+ $this->editPage( 'AQCT73462-B', '[[AQCT73462-A]] **AQCT73462-B** [[AQCT73462-C]]' );
+ $this->editPage( 'AQCT73462-C', '[[AQCT73462-A]] [[AQCT73462-B]] **AQCT73462-C**' );
+ } catch ( Exception $e ) {
+ $this->exceptionFromAddDBData = $e;
+ }
+ }
+
+ /**
+ * @group medium
+ */
+ public function testA() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p, $gDir ) {
+ return [
+ 'generator' => 'allpages',
+ 'gapprefix' => 'AQCT73462-',
+ 'prop' => 'links',
+ 'gaplimit' => "$g",
+ 'pllimit' => "$p",
+ 'gapdir' => $gDir ? "ascending" : "descending",
+ ];
+ };
+ // generator + 1 prop + 1 list
+ $data = $this->query( $mk( 99, 99, true ), 1, 'g1p', false ) +
+ [ 'batchcomplete' => true ];
+ $this->checkC( $data, $mk( 1, 1, true ), 6, 'g1p-11t' );
+ $this->checkC( $data, $mk( 2, 2, true ), 3, 'g1p-22t' );
+ $this->checkC( $data, $mk( 1, 1, false ), 6, 'g1p-11f' );
+ $this->checkC( $data, $mk( 2, 2, false ), 3, 'g1p-22f' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTest.php
new file mode 100644
index 00000000..7259bb81
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTest.php
@@ -0,0 +1,323 @@
+<?php
+/**
+ * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+/**
+ * These tests validate the new continue functionality of the api query module by
+ * doing multiple requests with varying parameters, merging the results, and checking
+ * that the result matches the full data received in one no-limits call.
+ *
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiQuery
+ */
+class ApiQueryContinueTest extends ApiQueryContinueTestBase {
+ protected $exceptionFromAddDBData;
+
+ /**
+ * Create a set of pages. These must not change, otherwise the tests might give wrong results.
+ *
+*@see MediaWikiTestCase::addDBDataOnce()
+ */
+ function addDBDataOnce() {
+ try {
+ $this->editPage( 'Template:AQCT-T1', '**Template:AQCT-T1**' );
+ $this->editPage( 'Template:AQCT-T2', '**Template:AQCT-T2**' );
+ $this->editPage( 'Template:AQCT-T3', '**Template:AQCT-T3**' );
+ $this->editPage( 'Template:AQCT-T4', '**Template:AQCT-T4**' );
+ $this->editPage( 'Template:AQCT-T5', '**Template:AQCT-T5**' );
+
+ $this->editPage( 'AQCT-1', '**AQCT-1** {{AQCT-T2}} {{AQCT-T3}} {{AQCT-T4}} {{AQCT-T5}}' );
+ $this->editPage( 'AQCT-2', '[[AQCT-1]] **AQCT-2** {{AQCT-T3}} {{AQCT-T4}} {{AQCT-T5}}' );
+ $this->editPage( 'AQCT-3', '[[AQCT-1]] [[AQCT-2]] **AQCT-3** {{AQCT-T4}} {{AQCT-T5}}' );
+ $this->editPage( 'AQCT-4', '[[AQCT-1]] [[AQCT-2]] [[AQCT-3]] **AQCT-4** {{AQCT-T5}}' );
+ $this->editPage( 'AQCT-5', '[[AQCT-1]] [[AQCT-2]] [[AQCT-3]] [[AQCT-4]] **AQCT-5**' );
+ } catch ( Exception $e ) {
+ $this->exceptionFromAddDBData = $e;
+ }
+ }
+
+ /**
+ * Test smart continue - list=allpages
+ * @group medium
+ */
+ public function test1List() {
+ $this->mVerbose = false;
+ $mk = function ( $l ) {
+ return [
+ 'list' => 'allpages',
+ 'apprefix' => 'AQCT-',
+ 'aplimit' => "$l",
+ ];
+ };
+ $data = $this->query( $mk( 99 ), 1, '1L', false ) +
+ [ 'batchcomplete' => true ];
+
+ // 1 list
+ $this->checkC( $data, $mk( 1 ), 5, '1L-1' );
+ $this->checkC( $data, $mk( 2 ), 3, '1L-2' );
+ $this->checkC( $data, $mk( 3 ), 2, '1L-3' );
+ $this->checkC( $data, $mk( 4 ), 2, '1L-4' );
+ $this->checkC( $data, $mk( 5 ), 1, '1L-5' );
+ }
+
+ /**
+ * Test smart continue - list=allpages|alltransclusions
+ * @group medium
+ */
+ public function test2Lists() {
+ $this->mVerbose = false;
+ $mk = function ( $l1, $l2 ) {
+ return [
+ 'list' => 'allpages|alltransclusions',
+ 'apprefix' => 'AQCT-',
+ 'atprefix' => 'AQCT-',
+ 'atunique' => '',
+ 'aplimit' => "$l1",
+ 'atlimit' => "$l2",
+ ];
+ };
+ // 2 lists
+ $data = $this->query( $mk( 99, 99 ), 1, '2L', false ) +
+ [ 'batchcomplete' => true ];
+ $this->checkC( $data, $mk( 1, 1 ), 5, '2L-11' );
+ $this->checkC( $data, $mk( 2, 2 ), 3, '2L-22' );
+ $this->checkC( $data, $mk( 3, 3 ), 2, '2L-33' );
+ $this->checkC( $data, $mk( 4, 4 ), 2, '2L-44' );
+ $this->checkC( $data, $mk( 5, 5 ), 1, '2L-55' );
+ }
+
+ /**
+ * Test smart continue - generator=allpages, prop=links
+ * @group medium
+ */
+ public function testGen1Prop() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p ) {
+ return [
+ 'generator' => 'allpages',
+ 'gapprefix' => 'AQCT-',
+ 'gaplimit' => "$g",
+ 'prop' => 'links',
+ 'pllimit' => "$p",
+ ];
+ };
+ // generator + 1 prop
+ $data = $this->query( $mk( 99, 99 ), 1, 'G1P', false ) +
+ [ 'batchcomplete' => true ];
+ $this->checkC( $data, $mk( 1, 1 ), 11, 'G1P-11' );
+ $this->checkC( $data, $mk( 2, 2 ), 6, 'G1P-22' );
+ $this->checkC( $data, $mk( 3, 3 ), 4, 'G1P-33' );
+ $this->checkC( $data, $mk( 4, 4 ), 3, 'G1P-44' );
+ $this->checkC( $data, $mk( 5, 5 ), 2, 'G1P-55' );
+ }
+
+ /**
+ * Test smart continue - generator=allpages, prop=links|templates
+ * @group medium
+ */
+ public function testGen2Prop() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p1, $p2 ) {
+ return [
+ 'generator' => 'allpages',
+ 'gapprefix' => 'AQCT-',
+ 'gaplimit' => "$g",
+ 'prop' => 'links|templates',
+ 'pllimit' => "$p1",
+ 'tllimit' => "$p2",
+ ];
+ };
+ // generator + 2 props
+ $data = $this->query( $mk( 99, 99, 99 ), 1, 'G2P', false ) +
+ [ 'batchcomplete' => true ];
+ $this->checkC( $data, $mk( 1, 1, 1 ), 16, 'G2P-111' );
+ $this->checkC( $data, $mk( 2, 2, 2 ), 9, 'G2P-222' );
+ $this->checkC( $data, $mk( 3, 3, 3 ), 6, 'G2P-333' );
+ $this->checkC( $data, $mk( 4, 4, 4 ), 4, 'G2P-444' );
+ $this->checkC( $data, $mk( 5, 5, 5 ), 2, 'G2P-555' );
+ $this->checkC( $data, $mk( 5, 1, 1 ), 10, 'G2P-511' );
+ $this->checkC( $data, $mk( 4, 2, 2 ), 7, 'G2P-422' );
+ $this->checkC( $data, $mk( 2, 3, 3 ), 7, 'G2P-233' );
+ $this->checkC( $data, $mk( 2, 4, 4 ), 5, 'G2P-244' );
+ $this->checkC( $data, $mk( 1, 5, 5 ), 5, 'G2P-155' );
+ }
+
+ /**
+ * Test smart continue - generator=allpages, prop=links, list=alltransclusions
+ * @group medium
+ */
+ public function testGen1Prop1List() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p, $l ) {
+ return [
+ 'generator' => 'allpages',
+ 'gapprefix' => 'AQCT-',
+ 'gaplimit' => "$g",
+ 'prop' => 'links',
+ 'pllimit' => "$p",
+ 'list' => 'alltransclusions',
+ 'atprefix' => 'AQCT-',
+ 'atunique' => '',
+ 'atlimit' => "$l",
+ ];
+ };
+ // generator + 1 prop + 1 list
+ $data = $this->query( $mk( 99, 99, 99 ), 1, 'G1P1L', false ) +
+ [ 'batchcomplete' => true ];
+ $this->checkC( $data, $mk( 1, 1, 1 ), 11, 'G1P1L-111' );
+ $this->checkC( $data, $mk( 2, 2, 2 ), 6, 'G1P1L-222' );
+ $this->checkC( $data, $mk( 3, 3, 3 ), 4, 'G1P1L-333' );
+ $this->checkC( $data, $mk( 4, 4, 4 ), 3, 'G1P1L-444' );
+ $this->checkC( $data, $mk( 5, 5, 5 ), 2, 'G1P1L-555' );
+ $this->checkC( $data, $mk( 5, 5, 1 ), 4, 'G1P1L-551' );
+ $this->checkC( $data, $mk( 5, 5, 2 ), 2, 'G1P1L-552' );
+ }
+
+ /**
+ * Test smart continue - generator=allpages, prop=links|templates,
+ * list=alllinks|alltransclusions, meta=siteinfo
+ * @group medium
+ */
+ public function testGen2Prop2List1Meta() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p1, $p2, $l1, $l2 ) {
+ return [
+ 'generator' => 'allpages',
+ 'gapprefix' => 'AQCT-',
+ 'gaplimit' => "$g",
+ 'prop' => 'links|templates',
+ 'pllimit' => "$p1",
+ 'tllimit' => "$p2",
+ 'list' => 'alllinks|alltransclusions',
+ 'alprefix' => 'AQCT-',
+ 'alunique' => '',
+ 'allimit' => "$l1",
+ 'atprefix' => 'AQCT-',
+ 'atunique' => '',
+ 'atlimit' => "$l2",
+ 'meta' => 'siteinfo',
+ 'siprop' => 'namespaces',
+ ];
+ };
+ // generator + 1 prop + 1 list
+ $data = $this->query( $mk( 99, 99, 99, 99, 99 ), 1, 'G2P2L1M', false ) +
+ [ 'batchcomplete' => true ];
+ $this->checkC( $data, $mk( 1, 1, 1, 1, 1 ), 16, 'G2P2L1M-11111' );
+ $this->checkC( $data, $mk( 2, 2, 2, 2, 2 ), 9, 'G2P2L1M-22222' );
+ $this->checkC( $data, $mk( 3, 3, 3, 3, 3 ), 6, 'G2P2L1M-33333' );
+ $this->checkC( $data, $mk( 4, 4, 4, 4, 4 ), 4, 'G2P2L1M-44444' );
+ $this->checkC( $data, $mk( 5, 5, 5, 5, 5 ), 2, 'G2P2L1M-55555' );
+ $this->checkC( $data, $mk( 5, 5, 5, 1, 1 ), 4, 'G2P2L1M-55511' );
+ $this->checkC( $data, $mk( 5, 5, 5, 2, 2 ), 2, 'G2P2L1M-55522' );
+ $this->checkC( $data, $mk( 5, 1, 1, 5, 5 ), 10, 'G2P2L1M-51155' );
+ $this->checkC( $data, $mk( 5, 2, 2, 5, 5 ), 5, 'G2P2L1M-52255' );
+ }
+
+ /**
+ * Test smart continue - generator=templates, prop=templates
+ * @group medium
+ */
+ public function testSameGenAndProp() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $gDir, $p, $pDir ) {
+ return [
+ 'titles' => 'AQCT-1',
+ 'generator' => 'templates',
+ 'gtllimit' => "$g",
+ 'gtldir' => $gDir ? 'ascending' : 'descending',
+ 'prop' => 'templates',
+ 'tllimit' => "$p",
+ 'tldir' => $pDir ? 'ascending' : 'descending',
+ ];
+ };
+ // generator + 1 prop
+ $data = $this->query( $mk( 99, true, 99, true ), 1, 'G=P', false ) +
+ [ 'batchcomplete' => true ];
+
+ $this->checkC( $data, $mk( 1, true, 1, true ), 4, 'G=P-1t1t' );
+ $this->checkC( $data, $mk( 2, true, 2, true ), 2, 'G=P-2t2t' );
+ $this->checkC( $data, $mk( 3, true, 3, true ), 2, 'G=P-3t3t' );
+ $this->checkC( $data, $mk( 1, true, 3, true ), 4, 'G=P-1t3t' );
+ $this->checkC( $data, $mk( 3, true, 1, true ), 2, 'G=P-3t1t' );
+
+ $this->checkC( $data, $mk( 1, true, 1, false ), 4, 'G=P-1t1f' );
+ $this->checkC( $data, $mk( 2, true, 2, false ), 2, 'G=P-2t2f' );
+ $this->checkC( $data, $mk( 3, true, 3, false ), 2, 'G=P-3t3f' );
+ $this->checkC( $data, $mk( 1, true, 3, false ), 4, 'G=P-1t3f' );
+ $this->checkC( $data, $mk( 3, true, 1, false ), 2, 'G=P-3t1f' );
+
+ $this->checkC( $data, $mk( 1, false, 1, true ), 4, 'G=P-1f1t' );
+ $this->checkC( $data, $mk( 2, false, 2, true ), 2, 'G=P-2f2t' );
+ $this->checkC( $data, $mk( 3, false, 3, true ), 2, 'G=P-3f3t' );
+ $this->checkC( $data, $mk( 1, false, 3, true ), 4, 'G=P-1f3t' );
+ $this->checkC( $data, $mk( 3, false, 1, true ), 2, 'G=P-3f1t' );
+
+ $this->checkC( $data, $mk( 1, false, 1, false ), 4, 'G=P-1f1f' );
+ $this->checkC( $data, $mk( 2, false, 2, false ), 2, 'G=P-2f2f' );
+ $this->checkC( $data, $mk( 3, false, 3, false ), 2, 'G=P-3f3f' );
+ $this->checkC( $data, $mk( 1, false, 3, false ), 4, 'G=P-1f3f' );
+ $this->checkC( $data, $mk( 3, false, 1, false ), 2, 'G=P-3f1f' );
+ }
+
+ /**
+ * Test smart continue - generator=allpages, list=allpages
+ * @group medium
+ */
+ public function testSameGenList() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $gDir, $l, $pDir ) {
+ return [
+ 'generator' => 'allpages',
+ 'gapprefix' => 'AQCT-',
+ 'gaplimit' => "$g",
+ 'gapdir' => $gDir ? 'ascending' : 'descending',
+ 'list' => 'allpages',
+ 'apprefix' => 'AQCT-',
+ 'aplimit' => "$l",
+ 'apdir' => $pDir ? 'ascending' : 'descending',
+ ];
+ };
+ // generator + 1 list
+ $data = $this->query( $mk( 99, true, 99, true ), 1, 'G=L', false ) +
+ [ 'batchcomplete' => true ];
+
+ $this->checkC( $data, $mk( 1, true, 1, true ), 5, 'G=L-1t1t' );
+ $this->checkC( $data, $mk( 2, true, 2, true ), 3, 'G=L-2t2t' );
+ $this->checkC( $data, $mk( 3, true, 3, true ), 2, 'G=L-3t3t' );
+ $this->checkC( $data, $mk( 1, true, 3, true ), 5, 'G=L-1t3t' );
+ $this->checkC( $data, $mk( 3, true, 1, true ), 5, 'G=L-3t1t' );
+ $this->checkC( $data, $mk( 1, true, 1, false ), 5, 'G=L-1t1f' );
+ $this->checkC( $data, $mk( 2, true, 2, false ), 3, 'G=L-2t2f' );
+ $this->checkC( $data, $mk( 3, true, 3, false ), 2, 'G=L-3t3f' );
+ $this->checkC( $data, $mk( 1, true, 3, false ), 5, 'G=L-1t3f' );
+ $this->checkC( $data, $mk( 3, true, 1, false ), 5, 'G=L-3t1f' );
+ $this->checkC( $data, $mk( 1, false, 1, true ), 5, 'G=L-1f1t' );
+ $this->checkC( $data, $mk( 2, false, 2, true ), 3, 'G=L-2f2t' );
+ $this->checkC( $data, $mk( 3, false, 3, true ), 2, 'G=L-3f3t' );
+ $this->checkC( $data, $mk( 1, false, 3, true ), 5, 'G=L-1f3t' );
+ $this->checkC( $data, $mk( 3, false, 1, true ), 5, 'G=L-3f1t' );
+ $this->checkC( $data, $mk( 1, false, 1, false ), 5, 'G=L-1f1f' );
+ $this->checkC( $data, $mk( 2, false, 2, false ), 3, 'G=L-2f2f' );
+ $this->checkC( $data, $mk( 3, false, 3, false ), 2, 'G=L-3f3f' );
+ $this->checkC( $data, $mk( 1, false, 3, false ), 5, 'G=L-1f3f' );
+ $this->checkC( $data, $mk( 3, false, 1, false ), 5, 'G=L-3f1f' );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php
new file mode 100644
index 00000000..d2bdb496
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php
@@ -0,0 +1,210 @@
+<?php
+/**
+ * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+abstract class ApiQueryContinueTestBase extends ApiQueryTestBase {
+
+ /**
+ * Enable to print in-depth debugging info during the test run
+ */
+ protected $mVerbose = false;
+
+ /**
+ * Run query() and compare against expected values
+ * @param array $expected
+ * @param array $params Api parameters
+ * @param int $expectedCount Max number of iterations
+ * @param string $id Unit test id
+ * @param bool $continue True to use smart continue
+ * @return array Merged results data array
+ */
+ protected function checkC( $expected, $params, $expectedCount, $id, $continue = true ) {
+ $result = $this->query( $params, $expectedCount, $id, $continue );
+ $this->assertResult( $expected, $result, $id );
+ }
+
+ /**
+ * Run query in a loop until no more values are available
+ * @param array $params Api parameters
+ * @param int $expectedCount Max number of iterations
+ * @param string $id Unit test id
+ * @param bool $useContinue True to use smart continue
+ * @return array Merged results data array
+ * @throws Exception
+ */
+ protected function query( $params, $expectedCount, $id, $useContinue = true ) {
+ if ( isset( $params['action'] ) ) {
+ $this->assertEquals( 'query', $params['action'], 'Invalid query action' );
+ } else {
+ $params['action'] = 'query';
+ }
+ $count = 0;
+ $result = [];
+ $continue = [];
+ do {
+ $request = array_merge( $params, $continue );
+ uksort( $request, function ( $a, $b ) {
+ // put 'continue' params at the end - lazy method
+ $a = strpos( $a, 'continue' ) !== false ? 'zzz ' . $a : $a;
+ $b = strpos( $b, 'continue' ) !== false ? 'zzz ' . $b : $b;
+
+ return strcmp( $a, $b );
+ } );
+ $reqStr = http_build_query( $request );
+ // $reqStr = str_replace( '&', ' & ', $reqStr );
+ $this->assertLessThan( $expectedCount, $count, "$id more data: $reqStr" );
+ if ( $this->mVerbose ) {
+ print "$id (#$count): $reqStr\n";
+ }
+ try {
+ $data = $this->doApiRequest( $request );
+ } catch ( Exception $e ) {
+ throw new Exception( "$id on $count", 0, $e );
+ }
+ $data = $data[0];
+ if ( isset( $data['warnings'] ) ) {
+ $warnings = json_encode( $data['warnings'] );
+ $this->fail( "$id Warnings on #$count in $reqStr\n$warnings" );
+ }
+ $this->assertArrayHasKey( 'query', $data, "$id no 'query' on #$count in $reqStr" );
+ if ( isset( $data['continue'] ) ) {
+ $continue = $data['continue'];
+ unset( $data['continue'] );
+ } else {
+ $continue = [];
+ }
+ if ( $this->mVerbose ) {
+ $this->printResult( $data );
+ }
+ $this->mergeResult( $result, $data );
+ $count++;
+ if ( empty( $continue ) ) {
+ $this->assertEquals( $expectedCount, $count, "$id finished early" );
+
+ return $result;
+ } elseif ( !$useContinue ) {
+ $this->assertFalse( 'Non-smart query must be requested all at once' );
+ }
+ } while ( true );
+ }
+
+ /**
+ * @param array $data
+ */
+ private function printResult( $data ) {
+ $q = $data['query'];
+ $print = [];
+ if ( isset( $q['pages'] ) ) {
+ foreach ( $q['pages'] as $p ) {
+ $m = $p['title'];
+ if ( isset( $p['links'] ) ) {
+ $m .= '/[' . implode( ',', array_map(
+ function ( $v ) {
+ return $v['title'];
+ },
+ $p['links'] ) ) . ']';
+ }
+ if ( isset( $p['categories'] ) ) {
+ $m .= '/(' . implode( ',', array_map(
+ function ( $v ) {
+ return str_replace( 'Category:', '', $v['title'] );
+ },
+ $p['categories'] ) ) . ')';
+ }
+ $print[] = $m;
+ }
+ }
+ if ( isset( $q['allcategories'] ) ) {
+ $print[] = '*Cats/(' . implode( ',', array_map(
+ function ( $v ) {
+ return $v['*'];
+ },
+ $q['allcategories'] ) ) . ')';
+ }
+ self::GetItems( $q, 'allpages', 'Pages', $print );
+ self::GetItems( $q, 'alllinks', 'Links', $print );
+ self::GetItems( $q, 'alltransclusions', 'Trnscl', $print );
+ print ' ' . implode( ' ', $print ) . "\n";
+ }
+
+ private static function GetItems( $q, $moduleName, $name, &$print ) {
+ if ( isset( $q[$moduleName] ) ) {
+ $print[] = "*$name/[" . implode( ',',
+ array_map(
+ function ( $v ) {
+ return $v['title'];
+ },
+ $q[$moduleName] ) ) . ']';
+ }
+ }
+
+ /**
+ * Recursively merge the new result returned from the query to the previous results.
+ * @param mixed &$results
+ * @param mixed $newResult
+ * @param bool $numericIds If true, treat keys as ids to be merged instead of appending
+ */
+ protected function mergeResult( &$results, $newResult, $numericIds = false ) {
+ $this->assertEquals(
+ is_array( $results ),
+ is_array( $newResult ),
+ 'Type of result and data do not match'
+ );
+ if ( !is_array( $results ) ) {
+ $this->assertEquals( $results, $newResult, 'Repeated result must be the same as before' );
+ } else {
+ $sort = null;
+ foreach ( $newResult as $key => $value ) {
+ if ( !$numericIds && $sort === null ) {
+ if ( !is_array( $value ) ) {
+ $sort = false;
+ } elseif ( array_key_exists( 'title', $value ) ) {
+ $sort = function ( $a, $b ) {
+ return strcmp( $a['title'], $b['title'] );
+ };
+ } else {
+ $sort = false;
+ }
+ }
+ $keyExists = array_key_exists( $key, $results );
+ if ( is_numeric( $key ) ) {
+ if ( $numericIds ) {
+ if ( !$keyExists ) {
+ $results[$key] = $value;
+ } else {
+ $this->mergeResult( $results[$key], $value );
+ }
+ } else {
+ $results[] = $value;
+ }
+ } elseif ( !$keyExists ) {
+ $results[$key] = $value;
+ } else {
+ $this->mergeResult( $results[$key], $value, $key === 'pages' );
+ }
+ }
+ if ( $numericIds ) {
+ ksort( $results, SORT_NUMERIC );
+ } elseif ( $sort !== null && $sort !== false ) {
+ usort( $results, $sort );
+ }
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php
new file mode 100644
index 00000000..38a1d685
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php
@@ -0,0 +1,44 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiQueryRevisions
+ */
+class ApiQueryRevisionsTest extends ApiTestCase {
+
+ /**
+ * @group medium
+ */
+ public function testContentComesWithContentModelAndFormat() {
+ $pageName = 'Help:' . __METHOD__;
+ $title = Title::newFromText( $pageName );
+ $page = WikiPage::factory( $title );
+
+ $page->doEditContent(
+ ContentHandler::makeContent( 'Some text', $page->getTitle() ),
+ 'inserting content'
+ );
+
+ $apiResult = $this->doApiRequest( [
+ 'action' => 'query',
+ 'prop' => 'revisions',
+ 'titles' => $pageName,
+ 'rvprop' => 'content',
+ ] );
+ $this->assertArrayHasKey( 'query', $apiResult[0] );
+ $this->assertArrayHasKey( 'pages', $apiResult[0]['query'] );
+ foreach ( $apiResult[0]['query']['pages'] as $page ) {
+ $this->assertArrayHasKey( 'revisions', $page );
+ foreach ( $page['revisions'] as $revision ) {
+ $this->assertArrayHasKey( 'contentformat', $revision,
+ 'contentformat should be included when asking content so client knows how to interpret it'
+ );
+ $this->assertArrayHasKey( 'contentmodel', $revision,
+ 'contentmodel should be included when asking content so client knows how to interpret it'
+ );
+ }
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryTest.php
new file mode 100644
index 00000000..de8d8156
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryTest.php
@@ -0,0 +1,151 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiQuery
+ */
+class ApiQueryTest extends ApiTestCase {
+ protected function setUp() {
+ parent::setUp();
+
+ // Setup apiquerytestiw: as interwiki prefix
+ $this->setMwGlobals( 'wgHooks', [
+ 'InterwikiLoadPrefix' => [
+ function ( $prefix, &$data ) {
+ if ( $prefix == 'apiquerytestiw' ) {
+ $data = [ 'iw_url' => 'wikipedia' ];
+ }
+ return false;
+ }
+ ]
+ ] );
+ }
+
+ public function testTitlesGetNormalized() {
+ global $wgMetaNamespace;
+
+ $this->setMwGlobals( [
+ 'wgCapitalLinks' => true,
+ ] );
+
+ $data = $this->doApiRequest( [
+ 'action' => 'query',
+ 'titles' => 'Project:articleA|article_B' ] );
+
+ $this->assertArrayHasKey( 'query', $data[0] );
+ $this->assertArrayHasKey( 'normalized', $data[0]['query'] );
+
+ // Forge a normalized title
+ $to = Title::newFromText( $wgMetaNamespace . ':ArticleA' );
+
+ $this->assertEquals(
+ [
+ 'fromencoded' => false,
+ 'from' => 'Project:articleA',
+ 'to' => $to->getPrefixedText(),
+ ],
+ $data[0]['query']['normalized'][0]
+ );
+
+ $this->assertEquals(
+ [
+ 'fromencoded' => false,
+ 'from' => 'article_B',
+ 'to' => 'Article B'
+ ],
+ $data[0]['query']['normalized'][1]
+ );
+ }
+
+ public function testTitlesAreRejectedIfInvalid() {
+ $title = false;
+ while ( !$title || Title::newFromText( $title )->exists() ) {
+ $title = md5( mt_rand( 0, 100000 ) );
+ }
+
+ $data = $this->doApiRequest( [
+ 'action' => 'query',
+ 'titles' => $title . '|Talk:' ] );
+
+ $this->assertArrayHasKey( 'query', $data[0] );
+ $this->assertArrayHasKey( 'pages', $data[0]['query'] );
+ $this->assertEquals( 2, count( $data[0]['query']['pages'] ) );
+
+ $this->assertArrayHasKey( -2, $data[0]['query']['pages'] );
+ $this->assertArrayHasKey( -1, $data[0]['query']['pages'] );
+
+ $this->assertArrayHasKey( 'missing', $data[0]['query']['pages'][-2] );
+ $this->assertArrayHasKey( 'invalid', $data[0]['query']['pages'][-1] );
+ }
+
+ public function testTitlesWithWhitespaces() {
+ $data = $this->doApiRequest( [
+ 'action' => 'query',
+ 'titles' => ' '
+ ] );
+
+ $this->assertArrayHasKey( 'query', $data[0] );
+ $this->assertArrayHasKey( 'pages', $data[0]['query'] );
+ $this->assertEquals( 1, count( $data[0]['query']['pages'] ) );
+ $this->assertArrayHasKey( -1, $data[0]['query']['pages'] );
+ $this->assertArrayHasKey( 'invalid', $data[0]['query']['pages'][-1] );
+ }
+
+ /**
+ * Test the ApiBase::titlePartToKey function
+ *
+ * @param string $titlePart
+ * @param int $namespace
+ * @param string $expected
+ * @param string $expectException
+ * @dataProvider provideTestTitlePartToKey
+ */
+ function testTitlePartToKey( $titlePart, $namespace, $expected, $expectException ) {
+ $this->setMwGlobals( [
+ 'wgCapitalLinks' => true,
+ ] );
+
+ $api = new MockApiQueryBase();
+ $exceptionCaught = false;
+ try {
+ $this->assertEquals( $expected, $api->titlePartToKey( $titlePart, $namespace ) );
+ } catch ( ApiUsageException $e ) {
+ $exceptionCaught = true;
+ }
+ $this->assertEquals( $expectException, $exceptionCaught,
+ 'ApiUsageException thrown by titlePartToKey' );
+ }
+
+ function provideTestTitlePartToKey() {
+ return [
+ [ 'a b c', NS_MAIN, 'A_b_c', false ],
+ [ 'x', NS_MAIN, 'X', false ],
+ [ 'y ', NS_MAIN, 'Y_', false ],
+ [ 'template:foo', NS_CATEGORY, 'Template:foo', false ],
+ [ 'apiquerytestiw:foo', NS_CATEGORY, 'Apiquerytestiw:foo', false ],
+ [ "\xF7", NS_MAIN, null, true ],
+ [ 'template:foo', NS_MAIN, null, true ],
+ [ 'apiquerytestiw:foo', NS_MAIN, null, true ],
+ ];
+ }
+
+ /**
+ * Test if all classes in the query module manager exists
+ */
+ public function testClassNamesInModuleManager() {
+ $api = new ApiMain(
+ new FauxRequest( [ 'action' => 'query', 'meta' => 'siteinfo' ] )
+ );
+ $queryApi = new ApiQuery( $api, 'query' );
+ $modules = $queryApi->getModuleManager()->getNamesWithClasses();
+
+ foreach ( $modules as $name => $class ) {
+ $this->assertTrue(
+ class_exists( $class ),
+ 'Class ' . $class . ' for api module ' . $name . ' does not exist (with exact case)'
+ );
+ }
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryTestBase.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryTestBase.php
new file mode 100644
index 00000000..e7588cb5
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryTestBase.php
@@ -0,0 +1,158 @@
+<?php
+/**
+ * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/** This class has some common functionality for testing query module
+ */
+abstract class ApiQueryTestBase extends ApiTestCase {
+
+ const PARAM_ASSERT = <<<STR
+Each parameter must be an array of two elements,
+first - an array of params to the API call,
+and the second array - expected results as returned by the API
+STR;
+
+ /**
+ * Merges all requests parameter + expected values into one
+ * @param array $v,... List of arrays, each of which contains exactly two
+ * @return array
+ */
+ protected function merge( /*...*/ ) {
+ $request = [];
+ $expected = [];
+ foreach ( func_get_args() as $v ) {
+ list( $req, $exp ) = $this->validateRequestExpectedPair( $v );
+ $request = array_merge_recursive( $request, $req );
+ $this->mergeExpected( $expected, $exp );
+ }
+
+ return [ $request, $expected ];
+ }
+
+ /**
+ * Check that the parameter is a valid two element array,
+ * with the first element being API request and the second - expected result
+ * @param array $v
+ * @return array
+ */
+ private function validateRequestExpectedPair( $v ) {
+ $this->assertInternalType( 'array', $v, self::PARAM_ASSERT );
+ $this->assertEquals( 2, count( $v ), self::PARAM_ASSERT );
+ $this->assertArrayHasKey( 0, $v, self::PARAM_ASSERT );
+ $this->assertArrayHasKey( 1, $v, self::PARAM_ASSERT );
+ $this->assertInternalType( 'array', $v[0], self::PARAM_ASSERT );
+ $this->assertInternalType( 'array', $v[1], self::PARAM_ASSERT );
+
+ return $v;
+ }
+
+ /**
+ * Recursively merges the expected values in the $item into the $all
+ * @param array &$all
+ * @param array $item
+ */
+ private function mergeExpected( &$all, $item ) {
+ foreach ( $item as $k => $v ) {
+ if ( array_key_exists( $k, $all ) ) {
+ if ( is_array( $all[$k] ) ) {
+ $this->mergeExpected( $all[$k], $v );
+ } else {
+ $this->assertEquals( $all[$k], $v );
+ }
+ } else {
+ $all[$k] = $v;
+ }
+ }
+ }
+
+ /**
+ * Checks that the request's result matches the expected results.
+ * Assumes no rawcontinue and a complete batch.
+ * @param array $values Array is a two element array( request, expected_results )
+ * @param array $session
+ * @param bool $appendModule
+ * @param User $user
+ */
+ protected function check( $values, array $session = null,
+ $appendModule = false, User $user = null
+ ) {
+ list( $req, $exp ) = $this->validateRequestExpectedPair( $values );
+ if ( !array_key_exists( 'action', $req ) ) {
+ $req['action'] = 'query';
+ }
+ foreach ( $req as &$val ) {
+ if ( is_array( $val ) ) {
+ $val = implode( '|', array_unique( $val ) );
+ }
+ }
+ $result = $this->doApiRequest( $req, $session, $appendModule, $user );
+ $this->assertResult( [ 'batchcomplete' => true, 'query' => $exp ], $result[0], $req );
+ }
+
+ protected function assertResult( $exp, $result, $message = '' ) {
+ try {
+ $exp = self::sanitizeResultArray( $exp );
+ $result = self::sanitizeResultArray( $result );
+ $this->assertEquals( $exp, $result );
+ } catch ( PHPUnit_Framework_ExpectationFailedException $e ) {
+ if ( is_array( $message ) ) {
+ $message = http_build_query( $message );
+ }
+
+ // FIXME: once we migrate to phpunit 4.1+, hardcode ComparisonFailure exception use
+ $compEx = 'SebastianBergmann\Comparator\ComparisonFailure';
+ if ( !class_exists( $compEx ) ) {
+ $compEx = 'PHPUnit_Framework_ComparisonFailure';
+ }
+
+ throw new PHPUnit_Framework_ExpectationFailedException(
+ $e->getMessage() . "\nRequest: $message",
+ new $compEx(
+ $exp,
+ $result,
+ print_r( $exp, true ),
+ print_r( $result, true ),
+ false,
+ $e->getComparisonFailure()->getMessage() . "\nRequest: $message"
+ )
+ );
+ }
+ }
+
+ /**
+ * Recursively ksorts a result array and removes any 'pageid' keys.
+ * @param array $result
+ * @return array
+ */
+ private static function sanitizeResultArray( $result ) {
+ unset( $result['pageid'] );
+ foreach ( $result as $key => $value ) {
+ if ( is_array( $value ) ) {
+ $result[$key] = self::sanitizeResultArray( $value );
+ }
+ }
+
+ // Sort the result by keys, then take advantage of how array_merge will
+ // renumber numeric keys while leaving others alone.
+ ksort( $result );
+ return array_merge( $result );
+ }
+}
diff --git a/www/wiki/tests/phpunit/includes/api/query/ApiQueryUserContributionsTest.php b/www/wiki/tests/phpunit/includes/api/query/ApiQueryUserContributionsTest.php
new file mode 100644
index 00000000..ca6a929a
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/query/ApiQueryUserContributionsTest.php
@@ -0,0 +1,194 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiQueryContributions
+ */
+class ApiQueryContributionsTest extends ApiTestCase {
+ public function addDBDataOnce() {
+ global $wgActorTableSchemaMigrationStage;
+
+ $reset = new \Wikimedia\ScopedCallback( function ( $v ) {
+ global $wgActorTableSchemaMigrationStage;
+ $wgActorTableSchemaMigrationStage = $v;
+ $this->overrideMwServices();
+ }, [ $wgActorTableSchemaMigrationStage ] );
+ $wgActorTableSchemaMigrationStage = MIGRATION_WRITE_BOTH;
+ $this->overrideMwServices();
+
+ $users = [
+ User::newFromName( '192.168.2.2', false ),
+ User::newFromName( '192.168.2.1', false ),
+ User::newFromName( '192.168.2.3', false ),
+ User::createNew( __CLASS__ . ' B' ),
+ User::createNew( __CLASS__ . ' A' ),
+ User::createNew( __CLASS__ . ' C' ),
+ User::newFromName( 'IW>' . __CLASS__, false ),
+ ];
+
+ $title = Title::newFromText( __CLASS__ );
+ $page = WikiPage::factory( $title );
+ for ( $i = 0; $i < 3; $i++ ) {
+ foreach ( array_reverse( $users ) as $user ) {
+ $status = $page->doEditContent(
+ ContentHandler::makeContent( "Test revision $user #$i", $title ), 'Test edit', 0, false, $user
+ );
+ if ( !$status->isOK() ) {
+ $this->fail( "Failed to edit $title: " . $status->getWikiText( false, false, 'en' ) );
+ }
+ }
+ }
+ }
+
+ /**
+ * @dataProvider provideSorting
+ * @param int $stage One of the MIGRATION_* constants for $wgActorTableSchemaMigrationStage
+ * @param array $params Extra parameters for the query
+ * @param bool $reverse Reverse order?
+ * @param int $revs Number of revisions to expect
+ */
+ public function testSorting( $stage, $params, $reverse, $revs ) {
+ if ( isset( $params['ucuserprefix'] ) &&
+ ( $stage === MIGRATION_WRITE_BOTH || $stage === MIGRATION_WRITE_NEW ) &&
+ $this->db->getType() === 'mysql' && $this->usesTemporaryTables()
+ ) {
+ // https://bugs.mysql.com/bug.php?id=10327
+ $this->markTestSkipped( 'MySQL bug 10327 - can\'t reopen temporary tables' );
+ }
+
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', $stage );
+ $this->overrideMwServices();
+
+ if ( isset( $params['ucuserids'] ) ) {
+ $params['ucuserids'] = implode( '|', array_map( 'User::idFromName', $params['ucuserids'] ) );
+ }
+ if ( isset( $params['ucuser'] ) ) {
+ $params['ucuser'] = implode( '|', $params['ucuser'] );
+ }
+
+ $sort = 'rsort';
+ if ( $reverse ) {
+ $params['ucdir'] = 'newer';
+ $sort = 'sort';
+ }
+
+ $params += [
+ 'action' => 'query',
+ 'list' => 'usercontribs',
+ 'ucprop' => 'ids',
+ ];
+
+ $apiResult = $this->doApiRequest( $params + [ 'uclimit' => 500 ] );
+ $this->assertArrayNotHasKey( 'continue', $apiResult[0] );
+ $this->assertArrayHasKey( 'query', $apiResult[0] );
+ $this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'] );
+
+ $count = 0;
+ $ids = [];
+ foreach ( $apiResult[0]['query']['usercontribs'] as $page ) {
+ $count++;
+ $ids[$page['user']][] = $page['revid'];
+ }
+ $this->assertSame( $revs, $count, 'Expected number of revisions' );
+ foreach ( $ids as $user => $revids ) {
+ $sorted = $revids;
+ call_user_func_array( $sort, [ &$sorted ] );
+ $this->assertSame( $sorted, $revids, "IDs for $user are sorted" );
+ }
+
+ for ( $limit = 1; $limit < $revs; $limit++ ) {
+ $continue = [];
+ $count = 0;
+ $batchedIds = [];
+ while ( $continue !== null ) {
+ $apiResult = $this->doApiRequest( $params + [ 'uclimit' => $limit ] + $continue );
+ $this->assertArrayHasKey( 'query', $apiResult[0], "Batching with limit $limit" );
+ $this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'],
+ "Batching with limit $limit" );
+ $continue = isset( $apiResult[0]['continue'] ) ? $apiResult[0]['continue'] : null;
+ foreach ( $apiResult[0]['query']['usercontribs'] as $page ) {
+ $count++;
+ $batchedIds[$page['user']][] = $page['revid'];
+ }
+ $this->assertLessThanOrEqual( $revs, $count, "Batching with limit $limit" );
+ }
+ $this->assertSame( $ids, $batchedIds, "Result set is the same when batching with limit $limit" );
+ }
+ }
+
+ public static function provideSorting() {
+ $users = [ __CLASS__ . ' A', __CLASS__ . ' B', __CLASS__ . ' C' ];
+ $users2 = [ __CLASS__ . ' A', __CLASS__ . ' B', __CLASS__ . ' D' ];
+ $ips = [ '192.168.2.1', '192.168.2.2', '192.168.2.3', '192.168.2.4' ];
+
+ foreach (
+ [
+ 'old' => MIGRATION_OLD,
+ 'write both' => MIGRATION_WRITE_BOTH,
+ 'write new' => MIGRATION_WRITE_NEW,
+ 'new' => MIGRATION_NEW,
+ ] as $stageName => $stage
+ ) {
+ foreach ( [ false, true ] as $reverse ) {
+ $name = $stageName . ( $reverse ? ', reverse' : '' );
+ yield "Named users, $name" => [ $stage, [ 'ucuser' => $users ], $reverse, 9 ];
+ yield "Named users including a no-edit user, $name" => [
+ $stage, [ 'ucuser' => $users2 ], $reverse, 6
+ ];
+ yield "IP users, $name" => [ $stage, [ 'ucuser' => $ips ], $reverse, 9 ];
+ yield "All users, $name" => [
+ $stage, [ 'ucuser' => array_merge( $users, $ips ) ], $reverse, 18
+ ];
+ yield "User IDs, $name" => [ $stage, [ 'ucuserids' => $users ], $reverse, 9 ];
+ yield "Users by prefix, $name" => [ $stage, [ 'ucuserprefix' => __CLASS__ ], $reverse, 9 ];
+ yield "IPs by prefix, $name" => [ $stage, [ 'ucuserprefix' => '192.168.2.' ], $reverse, 9 ];
+ }
+ }
+ }
+
+ /**
+ * @dataProvider provideInterwikiUser
+ * @param int $stage One of the MIGRATION_* constants for $wgActorTableSchemaMigrationStage
+ */
+ public function testInterwikiUser( $stage ) {
+ $this->setMwGlobals( 'wgActorTableSchemaMigrationStage', $stage );
+ $this->overrideMwServices();
+
+ $params = [
+ 'action' => 'query',
+ 'list' => 'usercontribs',
+ 'ucuser' => 'IW>' . __CLASS__,
+ 'ucprop' => 'ids',
+ 'uclimit' => 'max',
+ ];
+
+ $apiResult = $this->doApiRequest( $params );
+ $this->assertArrayNotHasKey( 'continue', $apiResult[0] );
+ $this->assertArrayHasKey( 'query', $apiResult[0] );
+ $this->assertArrayHasKey( 'usercontribs', $apiResult[0]['query'] );
+
+ $count = 0;
+ $ids = [];
+ foreach ( $apiResult[0]['query']['usercontribs'] as $page ) {
+ $count++;
+ $this->assertSame( 'IW>' . __CLASS__, $page['user'], 'Correct user returned' );
+ $ids[] = $page['revid'];
+ }
+ $this->assertSame( 3, $count, 'Expected number of revisions' );
+ $sorted = $ids;
+ rsort( $sorted );
+ $this->assertSame( $sorted, $ids, "IDs are sorted" );
+ }
+
+ public static function provideInterwikiUser() {
+ return [
+ 'old' => [ MIGRATION_OLD ],
+ 'write both' => [ MIGRATION_WRITE_BOTH ],
+ 'write new' => [ MIGRATION_WRITE_NEW ],
+ 'new' => [ MIGRATION_NEW ],
+ ];
+ }
+
+}
diff --git a/www/wiki/tests/phpunit/includes/api/words.txt b/www/wiki/tests/phpunit/includes/api/words.txt
new file mode 100644
index 00000000..7ce23ee3
--- /dev/null
+++ b/www/wiki/tests/phpunit/includes/api/words.txt
@@ -0,0 +1,1000 @@
+Andaquian
+Anoplanthus
+Araquaju
+Astrophyton
+Avarish
+Batonga
+Bdellidae
+Betoyan
+Bismarck
+Britishness
+Carmen
+Chatillon
+Clement
+Coryphaena
+Croton
+Cyrillianism
+Dagomba
+Decimus
+Dichorisandra
+Duculinae
+Empusa
+Escallonia
+Fathometer
+Fon
+Fundulinae
+Gadswoons
+Gederathite
+Gemini
+Gerbera
+Gregarinida
+Gyracanthus
+Halopsychidae
+Hasidim
+Hemerobius
+Ichthyosauridae
+Iscariot
+Jeames
+Jesuitry
+Jovian
+Judaization
+Katie
+Ladin
+Langhian
+Lapithaean
+Lisette
+Macrochira
+Malaxis
+Malvastrum
+Maranhao
+Marxian
+Maurist
+Metrosideros
+Micky
+Microsporon
+Odacidae
+Ophiuchid
+Osmorhiza
+Paguma
+Palesman
+Papayaceae
+Pastinaca
+Philoxenian
+Pleurostigma
+Rarotongan
+Rhodoraceae
+Rong
+Saho
+Sanyakoan
+Sardanapalian
+Sauropoda
+Sedentaria
+Shambu
+Shukulumbwe
+Solonian
+Spaniardization
+Spirochaetaceae
+Stomatopoda
+Stratiotes
+Taiwanhemp
+Titanically
+Venetianed
+Victrola
+Yuman
+abatis
+abaton
+abjoint
+acanthoma
+acari
+acceptance
+actinography
+acuteness
+addiment
+adelite
+adelomorphic
+adelphogamy
+adipocele
+aelurophobia
+affined
+aflaunt
+agathokakological
+aischrolatreia
+alarmedly
+alebench
+aleurone
+allelotropic
+allerion
+alloplastic
+allowable
+alternacy
+alternariose
+altricial
+ambitionist
+amendment
+amiableness
+amicableness
+ammo
+amortizable
+anchorate
+anemometrically
+angelocracy
+angelological
+anodal
+anomalure
+antedate
+antiagglutinin
+antirationalist
+antiscorbutic
+antisplasher
+antithesize
+antiunionist
+antoecian
+apolegamic
+appropriation
+archididascalian
+archival
+arteriophlebotomy
+articulable
+asseveration
+assignation
+atelo
+atrienses
+atrophy
+atterminement
+atypic
+automower
+aveloz
+awrist
+azteca
+bairnteam
+balsamweed
+bannerman
+beardy
+becry
+beek
+beggarwise
+bescab
+bestness
+bethel
+bewildering
+bibliophilism
+bitterblain
+blakeberyed
+boccarella
+bocedization
+boobyalla
+bourbon
+bowbent
+bowerbird
+brachygnathous
+brail
+branchiferous
+brelaw
+brew
+brideweed
+bridgeable
+brombenzamide
+buddler
+burbankian
+burr
+buskin
+cacochymical
+calefactory
+caliper
+canaliculus
+candidature
+canellaceous
+canniness
+canning
+cantilene
+carbonatation
+carthamic
+caseum
+caudated
+causationist
+ceruleite
+chalder
+chalta
+charmel
+chekan
+chillness
+chirogymnast
+chirpling
+chlorinous
+cholanthrene
+chondroblast
+chromatography
+chromophilous
+chronical
+cicatrice
+cinchonine
+city
+clubbing
+coastal
+coaxially
+coercible
+coeternity
+coff
+coinventor
+collyba
+combinator
+complanation
+comprehensibility
+conchuela
+congenital
+context
+contranatural
+corallum
+cordately
+cornupete
+corolliferous
+coroneted
+corticosterone
+coseat
+cottage
+crocetin
+crossleted
+crottels
+curvedness
+cycadeous
+cyclism
+cylindrically
+cynanche
+cyrtoceratitic
+cystospasm
+danceress
+dancette
+dawny
+daydreamy
+debar
+decarburization
+decorousness
+decrepitness
+delirious
+deozonizer
+dermatosis
+desma
+deutencephalic
+diacetate
+diarthrodial
+diathermy
+dicolic
+dimastigate
+dimidiation
+dipetto
+disavowable
+disintrench
+disman
+dismay
+disorder
+disoxygenation
+dithionous
+dogman
+dragonfly
+dramatical
+drawspan
+drubbly
+drunk
+duskly
+ecderonic
+ectocuniform
+ectocyst
+ehrwaldite
+electrocute
+elemicin
+embracing
+emotionality
+enactment
+enamor
+enclave
+endameba
+endochylous
+endocrinologist
+endolymph
+endothecal
+entasia
+epigeous
+episcopicide
+epitrichial
+erminee
+erraticalness
+eruptivity
+erythrocytoschisis
+esperance
+estuous
+eucrystalline
+eugeny
+evacuant
+everbloomer
+evocation
+exarchateship
+exasperate
+excorticate
+excrementary
+exile
+expandedly
+exponency
+expressionist
+expulsion
+extemporary
+extollation
+extortive
+extrabulbar
+extraprostatic
+facticide
+fairer
+fakery
+fasibitikite
+fatiscent
+fearless
+febrifuge
+ferie
+fibrousness
+fingered
+fisheye
+flagpole
+flagrantness
+fleche
+fluidism
+folliculin
+footbreadth
+forceps
+forecontrive
+forthbring
+foveated
+fuchsin
+fungicidal
+funori
+gamelang
+gametically
+garvanzo
+gasoliner
+gastrophile
+germproof
+gerontism
+gigantical
+glaciology
+godmotherhood
+gooseherd
+gordunite
+gove
+gracilis
+greathead
+grieveship
+guidable
+gyromancy
+gyrostat
+habitus
+hailweed
+handhole
+hangalai
+haznadar
+heliced
+hemihypertrophy
+hemimorphic
+hemistrumectomy
+heptavalent
+heptite
+herbalist
+herpetology
+hesperid
+hexacarbon
+hieromnemon
+hobbyless
+holodactylic
+homoeoarchy
+hopperings
+hospitable
+houseboat
+huh
+huntedly
+hydroponics
+hydrosomal
+hyperdactylia
+hyperperistalsis
+hypogeocarpous
+ideogram
+idiopathical
+illegitimate
+imambarah
+impotently
+improvise
+impuberal
+inaccurately
+incarnant
+inchoation
+incliner
+incredulous
+indiscriminateness
+indulgenced
+inebriation
+inexpressiveness
+infibulate
+inflectedness
+iniome
+ink
+inquietly
+insaturable
+insinuative
+instiller
+institutive
+insultproof
+interactionist
+intercensal
+interpenetrable
+intertranspicuous
+intrinsicality
+inwards
+iridiocyte
+iridoparalysis
+irreportable
+isoprene
+isosmotic
+izard
+jacuaru
+jaculative
+jerkined
+joe
+joyous
+julienne
+justicehood
+kali
+kalidium
+katha
+kathal
+keelage
+keratomycosis
+khaki
+khedival
+kinkily
+knife
+kolo
+kraken
+kwarta
+labba
+labber
+laboress
+lacunar
+latch
+lauric
+lawter
+lectotype
+leeches
+legible
+lepidosteoid
+leucobasalt
+leverer
+libellate
+limnimeter
+lithography
+lithotypic
+locomotor
+logarithmetically
+logistician
+lyncine
+lysogenesis
+machan
+macromyelon
+maharana
+mandibulate
+manganapatite
+marchpane
+mas
+masochistic
+mastaba
+matching
+meditatively
+megalopolitan
+melaniline
+mentum
+mercaptides
+mestome
+metasomatism
+meterless
+micronuclear
+micropetalous
+microreaction
+microsporophore
+mileway
+milliarium
+millisecond
+misbind
+miscollocation
+misreader
+modernicide
+modification
+modulant
+monkfish
+monoamino
+monocarbide
+monographical
+morphinomaniac
+mullein
+munge
+mutilate
+mycophagist
+myelosarcoma
+myospasm
+myriadly
+nagaika
+naphthionate
+natant
+naviculaeform
+nayward
+neallotype
+necrophilia
+nectared
+neigher
+neogamous
+neurodynia
+neurorthopteran
+nidation
+nieceship
+nitrobacteria
+nitrosification
+nogheaded
+nonassertive
+noneuphonious
+nonextant
+nonincrease
+nonintermittent
+nonmetallic
+nonprehensile
+nonremunerative
+nonsocial
+nonvesting
+noontime
+noreaster
+nounal
+nub
+nucleoplasm
+nullisome
+numero
+numerous
+oblongatal
+observe
+obtusilingual
+obvert
+occipitoatlantal
+oceanside
+ochlophobist
+odontiasis
+opalescence
+opticon
+oraculousness
+orarium
+organically
+orthopedically
+ostosis
+overadvance
+overbuilt
+overdiscouragement
+overdoer
+overhardy
+overjocular
+overmagnify
+overofficered
+overpotent
+overprizer
+overrunner
+overshrink
+oversimply
+oversplash
+ovology
+oxskin
+oxychloride
+oxygenant
+ozokerite
+pactional
+palaeoanthropography
+palaeographical
+palaeopsychology
+palliasse
+palpebral
+pandaric
+pantelegraph
+papicolist
+papulate
+parakinetic
+parasitism
+parochialic
+parochialize
+passionlike
+patch
+paucidentate
+pawnbrokeress
+pecite
+pecky
+pedipulation
+pellitory
+perfilograph
+periblast
+perigemmal
+periost
+periplus
+perishable
+periwig
+permansive
+persistingly
+persymmetrical
+phantom
+phasmatrope
+philocaly
+philogyny
+philosophister
+philotherianism
+phorology
+phototrophic
+phrator
+phratral
+phthisipneumony
+physogastry
+phytologic
+phytoptid
+pianograph
+picqueter
+piculet
+pigeoner
+pimaric
+pinesap
+pist
+planometer
+platano
+playful
+plea
+pleuropneumonic
+plowwoman
+plump
+pluviographical
+pneumocele
+podophthalmate
+polyad
+polythalamian
+poppyhead
+portamento
+portmanteau
+portraitlike
+possible
+potassamide
+powderer
+praepubis
+preanesthetic
+prebarbaric
+predealer
+predomination
+prefactory
+preirrigational
+prelector
+presbytership
+presecure
+preservable
+prespecialist
+preventionism
+prewound
+princely
+priorship
+proannexationist
+proanthropos
+probeable
+probouleutic
+profitless
+proplasma
+prosectorial
+protecting
+protochemistry
+protosulphate
+pseudoataxia
+psilology
+psychoneurotic
+pterygial
+publicist
+purgation
+purplishness
+putatively
+pyracene
+pyrenomycete
+pyromancy
+pyrophone
+quadroon
+quailhead
+qualifier
+quaternal
+rabblelike
+rambunctious
+rapidness
+ratably
+rationalism
+razor
+reannoy
+recultivation
+regulable
+reimplant
+reimposition
+reimprison
+reinjure
+reinspiration
+reintroduce
+remantle
+reprehensibility
+reptant
+require
+resteal
+restful
+returnability
+revisableness
+rewash
+rewhirl
+reyield
+rhizotomy
+rhodamine
+rigwiddie
+rimester
+ripper
+rippet
+rockish
+rockwards
+rollicky
+roosters
+rooted
+rosal
+rozum
+saccharated
+sagamore
+sagy
+salesmanship
+salivous
+sallet
+salta
+saprostomous
+satiation
+sauropsid
+sawarra
+sawback
+scabish
+scabrate
+scampavia
+scientificophilosophical
+scirrosity
+scoliometer
+scolopendrelloid
+secantly
+seignioral
+semibull
+semic
+seminarianism
+semiped
+semiprivate
+semispherical
+semispontaneous
+seneschal
+septendecimal
+serotherapist
+servation
+sesquisulphuret
+severish
+sextipartite
+sextubercular
+shipyard
+shuckpen
+siderosis
+silex
+sillyhow
+silverbelly
+silverbelly
+simulacrum
+sisham
+sixte
+skeiner
+skiapod
+slopped
+slubby
+smalts
+sockmaker
+solute
+somethingness
+somnify
+southwester
+spathilla
+spectrochemical
+sphagnology
+spinales
+spiriting
+spirling
+spirochetemia
+spreadboard
+spurflower
+squawdom
+squeezing
+staircase
+staker
+stamphead
+statolith
+stekan
+stellulate
+stinker
+stomodaea
+streamingly
+strikingness
+strouthocamelian
+stuprum
+subacutely
+subboreal
+subcontractor
+subendorsement
+subprofitable
+subserviate
+subsneer
+subungual
+sucuruju
+sugan
+sulphocarbolate
+summerwood
+superficialist
+superinference
+superregenerative
+supplicate
+suspendible
+synchronizer
+syntectic
+tachyglossate
+tailless
+taintment
+takingly
+taletelling
+tarpon
+tasteful
+taxeater
+taxy
+teache
+teachless
+teg
+tegmen
+teletyper
+temperable
+ten
+tenent
+teskere
+testes
+thallogen
+thapsia
+thewness
+thickety
+thiobacteria
+thorniness
+throwing
+thyroprivic
+tinnitus
+tocalote
+tolerationist
+tonalamatl
+torvous
+totality
+tottering
+toug
+tracheopathia
+tragedical
+translucent
+trifoveolate
+trilaurin
+trophoplasmatic
+trunkless
+turbanless
+turnpiker
+twangle
+twitterboned
+ultraornate
+umbilication
+unabatingly
+unabjured
+unadequateness
+unaffectedness
+unarriving
+unassorted
+unattacked
+unbenumbed
+unboasted
+unburning
+uncensorious
+uncongested
+uncontemnedly
+uncontemporary
+uncrook
+uncrystallizability
+uncurb
+uncustomariness
+underbillow
+undercanopy
+underestimation
+underhanging
+underpetticoated
+underpropped
+undersole
+understocking
+underworld
+undevout
+undisappointing
+undistinctive
+unfiscal
+unfluted
+unfreckled
+ungentilize
+unglobe
+unhelped
+unhomogeneously
+unifoliate
+uninflammable
+uninterrogated
+unisonal
+unkindled
+unlikeableness
+unlisty
+unlocked
+unmoving
+unmultipliable
+unnestled
+unnoticed
+unobservable
+unobviated
+unoffensively
+unofficerlike
+unpoetic
+unpractically
+unquestionableness
+unrehearsed
+unrevised
+unrhetorical
+unsadden
+unsaluting
+unscriptural
+unseeking
+unshowed
+unsolicitous
+unsprouted
+unsubjective
+unsubsidized
+unsymbolic
+untenant
+unterrified
+untranquil
+untraversed
+untrusty
+untying
+unwillful
+unwinding
+upspring
+uptwist
+urachovesical
+uropygial
+vagabondism
+varicoid
+varletess
+vasal
+ventrocaudal
+verisimilitude
+vermigerous
+vibrometer
+viminal
+virus
+vocationalism
+voguey
+vulnerability
+waggle
+wamblingly
+warmus
+waxer
+waying
+wedgeable
+wellmaker
+whomever
+wigged
+witchlike
+wokas
+woodrowel
+woodsman
+woolding
+xanthelasmic
+xiphosternum
+yachtman
+yachtsmanlike
+yelp
+zoophytal \ No newline at end of file