summaryrefslogtreecommitdiff
path: root/tests/phpunit/includes/api
diff options
context:
space:
mode:
authorPierre Schmitz <pierre@archlinux.de>2011-12-03 13:29:22 +0100
committerPierre Schmitz <pierre@archlinux.de>2011-12-03 13:29:22 +0100
commitca32f08966f1b51fcb19460f0996bb0c4048e6fe (patch)
treeec04cc15b867bc21eedca904cea9af0254531a11 /tests/phpunit/includes/api
parenta22fbfc60f36f5f7ee10d5ae6fe347340c2ee67c (diff)
Update to MediaWiki 1.18.0
* also update ArchLinux skin to chagnes in MonoBook * Use only css to hide our menu bar when printing
Diffstat (limited to 'tests/phpunit/includes/api')
-rw-r--r--tests/phpunit/includes/api/ApiBlockTest.php62
-rw-r--r--tests/phpunit/includes/api/ApiPurgeTest.php41
-rw-r--r--tests/phpunit/includes/api/ApiQueryTest.php67
-rw-r--r--tests/phpunit/includes/api/ApiTest.php277
-rw-r--r--tests/phpunit/includes/api/ApiTestCase.php139
-rw-r--r--tests/phpunit/includes/api/ApiTestCaseUpload.php114
-rw-r--r--tests/phpunit/includes/api/ApiTestUser.php59
-rw-r--r--tests/phpunit/includes/api/ApiUploadTest.php433
-rw-r--r--tests/phpunit/includes/api/ApiWatchTest.php179
-rw-r--r--tests/phpunit/includes/api/RandomImageGenerator.php473
-rw-r--r--tests/phpunit/includes/api/format/ApiFormatPhpTest.php19
-rw-r--r--tests/phpunit/includes/api/format/ApiFormatTestBase.php22
-rw-r--r--tests/phpunit/includes/api/generateRandomImages.php47
-rw-r--r--tests/phpunit/includes/api/words.txt1000
14 files changed, 2932 insertions, 0 deletions
diff --git a/tests/phpunit/includes/api/ApiBlockTest.php b/tests/phpunit/includes/api/ApiBlockTest.php
new file mode 100644
index 00000000..227555eb
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiBlockTest.php
@@ -0,0 +1,62 @@
+<?php
+
+/**
+ * @group Database
+ */
+class ApiBlockTest extends ApiTestCase {
+
+ function setUp() {
+ parent::setUp();
+ $this->doLogin();
+ }
+
+ function getTokens() {
+ return $this->getTokenList( self::$users['sysop'] );
+ }
+
+ function addDBData() {
+ $user = User::newFromName( 'UTBlockee' );
+
+ if ( $user->getId() == 0 ) {
+ $user->addToDatabase();
+ $user->setPassword( 'UTBlockeePassword' );
+
+ $user->saveSettings();
+ }
+ }
+
+ function testMakeNormalBlock() {
+
+ $data = $this->getTokens();
+
+ $user = User::newFromName( 'UTBlockee' );
+
+ if ( !$user->getId() ) {
+ $this->markTestIncomplete( "The user UTBlockee does not exist" );
+ }
+
+ if( !isset( $data[0]['query']['pages'] ) ) {
+ $this->markTestIncomplete( "No block token found" );
+ }
+
+ $keys = array_keys( $data[0]['query']['pages'] );
+ $key = array_pop( $keys );
+ $pageinfo = $data[0]['query']['pages'][$key];
+
+ $data = $this->doApiRequest( array(
+ 'action' => 'block',
+ 'user' => 'UTBlockee',
+ 'reason' => BlockTest::REASON,
+ 'token' => $pageinfo['blocktoken'] ), $data );
+
+ $block = Block::newFromTarget('UTBlockee');
+
+ $this->assertTrue( !is_null( $block ), 'Block is valid' );
+
+ $this->assertEquals( 'UTBlockee', (string)$block->getTarget() );
+ $this->assertEquals( 'Some reason', $block->mReason );
+ $this->assertEquals( 'infinity', $block->mExpiry );
+
+ }
+
+}
diff --git a/tests/phpunit/includes/api/ApiPurgeTest.php b/tests/phpunit/includes/api/ApiPurgeTest.php
new file mode 100644
index 00000000..db1563e9
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiPurgeTest.php
@@ -0,0 +1,41 @@
+<?php
+
+/**
+ * @group Database
+ */
+class ApiPurgeTest extends ApiTestCase {
+
+ function setUp() {
+ parent::setUp();
+ $this->doLogin();
+ }
+
+ 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] );
+
+ $this->assertArrayHasKey( 0, $data[0]['purge'] );
+ $this->assertArrayHasKey( 'purged', $data[0]['purge'][0] );
+ $this->assertEquals( 'UTPage', $data[0]['purge'][0]['title'] );
+
+ $this->assertArrayHasKey( 1, $data[0]['purge'] );
+ $this->assertArrayHasKey( 'missing', $data[0]['purge'][1] );
+ $this->assertEquals( $somePage, $data[0]['purge'][1]['title'] );
+
+ $this->assertArrayHasKey( 2, $data[0]['purge'] );
+ $this->assertArrayHasKey( 'invalid', $data[0]['purge'][2] );
+ $this->assertEquals( '%5D', $data[0]['purge'][2]['title'] );
+
+ }
+
+}
diff --git a/tests/phpunit/includes/api/ApiQueryTest.php b/tests/phpunit/includes/api/ApiQueryTest.php
new file mode 100644
index 00000000..114eadf3
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiQueryTest.php
@@ -0,0 +1,67 @@
+<?php
+
+/**
+ * @group Database
+ */
+class ApiQueryTest extends ApiTestCase {
+
+ function setUp() {
+ parent::setUp();
+ $this->doLogin();
+ }
+
+ function testTitlesGetNormalized() {
+
+ global $wgMetaNamespace;
+
+ $data = $this->doApiRequest( array(
+ 'action' => 'query',
+ 'titles' => 'Project:articleA|article_B' ) );
+
+
+ $this->assertArrayHasKey( 'query', $data[0] );
+ $this->assertArrayHasKey( 'normalized', $data[0]['query'] );
+
+ $this->assertEquals(
+ array(
+ 'from' => 'Project:articleA',
+ 'to' => $wgMetaNamespace . ':ArticleA'
+ ),
+ $data[0]['query']['normalized'][0]
+ );
+
+ $this->assertEquals(
+ array(
+ 'from' => 'article_B',
+ 'to' => 'Article B'
+ ),
+ $data[0]['query']['normalized'][1]
+ );
+
+ }
+
+ 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] );
+
+
+ }
+
+}
diff --git a/tests/phpunit/includes/api/ApiTest.php b/tests/phpunit/includes/api/ApiTest.php
new file mode 100644
index 00000000..a587e6b1
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiTest.php
@@ -0,0 +1,277 @@
+<?php
+
+/**
+ * @group Database
+ */
+class ApiTest extends ApiTestCase {
+
+ function testRequireOnlyOneParameterDefault() {
+ $mock = new MockApi();
+
+ $this->assertEquals(
+ null, $mock->requireOnlyOneParameter( array( "filename" => "foo.txt",
+ "enablechunks" => false ), "filename", "enablechunks" ) );
+ }
+
+ /**
+ * @expectedException UsageException
+ */
+ function testRequireOnlyOneParameterZero() {
+ $mock = new MockApi();
+
+ $this->assertEquals(
+ null, $mock->requireOnlyOneParameter( array( "filename" => "foo.txt",
+ "enablechunks" => 0 ), "filename", "enablechunks" ) );
+ }
+
+ /**
+ * @expectedException UsageException
+ */
+ function testRequireOnlyOneParameterTrue() {
+ $mock = new MockApi();
+
+ $this->assertEquals(
+ null, $mock->requireOnlyOneParameter( array( "filename" => "foo.txt",
+ "enablechunks" => true ), "filename", "enablechunks" ) );
+ }
+
+ /**
+ * Test that the API will accept a FauxRequest and execute. The help action
+ * (default) throws a UsageException. Just validate we're getting proper XML
+ *
+ * @expectedException UsageException
+ */
+ function testApi() {
+
+ $api = new ApiMain(
+ new FauxRequest( array( 'action' => 'help', 'format' => 'xml' ) )
+ );
+ $api->execute();
+ $api->getPrinter()->setBufferResult( true );
+ $api->printResult( false );
+ $resp = $api->getPrinter()->getBuffer();
+
+ libxml_use_internal_errors( true );
+ $sxe = simplexml_load_string( $resp );
+ $this->assertNotInternalType( "bool", $sxe );
+ $this->assertThat( $sxe, $this->isInstanceOf( "SimpleXMLElement" ) );
+ }
+
+ /**
+ * Test result of attempted login with an empty username
+ */
+ function testApiLoginNoName() {
+ $data = $this->doApiRequest( array( 'action' => 'login',
+ 'lgname' => '', 'lgpassword' => self::$users['sysop']->password,
+ ) );
+ $this->assertEquals( 'NoName', $data[0]['login']['result'] );
+ }
+
+ 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",
+ )
+ );
+
+ $result = $ret[0];
+
+ $this->assertNotInternalType( "bool", $result );
+ $a = $result["login"]["result"];
+
+ $this->assertEquals( "WrongPass", $a );
+ }
+
+ 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,
+ )
+ );
+
+ $result = $ret[0];
+
+ $this->assertNotInternalType( "bool", $result );
+ $a = $result["login"]["result"];
+
+ $this->assertEquals( "Success", $a );
+ }
+
+ function testApiGotCookie() {
+ $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 );
+
+ return $cj;
+ }
+
+ /**
+ * @depends testApiGotCookie
+ */
+ function testApiListPages( CookieJar $cj ) {
+ $this->markTestIncomplete( "Not done with this yet" );
+ global $wgServer;
+
+ if ( $wgServer == "http://localhost" ) {
+ $this->markTestIncomplete( 'This test needs $wgServer to be set in LocalSettings.php' );
+ }
+ $req = MWHttpRequest::factory( self::$apiUrl . "?action=query&format=xml&prop=revisions&" .
+ "titles=Main%20Page&rvprop=timestamp|user|comment|content" );
+ $req->setCookieJar( $cj );
+ $req->execute();
+ libxml_use_internal_errors( true );
+ $sxe = simplexml_load_string( $req->getContent() );
+ $this->assertNotInternalType( "bool", $sxe );
+ $this->assertThat( $sxe, $this->isInstanceOf( "SimpleXMLElement" ) );
+ $a = $sxe->query[0]->pages[0]->page[0]->attributes();
+ }
+
+ 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 );
+
+ $this->assertArrayHasKey( "login", $data[0] );
+ $this->assertArrayHasKey( "result", $data[0]['login'] );
+ $this->assertEquals( "Success", $data[0]['login']['result'] );
+ $this->assertArrayHasKey( 'lgtoken', $data[0]['login'] );
+
+ return $data;
+ }
+
+ function testGettingToken() {
+ foreach ( self::$users as $user ) {
+ $this->runTokenTest( $user );
+ }
+ }
+
+ function runTokenTest( $user ) {
+
+ $data = $this->getTokenList( $user );
+
+ $this->assertArrayHasKey( 'query', $data[0] );
+ $this->assertArrayHasKey( 'pages', $data[0]['query'] );
+ $keys = array_keys( $data[0]['query']['pages'] );
+ $key = array_pop( $keys );
+
+ $rights = $user->user->getRights();
+
+ $this->assertArrayHasKey( $key, $data[0]['query']['pages'] );
+ $this->assertArrayHasKey( 'edittoken', $data[0]['query']['pages'][$key] );
+ $this->assertArrayHasKey( 'movetoken', $data[0]['query']['pages'][$key] );
+
+ if ( isset( $rights['delete'] ) ) {
+ $this->assertArrayHasKey( 'deletetoken', $data[0]['query']['pages'][$key] );
+ }
+
+ if ( isset( $rights['block'] ) ) {
+ $this->assertArrayHasKey( 'blocktoken', $data[0]['query']['pages'][$key] );
+ $this->assertArrayHasKey( 'unblocktoken', $data[0]['query']['pages'][$key] );
+ }
+
+ if ( isset( $rights['protect'] ) ) {
+ $this->assertArrayHasKey( 'protecttoken', $data[0]['query']['pages'][$key] );
+ }
+
+ return $data;
+ }
+}
diff --git a/tests/phpunit/includes/api/ApiTestCase.php b/tests/phpunit/includes/api/ApiTestCase.php
new file mode 100644
index 00000000..2917c880
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiTestCase.php
@@ -0,0 +1,139 @@
+<?php
+
+abstract class ApiTestCase extends MediaWikiLangTestCase {
+ /**
+ * @var Array of ApiTestUser
+ */
+ public static $users;
+ protected static $apiUrl;
+
+ function setUp() {
+ global $wgContLang, $wgAuth, $wgMemc, $wgRequest, $wgUser, $wgServer;
+
+ parent::setUp();
+ self::$apiUrl = $wgServer . wfScript( 'api' );
+ $wgMemc = new EmptyBagOStuff();
+ $wgContLang = Language::factory( 'en' );
+ $wgAuth = new StubObject( 'wgAuth', 'AuthPlugin' );
+ $wgRequest = new FauxRequest( array() );
+
+ self::$users = array(
+ 'sysop' => new ApiTestUser(
+ 'Apitestsysop',
+ 'Api Test Sysop',
+ 'api_test_sysop@sample.com',
+ array( 'sysop' )
+ ),
+ 'uploader' => new ApiTestUser(
+ 'Apitestuser',
+ 'Api Test User',
+ 'api_test_user@sample.com',
+ array()
+ )
+ );
+
+ $wgUser = self::$users['sysop']->user;
+
+ }
+
+ protected function doApiRequest( $params, $session = null, $appendModule = false ) {
+ if ( is_null( $session ) ) {
+ $session = array();
+ }
+
+ $request = new FauxRequest( $params, true, $session );
+ $module = new ApiMain( $request, true );
+ $module->execute();
+
+ $results = array( $module->getResultData(), $request, $request->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 $params: key-value API params
+ * @param $session: session array
+ */
+ protected function doApiRequestWithToken( $params, $session ) {
+ if ( $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 );
+ } else {
+ throw new Exception( "request data not in right format" );
+ }
+ }
+
+ protected function doLogin() {
+ $data = $this->doApiRequest( array(
+ 'action' => 'login',
+ 'lgname' => self::$users['sysop']->username,
+ 'lgpassword' => self::$users['sysop']->password ) );
+
+ $token = $data[0]['login']['token'];
+
+ $data = $this->doApiRequest( array(
+ 'action' => 'login',
+ 'lgtoken' => $token,
+ 'lgname' => self::$users['sysop']->username,
+ 'lgpassword' => self::$users['sysop']->password
+ ), $data );
+
+ return $data;
+ }
+
+ protected function getTokenList( $user ) {
+ $GLOBALS['wgUser'] = $user->user;
+ $data = $this->doApiRequest( array(
+ 'action' => 'query',
+ 'titles' => 'Main Page',
+ 'intoken' => 'edit|delete|protect|move|block|unblock',
+ 'prop' => 'info' ) );
+ return $data;
+ }
+}
+
+class UserWrapper {
+ public $userName, $password, $user;
+
+ public function __construct( $userName, $password, $group = '' ) {
+ $this->userName = $userName;
+ $this->password = $password;
+
+ $this->user = User::newFromName( $this->userName );
+ if ( !$this->user->getID() ) {
+ $this->user = User::createNew( $this->userName, array(
+ "email" => "test@example.com",
+ "real_name" => "Test User" ) );
+ }
+ $this->user->setPassword( $this->password );
+
+ if ( $group !== '' ) {
+ $this->user->addGroup( $group );
+ }
+ $this->user->saveSettings();
+ }
+}
+
+class MockApi extends ApiBase {
+ public function execute() { }
+ public function getVersion() { }
+
+ public function __construct() { }
+
+ public function getAllowedParams() {
+ return array(
+ 'filename' => null,
+ 'enablechunks' => false,
+ 'sessionkey' => null,
+ );
+ }
+}
diff --git a/tests/phpunit/includes/api/ApiTestCaseUpload.php b/tests/phpunit/includes/api/ApiTestCaseUpload.php
new file mode 100644
index 00000000..e51e7214
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiTestCaseUpload.php
@@ -0,0 +1,114 @@
+<?php
+
+/**
+ * * Abstract class to support upload tests
+ */
+
+abstract class ApiTestCaseUpload extends ApiTestCase {
+ /**
+ * Fixture -- run before every test
+ */
+ public function setUp() {
+ global $wgEnableUploads, $wgEnableAPI;
+ parent::setUp();
+
+ $wgEnableUploads = true;
+ $wgEnableAPI = true;
+ wfSetupSession();
+
+ $this->clearFakeUploads();
+ }
+
+ /**
+ * Helper function -- remove files and associated articles by Title
+ * @param $title Title: title to be removed
+ */
+ 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;
+ }
+ $article = new Article( $title );
+ $article->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 $fileName String: filename to be removed
+ */
+ 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 $filePath String: path to file on the filesystem
+ */
+ public function deleteFileByContent( $filePath ) {
+ $hash = File::sha1Base36( $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 $fieldName String: name this would have in the upload form
+ * @param $fileName String: name to title this
+ * @param $type String: mime type
+ * @param $filePath String: path where to find file contents
+ */
+ 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;
+
+ }
+
+ /**
+ * Remove traces of previous fake uploads
+ */
+ function clearFakeUploads() {
+ $_FILES = array();
+ }
+
+
+
+
+}
diff --git a/tests/phpunit/includes/api/ApiTestUser.php b/tests/phpunit/includes/api/ApiTestUser.php
new file mode 100644
index 00000000..df60682f
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiTestUser.php
@@ -0,0 +1,59 @@
+<?php
+
+/* Wraps the user object, so we can also retain full access to properties like password if we log in via the API */
+class ApiTestUser {
+ public $username;
+ public $password;
+ public $email;
+ public $groups;
+ public $user;
+
+ function __construct( $username, $realname = 'Real Name', $email = 'sample@sample.com', $groups = array() ) {
+ $this->username = $username;
+ $this->realname = $realname;
+ $this->email = $email;
+ $this->groups = $groups;
+
+ // don't allow user to hardcode or select passwords -- people sometimes run tests
+ // on live wikis. Sometimes we create sysop users in these tests. A sysop user with
+ // a known password would be a Bad Thing.
+ $this->password = User::randomPassword();
+
+ $this->user = User::newFromName( $this->username );
+ $this->user->load();
+
+ // In an ideal world we'd have a new wiki (or mock data store) for every single test.
+ // But for now, we just need to create or update the user with the desired properties.
+ // we particularly need the new password, since we just generated it randomly.
+ // In core MediaWiki, there is no functionality to delete users, so this is the best we can do.
+ if ( !$this->user->getID() ) {
+ // create the user
+ $this->user = User::createNew(
+ $this->username, array(
+ "email" => $this->email,
+ "real_name" => $this->realname
+ )
+ );
+ if ( !$this->user ) {
+ throw new Exception( "error creating user" );
+ }
+ }
+
+ // update the user to use the new random password and other details
+ $this->user->setPassword( $this->password );
+ $this->user->setEmail( $this->email );
+ $this->user->setRealName( $this->realname );
+ // remove all groups, replace with any groups specified
+ foreach ( $this->user->getGroups() as $group ) {
+ $this->user->removeGroup( $group );
+ }
+ if ( count( $this->groups ) ) {
+ foreach ( $this->groups as $group ) {
+ $this->user->addGroup( $group );
+ }
+ }
+ $this->user->saveSettings();
+
+ }
+
+}
diff --git a/tests/phpunit/includes/api/ApiUploadTest.php b/tests/phpunit/includes/api/ApiUploadTest.php
new file mode 100644
index 00000000..5c929784
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiUploadTest.php
@@ -0,0 +1,433 @@
+<?php
+
+/**
+ * @group Database
+ */
+
+/**
+ * n.b. Ensure that you can write to the images/ directory as the
+ * user that will run tests.
+ */
+
+// Note for reviewers: this intentionally duplicates functionality already in "ApiSetup" and so on.
+// This framework works better IMO and has less strangeness (such as test cases inheriting from "ApiSetup"...)
+// (and in the case of the other Upload tests, this flat out just actually works... )
+
+// TODO: port the other Upload tests, and other API tests to this framework
+
+require_once( 'ApiTestCaseUpload.php' );
+
+/**
+ * @group Database
+ *
+ * This is pretty sucky... needs to be prettified.
+ */
+class ApiUploadTest extends ApiTestCaseUpload {
+
+ /**
+ * Testing login
+ * XXX this is a funny way of getting session context
+ */
+ function testLogin() {
+ $user = self::$users['uploader'];
+
+ $params = array(
+ 'action' => 'login',
+ 'lgname' => $user->username,
+ 'lgpassword' => $user->password
+ );
+ list( $result, , $session ) = $this->doApiRequest( $params );
+ $this->assertArrayHasKey( "login", $result );
+ $this->assertArrayHasKey( "result", $result['login'] );
+ $this->assertEquals( "NeedToken", $result['login']['result'] );
+ $token = $result['login']['token'];
+
+ $params = array(
+ 'action' => 'login',
+ 'lgtoken' => $token,
+ 'lgname' => $user->username,
+ 'lgpassword' => $user->password
+ );
+ list( $result, , $session ) = $this->doApiRequest( $params, $session );
+ $this->assertArrayHasKey( "login", $result );
+ $this->assertArrayHasKey( "result", $result['login'] );
+ $this->assertEquals( "Success", $result['login']['result'] );
+ $this->assertArrayHasKey( 'lgtoken', $result['login'] );
+
+ 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 ) {
+ global $wgUser;
+ $wgUser = self::$users['uploader']->user;
+
+ $exception = false;
+ try {
+ $this->doApiRequestWithToken( array(
+ 'action' => 'upload',
+ ), $session );
+ } 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 ) {
+ global $wgUser;
+ $wgUser = self::$users['uploader']->user;
+
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ }
+ catch ( Exception $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+
+ $filePaths = $randomImageGenerator->writeImages( 1, $extension, wfTempDir() );
+ $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 );
+ } 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 ) {
+ global $wgUser;
+ $wgUser = self::$users['uploader']->user;
+
+ $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 );
+ } 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 ) {
+ global $wgUser;
+ $wgUser = self::$users['uploader']->user;
+
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ }
+ catch ( Exception $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+
+ $filePaths = $randomImageGenerator->writeImages( 2, $extension, wfTempDir() );
+ // we'll reuse this filename
+ $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 );
+ } 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 );
+ } 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 ) {
+ global $wgUser;
+ $wgUser = self::$users['uploader']->user;
+
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ }
+ catch ( Exception $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+ $filePaths = $randomImageGenerator->writeImages( 1, $extension, wfTempDir() );
+ $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, $request, $session ) = $this->doApiRequestWithToken( $params, $session );
+ } 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, $request, $session ) = $this->doApiRequestWithToken( $params, $session );
+ } 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 ) {
+ global $wgUser;
+ $wgUser = self::$users['uploader']->user;
+
+ $extension = 'png';
+ $mimeType = 'image/png';
+
+ try {
+ $randomImageGenerator = new RandomImageGenerator();
+ }
+ catch ( Exception $e ) {
+ $this->markTestIncomplete( $e->getMessage() );
+ }
+
+ $filePaths = $randomImageGenerator->writeImages( 1, $extension, wfTempDir() );
+ $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, $request, $session ) = $this->doApiRequestWithToken( $params, $session );
+ } 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, $request, $session ) = $this->doApiRequestWithToken( $params, $session );
+ } catch ( UsageException $e ) {
+ $exception = true;
+ }
+ $this->assertTrue( isset( $result['upload'] ) );
+ $this->assertEquals( 'Success', $result['upload']['result'] );
+ $this->assertFalse( $exception );
+
+ // clean up
+ $this->deleteFileByFilename( $fileName );
+ unlink( $filePath );
+ }
+}
+
diff --git a/tests/phpunit/includes/api/ApiWatchTest.php b/tests/phpunit/includes/api/ApiWatchTest.php
new file mode 100644
index 00000000..3c7ff304
--- /dev/null
+++ b/tests/phpunit/includes/api/ApiWatchTest.php
@@ -0,0 +1,179 @@
+<?php
+
+/**
+ * @group Database
+ * @todo This test suite is severly broken and need a full review
+ */
+class ApiWatchTest extends ApiTestCase {
+
+ function setUp() {
+ parent::setUp();
+ $this->doLogin();
+ }
+
+ function getTokens() {
+ return $this->getTokenList( self::$users['sysop'] );
+ }
+
+ /**
+ * @group Broken
+ */
+ function testWatchEdit() {
+
+ $data = $this->getTokens();
+
+ $keys = array_keys( $data[0]['query']['pages'] );
+ $key = array_pop( $keys );
+ $pageinfo = $data[0]['query']['pages'][$key];
+
+ $data = $this->doApiRequest( array(
+ 'action' => 'edit',
+ 'title' => 'UTPage',
+ 'text' => 'new text',
+ 'token' => $pageinfo['edittoken'],
+ 'watchlist' => 'watch' ), $data );
+ $this->assertArrayHasKey( 'edit', $data[0] );
+ $this->assertArrayHasKey( 'result', $data[0]['edit'] );
+ $this->assertEquals( 'Success', $data[0]['edit']['result'] );
+
+ return $data;
+ }
+
+ /**
+ * @depends testWatchEdit
+ */
+ function testWatchClear() {
+
+ $data = $this->doApiRequest( array(
+ 'action' => 'query',
+ 'list' => 'watchlist' ), $data );
+
+ 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 ), $data );
+ }
+ }
+ $data = $this->doApiRequest( array(
+ 'action' => 'query',
+ 'list' => 'watchlist' ), $data );
+ $this->assertArrayHasKey( 'query', $data[0] );
+ $this->assertArrayHasKey( 'watchlist', $data[0]['query'] );
+ $this->assertEquals( 0, count( $data[0]['query']['watchlist'] ) );
+
+ return $data;
+ }
+
+ /**
+ * @group Broken
+ */
+ function testWatchProtect() {
+
+ $data = $this->getTokens();
+
+ $keys = array_keys( $data[0]['query']['pages'] );
+ $key = array_pop( $keys );
+ $pageinfo = $data[0]['query']['pages'][$key];
+
+ $data = $this->doApiRequest( array(
+ 'action' => 'protect',
+ 'token' => $pageinfo['protecttoken'],
+ 'title' => 'UTPage',
+ 'protections' => 'edit=sysop',
+ 'watchlist' => 'unwatch' ), $data );
+
+ $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] );
+ }
+
+
+ function testGetRollbackToken() {
+
+ $data = $this->getTokens();
+
+ if ( !Title::newFromText( 'UTPage' )->exists() ) {
+ $this->markTestIncomplete( "The article [[UTPage]] does not exist" );
+ }
+
+ $data = $this->doApiRequest( array(
+ 'action' => 'query',
+ 'prop' => 'revisions',
+ 'titles' => 'UTPage',
+ 'rvtoken' => 'rollback' ), $data );
+
+ $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->markTestIncomplete( "Target page (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;
+ }
+
+ /**
+ * @depends testGetRollbackToken
+ * @group Broken
+ */
+ function testWatchRollback( $data ) {
+ $keys = array_keys( $data[0]['query']['pages'] );
+ $key = array_pop( $keys );
+ $pageinfo = $data[0]['query']['pages'][$key]['revisions'][0];
+
+ try {
+ $data = $this->doApiRequest( array(
+ 'action' => 'rollback',
+ 'title' => 'UTPage',
+ 'user' => $pageinfo['user'],
+ 'token' => $pageinfo['rollbacktoken'],
+ 'watchlist' => 'watch' ), $data );
+ } catch( UsageException $ue ) {
+ if( $ue->getCodeString() == 'onlyauthor' ) {
+ $this->markTestIncomplete( "Only one author to 'UTPage', cannot test rollback" );
+ } else {
+ $this->fail( "Received error '" . $ue->getCodeString() . "'" );
+ }
+ }
+
+ $this->assertArrayHasKey( 'rollback', $data[0] );
+ $this->assertArrayHasKey( 'title', $data[0]['rollback'] );
+ }
+
+ /**
+ * @group Broken
+ */
+ function testWatchDelete() {
+
+ $data = $this->getTokens();
+
+ $keys = array_keys( $data[0]['query']['pages'] );
+ $key = array_pop( $keys );
+ $pageinfo = $data[0]['query']['pages'][$key];
+
+ $data = $this->doApiRequest( array(
+ 'action' => 'delete',
+ 'token' => $pageinfo['deletetoken'],
+ 'title' => 'UTPage' ), $data );
+ $this->assertArrayHasKey( 'delete', $data[0] );
+ $this->assertArrayHasKey( 'title', $data[0]['delete'] );
+
+ $data = $this->doApiRequest( array(
+ 'action' => 'query',
+ 'list' => 'watchlist' ), $data );
+
+ $this->markTestIncomplete( 'This test needs to verify the deleted article was added to the users watchlist' );
+ }
+}
diff --git a/tests/phpunit/includes/api/RandomImageGenerator.php b/tests/phpunit/includes/api/RandomImageGenerator.php
new file mode 100644
index 00000000..ae349978
--- /dev/null
+++ b/tests/phpunit/includes/api/RandomImageGenerator.php
@@ -0,0 +1,473 @@
+<?php
+
+/*
+ * RandomImageGenerator -- does what it says on the tin.
+ * Requires Imagick, the ImageMagick library for PHP, or the command line equivalent (usually 'convert').
+ *
+ * Because MediaWiki tests the uniqueness of media upload content, and filenames, it is sometimes useful to generate
+ * files that are guaranteed (or at least very likely) to be unique in both those ways.
+ * This generates a number of filenames with random names and random content (colored triangles)
+ *
+ * It is also useful to have fresh content because our tests currently run in a "destructive" mode, and don't create a fresh new wiki for each
+ * test run.
+ * Consequently, if we just had a few static files we kept re-uploading, we'd get lots of warnings about matching content or filenames,
+ * and even if we deleted those files, we'd get warnings about archived files.
+ *
+ * This can also be used with a cronjob to generate random files all the time -- I use it to have a constant, never ending supply when I'm
+ * testing interactively.
+ *
+ * @file
+ * @author Neil Kandalgaonkar <neilk@wikimedia.org>
+ */
+
+/**
+ * RandomImageGenerator: does what it says on the tin.
+ * Can fetch a random image, or also write a number of them to disk with random filenames.
+ */
+class RandomImageGenerator {
+
+ private $dictionaryFile;
+ private $minWidth = 400;
+ private $maxWidth = 800;
+ private $minHeight = 400;
+ private $maxHeight = 800;
+ private $shapesToDraw = 5;
+ private $imageWriteMethod;
+
+ /**
+ * 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', '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',
+ dirname( __FILE__ ) . '/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" );
+ }
+
+ if ( !class_exists( 'Imagick' ) ) {
+ throw new Exception( 'No Imagick extension' );
+ }
+ global $wgExiv2Command;
+ if ( !$wgExiv2Command || !is_executable( $wgExiv2Command ) ) {
+ throw new Exception( 'exiv2 not executable or $wgExiv2Command not set' );
+ }
+ }
+
+ /**
+ * Writes random images with random filenames to disk in the directory you specify, or current working directory
+ *
+ * @param $number Integer: number of filenames to write
+ * @param $format String: optional, must be understood by ImageMagick, such as 'jpg' or 'gif'
+ * @param $dir String: 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 $format (a typical extension like 'svg', 'jpg', etc.)
+ */
+ function getImageWriteMethod( $format ) {
+ global $wgUseImageMagick, $wgImageMagickConvertCommand;
+ if ( $format === 'svg' ) {
+ return 'writeSvg';
+ } else {
+ // figure out how to write images
+ if ( class_exists( 'Imagick' ) ) {
+ 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 $number Integer: of filenames to generate
+ * @param $extension String: optional, defaults to 'jpg'
+ * @param $dir String: optional, defaults to current working directory
+ * @return 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 $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 $spec: spec describing background and shapes to draw
+ * @param $format: file format to write (which is obviously always svg here)
+ * @param $filename: filename to write to
+ */
+ public function writeSvg( $spec, $format, $filename ) {
+ $svg = new SimpleXmlElement( '<svg/>' );
+ $svg->addAttribute( 'xmlns', 'http://www.w3.org/2000/svg' );
+ $svg->addAttribute( 'version', '1.1' );
+ $svg->addAttribute( 'width', $spec['width'] );
+ $svg->addAttribute( 'height', $spec['height'] );
+ $g = $svg->addChild( 'g' );
+ foreach ( $spec['draws'] as $drawSpec ) {
+ $shape = $g->addChild( 'polygon' );
+ $shape->addAttribute( 'fill', $drawSpec['fill'] );
+ $shape->addAttribute( 'points', self::shapePointsToString( $drawSpec['shape'] ) );
+ };
+ if ( ! $fh = fopen( $filename, 'w' ) ) {
+ throw new Exception( "couldn't open $filename for writing" );
+ }
+ fwrite( $fh, $svg->asXML() );
+ if ( !fclose($fh) ) {
+ throw new Exception( "couldn't close $filename" );
+ }
+ }
+
+ /**
+ * Based on an image specification, write such an image to disk, using Imagick PHP extension
+ * @param $spec: spec describing background and circles to draw
+ * @param $format: file format to write
+ * @param $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 $spec Object returned by getImageSpec
+ * @param $matrix 2x2 transformation matrix
+ * @return 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 $matrix: 2x2 rotation matrix
+ * @param $x: x-coordinate number
+ * @param $y: 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 $spec: spec describing background and shapes to draw
+ * @param $format: file format to write (unused by this method but kept so it has the same signature as writeImageWithApi)
+ * @param $filename: filename to write to
+ */
+ 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 $number Integer: number of pairs
+ * @return Array: of 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 $number_desired Integer: number of lines desired
+ * @return 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/format/ApiFormatPhpTest.php b/tests/phpunit/includes/api/format/ApiFormatPhpTest.php
new file mode 100644
index 00000000..8209f591
--- /dev/null
+++ b/tests/phpunit/includes/api/format/ApiFormatPhpTest.php
@@ -0,0 +1,19 @@
+<?php
+
+/**
+ * @group API
+ * @group Database
+ */
+class ApiFormatPhpTest extends ApiFormatTestBase {
+
+ function testValidPhpSyntax() {
+
+ $data = $this->apiRequest( 'php', array( 'action' => 'query', 'meta' => 'siteinfo' ) );
+
+ $this->assertInternalType( 'array', unserialize( $data ) );
+ $this->assertGreaterThan( 0, count( (array) $data ) );
+
+
+ }
+
+}
diff --git a/tests/phpunit/includes/api/format/ApiFormatTestBase.php b/tests/phpunit/includes/api/format/ApiFormatTestBase.php
new file mode 100644
index 00000000..a0b7b020
--- /dev/null
+++ b/tests/phpunit/includes/api/format/ApiFormatTestBase.php
@@ -0,0 +1,22 @@
+<?php
+
+abstract class ApiFormatTestBase extends ApiTestCase {
+ protected function apiRequest( $format, $params, $data = null ) {
+ $data = parent::doApiRequest( $params, $data, true );
+
+ $module = $data[3];
+
+ $printer = $module->createPrinterByName( $format );
+ $printer->setUnescapeAmps( false );
+
+ $printer->initPrinter( false );
+
+ ob_start();
+ $printer->execute();
+ $out = ob_get_clean();
+
+ $printer->closePrinter();
+
+ return $out;
+ }
+}
diff --git a/tests/phpunit/includes/api/generateRandomImages.php b/tests/phpunit/includes/api/generateRandomImages.php
new file mode 100644
index 00000000..f3a14e5b
--- /dev/null
+++ b/tests/phpunit/includes/api/generateRandomImages.php
@@ -0,0 +1,47 @@
+<?php
+/**
+ * Bootstrapping for test image file generation
+ *
+ * @file
+ */
+
+// Evaluate the include path relative to this file
+$IP = dirname( dirname( dirname( dirname( dirname( __FILE__ ) ) ) ) );
+
+// Start up MediaWiki in command-line mode
+require_once( "$IP/maintenance/Maintenance.php" );
+require("RandomImageGenerator.php");
+
+class GenerateRandomImages extends Maintenance {
+
+ public function execute() {
+
+ $getOptSpec = array(
+ 'dictionaryFile::',
+ 'minWidth::',
+ 'maxWidth::',
+ 'minHeight::',
+ 'maxHeight::',
+ 'shapesToDraw::',
+ 'shape::',
+
+ 'number::',
+ 'format::'
+ );
+ $options = getopt( null, $getOptSpec );
+
+ $format = isset( $options['format'] ) ? $options['format'] : 'jpg';
+ unset( $options['format'] );
+
+ $number = isset( $options['number'] ) ? intval( $options['number'] ) : 10;
+ unset( $options['number'] );
+
+ $randomImageGenerator = new RandomImageGenerator( $options );
+ $randomImageGenerator->writeImages( $number, $format );
+ }
+}
+
+$maintClass = 'GenerateRandomImages';
+require( RUN_MAINTENANCE_IF_MAIN );
+
+
diff --git a/tests/phpunit/includes/api/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