From 14f74d141ab5580688bfd46d2f74c026e43ed967 Mon Sep 17 00:00:00 2001 From: Pierre Schmitz Date: Wed, 1 Apr 2015 06:11:44 +0200 Subject: Update to MediaWiki 1.24.2 --- tests/phpunit/includes/api/ApiBaseTest.php | 46 + tests/phpunit/includes/api/ApiBlockTest.php | 83 ++ .../phpunit/includes/api/ApiCreateAccountTest.php | 161 ++++ tests/phpunit/includes/api/ApiEditPageTest.php | 496 ++++++++++ tests/phpunit/includes/api/ApiLoginTest.php | 181 ++++ tests/phpunit/includes/api/ApiMainTest.php | 72 ++ .../phpunit/includes/api/ApiModuleManagerTest.php | 318 +++++++ tests/phpunit/includes/api/ApiOptionsTest.php | 459 +++++++++ tests/phpunit/includes/api/ApiParseTest.php | 35 + tests/phpunit/includes/api/ApiPurgeTest.php | 45 + .../phpunit/includes/api/ApiQueryAllPagesTest.php | 34 + .../phpunit/includes/api/ApiRevisionDeleteTest.php | 114 +++ tests/phpunit/includes/api/ApiTestCase.php | 196 ++++ tests/phpunit/includes/api/ApiTestCaseUpload.php | 171 ++++ tests/phpunit/includes/api/ApiTestContext.php | 21 + tests/phpunit/includes/api/ApiTokensTest.php | 40 + tests/phpunit/includes/api/ApiUnblockTest.php | 31 + tests/phpunit/includes/api/ApiUploadTest.php | 572 +++++++++++ tests/phpunit/includes/api/ApiWatchTest.php | 157 +++ tests/phpunit/includes/api/MockApi.php | 20 + tests/phpunit/includes/api/MockApiQueryBase.php | 11 + .../phpunit/includes/api/PrefixUniquenessTest.php | 30 + .../phpunit/includes/api/RandomImageGenerator.php | 496 ++++++++++ tests/phpunit/includes/api/UserWrapper.php | 25 + .../includes/api/format/ApiFormatJsonTest.php | 22 + .../includes/api/format/ApiFormatNoneTest.php | 16 + .../includes/api/format/ApiFormatPhpTest.php | 17 + .../includes/api/format/ApiFormatTestBase.php | 32 + .../includes/api/format/ApiFormatWddxTest.php | 20 + .../phpunit/includes/api/generateRandomImages.php | 46 + .../includes/api/query/ApiQueryBasicTest.php | 353 +++++++ .../includes/api/query/ApiQueryContinue2Test.php | 71 ++ .../includes/api/query/ApiQueryContinueTest.php | 316 +++++++ .../api/query/ApiQueryContinueTestBase.php | 218 +++++ .../includes/api/query/ApiQueryRevisionsTest.php | 40 + tests/phpunit/includes/api/query/ApiQueryTest.php | 130 +++ .../includes/api/query/ApiQueryTestBase.php | 148 +++ tests/phpunit/includes/api/words.txt | 1000 ++++++++++++++++++++ 38 files changed, 6243 insertions(+) create mode 100644 tests/phpunit/includes/api/ApiBaseTest.php create mode 100644 tests/phpunit/includes/api/ApiBlockTest.php create mode 100644 tests/phpunit/includes/api/ApiCreateAccountTest.php create mode 100644 tests/phpunit/includes/api/ApiEditPageTest.php create mode 100644 tests/phpunit/includes/api/ApiLoginTest.php create mode 100644 tests/phpunit/includes/api/ApiMainTest.php create mode 100644 tests/phpunit/includes/api/ApiModuleManagerTest.php create mode 100644 tests/phpunit/includes/api/ApiOptionsTest.php create mode 100644 tests/phpunit/includes/api/ApiParseTest.php create mode 100644 tests/phpunit/includes/api/ApiPurgeTest.php create mode 100644 tests/phpunit/includes/api/ApiQueryAllPagesTest.php create mode 100644 tests/phpunit/includes/api/ApiRevisionDeleteTest.php create mode 100644 tests/phpunit/includes/api/ApiTestCase.php create mode 100644 tests/phpunit/includes/api/ApiTestCaseUpload.php create mode 100644 tests/phpunit/includes/api/ApiTestContext.php create mode 100644 tests/phpunit/includes/api/ApiTokensTest.php create mode 100644 tests/phpunit/includes/api/ApiUnblockTest.php create mode 100644 tests/phpunit/includes/api/ApiUploadTest.php create mode 100644 tests/phpunit/includes/api/ApiWatchTest.php create mode 100644 tests/phpunit/includes/api/MockApi.php create mode 100644 tests/phpunit/includes/api/MockApiQueryBase.php create mode 100644 tests/phpunit/includes/api/PrefixUniquenessTest.php create mode 100644 tests/phpunit/includes/api/RandomImageGenerator.php create mode 100644 tests/phpunit/includes/api/UserWrapper.php create mode 100644 tests/phpunit/includes/api/format/ApiFormatJsonTest.php create mode 100644 tests/phpunit/includes/api/format/ApiFormatNoneTest.php create mode 100644 tests/phpunit/includes/api/format/ApiFormatPhpTest.php create mode 100644 tests/phpunit/includes/api/format/ApiFormatTestBase.php create mode 100644 tests/phpunit/includes/api/format/ApiFormatWddxTest.php create mode 100644 tests/phpunit/includes/api/generateRandomImages.php create mode 100644 tests/phpunit/includes/api/query/ApiQueryBasicTest.php create mode 100644 tests/phpunit/includes/api/query/ApiQueryContinue2Test.php create mode 100644 tests/phpunit/includes/api/query/ApiQueryContinueTest.php create mode 100644 tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php create mode 100644 tests/phpunit/includes/api/query/ApiQueryRevisionsTest.php create mode 100644 tests/phpunit/includes/api/query/ApiQueryTest.php create mode 100644 tests/phpunit/includes/api/query/ApiQueryTestBase.php create mode 100644 tests/phpunit/includes/api/words.txt (limited to 'tests/phpunit/includes/api') 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 @@ +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 @@ +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 @@ +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 @@ +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§ion=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 @@ +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 @@ + '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 @@ + 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 @@ + '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' => ' ', + ); + } + + $preferences['testmultiselect'] = array( + 'type' => 'multiselect', + 'options' => array( + 'Test' => array( + 'Some HTML here for option 1' => 'opt1', + 'Some HTML here for option 2' => 'opt2', + 'Some HTML here for option 3' => 'opt3', + 'Some HTML here for option 4' => 'opt4', + ), + ), + 'section' => 'test', + 'label' => ' ', + '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 @@ +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 @@ +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 @@ +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 @@ +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 @@ + 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 @@ +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 @@ +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 @@ +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 @@ +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 @@ + '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 @@ +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 @@ + 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 @@ +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 @@ + + */ + +/** + * 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->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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +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 @@ +@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 @@ +@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 @@ +@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 @@ +@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 @@ +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 @@ +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 @@ +@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 = <<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 -- cgit v1.2.2