summaryrefslogtreecommitdiff
path: root/tests/phpunit/includes/api
diff options
context:
space:
mode:
authorLuke Shumaker <lukeshu@sbcglobal.net>2016-05-01 15:17:42 -0400
committerLuke Shumaker <lukeshu@sbcglobal.net>2016-05-01 15:17:42 -0400
commitf7d4cf9ed0ae68fec630d14e8f6aade38e49f036 (patch)
treea730c57badbe0e2f0f064ca2006c82d4b6ed54ea /tests/phpunit/includes/api
parentaee35e4a93d105024bcae947cd8b16c962191f5c (diff)
parent5d1e7dd0ccda0984ccf3e8e3d0f88ac888b05819 (diff)
Merge commit '5d1e7'
Diffstat (limited to 'tests/phpunit/includes/api')
-rw-r--r--tests/phpunit/includes/api/ApiBaseTest.php46
-rw-r--r--tests/phpunit/includes/api/ApiBlockTest.php83
-rw-r--r--tests/phpunit/includes/api/ApiCreateAccountTest.php161
-rw-r--r--tests/phpunit/includes/api/ApiEditPageTest.php496
-rw-r--r--tests/phpunit/includes/api/ApiLoginTest.php181
-rw-r--r--tests/phpunit/includes/api/ApiMainTest.php72
-rw-r--r--tests/phpunit/includes/api/ApiModuleManagerTest.php318
-rw-r--r--tests/phpunit/includes/api/ApiOptionsTest.php459
-rw-r--r--tests/phpunit/includes/api/ApiParseTest.php35
-rw-r--r--tests/phpunit/includes/api/ApiPurgeTest.php45
-rw-r--r--tests/phpunit/includes/api/ApiQueryAllPagesTest.php34
-rw-r--r--tests/phpunit/includes/api/ApiRevisionDeleteTest.php114
-rw-r--r--tests/phpunit/includes/api/ApiTestCase.php196
-rw-r--r--tests/phpunit/includes/api/ApiTestCaseUpload.php171
-rw-r--r--tests/phpunit/includes/api/ApiTestContext.php21
-rw-r--r--tests/phpunit/includes/api/ApiTokensTest.php40
-rw-r--r--tests/phpunit/includes/api/ApiUnblockTest.php31
-rw-r--r--tests/phpunit/includes/api/ApiUploadTest.php572
-rw-r--r--tests/phpunit/includes/api/ApiWatchTest.php157
-rw-r--r--tests/phpunit/includes/api/MockApi.php20
-rw-r--r--tests/phpunit/includes/api/MockApiQueryBase.php11
-rw-r--r--tests/phpunit/includes/api/PrefixUniquenessTest.php30
-rw-r--r--tests/phpunit/includes/api/RandomImageGenerator.php496
-rw-r--r--tests/phpunit/includes/api/UserWrapper.php25
-rw-r--r--tests/phpunit/includes/api/format/ApiFormatJsonTest.php22
-rw-r--r--tests/phpunit/includes/api/format/ApiFormatNoneTest.php16
-rw-r--r--tests/phpunit/includes/api/format/ApiFormatPhpTest.php17
-rw-r--r--tests/phpunit/includes/api/format/ApiFormatTestBase.php32
-rw-r--r--tests/phpunit/includes/api/format/ApiFormatWddxTest.php20
-rw-r--r--tests/phpunit/includes/api/generateRandomImages.php46
-rw-r--r--tests/phpunit/includes/api/query/ApiQueryBasicTest.php353
-rw-r--r--tests/phpunit/includes/api/query/ApiQueryContinue2Test.php71
-rw-r--r--tests/phpunit/includes/api/query/ApiQueryContinueTest.php316
-rw-r--r--tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php218
-rw-r--r--tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php40
-rw-r--r--tests/phpunit/includes/api/query/ApiQueryTest.php130
-rw-r--r--tests/phpunit/includes/api/query/ApiQueryTestBase.php148
-rw-r--r--tests/phpunit/includes/api/words.txt1000
38 files changed, 6243 insertions, 0 deletions
diff --git a/tests/phpunit/includes/api/ApiBaseTest.php b/tests/phpunit/includes/api/ApiBaseTest.php
new file mode 100644
index 00000000..a05c4fa8
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiBaseTest.php
@@ -0,0 +1,46 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ */
+class ApiBaseTest extends ApiTestCase {
+
+ /**
+ * @covers ApiBase::requireOnlyOneParameter
+ */
+ public function testRequireOnlyOneParameterDefault() {
+ $mock = new MockApi();
+ $mock->requireOnlyOneParameter(
+ array( "filename" => "foo.txt", "enablechunks" => false ),
+ "filename", "enablechunks"
+ );
+ $this->assertTrue( true );
+ }
+
+ /**
+ * @expectedException UsageException
+ * @covers ApiBase::requireOnlyOneParameter
+ */
+ public function testRequireOnlyOneParameterZero() {
+ $mock = new MockApi();
+ $mock->requireOnlyOneParameter(
+ array( "filename" => "foo.txt", "enablechunks" => 0 ),
+ "filename", "enablechunks"
+ );
+ }
+
+ /**
+ * @expectedException UsageException
+ * @covers ApiBase::requireOnlyOneParameter
+ */
+ public function testRequireOnlyOneParameterTrue() {
+ $mock = new MockApi();
+ $mock->requireOnlyOneParameter(
+ array( "filename" => "foo.txt", "enablechunks" => true ),
+ "filename", "enablechunks"
+ );
+ }
+
+}
diff --git a/tests/phpunit/includes/api/ApiBlockTest.php b/tests/phpunit/includes/api/ApiBlockTest.php
new file mode 100644
index 00000000..d98eec6a
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiBlockTest.php
@@ -0,0 +1,83 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiBlock
+ */
+class ApiBlockTest extends ApiTestCase {
+ protected function setUp() {
+ parent::setUp();
+ $this->doLogin();
+ }
+
+ protected function getTokens() {
+ return $this->getTokenList( self::$users['sysop'] );
+ }
+
+ function addDBData() {
+ $user = User::newFromName( 'UTApiBlockee' );
+
+ if ( $user->getId() == 0 ) {
+ $user->addToDatabase();
+ $user->setPassword( 'UTApiBlockeePassword' );
+
+ $user->saveSettings();
+ }
+ }
+
+ /**
+ * This test has probably always been broken and use an invalid token
+ * Bug tracking brokenness is https://bugzilla.wikimedia.org/35646
+ *
+ * Root cause is https://gerrit.wikimedia.org/r/3434
+ * Which made the Block/Unblock API to actually verify the token
+ * previously always considered valid (bug 34212).
+ */
+ public function testMakeNormalBlock() {
+ $tokens = $this->getTokens();
+
+ $user = User::newFromName( 'UTApiBlockee' );
+
+ if ( !$user->getId() ) {
+ $this->markTestIncomplete( "The user UTApiBlockee does not exist" );
+ }
+
+ if ( !array_key_exists( 'blocktoken', $tokens ) ) {
+ $this->markTestIncomplete( "No block token found" );
+ }
+
+ $this->doApiRequest( array(
+ 'action' => 'block',
+ 'user' => 'UTApiBlockee',
+ 'reason' => 'Some reason',
+ 'token' => $tokens['blocktoken'] ), null, false, self::$users['sysop']->user );
+
+ $block = Block::newFromTarget( 'UTApiBlockee' );
+
+ $this->assertTrue( !is_null( $block ), 'Block is valid' );
+
+ $this->assertEquals( 'UTApiBlockee', (string)$block->getTarget() );
+ $this->assertEquals( 'Some reason', $block->mReason );
+ $this->assertEquals( 'infinity', $block->mExpiry );
+ }
+
+ /**
+ * @expectedException UsageException
+ * @expectedExceptionMessage The token parameter must be set
+ */
+ public function testBlockingActionWithNoToken( ) {
+ $this->doApiRequest(
+ array(
+ 'action' => 'block',
+ 'user' => 'UTApiBlockee',
+ 'reason' => 'Some reason',
+ ),
+ null,
+ false,
+ self::$users['sysop']->user
+ );
+ }
+}
diff --git a/tests/phpunit/includes/api/ApiCreateAccountTest.php b/tests/phpunit/includes/api/ApiCreateAccountTest.php
new file mode 100644
index 00000000..8d134f76
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiCreateAccountTest.php
@@ -0,0 +1,161 @@
+<?php
+
+/**
+ * @group Database
+ * @group API
+ * @group medium
+ *
+ * @covers ApiCreateAccount
+ */
+class ApiCreateAccountTest extends ApiTestCase {
+ protected function setUp() {
+ parent::setUp();
+ LoginForm::setCreateaccountToken();
+ $this->setMwGlobals( array( 'wgEnableEmail' => true ) );
+ }
+
+ /**
+ * Test the account creation API with a valid request. Also
+ * make sure the new account can log in and is valid.
+ *
+ * This test does multiple API requests so it might end up being
+ * a bit slow. Raise the default timeout.
+ * @group medium
+ */
+ public function testValid() {
+ global $wgServer;
+
+ if ( !isset( $wgServer ) ) {
+ $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
+ }
+
+ $password = User::randomPassword();
+
+ $ret = $this->doApiRequest( array(
+ 'action' => 'createaccount',
+ 'name' => 'Apitestnew',
+ 'password' => $password,
+ 'email' => 'test@domain.test',
+ 'realname' => 'Test Name'
+ ) );
+
+ $result = $ret[0];
+ $this->assertNotInternalType( 'bool', $result );
+ $this->assertNotInternalType( 'null', $result['createaccount'] );
+
+ // Should first ask for token.
+ $a = $result['createaccount'];
+ $this->assertEquals( 'NeedToken', $a['result'] );
+ $token = $a['token'];
+
+ // Finally create the account
+ $ret = $this->doApiRequest(
+ array(
+ 'action' => 'createaccount',
+ 'name' => 'Apitestnew',
+ 'password' => $password,
+ 'token' => $token,
+ 'email' => 'test@domain.test',
+ 'realname' => 'Test Name'
+ ),
+ $ret[2]
+ );
+
+ $result = $ret[0];
+ $this->assertNotInternalType( 'bool', $result );
+ $this->assertEquals( 'Success', $result['createaccount']['result'] );
+
+ // Try logging in with the new user.
+ $ret = $this->doApiRequest( array(
+ 'action' => 'login',
+ 'lgname' => 'Apitestnew',
+ '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(
+ array(
+ 'action' => 'login',
+ 'lgtoken' => $token,
+ 'lgname' => 'Apitestnew',
+ 'lgpassword' => $password,
+ ),
+ $ret[2]
+ );
+
+ $result = $ret[0];
+
+ $this->assertNotInternalType( 'bool', $result );
+ $a = $result['login']['result'];
+
+ $this->assertEquals( 'Success', $a );
+
+ // log out to destroy the session
+ $ret = $this->doApiRequest(
+ array(
+ 'action' => 'logout',
+ ),
+ $ret[2]
+ );
+ $this->assertEquals( array(), $ret[0] );
+ }
+
+ /**
+ * Make sure requests with no names are invalid.
+ * @expectedException UsageException
+ */
+ public function testNoName() {
+ $this->doApiRequest( array(
+ 'action' => 'createaccount',
+ 'token' => LoginForm::getCreateaccountToken(),
+ 'password' => 'password',
+ ) );
+ }
+
+ /**
+ * Make sure requests with no password are invalid.
+ * @expectedException UsageException
+ */
+ public function testNoPassword() {
+ $this->doApiRequest( array(
+ 'action' => 'createaccount',
+ 'name' => 'testName',
+ 'token' => LoginForm::getCreateaccountToken(),
+ ) );
+ }
+
+ /**
+ * Make sure requests with existing users are invalid.
+ * @expectedException UsageException
+ */
+ public function testExistingUser() {
+ $this->doApiRequest( array(
+ 'action' => 'createaccount',
+ 'name' => 'Apitestsysop',
+ 'token' => LoginForm::getCreateaccountToken(),
+ 'password' => 'password',
+ 'email' => 'test@domain.test',
+ ) );
+ }
+
+ /**
+ * Make sure requests with invalid emails are invalid.
+ * @expectedException UsageException
+ */
+ public function testInvalidEmail() {
+ $this->doApiRequest( array(
+ 'action' => 'createaccount',
+ 'name' => 'Test User',
+ 'token' => LoginForm::getCreateaccountToken(),
+ 'password' => 'password',
+ 'email' => 'invalid',
+ ) );
+ }
+}
diff --git a/tests/phpunit/includes/api/ApiEditPageTest.php b/tests/phpunit/includes/api/ApiEditPageTest.php
new file mode 100644
index 00000000..3179a452
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiEditPageTest.php
@@ -0,0 +1,496 @@
+<?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( array(
+ 'wgExtraNamespaces' => $wgExtraNamespaces,
+ 'wgNamespaceContentModels' => $wgNamespaceContentModels,
+ 'wgContentHandlers' => $wgContentHandlers,
+ 'wgContLang' => $wgContLang,
+ ) );
+
+ $wgExtraNamespaces[12312] = 'Dummy';
+ $wgExtraNamespaces[12313] = 'Dummy_talk';
+
+ $wgNamespaceContentModels[12312] = "testing";
+ $wgContentHandlers["testing"] = 'DummyContentHandlerForTesting';
+
+ MWNamespace::getCanonicalNamespaces( true ); # reset namespace cache
+ $wgContLang->resetNamespaces(); # reset namespace cache
+
+ $this->doLogin();
+ }
+
+ protected function tearDown() {
+ MWNamespace::getCanonicalNamespaces( true ); # 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( array(
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ ) );
+ $apiResult = $apiResult[0];
+
+ // Validate API result data
+ $this->assertArrayHasKey( 'edit', $apiResult );
+ $this->assertArrayHasKey( 'result', $apiResult['edit'] );
+ $this->assertEquals( '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( array(
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'some text',
+ ) );
+
+ $this->assertEquals( '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( array(
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'different text'
+ ) );
+
+ $this->assertEquals( '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"
+ );
+ }
+
+ public function testNonTextEdit() {
+ $name = 'Dummy:ApiEditPageTest_testNonTextEdit';
+ $data = serialize( 'some bla bla text' );
+
+ // -- test new page --------------------------------------------
+ $apiResult = $this->doApiRequestWithToken( array(
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => $data, ) );
+ $apiResult = $apiResult[0];
+
+ // Validate API result data
+ $this->assertArrayHasKey( 'edit', $apiResult );
+ $this->assertArrayHasKey( 'result', $apiResult['edit'] );
+ $this->assertEquals( '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->assertEquals( "testing", $page->getContentModel() );
+ $this->assertEquals( $data, $page->getContent()->serialize() );
+ }
+
+ /**
+ * @return array
+ */
+ public static function provideEditAppend() {
+ return array(
+ array( #0: append
+ 'foo', 'append', 'bar', "foobar"
+ ),
+ array( #1: prepend
+ 'foo', 'prepend', 'bar', "barfoo"
+ ),
+ array( #2: append to empty page
+ '', 'append', 'foo', "foo"
+ ),
+ array( #3: prepend to empty page
+ '', 'prepend', 'foo', "foo"
+ ),
+ array( #4: append to non-existing page
+ null, 'append', 'foo', "foo"
+ ),
+ array( #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( array(
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => $text, ) );
+
+ $this->assertEquals( 'Success', $re['edit']['result'] ); // sanity
+ }
+
+ // -- try append/prepend --------------------------------------------
+ list( $re ) = $this->doApiRequestWithToken( array(
+ 'action' => 'edit',
+ 'title' => $name,
+ $op . 'text' => $append, ) );
+
+ $this->assertEquals( '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->assertEquals( $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( array(
+ 'action' => 'edit',
+ 'title' => $name,
+ 'section' => '1',
+ 'text' => "==section 1==\nnew content 1",
+ ) );
+ $this->assertEquals( 'Success', $re['edit']['result'] );
+ $newtext = WikiPage::factory( Title::newFromText( $name ) )
+ ->getContent( Revision::RAW )
+ ->getNativeData();
+ $this->assertEquals( "==section 1==\nnew content 1\n\n==section 2==\ncontent2", $newtext );
+
+ // Test that we raise a 'nosuchsection' error
+ try {
+ $this->doApiRequestWithToken( array(
+ 'action' => 'edit',
+ 'title' => $name,
+ 'section' => '9999',
+ 'text' => 'text',
+ ) );
+ $this->fail( "Should have raised a UsageException" );
+ } catch ( UsageException $e ) {
+ $this->assertEquals( 'nosuchsection', $e->getCodeString() );
+ }
+ }
+
+ /**
+ * Test action=edit&section=new
+ * Run it twice so we test adding a new section on a
+ * page that doesn't exist (bug 52830) 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( array(
+ 'action' => 'edit',
+ 'title' => $name,
+ 'section' => 'new',
+ 'text' => 'test',
+ 'summary' => 'header',
+ ));
+
+ $this->assertEquals( 'Success', $re['edit']['result'] );
+ // Check the page text is correct
+ $text = WikiPage::factory( Title::newFromText( $name ) )
+ ->getContent( Revision::RAW )
+ ->getNativeData();
+ $this->assertEquals( "== header ==\n\ntest", $text );
+
+ // Now on one that does
+ $this->assertTrue( Title::newFromText( $name )->exists() );
+ list( $re2 ) = $this->doApiRequestWithToken( array(
+ 'action' => 'edit',
+ 'title' => $name,
+ 'section' => 'new',
+ 'text' => 'test',
+ 'summary' => 'header',
+ ));
+
+ $this->assertEquals( 'Success', $re2['edit']['result'] );
+ $text = WikiPage::factory( Title::newFromText( $name ) )
+ ->getContent( Revision::RAW )
+ ->getNativeData();
+ $this->assertEquals( "== 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']->user );
+ $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']->user );
+ $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']->user );
+ $this->forceRevisionDate( $rpage, '20120101020202' );
+
+ // try to save edit, following the redirect
+ list( $re, , ) = $this->doApiRequestWithToken( array(
+ 'action' => 'edit',
+ 'title' => $rname,
+ 'text' => 'nix bar!',
+ 'basetimestamp' => $baseTime,
+ 'section' => 'new',
+ 'redirect' => true,
+ ), null, self::$users['sysop']->user );
+
+ $this->assertEquals( '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']->user );
+ $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']->user );
+ $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']->user );
+ $this->forceRevisionDate( $rpage, '20120101020202' );
+
+ // try to save edit, following the redirect but without creating a section
+ try {
+ $this->doApiRequestWithToken( array(
+ 'action' => 'edit',
+ 'title' => $rname,
+ 'text' => 'nix bar!',
+ 'basetimestamp' => $baseTime,
+ 'redirect' => true,
+ ), null, self::$users['sysop']->user );
+
+ $this->fail( 'redirect-appendonly error expected' );
+ } catch ( UsageException $ex ) {
+ $this->assertEquals( 'redirect-appendonly', $ex->getCodeString() );
+ }
+ }
+
+ 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']->user );
+ $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']->user );
+ $this->forceRevisionDate( $page, '20120101020202' );
+
+ // try to save edit, expect conflict
+ try {
+ $this->doApiRequestWithToken( array(
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'nix bar!',
+ 'basetimestamp' => $baseTime,
+ ), null, self::$users['sysop']->user );
+
+ $this->fail( 'edit conflict expected' );
+ } catch ( UsageException $ex ) {
+ $this->assertEquals( 'editconflict', $ex->getCodeString() );
+ }
+ }
+
+ /**
+ * 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']->user );
+ $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']->user );
+ $this->forceRevisionDate( $page, '20120101020202' );
+
+ // try to save edit, expect no conflict
+ list( $re, , ) = $this->doApiRequestWithToken( array(
+ 'action' => 'edit',
+ 'title' => $name,
+ 'text' => 'nix bar!',
+ 'basetimestamp' => $baseTime,
+ 'section' => 'new',
+ ), null, self::$users['sysop']->user );
+
+ $this->assertEquals( 'Success', $re['edit']['result'],
+ "no edit conflict expected here" );
+ }
+
+ public function testEditConflict_bug41990() {
+ static $count = 0;
+ $count++;
+
+ /*
+ * bug 41990: 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']->user );
+ $this->forceRevisionDate( $page, '20120101000000' );
+
+ // base edit for redirect
+ $rpage->doEditContent( new WikitextContent( "#REDIRECT [[$name]]" ),
+ "testing 1", EDIT_NEW, false, self::$users['sysop']->user );
+ $this->forceRevisionDate( $rpage, '20120101000000' );
+
+ // new edit to content
+ $page->doEditContent( new WikitextContent( "Foo bar" ),
+ "testing 2", EDIT_UPDATE, $page->getLatest(), self::$users['uploader']->user );
+ $this->forceRevisionDate( $rpage, '20120101020202' );
+
+ // try to save edit; should work, following the redirect.
+ list( $re, , ) = $this->doApiRequestWithToken( array(
+ 'action' => 'edit',
+ 'title' => $rname,
+ 'text' => 'nix bar!',
+ 'section' => 'new',
+ 'redirect' => true,
+ ), null, self::$users['sysop']->user );
+
+ $this->assertEquals( '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',
+ array( 'rev_timestamp' => $dbw->timestamp( $timestamp ) ),
+ array( 'rev_id' => $page->getLatest() ) );
+
+ $page->clear();
+ }
+}
diff --git a/tests/phpunit/includes/api/ApiLoginTest.php b/tests/phpunit/includes/api/ApiLoginTest.php
new file mode 100644
index 00000000..67a75f36
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiLoginTest.php
@@ -0,0 +1,181 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiLogin
+ */
+class ApiLoginTest extends ApiTestCase {
+
+ /**
+ * Test result of attempted login with an empty username
+ */
+ public function testApiLoginNoName() {
+ $data = $this->doApiRequest( array( 'action' => 'login',
+ 'lgname' => '', 'lgpassword' => self::$users['sysop']->password,
+ ) );
+ $this->assertEquals( 'NoName', $data[0]['login']['result'] );
+ }
+
+ public function testApiLoginBadPass() {
+ global $wgServer;
+
+ $user = self::$users['sysop'];
+ $user->user->logOut();
+
+ if ( !isset( $wgServer ) ) {
+ $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
+ }
+ $ret = $this->doApiRequest( array(
+ "action" => "login",
+ "lgname" => $user->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(
+ array(
+ "action" => "login",
+ "lgtoken" => $token,
+ "lgname" => $user->username,
+ "lgpassword" => "badnowayinhell",
+ ),
+ $ret[2]
+ );
+
+ $result = $ret[0];
+
+ $this->assertNotInternalType( "bool", $result );
+ $a = $result["login"]["result"];
+
+ $this->assertEquals( "WrongPass", $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'];
+ $user->user->logOut();
+
+ $ret = $this->doApiRequest( array(
+ "action" => "login",
+ "lgname" => $user->username,
+ "lgpassword" => $user->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(
+ array(
+ "action" => "login",
+ "lgtoken" => $token,
+ "lgname" => $user->username,
+ "lgpassword" => $user->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'];
+
+ $req = MWHttpRequest::factory( self::$apiUrl . "?action=login&format=xml",
+ array( "method" => "POST",
+ "postData" => array(
+ "lgname" => $user->username,
+ "lgpassword" => $user->password
+ )
+ )
+ );
+ $req->execute();
+
+ libxml_use_internal_errors( true );
+ $sxe = simplexml_load_string( $req->getContent() );
+ $this->assertNotInternalType( "bool", $sxe );
+ $this->assertThat( $sxe, $this->isInstanceOf( "SimpleXMLElement" ) );
+ $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( array(
+ "lgtoken" => $token,
+ "lgname" => $user->username,
+ "lgpassword" => $user->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() {
+ $sysopUser = self::$users['sysop'];
+ $data = $this->doApiRequest( array(
+ 'action' => 'login',
+ 'lgname' => $sysopUser->username,
+ 'lgpassword' => $sysopUser->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( array(
+ 'action' => 'login',
+ "lgtoken" => $token,
+ "lgname" => $sysopUser->username,
+ "lgpassword" => $sysopUser->password ), $data[2] );
+
+ $this->assertArrayHasKey( "login", $data[0] );
+ $this->assertArrayHasKey( "result", $data[0]['login'] );
+ $this->assertEquals( "Success", $data[0]['login']['result'] );
+ $this->assertArrayHasKey( 'lgtoken', $data[0]['login'] );
+ }
+
+}
diff --git a/tests/phpunit/includes/api/ApiMainTest.php b/tests/phpunit/includes/api/ApiMainTest.php
new file mode 100644
index 00000000..780cf9ed
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiMainTest.php
@@ -0,0 +1,72 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiMain
+ */
+class ApiMainTest extends ApiTestCase {
+
+ /**
+ * Test that the API will accept a FauxRequest and execute. The help action
+ * (default) throws a UsageException. Just validate we're getting proper XML
+ *
+ * @expectedException UsageException
+ */
+ public function testApi() {
+ $api = new ApiMain(
+ new FauxRequest( array( 'action' => 'help', 'format' => 'xml' ) )
+ );
+ $api->execute();
+ $api->getPrinter()->setBufferResult( true );
+ $api->printResult( false );
+ $resp = $api->getPrinter()->getBuffer();
+
+ libxml_use_internal_errors( true );
+ $sxe = simplexml_load_string( $resp );
+ $this->assertNotInternalType( "bool", $sxe );
+ $this->assertThat( $sxe, $this->isInstanceOf( "SimpleXMLElement" ) );
+ }
+
+ public static function provideAssert() {
+ $anon = new User();
+ $bot = new User();
+ $bot->setName( 'Bot' );
+ $bot->addToDatabase();
+ $bot->addGroup( 'bot' );
+ $user = new User();
+ $user->setName( 'User' );
+ $user->addToDatabase();
+ return array(
+ array( $anon, 'user', 'assertuserfailed' ),
+ array( $user, 'user', false ),
+ array( $user, 'bot', 'assertbotfailed' ),
+ array( $bot, 'user', false ),
+ array( $bot, 'bot', false ),
+ );
+ }
+
+ /**
+ * Tests the assert={user|bot} functionality
+ *
+ * @covers ApiMain::checkAsserts
+ * @dataProvider provideAssert
+ * @param User $user
+ * @param string $assert
+ * @param string|bool $error False if no error expected
+ */
+ public function testAssert( $user, $assert, $error ) {
+ try {
+ $this->doApiRequest( array(
+ 'action' => 'query',
+ 'assert' => $assert,
+ ), null, null, $user );
+ $this->assertFalse( $error ); // That no error was expected
+ } catch ( UsageException $e ) {
+ $this->assertEquals( $e->getCodeString(), $error );
+ }
+ }
+
+}
diff --git a/tests/phpunit/includes/api/ApiModuleManagerTest.php b/tests/phpunit/includes/api/ApiModuleManagerTest.php
new file mode 100644
index 00000000..dab81e16
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiModuleManagerTest.php
@@ -0,0 +1,318 @@
+<?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 array(
+ 'plain class' => array(
+ 'login',
+ 'action',
+ 'ApiLogin',
+ null,
+ ),
+
+ 'with factory' => array(
+ 'login',
+ 'action',
+ 'ApiLogin',
+ array( $this, 'newApiLogin' ),
+ ),
+
+ 'with closure' => array(
+ 'logout',
+ 'action',
+ 'ApiLogout',
+ 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 array(
+ 'empty' => array(
+ array(),
+ 'action',
+ ),
+
+ 'simple' => array(
+ array(
+ 'login' => 'ApiLogin',
+ 'logout' => 'ApiLogout',
+ ),
+ 'action',
+ ),
+
+ 'with factories' => array(
+ array(
+ 'login' => array(
+ 'class' => 'ApiLogin',
+ 'factory' => array( $this, 'newApiLogin' ),
+ ),
+ 'logout' => array(
+ 'class' => 'ApiLogout',
+ '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 = array(
+ 'feedrecentchanges' => 'ApiFeedRecentChanges',
+ 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ),
+ 'login' => array(
+ 'class' => 'ApiLogin',
+ 'factory' => array( $this, 'newApiLogin' ),
+ ),
+ 'logout' => array(
+ 'class' => 'ApiLogout',
+ 'factory' => function ( ApiMain $main, $action ) {
+ return new ApiLogout( $main, $action );
+ },
+ ),
+ );
+
+ return array(
+ 'legacy entry' => array(
+ $modules,
+ 'feedrecentchanges',
+ 'ApiFeedRecentChanges',
+ ),
+
+ 'just a class' => array(
+ $modules,
+ 'feedcontributions',
+ 'ApiFeedContributions',
+ ),
+
+ 'with factory' => array(
+ $modules,
+ 'login',
+ 'ApiLogin',
+ ),
+
+ 'with closure' => array(
+ $modules,
+ 'logout',
+ 'ApiLogout',
+ ),
+ );
+ }
+
+ /**
+ * @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 = array(
+ 'login' => 'ApiLogin',
+ 'logout' => 'ApiLogout',
+ );
+
+ $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 = array(
+ 'login' => 'ApiLogin',
+ 'logout' => 'ApiLogout',
+ );
+
+ $barModules = array(
+ 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ),
+ 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ),
+ );
+
+ $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 = array(
+ 'login' => 'ApiLogin',
+ 'logout' => 'ApiLogout',
+ );
+
+ $barModules = array(
+ 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ),
+ 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ),
+ );
+
+ $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, array(
+ 'feedcontributions' => 'ApiFeedContributions',
+ 'feedrecentchanges' => 'ApiFeedRecentChanges',
+ ) );
+ $this->assertArrayEquals( $allModules, $allNamesWithClasses );
+ }
+
+ /**
+ * @covers ApiModuleManager::getModuleGroup
+ */
+ public function testGetModuleGroup() {
+ $fooModules = array(
+ 'login' => 'ApiLogin',
+ 'logout' => 'ApiLogout',
+ );
+
+ $barModules = array(
+ 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ),
+ 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ),
+ );
+
+ $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 = array(
+ 'login' => 'ApiLogin',
+ 'logout' => 'ApiLogout',
+ );
+
+ $barModules = array(
+ 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ),
+ 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ),
+ );
+
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $fooModules, 'foo' );
+ $moduleManager->addModules( $barModules, 'bar' );
+
+ $groups = $moduleManager->getGroups();
+ $this->assertArrayEquals( array( 'foo', 'bar' ), $groups );
+ }
+
+ /**
+ * @covers ApiModuleManager::getClassName
+ */
+ public function testGetClassName() {
+ $fooModules = array(
+ 'login' => 'ApiLogin',
+ 'logout' => 'ApiLogout',
+ );
+
+ $barModules = array(
+ 'feedcontributions' => array( 'class' => 'ApiFeedContributions' ),
+ 'feedrecentchanges' => array( 'class' => 'ApiFeedRecentChanges' ),
+ );
+
+ $moduleManager = $this->getModuleManager();
+ $moduleManager->addModules( $fooModules, 'foo' );
+ $moduleManager->addModules( $barModules, 'bar' );
+
+ $this->assertEquals( 'ApiLogin', $moduleManager->getClassName( 'login' ) );
+ $this->assertEquals( 'ApiLogout', $moduleManager->getClassName( 'logout' ) );
+ $this->assertEquals( 'ApiFeedContributions', $moduleManager->getClassName( 'feedcontributions' ) );
+ $this->assertEquals( 'ApiFeedRecentChanges', $moduleManager->getClassName( 'feedrecentchanges' ) );
+ $this->assertFalse( $moduleManager->getClassName( 'nonexistentmodule' ) );
+ }
+
+
+}
diff --git a/tests/phpunit/includes/api/ApiOptionsTest.php b/tests/phpunit/includes/api/ApiOptionsTest.php
new file mode 100644
index 00000000..5f955bbc
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiOptionsTest.php
@@ -0,0 +1,459 @@
+<?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 $mOldGetPreferencesHooks;
+
+ private static $Success = array( 'options' => 'success' );
+
+ protected function setUp() {
+ parent::setUp();
+
+ $this->mUserMock = $this->getMockBuilder( 'User' )
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ // Set up groups and rights
+ $this->mUserMock->expects( $this->any() )
+ ->method( 'getEffectiveGroups' )->will( $this->returnValue( array( '*', 'user' ) ) );
+ $this->mUserMock->expects( $this->any() )
+ ->method( 'isAllowed' )->will( $this->returnValue( true ) );
+
+ // Set up callback for User::getOptionKinds
+ $this->mUserMock->expects( $this->any() )
+ ->method( 'getOptionKinds' )->will( $this->returnCallback( array( $this, 'getOptionKinds' ) ) );
+
+ // 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 = array();
+
+ $this->mTested = new ApiOptions( $main, 'options' );
+
+ global $wgHooks;
+ if ( !isset( $wgHooks['GetPreferences'] ) ) {
+ $wgHooks['GetPreferences'] = array();
+ }
+ $this->mOldGetPreferencesHooks = $wgHooks['GetPreferences'];
+ $wgHooks['GetPreferences'][] = array( $this, 'hookGetPreferences' );
+ }
+
+ protected function tearDown() {
+ global $wgHooks;
+
+ $wgHooks['GetPreferences'] = $this->mOldGetPreferencesHooks;
+ $this->mOldGetPreferencesHooks = false;
+
+ parent::tearDown();
+ }
+
+ public function hookGetPreferences( $user, &$preferences ) {
+ $preferences = array();
+
+ foreach ( array( 'name', 'willBeNull', 'willBeEmpty', 'willBeHappy' ) as $k ) {
+ $preferences[$k] = array(
+ 'type' => 'text',
+ 'section' => 'test',
+ 'label' => '&#160;',
+ );
+ }
+
+ $preferences['testmultiselect'] = array(
+ 'type' => 'multiselect',
+ 'options' => array(
+ 'Test' => array(
+ '<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' => array(),
+ );
+
+ return true;
+ }
+
+ /**
+ * @param IContextSource $context
+ * @param array|null $options
+ *
+ * @return array
+ */
+ public function getOptionKinds( IContextSource $context, $options = null ) {
+ // Match with above.
+ $kinds = array(
+ '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 = array();
+ 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 = array() ) {
+ $request = array(
+ '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()->getData();
+ }
+
+ /**
+ * @expectedException UsageException
+ */
+ public function testNoToken() {
+ $request = $this->getSampleRequest( array( '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 ( UsageException $e ) {
+ $this->assertEquals( 'notloggedin', $e->getCodeString() );
+ $this->assertEquals( 'Anonymous users cannot change preferences', $e->getMessage() );
+
+ return;
+ }
+ $this->fail( "UsageException was not thrown" );
+ }
+
+ public function testNoOptionname() {
+ try {
+ $request = $this->getSampleRequest( array( 'optionvalue' => '1' ) );
+
+ $this->executeQuery( $request );
+ } catch ( UsageException $e ) {
+ $this->assertEquals( 'nooptionname', $e->getCodeString() );
+ $this->assertEquals( 'The optionname parameter must be set', $e->getMessage() );
+
+ return;
+ }
+ $this->fail( "UsageException 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 ( UsageException $e ) {
+ $this->assertEquals( 'nochanges', $e->getCodeString() );
+ $this->assertEquals( 'No changes were requested', $e->getMessage() );
+
+ return;
+ }
+ $this->fail( "UsageException was not thrown" );
+ }
+
+ public function testReset() {
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'resetOptions' )
+ ->with( $this->equalTo( array( 'all' ) ) );
+
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'setOption' );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( array( 'reset' => '' ) );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+
+ public function testResetKinds() {
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'resetOptions' )
+ ->with( $this->equalTo( array( 'registered' ) ) );
+
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'setOption' );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( array( '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( array( '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( array( '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->at( 2 ) )
+ ->method( 'getOptions' );
+
+ $this->mUserMock->expects( $this->at( 4 ) )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'willBeNull' ), $this->identicalTo( null ) );
+
+ $this->mUserMock->expects( $this->at( 5 ) )
+ ->method( 'getOptions' );
+
+ $this->mUserMock->expects( $this->at( 6 ) )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'willBeEmpty' ), $this->equalTo( '' ) );
+
+ $this->mUserMock->expects( $this->at( 7 ) )
+ ->method( 'getOptions' );
+
+ $this->mUserMock->expects( $this->at( 8 ) )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'willBeHappy' ), $this->equalTo( 'Happy' ) );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( array(
+ '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->at( 4 ) )
+ ->method( 'getOptions' );
+
+ $this->mUserMock->expects( $this->at( 5 ) )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'willBeHappy' ), $this->equalTo( 'Happy' ) );
+
+ $this->mUserMock->expects( $this->at( 6 ) )
+ ->method( 'getOptions' );
+
+ $this->mUserMock->expects( $this->at( 7 ) )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'name' ), $this->equalTo( 'value' ) );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $args = array(
+ '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->at( 3 ) )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'testmultiselect-opt1' ), $this->identicalTo( true ) );
+
+ $this->mUserMock->expects( $this->at( 4 ) )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'testmultiselect-opt2' ), $this->identicalTo( null ) );
+
+ $this->mUserMock->expects( $this->at( 5 ) )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'testmultiselect-opt3' ), $this->identicalTo( false ) );
+
+ $this->mUserMock->expects( $this->at( 6 ) )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'testmultiselect-opt4' ), $this->identicalTo( false ) );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( array(
+ '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( array(
+ 'change' => 'special=1'
+ ) );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( array(
+ 'options' => 'success',
+ 'warnings' => array(
+ 'options' => array(
+ '*' => "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( array(
+ 'change' => 'unknownOption=1'
+ ) );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( array(
+ 'options' => 'success',
+ 'warnings' => array(
+ 'options' => array(
+ '*' => "Validation error for 'unknownOption': not a valid preference"
+ )
+ )
+ ), $response );
+ }
+
+ public function testUserjsOption() {
+ $this->mUserMock->expects( $this->never() )
+ ->method( 'resetOptions' );
+
+ $this->mUserMock->expects( $this->at( 3 ) )
+ ->method( 'setOption' )
+ ->with( $this->equalTo( 'userjs-option' ), $this->equalTo( '1' ) );
+
+ $this->mUserMock->expects( $this->once() )
+ ->method( 'saveSettings' );
+
+ $request = $this->getSampleRequest( array(
+ 'change' => 'userjs-option=1'
+ ) );
+
+ $response = $this->executeQuery( $request );
+
+ $this->assertEquals( self::$Success, $response );
+ }
+}
diff --git a/tests/phpunit/includes/api/ApiParseTest.php b/tests/phpunit/includes/api/ApiParseTest.php
new file mode 100644
index 00000000..d038a4f5
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiParseTest.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiParse
+ */
+class ApiParseTest extends ApiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ $this->doLogin();
+ }
+
+ public function testParseNonexistentPage() {
+ $somePage = mt_rand();
+
+ try {
+ $this->doApiRequest( array(
+ 'action' => 'parse',
+ 'page' => $somePage ) );
+
+ $this->fail( "API did not return an error when parsing a nonexistent page" );
+ } catch ( UsageException $ex ) {
+ $this->assertEquals(
+ 'missingtitle',
+ $ex->getCodeString(),
+ "Parse request for nonexistent page must give 'missingtitle' error: "
+ . var_export( $ex->getMessageArray(), true )
+ );
+ }
+ }
+}
diff --git a/tests/phpunit/includes/api/ApiPurgeTest.php b/tests/phpunit/includes/api/ApiPurgeTest.php
new file mode 100644
index 00000000..7fce134a
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiPurgeTest.php
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiPurge
+ */
+class ApiPurgeTest extends ApiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ $this->doLogin();
+ }
+
+ /**
+ * @group Broken
+ */
+ public function testPurgeMainPage() {
+ if ( !Title::newFromText( 'UTPage' )->exists() ) {
+ $this->markTestIncomplete( "The article [[UTPage]] does not exist" );
+ }
+
+ $somePage = mt_rand();
+
+ $data = $this->doApiRequest( array(
+ '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 = array( 'UTPage' => 'purged', $somePage => 'missing', '%5D' => 'invalid' );
+ foreach ( $data[0]['purge'] as $v ) {
+ $this->assertArrayHasKey( $pages[$v['title']], $v );
+ }
+ }
+}
diff --git a/tests/phpunit/includes/api/ApiQueryAllPagesTest.php b/tests/phpunit/includes/api/ApiQueryAllPagesTest.php
new file mode 100644
index 00000000..124988f3
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiQueryAllPagesTest.php
@@ -0,0 +1,34 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ */
+class ApiQueryAllPagesTest extends ApiTestCase {
+
+ protected function setUp() {
+ parent::setUp();
+ $this->doLogin();
+ }
+
+ /**
+ * @todo give this test a real name explaining what is being tested here
+ */
+ public function testBug25702() {
+ $title = Title::newFromText( 'Category:Template:xyz' );
+ $page = WikiPage::factory( $title );
+ $page->doEdit( 'Some text', 'inserting content' );
+
+ $result = $this->doApiRequest( array(
+ '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/tests/phpunit/includes/api/ApiRevisionDeleteTest.php b/tests/phpunit/includes/api/ApiRevisionDeleteTest.php
new file mode 100644
index 00000000..b03836eb
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiRevisionDeleteTest.php
@@ -0,0 +1,114 @@
+<?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 = array();
+
+ protected function setUp() {
+ // Needs to be before setup since this gets cached
+ $this->mergeMwGlobalArrayValue( 'wgGroupPermissions', array( 'sysop' => array( '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']->user;
+ $revid = array_shift( $this->revs );
+ $out = $this->doApiRequest( array(
+ '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->assertArrayHasKey( 'userhidden', $item );
+ $this->assertArrayHasKey( 'commenthidden', $item );
+ $this->assertArrayHasKey( 'texthidden', $item );
+ $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( array(
+ '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->assertArrayNotHasKey( 'userhidden', $item );
+ $this->assertArrayNotHasKey( 'commenthidden', $item );
+ $this->assertArrayNotHasKey( 'texthidden', $item );
+
+ $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']->user;
+ $revid = array_shift( $this->revs );
+ // Hide revisions
+ $this->doApiRequest( array(
+ 'action' => 'revisiondelete',
+ 'type' => 'revision',
+ 'target' => self::$page,
+ 'ids' => $revid,
+ 'hide' => 'content|user|comment',
+ 'token' => $user->getEditToken(),
+ ) );
+
+ $out = $this->doApiRequest( array(
+ '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 keys
+ // but no commenthidden key
+ $this->assertArrayHasKey( 'userhidden', $item );
+ $this->assertArrayNotHasKey( 'commenthidden', $item );
+ $this->assertArrayHasKey( 'texthidden', $item );
+ $this->assertEquals( $item['id'], $revid );
+ }
+}
diff --git a/tests/phpunit/includes/api/ApiTestCase.php b/tests/phpunit/includes/api/ApiTestCase.php
new file mode 100644
index 00000000..cd141947
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiTestCase.php
@@ -0,0 +1,196 @@
+<?php
+
+abstract class ApiTestCase extends MediaWikiLangTestCase {
+ protected static $apiUrl;
+
+ /**
+ * @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 = array(
+ 'sysop' => new TestUser(
+ 'Apitestsysop',
+ 'Api Test Sysop',
+ 'api_test_sysop@example.com',
+ array( 'sysop' )
+ ),
+ 'uploader' => new TestUser(
+ 'Apitestuser',
+ 'Api Test User',
+ 'api_test_user@example.com',
+ array()
+ )
+ );
+
+ $this->setMwGlobals( array(
+ 'wgMemc' => new EmptyBagOStuff(),
+ 'wgAuth' => new StubObject( 'wgAuth', 'AuthPlugin' ),
+ 'wgRequest' => new FauxRequest( array() ),
+ 'wgUser' => self::$users['sysop']->user,
+ ) );
+
+ $this->apiContext = new ApiTestContext();
+ }
+
+ /**
+ * 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 );
+ }
+
+ /**
+ * 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
+ *
+ * @return array
+ */
+ protected function doApiRequest( array $params, array $session = null,
+ $appendModule = false, User $user = null
+ ) {
+ global $wgRequest, $wgUser;
+
+ if ( is_null( $session ) ) {
+ // re-use existing global session by default
+ $session = $wgRequest->getSessionArray();
+ }
+
+ // set up global environment
+ if ( $user ) {
+ $wgUser = $user;
+ }
+
+ $wgRequest = new FauxRequest( $params, true, $session );
+ RequestContext::getMain()->setRequest( $wgRequest );
+
+ // set up local environment
+ $context = $this->apiContext->newTestContext( $wgRequest, $wgUser );
+
+ $module = new ApiMain( $context, true );
+
+ // run it!
+ $module->execute();
+
+ // construct result
+ $results = array(
+ $module->getResultData(),
+ $context->getRequest(),
+ $context->getRequest()->getSessionArray()
+ );
+
+ if ( $appendModule ) {
+ $results[] = $module;
+ }
+
+ return $results;
+ }
+
+ /**
+ * Add an edit token to the API request
+ * This is cheating a bit -- we grab a token in the correct format and then
+ * add it to the pseudo-session and to the request, without actually
+ * requesting a "real" edit token.
+ *
+ * @param array $params Key-value API params
+ * @param array|null $session Session array
+ * @param User|null $user A User object for the context
+ * @return array Result of the API call
+ * @throws Exception In case wsToken is not set in the session
+ */
+ protected function doApiRequestWithToken( array $params, array $session = null,
+ User $user = null
+ ) {
+ global $wgRequest;
+
+ if ( $session === null ) {
+ $session = $wgRequest->getSessionArray();
+ }
+
+ if ( isset( $session['wsToken'] ) && $session['wsToken'] ) {
+ // add edit token to fake session
+ $session['wsEditToken'] = $session['wsToken'];
+ // add token to request parameters
+ $params['token'] = md5( $session['wsToken'] ) . User::EDIT_TOKEN_SUFFIX;
+
+ return $this->doApiRequest( $params, $session, false, $user );
+ } else {
+ throw new Exception( "Session token not available" );
+ }
+ }
+
+ protected function doLogin( $user = 'sysop' ) {
+ if ( !array_key_exists( $user, self::$users ) ) {
+ throw new MWException( "Can not log in to undefined user $user" );
+ }
+
+ $data = $this->doApiRequest( array(
+ 'action' => 'login',
+ 'lgname' => self::$users[$user]->username,
+ 'lgpassword' => self::$users[$user]->password ) );
+
+ $token = $data[0]['login']['token'];
+
+ $data = $this->doApiRequest(
+ array(
+ 'action' => 'login',
+ 'lgtoken' => $token,
+ 'lgname' => self::$users[$user]->username,
+ 'lgpassword' => self::$users[$user]->password,
+ ),
+ $data[2]
+ );
+
+ return $data;
+ }
+
+ protected function getTokenList( $user, $session = null ) {
+ $data = $this->doApiRequest( array(
+ 'action' => 'tokens',
+ 'type' => 'edit|delete|protect|move|block|unblock|watch'
+ ), $session, false, $user->user );
+
+ if ( !array_key_exists( 'tokens', $data[0] ) ) {
+ throw new MWException( 'Api failed to return a token list' );
+ }
+
+ return $data[0]['tokens'];
+ }
+
+ public function testApiTestGroup() {
+ $groups = PHPUnit_Util_Test::getGroups( get_class( $this ) );
+ $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"'
+ );
+ }
+}
diff --git a/tests/phpunit/includes/api/ApiTestCaseUpload.php b/tests/phpunit/includes/api/ApiTestCaseUpload.php
new file mode 100644
index 00000000..7e513394
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiTestCaseUpload.php
@@ -0,0 +1,171 @@
+<?php
+
+/**
+ * * Abstract class to support upload tests
+ */
+
+abstract class ApiTestCaseUpload extends ApiTestCase {
+ /**
+ * Fixture -- run before every test
+ */
+ protected function setUp() {
+ parent::setUp();
+
+ $this->setMwGlobals( array(
+ 'wgEnableUploads' => true,
+ 'wgEnableAPI' => true,
+ ) );
+
+ wfSetupSession();
+
+ $this->clearFakeUploads();
+ }
+
+ protected function tearDown() {
+ $this->clearTempUpload();
+
+ parent::tearDown();
+ }
+
+ /**
+ * 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, array( '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 = tempnam( wfTempDir(), "" );
+ 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] = array(
+ 'name' => $fileName,
+ 'type' => $type,
+ 'tmp_name' => $tmpName,
+ 'size' => $size,
+ 'error' => null
+ );
+
+ return true;
+ }
+
+ function fakeUploadChunk( $fieldName, $fileName, $type, & $chunkData ) {
+ $tmpName = tempnam( wfTempDir(), "" );
+ // 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] = array(
+ 'name' => $fileName,
+ 'type' => $type,
+ 'tmp_name' => $tmpName,
+ 'size' => $size,
+ 'error' => null
+ );
+ }
+
+ function clearTempUpload() {
+ if ( isset( $_FILES['file']['tmp_name'] ) ) {
+ $tmp = $_FILES['file']['tmp_name'];
+ if ( file_exists( $tmp ) ) {
+ unlink( $tmp );
+ }
+ }
+ }
+
+ /**
+ * Remove traces of previous fake uploads
+ */
+ function clearFakeUploads() {
+ $_FILES = array();
+ }
+}
diff --git a/tests/phpunit/includes/api/ApiTestContext.php b/tests/phpunit/includes/api/ApiTestContext.php
new file mode 100644
index 00000000..17dad1fa
--- /dev/null
+++ b/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/tests/phpunit/includes/api/ApiTokensTest.php b/tests/phpunit/includes/api/ApiTokensTest.php
new file mode 100644
index 00000000..fbe97893
--- /dev/null
+++ b/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( $user ) {
+ $tokens = $this->getTokenList( $user );
+
+ $rights = $user->user->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/tests/phpunit/includes/api/ApiUnblockTest.php b/tests/phpunit/includes/api/ApiUnblockTest.php
new file mode 100644
index 00000000..2c2370a8
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiUnblockTest.php
@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ *
+ * @covers ApiUnblock
+ */
+class ApiUnblockTest extends ApiTestCase {
+ protected function setUp() {
+ parent::setUp();
+ $this->doLogin();
+ }
+
+ /**
+ * @expectedException UsageException
+ */
+ public function testWithNoToken( ) {
+ $this->doApiRequest(
+ array(
+ 'action' => 'unblock',
+ 'user' => 'UTApiBlockee',
+ 'reason' => 'Some reason',
+ ),
+ null,
+ false,
+ self::$users['sysop']->user
+ );
+ }
+}
diff --git a/tests/phpunit/includes/api/ApiUploadTest.php b/tests/phpunit/includes/api/ApiUploadTest.php
new file mode 100644
index 00000000..8ea761f8
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiUploadTest.php
@@ -0,0 +1,572 @@
+<?php
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ */
+
+/**
+ * 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
+
+require_once 'ApiTestCaseUpload.php';
+
+/**
+ * @group Database
+ * @group Broken
+ * Broken test, reports false errors from time to time.
+ * See https://bugzilla.wikimedia.org/26169
+ *
+ * This is pretty sucky... needs to be prettified.
+ */
+class ApiUploadTest extends ApiTestCaseUpload {
+ /**
+ * Testing login
+ * XXX this is a funny way of getting session context
+ */
+ public function testLogin() {
+ $user = self::$users['uploader'];
+
+ $params = array(
+ 'action' => 'login',
+ 'lgname' => $user->username,
+ 'lgpassword' => $user->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 = array(
+ 'action' => 'login',
+ 'lgtoken' => $token,
+ 'lgname' => $user->username,
+ 'lgpassword' => $user->password
+ );
+ list( $result, , $session ) = $this->doApiRequest( $params, $session );
+ $this->assertArrayHasKey( "login", $result );
+ $this->assertArrayHasKey( "result", $result['login'] );
+ $this->assertEquals( "Success", $result['login']['result'] );
+ $this->assertArrayHasKey( 'lgtoken', $result['login'] );
+
+ $this->assertNotEmpty( $session, 'API Login must return a session' );
+
+ return $session;
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadRequiresToken( $session ) {
+ $exception = false;
+ try {
+ $this->doApiRequest( array(
+ 'action' => 'upload'
+ ) );
+ } catch ( UsageException $e ) {
+ $exception = true;
+ $this->assertEquals( "The token parameter must be set", $e->getMessage() );
+ }
+ $this->assertTrue( $exception, "Got exception" );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadMissingParams( $session ) {
+ $exception = false;
+ try {
+ $this->doApiRequestWithToken( array(
+ 'action' => 'upload',
+ ), $session, self::$users['uploader']->user );
+ } catch ( UsageException $e ) {
+ $exception = true;
+ $this->assertEquals( "One of the parameters filekey, file, url, statuskey 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, wfTempDir() );
+ } 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 = array(
+ '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']->user );
+ } catch ( UsageException $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 );
+ unlink( $filePath );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadZeroLength( $session ) {
+ $mimeType = 'image/png';
+
+ $filePath = tempnam( wfTempDir(), "" );
+ $fileName = "apiTestUploadZeroLength.png";
+
+ $this->deleteFileByFileName( $fileName );
+
+ if ( !$this->fakeUploadFile( 'file', $fileName, $mimeType, $filePath ) ) {
+ $this->markTestIncomplete( "Couldn't upload file!\n" );
+ }
+
+ $params = array(
+ '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']->user );
+ } catch ( UsageException $e ) {
+ $this->assertContains( 'The file you submitted was empty', $e->getMessage() );
+ $exception = true;
+ }
+ $this->assertTrue( $exception );
+
+ // clean up
+ $this->deleteFileByFilename( $fileName );
+ unlink( $filePath );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadSameFileName( $session ) {
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ $filePaths = $randomImageGenerator->writeImages( 2, $extension, wfTempDir() );
+ } 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 = array(
+ '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']->user );
+ } catch ( UsageException $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']->user ); // FIXME: leaks a temporary file
+ } catch ( UsageException $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 );
+ unlink( $filePaths[0] );
+ unlink( $filePaths[1] );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadSameContent( $session ) {
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ $filePaths = $randomImageGenerator->writeImages( 1, $extension, wfTempDir() );
+ } 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 = array(
+ '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']->user );
+ } catch ( UsageException $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 = array(
+ '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']->user ); // FIXME: leaks a temporary file
+ } catch ( UsageException $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] );
+ unlink( $filePaths[0] );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadStash( $session ) {
+ $this->setMwGlobals( array(
+ 'wgUser' => self::$users['uploader']->user, // @todo FIXME: still used somewhere
+ ) );
+
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ $filePaths = $randomImageGenerator->writeImages( 1, $extension, wfTempDir() );
+ } 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 = array(
+ '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']->user ); // FIXME: leaks a temporary file
+ } catch ( UsageException $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 = array(
+ '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']->user );
+ } catch ( UsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertFalse( $exception, "No UsageException exception." );
+
+ // clean up
+ $this->deleteFileByFilename( $fileName );
+ unlink( $filePath );
+ }
+
+ /**
+ * @depends testLogin
+ */
+ public function testUploadChunks( $session ) {
+ $this->setMwGlobals( array(
+ // @todo FIXME: still used somewhere
+ 'wgUser' => self::$users['uploader']->user,
+ ) );
+
+ $chunkSize = 1048576;
+ // Download a large image file
+ // ( using RandomImageGenerator for large files is not stable )
+ $mimeType = 'image/jpeg';
+ $url = 'http://upload.wikimedia.org/wikipedia/commons/'
+ . 'e/ed/Oberaargletscher_from_Oberaar%2C_2010_07.JPG';
+ $filePath = wfTempDir() . '/Oberaargletscher_from_Oberaar.jpg';
+ try {
+ // Only download if the file is not avaliable in the temp location:
+ if ( !is_file( $filePath ) ) {
+ 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 = array(
+ 'action' => 'upload',
+ 'stash' => 1,
+ 'filename' => $fileName,
+ 'filesize' => $fileSize,
+ 'offset' => 0,
+ );
+
+ // Upload chunks
+ $chunkSessionKey = false;
+ $resultOffset = 0;
+ // Open the file:
+ wfSuppressWarnings();
+ $handle = fopen( $filePath, "r" );
+ wfRestoreWarnings();
+
+ if ( $handle === false ) {
+ $this->markTestIncomplete( "could not open file: $filePath" );
+ }
+
+ while ( !feof( $handle ) ) {
+ // Get the current chunk
+ wfSuppressWarnings();
+ $chunkData = fread( $handle, $chunkSize );
+ wfRestoreWarnings();
+
+ // 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']->user );
+ } catch ( UsageException $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']->user );
+ } catch ( UsageException $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 = array(
+ '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']->user );
+ } catch ( UsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertFalse( $exception );
+
+ // clean up
+ $this->deleteFileByFilename( $fileName );
+ // don't remove downloaded temporary file for fast subquent tests.
+ //unlink( $filePath );
+ }
+}
diff --git a/tests/phpunit/includes/api/ApiWatchTest.php b/tests/phpunit/includes/api/ApiWatchTest.php
new file mode 100644
index 00000000..e49c6c0e
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiWatchTest.php
@@ -0,0 +1,157 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @todo This test suite is severly broken and need a full review
+ */
+class ApiWatchTest extends ApiTestCase {
+ protected function setUp() {
+ parent::setUp();
+ $this->doLogin();
+ }
+
+ function getTokens() {
+ return $this->getTokenList( self::$users['sysop'] );
+ }
+
+ /**
+ */
+ public function testWatchEdit() {
+ $tokens = $this->getTokens();
+
+ $data = $this->doApiRequest( array(
+ '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( array(
+ 'action' => 'query',
+ 'wllimit' => 'max',
+ 'list' => 'watchlist' ) );
+
+ if ( isset( $data[0]['query']['watchlist'] ) ) {
+ $wl = $data[0]['query']['watchlist'];
+
+ foreach ( $wl as $page ) {
+ $data = $this->doApiRequest( array(
+ 'action' => 'watch',
+ 'title' => $page['title'],
+ 'unwatch' => true,
+ 'token' => $tokens['watchtoken'] ) );
+ }
+ }
+ $data = $this->doApiRequest( array(
+ '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( array(
+ '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( array(
+ '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( array(
+ '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 ( UsageException $ue ) {
+ if ( $ue->getCodeString() == 'onlyauthor' ) {
+ $this->markTestIncomplete( "Only one author to 'Help:UTPage', cannot test rollback" );
+ } else {
+ $this->fail( "Received error '" . $ue->getCodeString() . "'" );
+ }
+ }
+ }
+}
diff --git a/tests/phpunit/includes/api/MockApi.php b/tests/phpunit/includes/api/MockApi.php
new file mode 100644
index 00000000..d94aa2cd
--- /dev/null
+++ b/tests/phpunit/includes/api/MockApi.php
@@ -0,0 +1,20 @@
+<?php
+
+class MockApi extends ApiBase {
+ public function execute() {
+ }
+
+ public function getVersion() {
+ }
+
+ public function __construct() {
+ }
+
+ public function getAllowedParams() {
+ return array(
+ 'filename' => null,
+ 'enablechunks' => false,
+ 'sessionkey' => null,
+ );
+ }
+}
diff --git a/tests/phpunit/includes/api/MockApiQueryBase.php b/tests/phpunit/includes/api/MockApiQueryBase.php
new file mode 100644
index 00000000..4bede519
--- /dev/null
+++ b/tests/phpunit/includes/api/MockApiQueryBase.php
@@ -0,0 +1,11 @@
+<?php
+class MockApiQueryBase extends ApiQueryBase {
+ public function execute() {
+ }
+
+ public function getVersion() {
+ }
+
+ public function __construct() {
+ }
+}
diff --git a/tests/phpunit/includes/api/PrefixUniquenessTest.php b/tests/phpunit/includes/api/PrefixUniquenessTest.php
new file mode 100644
index 00000000..13da33c7
--- /dev/null
+++ b/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 = array();
+
+ foreach ( $modules as $name ) {
+ $module = $moduleManager->getModule( $name );
+ $class = get_class( $module );
+
+ $prefix = $module->getModulePrefix();
+ if ( 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/tests/phpunit/includes/api/RandomImageGenerator.php b/tests/phpunit/includes/api/RandomImageGenerator.php
new file mode 100644
index 00000000..6374cfac
--- /dev/null
+++ b/tests/phpunit/includes/api/RandomImageGenerator.php
@@ -0,0 +1,496 @@
+<?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 = array(
+ array(
+ '0thRow' => 'top',
+ '0thCol' => 'left',
+ 'exifCode' => 1,
+ 'counterRotation' => array( array( 1, 0 ), array( 0, 1 ) )
+ ),
+ array(
+ '0thRow' => 'bottom',
+ '0thCol' => 'right',
+ 'exifCode' => 3,
+ 'counterRotation' => array( array( -1, 0 ), array( 0, -1 ) )
+ ),
+ array(
+ '0thRow' => 'right',
+ '0thCol' => 'top',
+ 'exifCode' => 6,
+ 'counterRotation' => array( array( 0, 1 ), array( 1, 0 ) )
+ ),
+ array(
+ '0thRow' => 'left',
+ '0thCol' => 'bottom',
+ 'exifCode' => 8,
+ 'counterRotation' => array( array( 0, -1 ), array( -1, 0 ) )
+ )
+ );
+
+ public function __construct( $options = array() ) {
+ foreach ( array( '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 (
+ array(
+ '/usr/share/dict/words',
+ '/usr/dict/words',
+ __DIR__ . '/words.txt'
+ ) as $dictionaryFile
+ ) {
+ if ( is_file( $dictionaryFile ) and 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 = array();
+ 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 = array();
+
+ $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 = array();
+ 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 = array();
+ $draw['fill'] = $this->getRandomColor();
+ $draw['shape'] = array(
+ array( 'x' => $originX, 'y' => $originY - $radius ),
+ array( 'x' => $originX + $legDeltaX, 'y' => $originY + $legDeltaY ),
+ array( 'x' => $originX - $legDeltaX, 'y' => $originY + $legDeltaY ),
+ array( 'x' => $originX, 'y' => $originY - $radius )
+ );
+ $draws[] = $draw;
+ }
+
+ $spec['draws'] = $draws;
+
+ return $spec;
+ }
+
+ /**
+ * Given array( array('x' => 10, 'y' => 20), array( '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 = array();
+ foreach ( $shape as $point ) {
+ $points[] = $point['x'] . ',' . $point['y'];
+ }
+
+ return join( " ", $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'] ) );
+ }
+
+ if ( !$fh = fopen( $filename, 'w' ) ) {
+ 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 = array();
+ $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'] = array();
+ foreach ( $spec['draws'] as $draw ) {
+ $tDraw = array(
+ 'fill' => $draw['fill'],
+ 'shape' => array()
+ );
+ 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 array(
+ '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 = array();
+ $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 = array();
+ for ( $i = 0; $i <= 2; $i++ ) {
+ $components[] = mt_rand( 0, 255 );
+ }
+
+ return 'rgb(' . join( ', ', $components ) . ')';
+ }
+
+ /**
+ * Get an array of random pairs of random words, like
+ * array( array( 'foo', 'bar' ), array( '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 = array();
+ $count = count( $lines );
+ for ( $i = 0; $i < $count; $i += 2 ) {
+ $pairs[] = array( $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 = array();
+ 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/tests/phpunit/includes/api/UserWrapper.php b/tests/phpunit/includes/api/UserWrapper.php
new file mode 100644
index 00000000..f8da0ff4
--- /dev/null
+++ b/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, array(
+ "email" => "test@example.com",
+ "real_name" => "Test User" ) );
+ }
+ $this->user->setPassword( $this->password );
+
+ if ( $group !== '' ) {
+ $this->user->addGroup( $group );
+ }
+ $this->user->saveSettings();
+ }
+}
diff --git a/tests/phpunit/includes/api/format/ApiFormatJsonTest.php b/tests/phpunit/includes/api/format/ApiFormatJsonTest.php
new file mode 100644
index 00000000..fc1f9021
--- /dev/null
+++ b/tests/phpunit/includes/api/format/ApiFormatJsonTest.php
@@ -0,0 +1,22 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiFormatJson
+ */
+class ApiFormatJsonTest extends ApiFormatTestBase {
+
+ public function testValidSyntax( ) {
+ $data = $this->apiRequest( 'json', array( 'action' => 'query', 'meta' => 'siteinfo' ) );
+
+ $this->assertInternalType( 'array', json_decode( $data, true ) );
+ $this->assertGreaterThan( 0, count( (array)$data ) );
+ }
+
+ public function testJsonpInjection( ) {
+ $data = $this->apiRequest( 'json', array( 'action' => 'query', 'meta' => 'siteinfo', 'callback' => 'myCallback' ) );
+ $this->assertEquals( '/**/myCallback(', substr( $data, 0, 15 ) );
+ }
+}
diff --git a/tests/phpunit/includes/api/format/ApiFormatNoneTest.php b/tests/phpunit/includes/api/format/ApiFormatNoneTest.php
new file mode 100644
index 00000000..cabd750b
--- /dev/null
+++ b/tests/phpunit/includes/api/format/ApiFormatNoneTest.php
@@ -0,0 +1,16 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiFormatNone
+ */
+class ApiFormatNoneTest extends ApiFormatTestBase {
+
+ public function testValidSyntax( ) {
+ $data = $this->apiRequest( 'none', array( 'action' => 'query', 'meta' => 'siteinfo' ) );
+
+ $this->assertEquals( '', $data ); // No output!
+ }
+}
diff --git a/tests/phpunit/includes/api/format/ApiFormatPhpTest.php b/tests/phpunit/includes/api/format/ApiFormatPhpTest.php
new file mode 100644
index 00000000..54f447a9
--- /dev/null
+++ b/tests/phpunit/includes/api/format/ApiFormatPhpTest.php
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiFormatPhp
+ */
+class ApiFormatPhpTest extends ApiFormatTestBase {
+
+ public function testValidSyntax( ) {
+ $data = $this->apiRequest( 'php', array( 'action' => 'query', 'meta' => 'siteinfo' ) );
+
+ $this->assertInternalType( 'array', unserialize( $data ) );
+ $this->assertGreaterThan( 0, count( (array)$data ) );
+ }
+}
diff --git a/tests/phpunit/includes/api/format/ApiFormatTestBase.php b/tests/phpunit/includes/api/format/ApiFormatTestBase.php
new file mode 100644
index 00000000..5f6d53ce
--- /dev/null
+++ b/tests/phpunit/includes/api/format/ApiFormatTestBase.php
@@ -0,0 +1,32 @@
+<?php
+
+abstract class ApiFormatTestBase extends ApiTestCase {
+
+ /**
+ * @param string $format
+ * @param array $params
+ * @param array $data
+ *
+ * @return string
+ */
+ protected function apiRequest( $format, $params, $data = null ) {
+ $data = parent::doApiRequest( $params, $data, true );
+
+ /** @var ApiMain $module */
+ $module = $data[3];
+
+ $printer = $module->createPrinterByName( $format );
+ $printer->setUnescapeAmps( false );
+
+ $printer->initPrinter( false );
+
+ ob_start();
+ $printer->execute();
+ $out = ob_get_clean();
+
+ $printer->closePrinter();
+
+ return $out;
+ }
+
+}
diff --git a/tests/phpunit/includes/api/format/ApiFormatWddxTest.php b/tests/phpunit/includes/api/format/ApiFormatWddxTest.php
new file mode 100644
index 00000000..d075f547
--- /dev/null
+++ b/tests/phpunit/includes/api/format/ApiFormatWddxTest.php
@@ -0,0 +1,20 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiFormatWddx
+ */
+class ApiFormatWddxTest extends ApiFormatTestBase {
+
+ /**
+ * @requires function wddx_deserialize
+ */
+ public function testValidSyntax( ) {
+ $data = $this->apiRequest( 'wddx', array( 'action' => 'query', 'meta' => 'siteinfo' ) );
+
+ $this->assertInternalType( 'array', wddx_deserialize( $data ) );
+ $this->assertGreaterThan( 0, count( (array)$data ) );
+ }
+}
diff --git a/tests/phpunit/includes/api/generateRandomImages.php b/tests/phpunit/includes/api/generateRandomImages.php
new file mode 100644
index 00000000..87f5c4c0
--- /dev/null
+++ b/tests/phpunit/includes/api/generateRandomImages.php
@@ -0,0 +1,46 @@
+<?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 = array(
+ '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/tests/phpunit/includes/api/query/ApiQueryBasicTest.php b/tests/phpunit/includes/api/query/ApiQueryBasicTest.php
new file mode 100644
index 00000000..e486c4f4
--- /dev/null
+++ b/tests/phpunit/includes/api/query/ApiQueryBasicTest.php
@@ -0,0 +1,353 @@
+<?php
+/**
+ *
+ * Created on Feb 6, 2013
+ *
+ * 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
+ */
+
+require_once 'ApiQueryTestBase.php';
+
+/**
+ * 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::addDBData()
+ */
+ function addDBData() {
+ 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 = array(
+ array( 'prop' => 'links', 'titles' => 'AQBT-All' ),
+ array( 'pages' => array(
+ '1' => array(
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All',
+ 'links' => array(
+ array( 'ns' => 0, 'title' => 'AQBT-Links' ),
+ )
+ )
+ ) )
+ );
+
+ private static $templates = array(
+ array( 'prop' => 'templates', 'titles' => 'AQBT-All' ),
+ array( 'pages' => array(
+ '1' => array(
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All',
+ 'templates' => array(
+ array( 'ns' => 10, 'title' => 'Template:AQBT-T' ),
+ )
+ )
+ ) )
+ );
+
+ private static $categories = array(
+ array( 'prop' => 'categories', 'titles' => 'AQBT-All' ),
+ array( 'pages' => array(
+ '1' => array(
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All',
+ 'categories' => array(
+ array( 'ns' => 14, 'title' => 'Category:AQBT-Cat' ),
+ )
+ )
+ ) )
+ );
+
+ private static $allpages = array(
+ array( 'list' => 'allpages', 'apprefix' => 'AQBT-' ),
+ array( 'allpages' => array(
+ array( 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ),
+ array( 'pageid' => 2, 'ns' => 0, 'title' => 'AQBT-Categories' ),
+ array( 'pageid' => 3, 'ns' => 0, 'title' => 'AQBT-Links' ),
+ array( 'pageid' => 4, 'ns' => 0, 'title' => 'AQBT-Templates' ),
+ ) )
+ );
+
+ private static $alllinks = array(
+ array( 'list' => 'alllinks', 'alprefix' => 'AQBT-' ),
+ array( 'alllinks' => array(
+ array( 'ns' => 0, 'title' => 'AQBT-All' ),
+ array( 'ns' => 0, 'title' => 'AQBT-Categories' ),
+ array( 'ns' => 0, 'title' => 'AQBT-Links' ),
+ array( 'ns' => 0, 'title' => 'AQBT-Templates' ),
+ ) )
+ );
+
+ private static $alltransclusions = array(
+ array( 'list' => 'alltransclusions', 'atprefix' => 'AQBT-' ),
+ array( 'alltransclusions' => array(
+ array( 'ns' => 10, 'title' => 'Template:AQBT-T' ),
+ array( 'ns' => 10, 'title' => 'Template:AQBT-T' ),
+ ) )
+ );
+
+ // Although this appears to have no use it is used by testLists()
+ private static $allcategories = array(
+ array( 'list' => 'allcategories', 'acprefix' => 'AQBT-' ),
+ array( 'allcategories' => array(
+ array( '*' => 'AQBT-Cat' ),
+ ) )
+ );
+
+ private static $backlinks = array(
+ array( 'list' => 'backlinks', 'bltitle' => 'AQBT-Links' ),
+ array( 'backlinks' => array(
+ array( 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ),
+ ) )
+ );
+
+ private static $embeddedin = array(
+ array( 'list' => 'embeddedin', 'eititle' => 'Template:AQBT-T' ),
+ array( 'embeddedin' => array(
+ array( 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ),
+ array( 'pageid' => 4, 'ns' => 0, 'title' => 'AQBT-Templates' ),
+ ) )
+ );
+
+ private static $categorymembers = array(
+ array( 'list' => 'categorymembers', 'cmtitle' => 'Category:AQBT-Cat' ),
+ array( 'categorymembers' => array(
+ array( 'pageid' => 1, 'ns' => 0, 'title' => 'AQBT-All' ),
+ array( 'pageid' => 2, 'ns' => 0, 'title' => 'AQBT-Categories' ),
+ ) )
+ );
+
+ private static $generatorAllpages = array(
+ array( 'generator' => 'allpages', 'gapprefix' => 'AQBT-' ),
+ array( 'pages' => array(
+ '1' => array(
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All' ),
+ '2' => array(
+ 'pageid' => 2,
+ 'ns' => 0,
+ 'title' => 'AQBT-Categories' ),
+ '3' => array(
+ 'pageid' => 3,
+ 'ns' => 0,
+ 'title' => 'AQBT-Links' ),
+ '4' => array(
+ 'pageid' => 4,
+ 'ns' => 0,
+ 'title' => 'AQBT-Templates' ),
+ ) )
+ );
+
+ private static $generatorLinks = array(
+ array( 'generator' => 'links', 'titles' => 'AQBT-Links' ),
+ array( 'pages' => array(
+ '1' => array(
+ 'pageid' => 1,
+ 'ns' => 0,
+ 'title' => 'AQBT-All' ),
+ '2' => array(
+ 'pageid' => 2,
+ 'ns' => 0,
+ 'title' => 'AQBT-Categories' ),
+ '4' => array(
+ 'pageid' => 4,
+ 'ns' => 0,
+ 'title' => 'AQBT-Templates' ),
+ ) )
+ );
+
+ private static $generatorLinksPropLinks = array(
+ array( 'prop' => 'links' ),
+ array( 'pages' => array(
+ '1' => array( 'links' => array(
+ array( 'ns' => 0, 'title' => 'AQBT-Links' ),
+ ) )
+ ) )
+ );
+
+ private static $generatorLinksPropTemplates = array(
+ array( 'prop' => 'templates' ),
+ array( 'pages' => array(
+ '1' => array( 'templates' => array(
+ array( 'ns' => 10, 'title' => 'Template:AQBT-T' ) ) ),
+ '4' => array( 'templates' => array(
+ array( '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 test is temporarily disabled until a sqlite bug is fixed
+ // Confirmed still broken 15-nov-2013
+ // $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 bug 51821
+ */
+ public function testGeneratorRedirects() {
+ $this->editPage( 'AQBT-Target', 'test' );
+ $this->editPage( 'AQBT-Redir', '#REDIRECT [[AQBT-Target]]' );
+ $this->check( array(
+ array( 'generator' => 'backlinks', 'gbltitle' => 'AQBT-Target', 'redirects' => '1' ),
+ array(
+ 'redirects' => array(
+ array(
+ 'from' => 'AQBT-Redir',
+ 'to' => 'AQBT-Target',
+ )
+ ),
+ 'pages' => array(
+ '6' => array(
+ 'pageid' => 6,
+ 'ns' => 0,
+ 'title' => 'AQBT-Target',
+ )
+ ),
+ )
+ ) );
+ }
+}
diff --git a/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php b/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php
new file mode 100644
index 00000000..347cd6f8
--- /dev/null
+++ b/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
+ */
+
+require_once 'ApiQueryContinueTestBase.php';
+
+/**
+ * @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::addDBData()
+ */
+ function addDBData() {
+ 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;
+ }
+ }
+
+ /**
+ * @medium
+ */
+ public function testA() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p, $gDir ) {
+ return array(
+ '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 );
+ $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/tests/phpunit/includes/api/query/ApiQueryContinueTest.php b/tests/phpunit/includes/api/query/ApiQueryContinueTest.php
new file mode 100644
index 00000000..03797901
--- /dev/null
+++ b/tests/phpunit/includes/api/query/ApiQueryContinueTest.php
@@ -0,0 +1,316 @@
+<?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
+ */
+
+require_once 'ApiQueryContinueTestBase.php';
+
+/**
+ * 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::addDBData()
+ */
+ function addDBData() {
+ 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
+ * @medium
+ */
+ public function test1List() {
+ $this->mVerbose = false;
+ $mk = function ( $l ) {
+ return array(
+ 'list' => 'allpages',
+ 'apprefix' => 'AQCT-',
+ 'aplimit' => "$l",
+ );
+ };
+ $data = $this->query( $mk( 99 ), 1, '1L', false );
+
+ // 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
+ * @medium
+ */
+ public function test2Lists() {
+ $this->mVerbose = false;
+ $mk = function ( $l1, $l2 ) {
+ return array(
+ 'list' => 'allpages|alltransclusions',
+ 'apprefix' => 'AQCT-',
+ 'atprefix' => 'AQCT-',
+ 'atunique' => '',
+ 'aplimit' => "$l1",
+ 'atlimit' => "$l2",
+ );
+ };
+ // 2 lists
+ $data = $this->query( $mk( 99, 99 ), 1, '2L', false );
+ $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
+ * @medium
+ */
+ public function testGen1Prop() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p ) {
+ return array(
+ 'generator' => 'allpages',
+ 'gapprefix' => 'AQCT-',
+ 'gaplimit' => "$g",
+ 'prop' => 'links',
+ 'pllimit' => "$p",
+ );
+ };
+ // generator + 1 prop
+ $data = $this->query( $mk( 99, 99 ), 1, 'G1P', false );
+ $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
+ * @medium
+ */
+ public function testGen2Prop() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p1, $p2 ) {
+ return array(
+ '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 );
+ $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
+ * @medium
+ */
+ public function testGen1Prop1List() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p, $l ) {
+ return array(
+ '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 );
+ $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
+ * @medium
+ */
+ public function testGen2Prop2List1Meta() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $p1, $p2, $l1, $l2 ) {
+ return array(
+ '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 );
+ $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
+ * @medium
+ */
+ public function testSameGenAndProp() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $gDir, $p, $pDir ) {
+ return array(
+ '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 );
+
+ $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
+ * @medium
+ */
+ public function testSameGenList() {
+ $this->mVerbose = false;
+ $mk = function ( $g, $gDir, $l, $pDir ) {
+ return array(
+ '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 );
+
+ $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/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php b/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php
new file mode 100644
index 00000000..bce62685
--- /dev/null
+++ b/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php
@@ -0,0 +1,218 @@
+<?php
+/**
+ * Created on Jan 1, 2013
+ *
+ * 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
+ */
+
+require_once 'ApiQueryTestBase.php';
+
+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';
+ }
+ if ( $useContinue && !isset( $params['continue'] ) ) {
+ $params['continue'] = '';
+ }
+ $count = 0;
+ $result = array();
+ $continue = array();
+ 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 = array();
+ }
+ 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 = array();
+ 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/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php b/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php
new file mode 100644
index 00000000..74ceff90
--- /dev/null
+++ b/tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php
@@ -0,0 +1,40 @@
+<?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->doEdit( 'Some text', 'inserting content' );
+
+ $apiResult = $this->doApiRequest( array(
+ '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/tests/phpunit/includes/api/query/ApiQueryTest.php b/tests/phpunit/includes/api/query/ApiQueryTest.php
new file mode 100644
index 00000000..bba22c77
--- /dev/null
+++ b/tests/phpunit/includes/api/query/ApiQueryTest.php
@@ -0,0 +1,130 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ * @covers ApiQuery
+ */
+class ApiQueryTest extends ApiTestCase {
+ /**
+ * @var array Storage for $wgHooks
+ */
+ protected $hooks;
+
+ protected function setUp() {
+ global $wgHooks;
+
+ parent::setUp();
+ $this->doLogin();
+
+ // Setup en: as interwiki prefix
+ $this->hooks = $wgHooks;
+ $wgHooks['InterwikiLoadPrefix'][] = function ( $prefix, &$data ) {
+ if ( $prefix == 'apiquerytestiw' ) {
+ $data = array( 'iw_url' => 'wikipedia' );
+ }
+ return false;
+ };
+ }
+
+ protected function tearDown() {
+ global $wgHooks;
+ $wgHooks = $this->hooks;
+
+ parent::tearDown();
+ }
+
+ public function testTitlesGetNormalized() {
+ global $wgMetaNamespace;
+
+ $this->setMwGlobals( array(
+ 'wgCapitalLinks' => true,
+ ) );
+
+ $data = $this->doApiRequest( array(
+ '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(
+ array(
+ 'from' => 'Project:articleA',
+ 'to' => $to->getPrefixedText(),
+ ),
+ $data[0]['query']['normalized'][0]
+ );
+
+ $this->assertEquals(
+ array(
+ '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, 10000 ) + rand( 0, 999000 ) );
+ }
+
+ $data = $this->doApiRequest( array(
+ '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] );
+ }
+
+ /**
+ * 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( array(
+ 'wgCapitalLinks' => true,
+ ) );
+
+ $api = new MockApiQueryBase();
+ $exceptionCaught = false;
+ try {
+ $this->assertEquals( $expected, $api->titlePartToKey( $titlePart, $namespace ) );
+ } catch ( UsageException $e ) {
+ $exceptionCaught = true;
+ }
+ $this->assertEquals( $expectException, $exceptionCaught,
+ 'UsageException thrown by titlePartToKey' );
+ }
+
+ function provideTestTitlePartToKey() {
+ return array(
+ array( 'a b c', NS_MAIN, 'A_b_c', false ),
+ array( 'x', NS_MAIN, 'X', false ),
+ array( 'y ', NS_MAIN, 'Y_', false ),
+ array( 'template:foo', NS_CATEGORY, 'Template:foo', false ),
+ array( 'apiquerytestiw:foo', NS_CATEGORY, 'Apiquerytestiw:foo', false ),
+ array( "\xF7", NS_MAIN, null, true ),
+ array( 'template:foo', NS_MAIN, null, true ),
+ array( 'apiquerytestiw:foo', NS_MAIN, null, true ),
+ );
+ }
+}
diff --git a/tests/phpunit/includes/api/query/ApiQueryTestBase.php b/tests/phpunit/includes/api/query/ApiQueryTestBase.php
new file mode 100644
index 00000000..56c15b23
--- /dev/null
+++ b/tests/phpunit/includes/api/query/ApiQueryTestBase.php
@@ -0,0 +1,148 @@
+<?php
+/**
+ * Created on Feb 10, 2013
+ *
+ * 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 = array();
+ $expected = array();
+ foreach ( func_get_args() as $v ) {
+ list( $req, $exp ) = $this->validateRequestExpectedPair( $v );
+ $request = array_merge_recursive( $request, $req );
+ $this->mergeExpected( $expected, $exp );
+ }
+
+ return array( $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->assertType( '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->assertType( 'array', $v[0], self::PARAM_ASSERT );
+ $this->assertType( '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.
+ * @param array $values Array is a two element array( request, expected_results )
+ * @throws Exception
+ */
+ protected function check( $values ) {
+ 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 );
+ $this->assertResult( array( '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 );
+ }
+ throw new PHPUnit_Framework_ExpectationFailedException(
+ $e->getMessage() . "\nRequest: $message",
+ new PHPUnit_Framework_ComparisonFailure(
+ $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/tests/phpunit/includes/api/words.txt b/tests/phpunit/includes/api/words.txt
new file mode 100644
index 00000000..7ce23ee3
--- /dev/null
+++ b/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